diff --git a/build.gradle b/build.gradle index b0ef3d59..7d6a9d0d 100644 --- a/build.gradle +++ b/build.gradle @@ -21,7 +21,7 @@ subprojects { ext { otelVersion = '1.30.1' otelVersionAlpha = "${otelVersion}-alpha" - javaSDKVersion = '1.37.0' + javaSDKVersion = '1.38.0' camelVersion = '3.22.1' jarVersion = '1.0.0' } diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/README.md b/core/src/main/java/io/temporal/samples/nexuswalkthrough/README.md new file mode 100644 index 00000000..b54298bb --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/README.md @@ -0,0 +1,220 @@ +# Nexus Microservice Development Walkthrough + +Sample code for the [Nexus Microservice Development Walkthrough](https://docs.temporal.io/develop/java/nexus/development-walkthrough). + +The walkthrough builds one Nexus Service from nothing to a complete API, adding a single Nexus +capability at each step. This sample is the finished Service, with each piece kept separate so you +can follow along step by step. + +## The problem + +A purchase request needs approval before it can proceed. Approval is slow and human-driven, so the +system has to survive the wait. While a request is pending, other systems need to nudge the approver +and attach information to it. Eventually a decision arrives, and the requesting system needs the +outcome. + +Each of those needs a different Nexus capability, which is what makes it a useful walkthrough. + +## Operations, and what backs each one + +| Operation | Backing | Walkthrough step | +|---|---|---| +| `checkApprovalRequired` | None - completes during the handler call | Step 3 | +| `requestApproval` | Workflow | Step 4 | +| `remindApprover` | Signal, sent as sync messaging | Step 7 | +| `submitDecision` | Workflow Update | Step 7 | +| `attachApprovalContext` | Signal-with-Start | Step 8 | +| `notifyRequester` | Standalone Activity | Step 9 | + +## Sample directory structure + +Read it in this order: the contract first, then what implements it, then what calls it. + +### The contract + +| File | What it does | +|---|---| +| [`approval.nexusrpc.yaml`](./approval.nexusrpc.yaml) | **The contract** (step 1). Six Operations and their types, in a language-neutral schema. The only thing the caller and handler share. | +| [generatedservice/](./generatedservice) | **Every file here is generated** from the contract (step 2). See below. | + +**Everything in [generatedservice](./generatedservice) is generated** - the Service interface, every +input and output model, and the validation support classes. Each file carries a +`// Generated by nex-gen. DO NOT EDIT.` header, and the whole directory is rewritten on every +generator run. Nothing outside that directory is generated. + +| Generated file | What it is | +|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---| +| `ApprovalService.java` | The Service definition: one method per Operation. The handler implements it; the caller uses it as a Nexus stub. | +| `*Input.java`, `*Output.java` | Typed models, one pair per Operation. Each carries its own Jackson serializer and deserializer. | +| `ValidationException.java`, `Violation.java` | Contract violations, aggregated so one bad payload reports every problem at once rather than the first. | +| `SpecNumbers.java` | Shared integer parsing. Enforces JSON Schema integer semantics and the +/-(2^53-1) cap, so a value Java accepts is also representable in TypeScript. | + +The package is named `generatedservice` rather than `service` so that the import lines in the +handler and the caller say plainly where those types come from. Nothing requires that naming, or +even a separate package - generated types can live alongside the code that uses them. It is a +convenience for reading this sample next to the walkthrough, not a rule to copy. + +### The handler - the team publishing the Service + +| File | What it does | +|---|---| +| [`handler/ApprovalServiceImpl.java`](./handler/ApprovalServiceImpl.java) | **The centerpiece.** All six Operations, in the order the walkthrough introduces them, each commented with the step it comes from and why it is backed the way it is. | +| [`handler/ApprovalWorkflow.java`](./handler/ApprovalWorkflow.java) | The approval Workflow interface: the Workflow method, two Signal handlers, one Update handler (steps 4 and 7). | +| [`handler/ApprovalWorkflowImpl.java`](./handler/ApprovalWorkflowImpl.java) | Runs two placeholder Activities, blocks until a decision arrives, returns it. The blocking is why this is a Workflow. | +| [`handler/ApprovalWorkflowId.java`](./handler/ApprovalWorkflowId.java) | Derives the Workflow Id from the item id (step 3). One place, because two Operations have to agree on which Execution they mean. | +| [`handler/ApprovalActivities.java`](./handler/ApprovalActivities.java) | Three Activities: two placeholders called from the Workflow, and `notifyRequester`, which backs an Operation directly as a Standalone Activity (step 9). | +| [`handler/ApprovalActivitiesImpl.java`](./handler/ApprovalActivitiesImpl.java) | Placeholder implementations. They only log. | +| [`handler/Decisions.java`](./handler/Decisions.java) | Converts a decision between the generated types that carry one. Exists only because the contract cannot declare a shared enum today. | +| [`handler/HandlerWorker.java`](./handler/HandlerWorker.java) | The Worker hosting the Service, the Workflow, and the Activities (step 4). | + +### The caller - a team in another Namespace + +| File | What it does | +|---|---| +| [`caller/ApprovalCallerWorkflowImpl.java`](./caller/ApprovalCallerWorkflowImpl.java) | Calls all six Operations end to end (steps 6, 8, 10). Knows only the Endpoint name and the contract. | +| [`caller/ApprovalCallerWorkflow.java`](./caller/ApprovalCallerWorkflow.java) | The caller Workflow interface. | +| [`caller/CallerWorker.java`](./caller/CallerWorker.java) | The caller Worker. The one place the Endpoint name is bound to the Service. | +| [`caller/CallerStarter.java`](./caller/CallerStarter.java) | Starts the caller Workflow twice: one purchase under the spend threshold, one over it. | + +### Supporting + +| File | What it does | +|---|---| +| [`options/ClientOptions.java`](./options/ClientOptions.java) | Command line parsing for host, Namespace, TLS, and API key. Shared with the other Nexus samples. | +| [`description.md`](./description.md) | The Endpoint description, passed to `temporal operator nexus endpoint create`. | + +### Regenerating the contract code + +The generated code is committed, so you only need this after changing the contract: + +```bash +nexgen java \ + --output core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice \ + --package-name io.temporal.samples.nexuswalkthrough.generatedservice \ + core/src/main/java/io/temporal/samples/nexuswalkthrough/approval.nexusrpc.yaml +``` + +Two things to know. The Java generator requires the package name's last segment to match the output +directory's name, which is why both end in `generatedservice`. And generation **clears the output +directory**, which is why the contract sits outside it - a schema kept in `generatedservice/` would +be deleted the first time you regenerate. + +## Start the Temporal server + +One dynamic config value is required. Everything else is default. + +```bash +temporal server start-dev --dynamic-config-value 'activity.enableCallbacks=true' +``` + +`activity.enableCallbacks` allows attaching completion callbacks to standalone Activity Executions. +The `notifyRequester` Operation is backed by a Standalone Activity, so without this setting it fails +with `completion callbacks are not enabled for this namespace`. The other five Operations are +unaffected. + +No callback address allowlist is needed. The Nexus completion callback uses the `temporal://system` +URL, which is always allowed; `callback.allowedAddresses` only matters for external endpoint +targets. + +## Create the namespaces and the Endpoint + +In a separate terminal. The walkthrough crosses a real Namespace boundary, so the handler and the +caller each get their own. + +```bash +temporal operator namespace create --namespace approval-handler-namespace +temporal operator namespace create --namespace approval-caller-namespace + +temporal operator nexus endpoint create \ + --name approval-endpoint \ + --target-namespace approval-handler-namespace \ + --target-task-queue approval-handler-task-queue \ + --description-file ./core/src/main/java/io/temporal/samples/nexuswalkthrough/description.md +``` + +## Run it + +Three terminals, from the repository root. + +The task is `:core:execute`, qualified with the subproject. An unqualified `execute` also runs the +`lambda-worker:starter` task, which ignores `-PmainClass` and starts an unrelated sample. + +### Handler Worker + +Hosts the Nexus Service, the approval Workflow, and the Activities. Like any Worker it runs until +you stop it with Ctrl-C, so Gradle keeps reporting the task as executing. + +```bash +./gradlew -q :core:execute -PmainClass=io.temporal.samples.nexuswalkthrough.handler.HandlerWorker \ + --args="-target-host localhost:7233 -namespace approval-handler-namespace" +``` + +### Caller Worker + +```bash +./gradlew -q :core:execute -PmainClass=io.temporal.samples.nexuswalkthrough.caller.CallerWorker \ + --args="-target-host localhost:7233 -namespace approval-caller-namespace" +``` + +### Starter + +```bash +./gradlew -q :core:execute -PmainClass=io.temporal.samples.nexuswalkthrough.caller.CallerStarter \ + --args="-target-host localhost:7233 -namespace approval-caller-namespace" +``` + +### Output + +``` +INFO i.t.s.n.caller.CallerStarter - Small purchase result: NO_APPROVAL_REQUIRED +INFO i.t.s.n.caller.CallerStarter - Large purchase result: APPROVED +``` + +The starter exits once both runs finish. The two Workers keep running until you stop them. + +The starter runs the flow twice. The first purchase is under the spend threshold, so +`checkApprovalRequired` answers "no" and nothing durable is created. The second is over the +threshold and runs the whole flow. + +The handler Worker shows the second run in order: + +``` +INFO ApprovalWorkflowImpl - Context attached: Approved in the Q3 ergonomics budget +INFO ApprovalActivitiesImpl - Evaluating auto-decision rules for standing-desk-... at 1250.0 +INFO ApprovalWorkflowImpl - Approver reminded, 1 reminder(s) so far +INFO ApprovalActivitiesImpl - Approver notified that standing-desk-... is waiting +INFO ApprovalWorkflowImpl - Approval for standing-desk-... decided APPROVED after 1 reminder(s) and 1 note(s) +INFO ApprovalActivitiesImpl - Notifying dana@example.com that their request was APPROVED +``` + +Note the ordering: the context is attached **before** the approval is requested. That is +Signal-with-Start creating the approval Workflow, which `requestApproval` then attaches to rather +than failing on. + +## What to look at afterwards + +One approval Workflow, with its Workflow Id derived from the item id: + +```bash +temporal workflow list --namespace approval-handler-namespace +``` + +The notification ran as a Standalone Activity, with no parent Workflow: + +```bash +temporal activity list --namespace approval-handler-namespace +``` + +The caller's Event History shows each Operation, and which ones completed during the call versus +through a completion callback: + +```bash +temporal workflow list --namespace approval-caller-namespace +temporal workflow show --namespace approval-caller-namespace --workflow-id +``` + +## Running against Temporal Cloud or a self-hosted service + +Supply the relevant [CLI flags](./options/ClientOptions.java) to set up the connection. On Temporal +Cloud you also have to add the caller Namespace to the Endpoint's allowed callers; creating the +Endpoint is not enough, and the failure looks like a routing problem rather than a permissions one. diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/approval.nexusrpc.yaml b/core/src/main/java/io/temporal/samples/nexuswalkthrough/approval.nexusrpc.yaml new file mode 100644 index 00000000..63bc6968 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/approval.nexusrpc.yaml @@ -0,0 +1,156 @@ +# WALKTHROUGH STEP 1 - Define the data contract. +# +# This file is the contract, and it is the only thing a caller and a handler share. It is written +# before any implementation and in no particular language; step 2 generates the typed models, +# validators, and Service definition for each language from it. +# +# Every Operation input and output is an object type rather than a bare value. An object can grow an +# optional field later without breaking the wire format; a bare string cannot. +# +# This file lives outside generatedservice/, because nexgen clears its output directory on every run +# - a contract kept in the output directory would be deleted the first time you regenerate. +# @@@SNIPSTART samples-java-nexus-walkthrough-contract +nexusrpc: "1.0.0" +$schema: https://json-schema.org/draft/2020-12/schema +description: Purchase approval service built by the Nexus Microservice Development Walkthrough. + +services: + ApprovalService: + fqn: temporal.samples.approval.v1.ApprovalService + description: Start a purchase approval, message it while it is pending, and learn the outcome. + operations: + + # Answers a question without starting anything. Backed by nothing at all - see step 3. + checkApprovalRequired: + description: Report whether a purchase needs approval, before any durable work starts. + input: { $ref: "#/$defs/CheckApprovalRequiredInput" } + output: { $ref: "#/$defs/CheckApprovalRequiredOutput" } + + # Backed by a Workflow. The Workflow's return value is this Operation's result - see step 4. + requestApproval: + description: Start an approval and return the decision once it is made. + input: { $ref: "#/$defs/RequestApprovalInput" } + output: { $ref: "#/$defs/RequestApprovalOutput" } + + # A Signal. Fire-and-forget, so it declares no output - see step 7. + remindApprover: + description: Ask the approver again. Returns nothing. + input: { $ref: "#/$defs/RemindApproverInput" } + + # An Update. The caller needs confirmation back, which is what makes this an Update rather + # than a Signal - see step 7. + submitDecision: + description: Supply the decision and confirm it was recorded. + input: { $ref: "#/$defs/SubmitDecisionInput" } + output: { $ref: "#/$defs/SubmitDecisionOutput" } + + # Signal-with-Start. Its input repeats the purchase details because it may have to create the + # approval it is messaging - see step 8. + attachApprovalContext: + description: Attach supporting information to a purchase, whether or not its approval exists yet. + input: { $ref: "#/$defs/AttachApprovalContextInput" } + + # Backed by a Standalone Activity - one durable step, no Workflow - see step 9. + notifyRequester: + description: Notify the requester once the decision is final. + input: { $ref: "#/$defs/NotifyRequesterInput" } + output: { $ref: "#/$defs/NotifyRequesterOutput" } + +# The APPROVED | DENIED value set is declared inline on each property that carries it, rather than +# once under $defs. A named enum under $defs is rejected by the generator today, so each Operation +# gets its own nested value class; handler/Decisions.java converts between them. +$defs: + + CheckApprovalRequiredInput: + type: object + additionalProperties: false + properties: + itemId: { description: Identifier of the purchase., type: string } + requester: { description: Who is asking., type: string } + amount: { description: Purchase amount., type: number } + required: [itemId, requester, amount] + + CheckApprovalRequiredOutput: + type: object + additionalProperties: false + properties: + approvalRequired: { description: Whether an approval has to be started., type: boolean } + threshold: { description: The spend threshold that was applied., type: number } + required: [approvalRequired, threshold] + + RequestApprovalInput: + type: object + additionalProperties: false + properties: + itemId: { type: string } + requester: { type: string } + amount: { type: number } + required: [itemId, requester, amount] + + RequestApprovalOutput: + type: object + additionalProperties: false + properties: + decision: + description: The outcome of the approval. + type: string + enum: [APPROVED, DENIED] + required: [decision] + + RemindApproverInput: + type: object + additionalProperties: false + properties: + itemId: { type: string } + required: [itemId] + + SubmitDecisionInput: + type: object + additionalProperties: false + properties: + itemId: { type: string } + decision: + description: The decision being submitted. + type: string + enum: [APPROVED, DENIED] + required: [itemId, decision] + + SubmitDecisionOutput: + type: object + additionalProperties: false + properties: + recorded: + description: The decision that was recorded. + type: string + enum: [APPROVED, DENIED] + remindersSent: { description: How many reminders were sent before the decision., type: integer } + required: [recorded, remindersSent] + + AttachApprovalContextInput: + type: object + additionalProperties: false + properties: + itemId: { type: string } + requester: { type: string } + amount: { type: number } + note: { description: The supporting information to attach., type: string } + required: [itemId, requester, amount, note] + + NotifyRequesterInput: + type: object + additionalProperties: false + properties: + requester: { type: string } + decision: + description: The final decision. + type: string + enum: [APPROVED, DENIED] + required: [requester, decision] + + NotifyRequesterOutput: + type: object + additionalProperties: false + properties: + deliveredTo: { description: Where the notification was sent., type: string } + required: [deliveredTo] +# @@@SNIPEND diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/caller/ApprovalCallerWorkflow.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/caller/ApprovalCallerWorkflow.java new file mode 100644 index 00000000..d659e759 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/caller/ApprovalCallerWorkflow.java @@ -0,0 +1,25 @@ +package io.temporal.samples.nexuswalkthrough.caller; + +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +/** + * WALKTHROUGH STEP 6 - The caller Workflow. + * + *

A caller Workflow is the usual pattern, because a Workflow gives the call durability and lets + * you orchestrate around it. If you only need to run one Operation and have nothing to orchestrate, + * a Client can start an Operation directly with no caller Workflow at all - that is a Standalone + * Nexus Operation, and it uses the same contract, handler, and Endpoint. + */ +@WorkflowInterface +public interface ApprovalCallerWorkflow { + + /** + * Runs the whole approval flow end to end, so one Workflow Execution shows every capability the + * walkthrough introduces. A real caller would rarely do all of this in one place - in particular, + * the decision would come from a human through a separate call rather than from the caller + * itself. + */ + @WorkflowMethod + String runApprovalFlow(String itemId, String requester, double amount, String note); +} diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/caller/ApprovalCallerWorkflowImpl.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/caller/ApprovalCallerWorkflowImpl.java new file mode 100644 index 00000000..71f3fa76 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/caller/ApprovalCallerWorkflowImpl.java @@ -0,0 +1,150 @@ +package io.temporal.samples.nexuswalkthrough.caller; + +import io.temporal.samples.nexuswalkthrough.generatedservice.ApprovalService; +import io.temporal.samples.nexuswalkthrough.generatedservice.AttachApprovalContextInput; +import io.temporal.samples.nexuswalkthrough.generatedservice.CheckApprovalRequiredInput; +import io.temporal.samples.nexuswalkthrough.generatedservice.CheckApprovalRequiredOutput; +import io.temporal.samples.nexuswalkthrough.generatedservice.NotifyRequesterInput; +import io.temporal.samples.nexuswalkthrough.generatedservice.NotifyRequesterOutput; +import io.temporal.samples.nexuswalkthrough.generatedservice.RemindApproverInput; +import io.temporal.samples.nexuswalkthrough.generatedservice.RequestApprovalInput; +import io.temporal.samples.nexuswalkthrough.generatedservice.RequestApprovalOutput; +import io.temporal.samples.nexuswalkthrough.generatedservice.SubmitDecisionInput; +import io.temporal.samples.nexuswalkthrough.generatedservice.SubmitDecisionOutput; +import io.temporal.workflow.NexusOperationHandle; +import io.temporal.workflow.NexusOperationOptions; +import io.temporal.workflow.NexusServiceOptions; +import io.temporal.workflow.Workflow; +import java.time.Duration; +import org.slf4j.Logger; + +/** + * WALKTHROUGH STEP 6, 8 and 10 - Calling the Service. + * + *

The caller knows two things: the Endpoint name and the contract. It does not know which + * Namespace the handler runs in, which Task Queue its Worker polls, or that requestApproval is + * backed by a Workflow while checkApprovalRequired is backed by nothing at all. + * + *

That is the property worth pausing on: the handler team can change what backs an Operation, + * move the handler to another Namespace, or rewrite it in another language, and this caller keeps + * working. + */ +// @@@SNIPSTART samples-java-nexus-walkthrough-caller-workflow +public class ApprovalCallerWorkflowImpl implements ApprovalCallerWorkflow { + + private static final Logger logger = Workflow.getLogger(ApprovalCallerWorkflowImpl.class); + + // STEP 6 - In Java the Service interface works directly as a Nexus Service stub. Because the stub + // is that interface, every call below is type-checked against the contract at compile time. + // + // The schedule-to-close timeout bounds the whole Operation. A human approval measured in days + // would need a timeout in days; this sample decides in seconds, so a short one is fine. The + // default would not be right for a real approval. + private final ApprovalService approvalService = + Workflow.newNexusServiceStub( + ApprovalService.class, + NexusServiceOptions.newBuilder() + .setOperationOptions( + NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofMinutes(2)) + .build()) + .build()); + + @Override + public String runApprovalFlow(String itemId, String requester, double amount, String note) { + + // ------------------------------------------------------------------------------------------- + // STEP 6 - A synchronous Operation. It returns during the call because nothing durable backs + // it: no callback, no Operation token, nothing to await. A caller can use it to skip the rest + // of this Service entirely. + // ------------------------------------------------------------------------------------------- + CheckApprovalRequiredOutput check = + approvalService.checkApprovalRequired( + new CheckApprovalRequiredInput(itemId, requester, amount)); + + logger.info( + "checkApprovalRequired -> required={} threshold={}", + check.getApprovalRequired(), + check.getThreshold()); + + if (!check.getApprovalRequired()) { + return "NO_APPROVAL_REQUIRED"; + } + + // ------------------------------------------------------------------------------------------- + // STEP 8 - Attach information before the approval exists. + // + // This is deliberately called BEFORE requestApproval, which is the harder ordering. Because + // attachApprovalContext is Signal-with-Start, this call creates the approval Workflow and + // delivers the note to it. + // ------------------------------------------------------------------------------------------- + approvalService.attachApprovalContext( + new AttachApprovalContextInput(itemId, requester, amount, note)); + logger.info("attachApprovalContext -> note attached, approval now exists"); + + // ------------------------------------------------------------------------------------------- + // STEP 6 - Request the approval. + // + // The approval Workflow is already running thanks to the call above, so this start would fail + // under the default conflict policy. The handler sets USE_EXISTING, so instead this attaches + // the Operation's completion callback to the running Execution. + // + // startNexusOperation returns a handle rather than blocking, so this Workflow can keep working + // while the approval is pending. The wait is durable: this caller can be evicted and its Worker + // can restart, and the result still arrives. + // ------------------------------------------------------------------------------------------- + NexusOperationHandle approvalHandle = + Workflow.startNexusOperation( + approvalService::requestApproval, new RequestApprovalInput(itemId, requester, amount)); + + // Wait for the Operation to be started before messaging it. NexusOperationExecution carries the + // Operation token for an asynchronous Operation. + approvalHandle.getExecution().get(); + logger.info("requestApproval -> started and attached to the existing approval"); + + // ------------------------------------------------------------------------------------------- + // STEP 8 - Nudge the pending approval. A Signal, so there is no result to collect. + // ------------------------------------------------------------------------------------------- + approvalService.remindApprover(new RemindApproverInput(itemId)); + logger.info("remindApprover -> approver nudged"); + + // ------------------------------------------------------------------------------------------- + // STEP 8 - Submit the decision. An Update, so the caller gets confirmation back. + // + // In a real system this arrives from a human through a separate caller. The sample submits it + // here so the flow completes without one. + // ------------------------------------------------------------------------------------------- + SubmitDecisionOutput ack = + approvalService.submitDecision( + new SubmitDecisionInput(itemId, SubmitDecisionInput.Decision.DECISION_APPROVED)); + logger.info( + "submitDecision -> recorded={} after {} reminder(s)", + ack.getRecorded().getValue(), + ack.getRemindersSent()); + + // ------------------------------------------------------------------------------------------- + // STEP 6 - Await the decision. + // + // The caller does not poll. The decision is the result of requestApproval, pushed here through + // the Nexus completion callback the moment the approval Workflow returns. Asking the approval + // for its status in a loop would be polling for something already on its way. + // ------------------------------------------------------------------------------------------- + RequestApprovalOutput.Decision decision = approvalHandle.getResult().get().getDecision(); + logger.info("requestApproval -> decision {}", decision.getValue()); + + // ------------------------------------------------------------------------------------------- + // STEP 10 - Call the Standalone Activity. + // + // From the caller this looks like any other Operation. It does not know that nothing but a + // single Activity Execution sits behind it. + // ------------------------------------------------------------------------------------------- + NotifyRequesterOutput notified = + approvalService.notifyRequester( + new NotifyRequesterInput( + requester, NotifyRequesterInput.Decision.fromString(decision.getValue()))); + logger.info("notifyRequester -> delivered to {}", notified.getDeliveredTo()); + + return decision.getValue(); + } +} +// @@@SNIPEND diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/caller/CallerStarter.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/caller/CallerStarter.java new file mode 100644 index 00000000..73c1fbae --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/caller/CallerStarter.java @@ -0,0 +1,50 @@ +package io.temporal.samples.nexuswalkthrough.caller; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; +import io.temporal.samples.nexuswalkthrough.options.ClientOptions; +import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Starts the caller Workflow twice, to show both branches of the flow. + * + *

The first purchase is under the spend threshold, so checkApprovalRequired answers "no" and the + * caller stops there without creating anything durable. The second is over the threshold and runs + * the full approval. + */ +public class CallerStarter { + + private static final Logger logger = LoggerFactory.getLogger(CallerStarter.class); + + // @@@SNIPSTART samples-java-nexus-walkthrough-caller-starter + public static void main(String[] args) { + WorkflowClient client = ClientOptions.getWorkflowClient(args); + + WorkflowOptions options = + WorkflowOptions.newBuilder().setTaskQueue(CallerWorker.DEFAULT_TASK_QUEUE_NAME).build(); + + // A small purchase. checkApprovalRequired returns false and nothing durable is created. + ApprovalCallerWorkflow small = client.newWorkflowStub(ApprovalCallerWorkflow.class, options); + String smallResult = + small.runApprovalFlow( + "laptop-charger-" + UUID.randomUUID(), + "dana@example.com", + 49.99, + "Replacement charger"); + logger.info("Small purchase result: {}", smallResult); + + // A large purchase. Runs the whole flow: context attached first, approval requested, approver + // reminded, decision submitted, decision awaited, requester notified. + ApprovalCallerWorkflow large = client.newWorkflowStub(ApprovalCallerWorkflow.class, options); + String largeResult = + large.runApprovalFlow( + "standing-desk-" + UUID.randomUUID(), + "dana@example.com", + 1250.00, + "Approved in the Q3 ergonomics budget"); + logger.info("Large purchase result: {}", largeResult); + } + // @@@SNIPEND +} diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/caller/CallerWorker.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/caller/CallerWorker.java new file mode 100644 index 00000000..5c18ff92 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/caller/CallerWorker.java @@ -0,0 +1,49 @@ +package io.temporal.samples.nexuswalkthrough.caller; + +import io.temporal.client.WorkflowClient; +import io.temporal.samples.nexuswalkthrough.options.ClientOptions; +import io.temporal.worker.Worker; +import io.temporal.worker.WorkerFactory; +import io.temporal.worker.WorkflowImplementationOptions; +import io.temporal.workflow.NexusServiceOptions; +import java.util.Collections; + +/** + * WALKTHROUGH STEP 6 - The caller Worker. + * + *

This Worker runs in the caller Namespace and knows nothing about the handler beyond the + * Endpoint name bound here. That binding is the only place the caller side names the Endpoint; the + * caller Workflow itself refers to the Service by its contract alone. + */ +public class CallerWorker { + + public static final String DEFAULT_TASK_QUEUE_NAME = "approval-caller-task-queue"; + public static final String DEFAULT_ENDPOINT_NAME = "approval-endpoint"; + + /** + * The Service name as it appears on the wire. The generated interface is annotated + * {@code @Service(name = "...")} with the contract's fully qualified name, so that - not the Java + * interface's simple name - is the key the Endpoint binding is registered under. + */ + public static final String SERVICE_NAME = "temporal.samples.approval.v1.ApprovalService"; + + // @@@SNIPSTART samples-java-nexus-walkthrough-caller-worker + public static void main(String[] args) { + WorkflowClient client = ClientOptions.getWorkflowClient(args); + + WorkerFactory factory = WorkerFactory.newInstance(client); + Worker worker = factory.newWorker(DEFAULT_TASK_QUEUE_NAME); + + worker.registerWorkflowImplementationTypes( + WorkflowImplementationOptions.newBuilder() + .setNexusServiceOptions( + Collections.singletonMap( + SERVICE_NAME, + NexusServiceOptions.newBuilder().setEndpoint(DEFAULT_ENDPOINT_NAME).build())) + .build(), + ApprovalCallerWorkflowImpl.class); + + factory.start(); + } + // @@@SNIPEND +} diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/description.md b/core/src/main/java/io/temporal/samples/nexuswalkthrough/description.md new file mode 100644 index 00000000..8458e7b4 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/description.md @@ -0,0 +1,4 @@ +Purchase approval service. + +Start an approval, message it while it is pending, and be notified when the decision is final. +Built by the Nexus Microservice Development Walkthrough. diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/ApprovalService.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/ApprovalService.java new file mode 100644 index 00000000..322964c2 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/ApprovalService.java @@ -0,0 +1,33 @@ +// Generated by nex-gen. DO NOT EDIT. +package io.temporal.samples.nexuswalkthrough.generatedservice; + +import io.nexusrpc.Operation; +import io.nexusrpc.Service; + +/** Start a purchase approval, message it while it is pending, and learn the outcome. */ +@Service(name = "temporal.samples.approval.v1.ApprovalService") +public interface ApprovalService { + /** Report whether a purchase needs approval, before any durable work starts. */ + @Operation(name = "CheckApprovalRequired") + CheckApprovalRequiredOutput checkApprovalRequired(CheckApprovalRequiredInput input); + + /** Start an approval and return the decision once it is made. */ + @Operation(name = "RequestApproval") + RequestApprovalOutput requestApproval(RequestApprovalInput input); + + /** Ask the approver again. Returns nothing. */ + @Operation(name = "RemindApprover") + void remindApprover(RemindApproverInput input); + + /** Supply the decision and confirm it was recorded. */ + @Operation(name = "SubmitDecision") + SubmitDecisionOutput submitDecision(SubmitDecisionInput input); + + /** Attach supporting information to a purchase, whether or not its approval exists yet. */ + @Operation(name = "AttachApprovalContext") + void attachApprovalContext(AttachApprovalContextInput input); + + /** Notify the requester once the decision is final. */ + @Operation(name = "NotifyRequester") + NotifyRequesterOutput notifyRequester(NotifyRequesterInput input); +} diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/AttachApprovalContextInput.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/AttachApprovalContextInput.java new file mode 100644 index 00000000..05c8a064 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/AttachApprovalContextInput.java @@ -0,0 +1,197 @@ +// Generated by nex-gen. DO NOT EDIT. +package io.temporal.samples.nexuswalkthrough.generatedservice; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +@JsonSerialize(using = AttachApprovalContextInput.Serializer.class) +@JsonDeserialize(using = AttachApprovalContextInput.Deserializer.class) +public final class AttachApprovalContextInput { + private final String itemId; + private final String requester; + private final double amount; + + /** The supporting information to attach. */ + private final String note; + + public AttachApprovalContextInput(String itemId, String requester, double amount, String note) { + this.itemId = itemId; + this.requester = requester; + this.amount = amount; + this.note = note; + } + + public String getItemId() { + return itemId; + } + + public String getRequester() { + return requester; + } + + public double getAmount() { + return amount; + } + + public String getNote() { + return note; + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (!(other instanceof AttachApprovalContextInput)) { + return false; + } + AttachApprovalContextInput that = (AttachApprovalContextInput) other; + return Objects.equals(this.itemId, that.itemId) + && Objects.equals(this.requester, that.requester) + && this.amount == that.amount + && Objects.equals(this.note, that.note); + } + + @Override + public int hashCode() { + return Objects.hash(itemId, requester, amount, note); + } + + @Override + public String toString() { + return "AttachApprovalContextInput{" + + "itemId=" + + itemId + + ", requester=" + + requester + + ", amount=" + + amount + + ", note=" + + note + + "}"; + } + + public static final class Serializer + extends com.fasterxml.jackson.databind.JsonSerializer { + @Override + public void serialize( + AttachApprovalContextInput value, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + gen.writeStartObject(); + if (value.itemId != null) { + gen.writeStringField("itemId", value.itemId); + } + if (value.requester != null) { + gen.writeStringField("requester", value.requester); + } + gen.writeNumberField("amount", value.amount); + + if (value.note != null) { + gen.writeStringField("note", value.note); + } + gen.writeEndObject(); + } + } + + public static final class Deserializer + extends com.fasterxml.jackson.databind.JsonDeserializer { + @Override + public AttachApprovalContextInput deserialize(JsonParser parser, DeserializationContext context) + throws IOException { + JsonNode node = parser.readValueAsTree(); + List violations = new ArrayList<>(); + if (node == null || !node.isObject()) { + violations.add(new Violation("", "expected object")); + throw new ValidationException(violations); + } + Iterator fieldNames = node.fieldNames(); + while (fieldNames.hasNext()) { + String key = fieldNames.next(); + switch (key) { + case "amount": + case "itemId": + case "note": + case "requester": + break; + default: + violations.add(new Violation(key, "unknown field")); + } + } + String itemId = null; + { + JsonNode field = node.get("itemId"); + if (field == null) { + violations.add(new Violation("itemId", "required")); + } else if (field.isNull()) { + violations.add(new Violation("itemId", "explicit null not allowed")); + } else { + if (!field.isTextual()) { + violations.add(new Violation("itemId", "expected string")); + } else { + itemId = field.textValue(); + } + } + } + String requester = null; + { + JsonNode field = node.get("requester"); + if (field == null) { + violations.add(new Violation("requester", "required")); + } else if (field.isNull()) { + violations.add(new Violation("requester", "explicit null not allowed")); + } else { + if (!field.isTextual()) { + violations.add(new Violation("requester", "expected string")); + } else { + requester = field.textValue(); + } + } + } + double amount = 0.0; + { + JsonNode field = node.get("amount"); + if (field == null) { + violations.add(new Violation("amount", "required")); + } else if (field.isNull()) { + violations.add(new Violation("amount", "explicit null not allowed")); + } else { + if (!field.isNumber()) { + violations.add(new Violation("amount", "expected number")); + } else { + amount = field.doubleValue(); + } + } + } + String note = null; + { + JsonNode field = node.get("note"); + if (field == null) { + violations.add(new Violation("note", "required")); + } else if (field.isNull()) { + violations.add(new Violation("note", "explicit null not allowed")); + } else { + if (!field.isTextual()) { + violations.add(new Violation("note", "expected string")); + } else { + note = field.textValue(); + } + } + } + if (!violations.isEmpty()) { + throw new ValidationException(violations); + } + return new AttachApprovalContextInput(itemId, requester, amount, note); + } + } +} diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/CheckApprovalRequiredInput.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/CheckApprovalRequiredInput.java new file mode 100644 index 00000000..e2d88187 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/CheckApprovalRequiredInput.java @@ -0,0 +1,172 @@ +// Generated by nex-gen. DO NOT EDIT. +package io.temporal.samples.nexuswalkthrough.generatedservice; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +@JsonSerialize(using = CheckApprovalRequiredInput.Serializer.class) +@JsonDeserialize(using = CheckApprovalRequiredInput.Deserializer.class) +public final class CheckApprovalRequiredInput { + /** Identifier of the purchase. */ + private final String itemId; + + /** Who is asking. */ + private final String requester; + + /** Purchase amount. */ + private final double amount; + + public CheckApprovalRequiredInput(String itemId, String requester, double amount) { + this.itemId = itemId; + this.requester = requester; + this.amount = amount; + } + + public String getItemId() { + return itemId; + } + + public String getRequester() { + return requester; + } + + public double getAmount() { + return amount; + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (!(other instanceof CheckApprovalRequiredInput)) { + return false; + } + CheckApprovalRequiredInput that = (CheckApprovalRequiredInput) other; + return Objects.equals(this.itemId, that.itemId) + && Objects.equals(this.requester, that.requester) + && this.amount == that.amount; + } + + @Override + public int hashCode() { + return Objects.hash(itemId, requester, amount); + } + + @Override + public String toString() { + return "CheckApprovalRequiredInput{" + + "itemId=" + + itemId + + ", requester=" + + requester + + ", amount=" + + amount + + "}"; + } + + public static final class Serializer + extends com.fasterxml.jackson.databind.JsonSerializer { + @Override + public void serialize( + CheckApprovalRequiredInput value, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + gen.writeStartObject(); + if (value.itemId != null) { + gen.writeStringField("itemId", value.itemId); + } + if (value.requester != null) { + gen.writeStringField("requester", value.requester); + } + gen.writeNumberField("amount", value.amount); + + gen.writeEndObject(); + } + } + + public static final class Deserializer + extends com.fasterxml.jackson.databind.JsonDeserializer { + @Override + public CheckApprovalRequiredInput deserialize(JsonParser parser, DeserializationContext context) + throws IOException { + JsonNode node = parser.readValueAsTree(); + List violations = new ArrayList<>(); + if (node == null || !node.isObject()) { + violations.add(new Violation("", "expected object")); + throw new ValidationException(violations); + } + Iterator fieldNames = node.fieldNames(); + while (fieldNames.hasNext()) { + String key = fieldNames.next(); + switch (key) { + case "amount": + case "itemId": + case "requester": + break; + default: + violations.add(new Violation(key, "unknown field")); + } + } + String itemId = null; + { + JsonNode field = node.get("itemId"); + if (field == null) { + violations.add(new Violation("itemId", "required")); + } else if (field.isNull()) { + violations.add(new Violation("itemId", "explicit null not allowed")); + } else { + if (!field.isTextual()) { + violations.add(new Violation("itemId", "expected string")); + } else { + itemId = field.textValue(); + } + } + } + String requester = null; + { + JsonNode field = node.get("requester"); + if (field == null) { + violations.add(new Violation("requester", "required")); + } else if (field.isNull()) { + violations.add(new Violation("requester", "explicit null not allowed")); + } else { + if (!field.isTextual()) { + violations.add(new Violation("requester", "expected string")); + } else { + requester = field.textValue(); + } + } + } + double amount = 0.0; + { + JsonNode field = node.get("amount"); + if (field == null) { + violations.add(new Violation("amount", "required")); + } else if (field.isNull()) { + violations.add(new Violation("amount", "explicit null not allowed")); + } else { + if (!field.isNumber()) { + violations.add(new Violation("amount", "expected number")); + } else { + amount = field.doubleValue(); + } + } + } + if (!violations.isEmpty()) { + throw new ValidationException(violations); + } + return new CheckApprovalRequiredInput(itemId, requester, amount); + } + } +} diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/CheckApprovalRequiredOutput.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/CheckApprovalRequiredOutput.java new file mode 100644 index 00000000..1ce4c28a --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/CheckApprovalRequiredOutput.java @@ -0,0 +1,140 @@ +// Generated by nex-gen. DO NOT EDIT. +package io.temporal.samples.nexuswalkthrough.generatedservice; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +@JsonSerialize(using = CheckApprovalRequiredOutput.Serializer.class) +@JsonDeserialize(using = CheckApprovalRequiredOutput.Deserializer.class) +public final class CheckApprovalRequiredOutput { + /** Whether an approval has to be started. */ + private final boolean approvalRequired; + + /** The spend threshold that was applied. */ + private final double threshold; + + public CheckApprovalRequiredOutput(boolean approvalRequired, double threshold) { + this.approvalRequired = approvalRequired; + this.threshold = threshold; + } + + public boolean getApprovalRequired() { + return approvalRequired; + } + + public double getThreshold() { + return threshold; + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (!(other instanceof CheckApprovalRequiredOutput)) { + return false; + } + CheckApprovalRequiredOutput that = (CheckApprovalRequiredOutput) other; + return this.approvalRequired == that.approvalRequired && this.threshold == that.threshold; + } + + @Override + public int hashCode() { + return Objects.hash(approvalRequired, threshold); + } + + @Override + public String toString() { + return "CheckApprovalRequiredOutput{" + + "approvalRequired=" + + approvalRequired + + ", threshold=" + + threshold + + "}"; + } + + public static final class Serializer + extends com.fasterxml.jackson.databind.JsonSerializer { + @Override + public void serialize( + CheckApprovalRequiredOutput value, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + gen.writeStartObject(); + gen.writeBooleanField("approvalRequired", value.approvalRequired); + + gen.writeNumberField("threshold", value.threshold); + + gen.writeEndObject(); + } + } + + public static final class Deserializer + extends com.fasterxml.jackson.databind.JsonDeserializer { + @Override + public CheckApprovalRequiredOutput deserialize( + JsonParser parser, DeserializationContext context) throws IOException { + JsonNode node = parser.readValueAsTree(); + List violations = new ArrayList<>(); + if (node == null || !node.isObject()) { + violations.add(new Violation("", "expected object")); + throw new ValidationException(violations); + } + Iterator fieldNames = node.fieldNames(); + while (fieldNames.hasNext()) { + String key = fieldNames.next(); + switch (key) { + case "approvalRequired": + case "threshold": + break; + default: + violations.add(new Violation(key, "unknown field")); + } + } + boolean approvalRequired = false; + { + JsonNode field = node.get("approvalRequired"); + if (field == null) { + violations.add(new Violation("approvalRequired", "required")); + } else if (field.isNull()) { + violations.add(new Violation("approvalRequired", "explicit null not allowed")); + } else { + if (!field.isBoolean()) { + violations.add(new Violation("approvalRequired", "expected boolean")); + } else { + approvalRequired = field.booleanValue(); + } + } + } + double threshold = 0.0; + { + JsonNode field = node.get("threshold"); + if (field == null) { + violations.add(new Violation("threshold", "required")); + } else if (field.isNull()) { + violations.add(new Violation("threshold", "explicit null not allowed")); + } else { + if (!field.isNumber()) { + violations.add(new Violation("threshold", "expected number")); + } else { + threshold = field.doubleValue(); + } + } + } + if (!violations.isEmpty()) { + throw new ValidationException(violations); + } + return new CheckApprovalRequiredOutput(approvalRequired, threshold); + } + } +} diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/NotifyRequesterInput.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/NotifyRequesterInput.java new file mode 100644 index 00000000..96de8c7b --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/NotifyRequesterInput.java @@ -0,0 +1,202 @@ +// Generated by nex-gen. DO NOT EDIT. +package io.temporal.samples.nexuswalkthrough.generatedservice; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +@JsonSerialize(using = NotifyRequesterInput.Serializer.class) +@JsonDeserialize(using = NotifyRequesterInput.Deserializer.class) +public final class NotifyRequesterInput { + public static final class Decision { + public static final Decision DECISION_APPROVED = new Decision("APPROVED"); + public static final Decision DECISION_DENIED = new Decision("DENIED"); + + private final String value; + + private Decision(String value) { + this.value = value; + } + + @JsonCreator + public static @Nullable Decision fromString(String value) { + if (value == null) { + return null; + } + if ("APPROVED".equals(value)) { + return DECISION_APPROVED; + } + if ("DENIED".equals(value)) { + return DECISION_DENIED; + } + throw new IllegalArgumentException( + "must be one of [\"APPROVED\", \"DENIED\"], got \"" + value + "\""); + } + + @JsonValue + public String getValue() { + return value; + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (!(other instanceof Decision)) { + return false; + } + Decision that = (Decision) other; + return Objects.equals(this.value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return "Decision[" + value + "]"; + } + } + + private final String requester; + + /** The final decision. */ + private final Decision decision; + + public NotifyRequesterInput(String requester, Decision decision) { + this.requester = requester; + this.decision = decision; + } + + public String getRequester() { + return requester; + } + + public Decision getDecision() { + return decision; + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (!(other instanceof NotifyRequesterInput)) { + return false; + } + NotifyRequesterInput that = (NotifyRequesterInput) other; + return Objects.equals(this.requester, that.requester) + && Objects.equals(this.decision, that.decision); + } + + @Override + public int hashCode() { + return Objects.hash(requester, decision); + } + + @Override + public String toString() { + return "NotifyRequesterInput{" + "requester=" + requester + ", decision=" + decision + "}"; + } + + public static final class Serializer + extends com.fasterxml.jackson.databind.JsonSerializer { + @Override + public void serialize( + NotifyRequesterInput value, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + gen.writeStartObject(); + if (value.requester != null) { + gen.writeStringField("requester", value.requester); + } + if (value.decision != null) { + gen.writeStringField("decision", value.decision.getValue()); + } + gen.writeEndObject(); + } + } + + public static final class Deserializer + extends com.fasterxml.jackson.databind.JsonDeserializer { + @Override + public NotifyRequesterInput deserialize(JsonParser parser, DeserializationContext context) + throws IOException { + JsonNode node = parser.readValueAsTree(); + List violations = new ArrayList<>(); + if (node == null || !node.isObject()) { + violations.add(new Violation("", "expected object")); + throw new ValidationException(violations); + } + Iterator fieldNames = node.fieldNames(); + while (fieldNames.hasNext()) { + String key = fieldNames.next(); + switch (key) { + case "decision": + case "requester": + break; + default: + violations.add(new Violation(key, "unknown field")); + } + } + String requester = null; + { + JsonNode field = node.get("requester"); + if (field == null) { + violations.add(new Violation("requester", "required")); + } else if (field.isNull()) { + violations.add(new Violation("requester", "explicit null not allowed")); + } else { + if (!field.isTextual()) { + violations.add(new Violation("requester", "expected string")); + } else { + requester = field.textValue(); + } + } + } + Decision decision = null; + { + JsonNode field = node.get("decision"); + if (field == null) { + violations.add(new Violation("decision", "required")); + } else if (field.isNull()) { + violations.add(new Violation("decision", "explicit null not allowed")); + } else { + if (!field.isTextual()) { + violations.add(new Violation("decision", "expected string")); + } else { + String decisionValue = field.textValue(); + if ("APPROVED".equals(decisionValue)) { + decision = Decision.DECISION_APPROVED; + } else if ("DENIED".equals(decisionValue)) { + decision = Decision.DECISION_DENIED; + } else { + violations.add( + new Violation( + "decision", + "must be one of [\"APPROVED\", \"DENIED\"], got " + decisionValue)); + } + } + } + } + if (!violations.isEmpty()) { + throw new ValidationException(violations); + } + return new NotifyRequesterInput(requester, decision); + } + } +} diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/NotifyRequesterOutput.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/NotifyRequesterOutput.java new file mode 100644 index 00000000..d4a745ed --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/NotifyRequesterOutput.java @@ -0,0 +1,110 @@ +// Generated by nex-gen. DO NOT EDIT. +package io.temporal.samples.nexuswalkthrough.generatedservice; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +@JsonSerialize(using = NotifyRequesterOutput.Serializer.class) +@JsonDeserialize(using = NotifyRequesterOutput.Deserializer.class) +public final class NotifyRequesterOutput { + /** Where the notification was sent. */ + private final String deliveredTo; + + public NotifyRequesterOutput(String deliveredTo) { + this.deliveredTo = deliveredTo; + } + + public String getDeliveredTo() { + return deliveredTo; + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (!(other instanceof NotifyRequesterOutput)) { + return false; + } + NotifyRequesterOutput that = (NotifyRequesterOutput) other; + return Objects.equals(this.deliveredTo, that.deliveredTo); + } + + @Override + public int hashCode() { + return Objects.hash(deliveredTo); + } + + @Override + public String toString() { + return "NotifyRequesterOutput{" + "deliveredTo=" + deliveredTo + "}"; + } + + public static final class Serializer + extends com.fasterxml.jackson.databind.JsonSerializer { + @Override + public void serialize( + NotifyRequesterOutput value, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + gen.writeStartObject(); + if (value.deliveredTo != null) { + gen.writeStringField("deliveredTo", value.deliveredTo); + } + gen.writeEndObject(); + } + } + + public static final class Deserializer + extends com.fasterxml.jackson.databind.JsonDeserializer { + @Override + public NotifyRequesterOutput deserialize(JsonParser parser, DeserializationContext context) + throws IOException { + JsonNode node = parser.readValueAsTree(); + List violations = new ArrayList<>(); + if (node == null || !node.isObject()) { + violations.add(new Violation("", "expected object")); + throw new ValidationException(violations); + } + Iterator fieldNames = node.fieldNames(); + while (fieldNames.hasNext()) { + String key = fieldNames.next(); + switch (key) { + case "deliveredTo": + break; + default: + violations.add(new Violation(key, "unknown field")); + } + } + String deliveredTo = null; + { + JsonNode field = node.get("deliveredTo"); + if (field == null) { + violations.add(new Violation("deliveredTo", "required")); + } else if (field.isNull()) { + violations.add(new Violation("deliveredTo", "explicit null not allowed")); + } else { + if (!field.isTextual()) { + violations.add(new Violation("deliveredTo", "expected string")); + } else { + deliveredTo = field.textValue(); + } + } + } + if (!violations.isEmpty()) { + throw new ValidationException(violations); + } + return new NotifyRequesterOutput(deliveredTo); + } + } +} diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/RemindApproverInput.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/RemindApproverInput.java new file mode 100644 index 00000000..bbfecaaa --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/RemindApproverInput.java @@ -0,0 +1,109 @@ +// Generated by nex-gen. DO NOT EDIT. +package io.temporal.samples.nexuswalkthrough.generatedservice; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +@JsonSerialize(using = RemindApproverInput.Serializer.class) +@JsonDeserialize(using = RemindApproverInput.Deserializer.class) +public final class RemindApproverInput { + private final String itemId; + + public RemindApproverInput(String itemId) { + this.itemId = itemId; + } + + public String getItemId() { + return itemId; + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (!(other instanceof RemindApproverInput)) { + return false; + } + RemindApproverInput that = (RemindApproverInput) other; + return Objects.equals(this.itemId, that.itemId); + } + + @Override + public int hashCode() { + return Objects.hash(itemId); + } + + @Override + public String toString() { + return "RemindApproverInput{" + "itemId=" + itemId + "}"; + } + + public static final class Serializer + extends com.fasterxml.jackson.databind.JsonSerializer { + @Override + public void serialize( + RemindApproverInput value, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + gen.writeStartObject(); + if (value.itemId != null) { + gen.writeStringField("itemId", value.itemId); + } + gen.writeEndObject(); + } + } + + public static final class Deserializer + extends com.fasterxml.jackson.databind.JsonDeserializer { + @Override + public RemindApproverInput deserialize(JsonParser parser, DeserializationContext context) + throws IOException { + JsonNode node = parser.readValueAsTree(); + List violations = new ArrayList<>(); + if (node == null || !node.isObject()) { + violations.add(new Violation("", "expected object")); + throw new ValidationException(violations); + } + Iterator fieldNames = node.fieldNames(); + while (fieldNames.hasNext()) { + String key = fieldNames.next(); + switch (key) { + case "itemId": + break; + default: + violations.add(new Violation(key, "unknown field")); + } + } + String itemId = null; + { + JsonNode field = node.get("itemId"); + if (field == null) { + violations.add(new Violation("itemId", "required")); + } else if (field.isNull()) { + violations.add(new Violation("itemId", "explicit null not allowed")); + } else { + if (!field.isTextual()) { + violations.add(new Violation("itemId", "expected string")); + } else { + itemId = field.textValue(); + } + } + } + if (!violations.isEmpty()) { + throw new ValidationException(violations); + } + return new RemindApproverInput(itemId); + } + } +} diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/RequestApprovalInput.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/RequestApprovalInput.java new file mode 100644 index 00000000..087eaa25 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/RequestApprovalInput.java @@ -0,0 +1,167 @@ +// Generated by nex-gen. DO NOT EDIT. +package io.temporal.samples.nexuswalkthrough.generatedservice; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +@JsonSerialize(using = RequestApprovalInput.Serializer.class) +@JsonDeserialize(using = RequestApprovalInput.Deserializer.class) +public final class RequestApprovalInput { + private final String itemId; + private final String requester; + private final double amount; + + public RequestApprovalInput(String itemId, String requester, double amount) { + this.itemId = itemId; + this.requester = requester; + this.amount = amount; + } + + public String getItemId() { + return itemId; + } + + public String getRequester() { + return requester; + } + + public double getAmount() { + return amount; + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (!(other instanceof RequestApprovalInput)) { + return false; + } + RequestApprovalInput that = (RequestApprovalInput) other; + return Objects.equals(this.itemId, that.itemId) + && Objects.equals(this.requester, that.requester) + && this.amount == that.amount; + } + + @Override + public int hashCode() { + return Objects.hash(itemId, requester, amount); + } + + @Override + public String toString() { + return "RequestApprovalInput{" + + "itemId=" + + itemId + + ", requester=" + + requester + + ", amount=" + + amount + + "}"; + } + + public static final class Serializer + extends com.fasterxml.jackson.databind.JsonSerializer { + @Override + public void serialize( + RequestApprovalInput value, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + gen.writeStartObject(); + if (value.itemId != null) { + gen.writeStringField("itemId", value.itemId); + } + if (value.requester != null) { + gen.writeStringField("requester", value.requester); + } + gen.writeNumberField("amount", value.amount); + + gen.writeEndObject(); + } + } + + public static final class Deserializer + extends com.fasterxml.jackson.databind.JsonDeserializer { + @Override + public RequestApprovalInput deserialize(JsonParser parser, DeserializationContext context) + throws IOException { + JsonNode node = parser.readValueAsTree(); + List violations = new ArrayList<>(); + if (node == null || !node.isObject()) { + violations.add(new Violation("", "expected object")); + throw new ValidationException(violations); + } + Iterator fieldNames = node.fieldNames(); + while (fieldNames.hasNext()) { + String key = fieldNames.next(); + switch (key) { + case "amount": + case "itemId": + case "requester": + break; + default: + violations.add(new Violation(key, "unknown field")); + } + } + String itemId = null; + { + JsonNode field = node.get("itemId"); + if (field == null) { + violations.add(new Violation("itemId", "required")); + } else if (field.isNull()) { + violations.add(new Violation("itemId", "explicit null not allowed")); + } else { + if (!field.isTextual()) { + violations.add(new Violation("itemId", "expected string")); + } else { + itemId = field.textValue(); + } + } + } + String requester = null; + { + JsonNode field = node.get("requester"); + if (field == null) { + violations.add(new Violation("requester", "required")); + } else if (field.isNull()) { + violations.add(new Violation("requester", "explicit null not allowed")); + } else { + if (!field.isTextual()) { + violations.add(new Violation("requester", "expected string")); + } else { + requester = field.textValue(); + } + } + } + double amount = 0.0; + { + JsonNode field = node.get("amount"); + if (field == null) { + violations.add(new Violation("amount", "required")); + } else if (field.isNull()) { + violations.add(new Violation("amount", "explicit null not allowed")); + } else { + if (!field.isNumber()) { + violations.add(new Violation("amount", "expected number")); + } else { + amount = field.doubleValue(); + } + } + } + if (!violations.isEmpty()) { + throw new ValidationException(violations); + } + return new RequestApprovalInput(itemId, requester, amount); + } + } +} diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/RequestApprovalOutput.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/RequestApprovalOutput.java new file mode 100644 index 00000000..b93ee745 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/RequestApprovalOutput.java @@ -0,0 +1,175 @@ +// Generated by nex-gen. DO NOT EDIT. +package io.temporal.samples.nexuswalkthrough.generatedservice; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +@JsonSerialize(using = RequestApprovalOutput.Serializer.class) +@JsonDeserialize(using = RequestApprovalOutput.Deserializer.class) +public final class RequestApprovalOutput { + public static final class Decision { + public static final Decision DECISION_APPROVED = new Decision("APPROVED"); + public static final Decision DECISION_DENIED = new Decision("DENIED"); + + private final String value; + + private Decision(String value) { + this.value = value; + } + + @JsonCreator + public static @Nullable Decision fromString(String value) { + if (value == null) { + return null; + } + if ("APPROVED".equals(value)) { + return DECISION_APPROVED; + } + if ("DENIED".equals(value)) { + return DECISION_DENIED; + } + throw new IllegalArgumentException( + "must be one of [\"APPROVED\", \"DENIED\"], got \"" + value + "\""); + } + + @JsonValue + public String getValue() { + return value; + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (!(other instanceof Decision)) { + return false; + } + Decision that = (Decision) other; + return Objects.equals(this.value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return "Decision[" + value + "]"; + } + } + + /** The outcome of the approval. */ + private final Decision decision; + + public RequestApprovalOutput(Decision decision) { + this.decision = decision; + } + + public Decision getDecision() { + return decision; + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (!(other instanceof RequestApprovalOutput)) { + return false; + } + RequestApprovalOutput that = (RequestApprovalOutput) other; + return Objects.equals(this.decision, that.decision); + } + + @Override + public int hashCode() { + return Objects.hash(decision); + } + + @Override + public String toString() { + return "RequestApprovalOutput{" + "decision=" + decision + "}"; + } + + public static final class Serializer + extends com.fasterxml.jackson.databind.JsonSerializer { + @Override + public void serialize( + RequestApprovalOutput value, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + gen.writeStartObject(); + if (value.decision != null) { + gen.writeStringField("decision", value.decision.getValue()); + } + gen.writeEndObject(); + } + } + + public static final class Deserializer + extends com.fasterxml.jackson.databind.JsonDeserializer { + @Override + public RequestApprovalOutput deserialize(JsonParser parser, DeserializationContext context) + throws IOException { + JsonNode node = parser.readValueAsTree(); + List violations = new ArrayList<>(); + if (node == null || !node.isObject()) { + violations.add(new Violation("", "expected object")); + throw new ValidationException(violations); + } + Iterator fieldNames = node.fieldNames(); + while (fieldNames.hasNext()) { + String key = fieldNames.next(); + switch (key) { + case "decision": + break; + default: + violations.add(new Violation(key, "unknown field")); + } + } + Decision decision = null; + { + JsonNode field = node.get("decision"); + if (field == null) { + violations.add(new Violation("decision", "required")); + } else if (field.isNull()) { + violations.add(new Violation("decision", "explicit null not allowed")); + } else { + if (!field.isTextual()) { + violations.add(new Violation("decision", "expected string")); + } else { + String decisionValue = field.textValue(); + if ("APPROVED".equals(decisionValue)) { + decision = Decision.DECISION_APPROVED; + } else if ("DENIED".equals(decisionValue)) { + decision = Decision.DECISION_DENIED; + } else { + violations.add( + new Violation( + "decision", + "must be one of [\"APPROVED\", \"DENIED\"], got " + decisionValue)); + } + } + } + } + if (!violations.isEmpty()) { + throw new ValidationException(violations); + } + return new RequestApprovalOutput(decision); + } + } +} diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/SpecNumbers.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/SpecNumbers.java new file mode 100644 index 00000000..ee2206a5 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/SpecNumbers.java @@ -0,0 +1,34 @@ +// Generated by nex-gen. DO NOT EDIT. +package io.temporal.samples.nexuswalkthrough.generatedservice; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** Shared spec-number parsing enforcing the JSON Schema integer semantics. */ +public final class SpecNumbers { + public static final long INTEGER_CAP = (1L << 53) - 1; + + private SpecNumbers() {} + + /** + * Parses a JSON number as a spec integer: rejects non-numbers, fractional values, and magnitudes + * beyond the +/-(2^53-1) cap. Adds a {@link Violation} and returns {@code null} on failure. + */ + public static @Nullable Long specLong(JsonNode node, String path, List violations) { + if (!node.isNumber()) { + violations.add(new Violation(path, "expected integer")); + return null; + } + double value = node.doubleValue(); + if (Double.isNaN(value) || Double.isInfinite(value) || value != Math.floor(value)) { + violations.add(new Violation(path, "not an integer")); + return null; + } + if (value < -(double) INTEGER_CAP || value > (double) INTEGER_CAP) { + violations.add(new Violation(path, "exceeds \u00b1(2^53-1) integer cap")); + return null; + } + return node.longValue(); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/SubmitDecisionInput.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/SubmitDecisionInput.java new file mode 100644 index 00000000..485adab4 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/SubmitDecisionInput.java @@ -0,0 +1,201 @@ +// Generated by nex-gen. DO NOT EDIT. +package io.temporal.samples.nexuswalkthrough.generatedservice; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +@JsonSerialize(using = SubmitDecisionInput.Serializer.class) +@JsonDeserialize(using = SubmitDecisionInput.Deserializer.class) +public final class SubmitDecisionInput { + public static final class Decision { + public static final Decision DECISION_APPROVED = new Decision("APPROVED"); + public static final Decision DECISION_DENIED = new Decision("DENIED"); + + private final String value; + + private Decision(String value) { + this.value = value; + } + + @JsonCreator + public static @Nullable Decision fromString(String value) { + if (value == null) { + return null; + } + if ("APPROVED".equals(value)) { + return DECISION_APPROVED; + } + if ("DENIED".equals(value)) { + return DECISION_DENIED; + } + throw new IllegalArgumentException( + "must be one of [\"APPROVED\", \"DENIED\"], got \"" + value + "\""); + } + + @JsonValue + public String getValue() { + return value; + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (!(other instanceof Decision)) { + return false; + } + Decision that = (Decision) other; + return Objects.equals(this.value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return "Decision[" + value + "]"; + } + } + + private final String itemId; + + /** The decision being submitted. */ + private final Decision decision; + + public SubmitDecisionInput(String itemId, Decision decision) { + this.itemId = itemId; + this.decision = decision; + } + + public String getItemId() { + return itemId; + } + + public Decision getDecision() { + return decision; + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (!(other instanceof SubmitDecisionInput)) { + return false; + } + SubmitDecisionInput that = (SubmitDecisionInput) other; + return Objects.equals(this.itemId, that.itemId) && Objects.equals(this.decision, that.decision); + } + + @Override + public int hashCode() { + return Objects.hash(itemId, decision); + } + + @Override + public String toString() { + return "SubmitDecisionInput{" + "itemId=" + itemId + ", decision=" + decision + "}"; + } + + public static final class Serializer + extends com.fasterxml.jackson.databind.JsonSerializer { + @Override + public void serialize( + SubmitDecisionInput value, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + gen.writeStartObject(); + if (value.itemId != null) { + gen.writeStringField("itemId", value.itemId); + } + if (value.decision != null) { + gen.writeStringField("decision", value.decision.getValue()); + } + gen.writeEndObject(); + } + } + + public static final class Deserializer + extends com.fasterxml.jackson.databind.JsonDeserializer { + @Override + public SubmitDecisionInput deserialize(JsonParser parser, DeserializationContext context) + throws IOException { + JsonNode node = parser.readValueAsTree(); + List violations = new ArrayList<>(); + if (node == null || !node.isObject()) { + violations.add(new Violation("", "expected object")); + throw new ValidationException(violations); + } + Iterator fieldNames = node.fieldNames(); + while (fieldNames.hasNext()) { + String key = fieldNames.next(); + switch (key) { + case "decision": + case "itemId": + break; + default: + violations.add(new Violation(key, "unknown field")); + } + } + String itemId = null; + { + JsonNode field = node.get("itemId"); + if (field == null) { + violations.add(new Violation("itemId", "required")); + } else if (field.isNull()) { + violations.add(new Violation("itemId", "explicit null not allowed")); + } else { + if (!field.isTextual()) { + violations.add(new Violation("itemId", "expected string")); + } else { + itemId = field.textValue(); + } + } + } + Decision decision = null; + { + JsonNode field = node.get("decision"); + if (field == null) { + violations.add(new Violation("decision", "required")); + } else if (field.isNull()) { + violations.add(new Violation("decision", "explicit null not allowed")); + } else { + if (!field.isTextual()) { + violations.add(new Violation("decision", "expected string")); + } else { + String decisionValue = field.textValue(); + if ("APPROVED".equals(decisionValue)) { + decision = Decision.DECISION_APPROVED; + } else if ("DENIED".equals(decisionValue)) { + decision = Decision.DECISION_DENIED; + } else { + violations.add( + new Violation( + "decision", + "must be one of [\"APPROVED\", \"DENIED\"], got " + decisionValue)); + } + } + } + } + if (!violations.isEmpty()) { + throw new ValidationException(violations); + } + return new SubmitDecisionInput(itemId, decision); + } + } +} diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/SubmitDecisionOutput.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/SubmitDecisionOutput.java new file mode 100644 index 00000000..9b362f5d --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/SubmitDecisionOutput.java @@ -0,0 +1,205 @@ +// Generated by nex-gen. DO NOT EDIT. +package io.temporal.samples.nexuswalkthrough.generatedservice; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +@JsonSerialize(using = SubmitDecisionOutput.Serializer.class) +@JsonDeserialize(using = SubmitDecisionOutput.Deserializer.class) +public final class SubmitDecisionOutput { + public static final class Recorded { + public static final Recorded RECORDED_APPROVED = new Recorded("APPROVED"); + public static final Recorded RECORDED_DENIED = new Recorded("DENIED"); + + private final String value; + + private Recorded(String value) { + this.value = value; + } + + @JsonCreator + public static @Nullable Recorded fromString(String value) { + if (value == null) { + return null; + } + if ("APPROVED".equals(value)) { + return RECORDED_APPROVED; + } + if ("DENIED".equals(value)) { + return RECORDED_DENIED; + } + throw new IllegalArgumentException( + "must be one of [\"APPROVED\", \"DENIED\"], got \"" + value + "\""); + } + + @JsonValue + public String getValue() { + return value; + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (!(other instanceof Recorded)) { + return false; + } + Recorded that = (Recorded) other; + return Objects.equals(this.value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return "Recorded[" + value + "]"; + } + } + + /** The decision that was recorded. */ + private final Recorded recorded; + + /** How many reminders were sent before the decision. */ + private final long remindersSent; + + public SubmitDecisionOutput(Recorded recorded, long remindersSent) { + this.recorded = recorded; + this.remindersSent = remindersSent; + } + + public Recorded getRecorded() { + return recorded; + } + + public long getRemindersSent() { + return remindersSent; + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (!(other instanceof SubmitDecisionOutput)) { + return false; + } + SubmitDecisionOutput that = (SubmitDecisionOutput) other; + return Objects.equals(this.recorded, that.recorded) && this.remindersSent == that.remindersSent; + } + + @Override + public int hashCode() { + return Objects.hash(recorded, remindersSent); + } + + @Override + public String toString() { + return "SubmitDecisionOutput{" + + "recorded=" + + recorded + + ", remindersSent=" + + remindersSent + + "}"; + } + + public static final class Serializer + extends com.fasterxml.jackson.databind.JsonSerializer { + @Override + public void serialize( + SubmitDecisionOutput value, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + gen.writeStartObject(); + if (value.recorded != null) { + gen.writeStringField("recorded", value.recorded.getValue()); + } + gen.writeNumberField("remindersSent", value.remindersSent); + + gen.writeEndObject(); + } + } + + public static final class Deserializer + extends com.fasterxml.jackson.databind.JsonDeserializer { + @Override + public SubmitDecisionOutput deserialize(JsonParser parser, DeserializationContext context) + throws IOException { + JsonNode node = parser.readValueAsTree(); + List violations = new ArrayList<>(); + if (node == null || !node.isObject()) { + violations.add(new Violation("", "expected object")); + throw new ValidationException(violations); + } + Iterator fieldNames = node.fieldNames(); + while (fieldNames.hasNext()) { + String key = fieldNames.next(); + switch (key) { + case "recorded": + case "remindersSent": + break; + default: + violations.add(new Violation(key, "unknown field")); + } + } + Recorded recorded = null; + { + JsonNode field = node.get("recorded"); + if (field == null) { + violations.add(new Violation("recorded", "required")); + } else if (field.isNull()) { + violations.add(new Violation("recorded", "explicit null not allowed")); + } else { + if (!field.isTextual()) { + violations.add(new Violation("recorded", "expected string")); + } else { + String recordedValue = field.textValue(); + if ("APPROVED".equals(recordedValue)) { + recorded = Recorded.RECORDED_APPROVED; + } else if ("DENIED".equals(recordedValue)) { + recorded = Recorded.RECORDED_DENIED; + } else { + violations.add( + new Violation( + "recorded", + "must be one of [\"APPROVED\", \"DENIED\"], got " + recordedValue)); + } + } + } + } + long remindersSent = 0L; + { + JsonNode field = node.get("remindersSent"); + if (field == null) { + violations.add(new Violation("remindersSent", "required")); + } else if (field.isNull()) { + violations.add(new Violation("remindersSent", "explicit null not allowed")); + } else { + Long numberValue = SpecNumbers.specLong(field, "remindersSent", violations); + if (numberValue != null) { + remindersSent = numberValue; + } + } + } + if (!violations.isEmpty()) { + throw new ValidationException(violations); + } + return new SubmitDecisionOutput(recorded, remindersSent); + } + } +} diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/ValidationException.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/ValidationException.java new file mode 100644 index 00000000..c8689e3b --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/ValidationException.java @@ -0,0 +1,33 @@ +// Generated by nex-gen. DO NOT EDIT. +package io.temporal.samples.nexuswalkthrough.generatedservice; + +import com.fasterxml.jackson.databind.JsonMappingException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Aggregates every {@link Violation} found while (de)serializing a value. */ +public final class ValidationException extends JsonMappingException { + private final List violations; + + public ValidationException(List violations) { + super((java.io.Closeable) null, buildMessage(violations)); + this.violations = Collections.unmodifiableList(new ArrayList<>(violations)); + } + + public List getViolations() { + return violations; + } + + private static String buildMessage(List violations) { + StringBuilder builder = new StringBuilder(); + builder.append(violations.size()).append(" validation error(s): "); + for (int index = 0; index < violations.size(); index++) { + if (index > 0) { + builder.append("; "); + } + builder.append(violations.get(index).toString()); + } + return builder.toString(); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/Violation.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/Violation.java new file mode 100644 index 00000000..99866ded --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/Violation.java @@ -0,0 +1,56 @@ +// Generated by nex-gen. DO NOT EDIT. +package io.temporal.samples.nexuswalkthrough.generatedservice; + +import org.jspecify.annotations.Nullable; + +/** A single constraint failure: a JSON member path and a human-readable reason. */ +public final class Violation { + private final String path; + private final String reason; + + public Violation(String path, String reason) { + this.path = path; + this.reason = reason; + } + + public String getPath() { + return path; + } + + public String getReason() { + return reason; + } + + public Violation withPathPrefix(String prefix) { + if (path == null || path.isEmpty()) { + return new Violation(prefix, reason); + } + return new Violation(prefix + "." + path, reason); + } + + @Override + public String toString() { + if (path == null || path.isEmpty()) { + return reason; + } + return path + ": " + reason; + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (!(other instanceof Violation)) { + return false; + } + Violation that = (Violation) other; + return java.util.Objects.equals(path, that.path) + && java.util.Objects.equals(reason, that.reason); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(path, reason); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/package-info.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/package-info.java new file mode 100644 index 00000000..71f0f510 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice/package-info.java @@ -0,0 +1,3 @@ +// Generated by nex-gen. DO NOT EDIT. +@org.jspecify.annotations.NullMarked +package io.temporal.samples.nexuswalkthrough.generatedservice; diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalActivities.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalActivities.java new file mode 100644 index 00000000..458bfc0a --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalActivities.java @@ -0,0 +1,39 @@ +package io.temporal.samples.nexuswalkthrough.handler; + +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.samples.nexuswalkthrough.generatedservice.NotifyRequesterInput; +import io.temporal.samples.nexuswalkthrough.generatedservice.NotifyRequesterOutput; + +/** + * Activities used by this sample. + * + *

Two of them are placeholders called from inside the approval Workflow (step 4). The third, + * {@code notifyRequester}, is the one backing a Nexus Operation directly as a Standalone Activity + * (step 9) - it is never called from a Workflow in this sample. + * + *

Nothing in any of them is Nexus-specific. The same Activity Function can be invoked from a + * Workflow and started behind an Operation with no code changes; what differs is what starts it, + * not how it is written. + */ +@ActivityInterface +// @@@SNIPSTART samples-java-nexus-walkthrough-activities +public interface ApprovalActivities { + + /** STEP 4 placeholder - real logic would apply policy, check limits, or call a risk service. */ + @ActivityMethod + void evaluateAutoDecision(String itemId, double amount); + + /** STEP 4 placeholder - real logic would page an approver or open a ticket. */ + @ActivityMethod + void notifyApproverOfPendingRequest(String itemId, String requester); + + /** + * STEP 9 - The Standalone Activity behind the notifyRequester Operation. One outbound + * notification, no state, nothing to wait for. In this sample it only logs; real logic would call + * an email provider, push to a notification service, or write to an outbox. + */ + @ActivityMethod + NotifyRequesterOutput notifyRequester(String requester, NotifyRequesterInput.Decision decision); +} +// @@@SNIPEND diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalActivitiesImpl.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalActivitiesImpl.java new file mode 100644 index 00000000..27167f5f --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalActivitiesImpl.java @@ -0,0 +1,29 @@ +package io.temporal.samples.nexuswalkthrough.handler; + +import io.temporal.samples.nexuswalkthrough.generatedservice.NotifyRequesterInput; +import io.temporal.samples.nexuswalkthrough.generatedservice.NotifyRequesterOutput; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Placeholder implementations. See {@link ApprovalActivities} for what each one stands in for. */ +public class ApprovalActivitiesImpl implements ApprovalActivities { + + private static final Logger logger = LoggerFactory.getLogger(ApprovalActivitiesImpl.class); + + @Override + public void evaluateAutoDecision(String itemId, double amount) { + logger.info("Evaluating auto-decision rules for {} at {}", itemId, amount); + } + + @Override + public void notifyApproverOfPendingRequest(String itemId, String requester) { + logger.info("Approver notified that {} from {} is waiting", itemId, requester); + } + + @Override + public NotifyRequesterOutput notifyRequester( + String requester, NotifyRequesterInput.Decision decision) { + logger.info("Notifying {} that their request was {}", requester, decision.getValue()); + return new NotifyRequesterOutput(requester); + } +} diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalServiceImpl.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalServiceImpl.java new file mode 100644 index 00000000..9cf5d0bb --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalServiceImpl.java @@ -0,0 +1,283 @@ +package io.temporal.samples.nexuswalkthrough.handler; + +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.api.enums.v1.WorkflowIdConflictPolicy; +import io.temporal.client.BatchRequest; +import io.temporal.client.StartActivityOptions; +import io.temporal.client.UpdateOptions; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowUpdateStage; +import io.temporal.common.RetryOptions; +import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.nexus.TemporalOperationResult; +import io.temporal.samples.nexuswalkthrough.generatedservice.ApprovalService; +import io.temporal.samples.nexuswalkthrough.generatedservice.AttachApprovalContextInput; +import io.temporal.samples.nexuswalkthrough.generatedservice.CheckApprovalRequiredInput; +import io.temporal.samples.nexuswalkthrough.generatedservice.CheckApprovalRequiredOutput; +import io.temporal.samples.nexuswalkthrough.generatedservice.NotifyRequesterInput; +import io.temporal.samples.nexuswalkthrough.generatedservice.NotifyRequesterOutput; +import io.temporal.samples.nexuswalkthrough.generatedservice.RemindApproverInput; +import io.temporal.samples.nexuswalkthrough.generatedservice.RequestApprovalInput; +import io.temporal.samples.nexuswalkthrough.generatedservice.RequestApprovalOutput; +import io.temporal.samples.nexuswalkthrough.generatedservice.SubmitDecisionInput; +import io.temporal.samples.nexuswalkthrough.generatedservice.SubmitDecisionOutput; +import java.time.Duration; + +/** + * The handler side of the approval Service. Every Operation in the contract is implemented here, + * and each one demonstrates a different Nexus capability from the walkthrough. + * + *

Read this file top to bottom alongside the walkthrough - the Operations appear in the order + * the walkthrough introduces them: + * + *

+ * + *

Every one of them uses {@link TemporalOperationHandler}, including the simplest. Starting with + * it means an Operation can later gain a message or change its backing without changing shape. + */ +@ServiceImpl(service = ApprovalService.class) +public class ApprovalServiceImpl { + + /** The spend threshold applied by checkApprovalRequired. Below this, no approval is needed. */ + private static final double APPROVAL_THRESHOLD = 500.00; + + // =============================================================================================== + // STEP 3 - An Operation with no backing Execution. + // + // The handler computes an answer and returns it. Nothing durable is created: no Workflow, no + // Activity, nothing to cancel, nothing in Event History. The Operation completes during the + // handler call and the caller gets the answer in the response. + // + // This fits work that cannot meaningfully fail and returns immediately. Compare it against + // notifyRequester at the bottom of this file: both are "one small thing", and they get opposite + // answers. Sending a notification can fail and you want that retried with a record of each + // attempt, so it needs an Activity. Comparing an amount to a threshold cannot fail in any way + // worth retrying, so an Activity Execution would be pure overhead. + // + // Note this still runs inside the Nexus handler call, so it is bounded by the handler deadline + // of under 10 seconds. That is plenty for a threshold comparison and would not be for anything + // that talks to a slow dependency. + // =============================================================================================== + // @@@SNIPSTART samples-java-nexus-walkthrough-check-approval-required + @OperationImpl + public OperationHandler + checkApprovalRequired() { + return TemporalOperationHandler.create( + (ctx, client, input) -> + TemporalOperationResult.sync( + new CheckApprovalRequiredOutput( + input.getAmount() >= APPROVAL_THRESHOLD, APPROVAL_THRESHOLD))); + } + + // @@@SNIPEND + + // =============================================================================================== + // STEP 4 - A Workflow-backed Operation, with the STEP 8 conflict policy applied. + // + // Calling startWorkflow on the injected client starts the approval Workflow and attaches this + // Operation's completion callback to it. The Operation then completes when the Workflow returns, + // delivering the Workflow's return value to the caller. + // + // The Workflow Id comes from the item id (see ApprovalWorkflowId), not from a random value, so + // that later messages can find this Execution. + // + // By default, starting a Workflow whose Id is already running FAILS the Operation. That default + // is deliberate: the Operation has only started successfully once its completion callback is + // attached to a Workflow, so failing loudly beats reporting success to a caller that would then + // wait forever. + // + // Here that default is replaced with USE_EXISTING, which step 8 explains. Once + // attachApprovalContext can create the approval first, this Operation needs to attach to the + // running Execution instead of failing. Two things follow: more than one caller can await the + // same approval, and the Operation becomes idempotent for genuinely separate callers rather than + // only for server retries of one request. + // =============================================================================================== + // @@@SNIPSTART samples-java-nexus-walkthrough-request-approval + @OperationImpl + public OperationHandler requestApproval() { + return TemporalOperationHandler.create( + (ctx, client, input) -> + client.startWorkflow( + ApprovalWorkflow.class, + ApprovalWorkflow::requestApproval, + input, + WorkflowOptions.newBuilder() + .setWorkflowId(ApprovalWorkflowId.forItem(input.getItemId())) + .setWorkflowIdConflictPolicy( + WorkflowIdConflictPolicy.WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING) + .build())); + } + + // @@@SNIPEND + + // =============================================================================================== + // STEP 7 - A Signal, delivered as sync messaging. + // + // Send the Signal through the client, then return a synchronous result. The Operation completes + // immediately, during the handler call - there is no completion callback and nothing to await, + // because a Signal is fire-and-forget. + // + // The whole handler call has to finish inside the handler deadline of under 10 seconds, and the + // budget is smaller than that because the clock starts on the caller's side and the request still + // routes through matching. One Signal is comfortably inside it. + // + // This targets a Workflow that already exists. The Temporal Service accepts a Signal only while + // the Workflow is still running, so a remindApprover for a purchase whose approval has already + // been decided fails with NOT_FOUND: workflow execution already completed, and one for a + // purchase that never had an approval fails as not found. + // =============================================================================================== + // @@@SNIPSTART samples-java-nexus-walkthrough-remind-approver + @OperationImpl + public OperationHandler remindApprover() { + return TemporalOperationHandler.create( + (ctx, client, input) -> { + client + .getWorkflowClient() + .newWorkflowStub( + ApprovalWorkflow.class, ApprovalWorkflowId.forItem(input.getItemId())) + .remindApprover(); + return TemporalOperationResult.sync(null); + }); + } + + // @@@SNIPEND + + // =============================================================================================== + // STEP 7 - An Update-backed Operation. + // + // The caller needs a result back - confirmation that the decision was recorded - which is what + // makes this an Update rather than a Signal. + // + // This is an async backing: the Operation completes when the Update completes, and its result is + // delivered through the Nexus completion callback. If the Update happens to come back already + // complete, the result returns synchronously instead. + // + // Two requirements follow. It targets a Workflow that already exists, so a submitDecision for a + // purchase with no approval running fails. And because it is an async backing, there is at most + // one per Operation invocation, though a handler could still combine it with sync side effects. + // =============================================================================================== + // @@@SNIPSTART samples-java-nexus-walkthrough-submit-decision + @OperationImpl + public OperationHandler submitDecision() { + return TemporalOperationHandler.create( + (ctx, client, input) -> + client.startWorkflowUpdate( + ApprovalWorkflow.class, + ApprovalWorkflowId.forItem(input.getItemId()), + ApprovalWorkflow::submitDecision, + input.getDecision(), + UpdateOptions.newBuilder() + .setResultClass(SubmitDecisionOutput.class) + // The Update to invoke has to be named explicitly; the method reference above + // supplies the argument types but not the wire name. + .setUpdateName(ApprovalWorkflow.SUBMIT_DECISION_UPDATE) + // An Update-backed Operation must wait for the ACCEPTED stage. The Operation + // completes later, when the Update completes, through the completion callback. + // Any other stage is rejected with "nexus op workflow updates only support + // WorkflowUpdateStageAccepted for async updates". + .setWaitForStage(WorkflowUpdateStage.ACCEPTED) + .build())); + } + + // @@@SNIPEND + + // =============================================================================================== + // STEP 8 - Signal-with-Start. + // + // Supporting information is produced by a different system than the one requesting approval, and + // the two messages can arrive in either order. This Operation is written so that either order + // works, which means it may have to start the approval itself. + // + // That is why its input repeats the purchase details rather than just naming an approval: an + // Operation that can create the thing it messages has to carry enough to create it. + // + // Both this and requestApproval derive the same Workflow Id from the same item id, which is what + // lets them agree on which Execution they mean regardless of which one arrives first. + // + // Like remindApprover this is sync messaging, so it completes during the handler call. + // =============================================================================================== + // @@@SNIPSTART samples-java-nexus-walkthrough-attach-context + @OperationImpl + public OperationHandler attachApprovalContext() { + return TemporalOperationHandler.create( + (ctx, client, input) -> { + WorkflowClient workflowClient = client.getWorkflowClient(); + ApprovalWorkflow stub = + workflowClient.newWorkflowStub( + ApprovalWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowId(ApprovalWorkflowId.forItem(input.getItemId())) + .setTaskQueue(HandlerWorker.DEFAULT_TASK_QUEUE_NAME) + .build()); + + // signalWithStart delivers the Signal, starting the Workflow first if it is not already + // running. When the approval already exists, only the Signal is delivered. + BatchRequest request = workflowClient.newSignalWithStartRequest(); + request.add(stub::attachContext, input.getNote()); + request.add( + stub::requestApproval, + new RequestApprovalInput(input.getItemId(), input.getRequester(), input.getAmount())); + workflowClient.signalWithStart(request); + + return TemporalOperationResult.sync(null); + }); + } + + // @@@SNIPEND + + // =============================================================================================== + // STEP 9 - An Activity-backed Operation, using a Standalone Activity. + // + // Use TemporalOperationHandler as with every other Operation, but start an Activity instead of a + // Workflow. The Operation starts an Activity Execution with no parent Workflow and completes when + // the Activity returns. + // + // This is the right shape whenever an Operation is one durable step behind a team boundary. The + // Activity supplies the durability - retries on the policy you set, timeouts you control, and a + // record of every attempt - and the Operation supplies the contract, so the notification is + // reachable by other teams without them sharing your code or your Namespace. + // + // Before Activity-backed Operations this would have needed a Workflow whose only job was to call + // one Activity: a wrapper with its own Event History and Workflow Id, providing nothing. + // + // An Activity-backed Operation requires an Activity Id, unique within the Namespace, because + // there is no parent Workflow to scope it. The Task Queue set below does not have to be the + // Endpoint's target Task Queue - notifications could run on their own Worker fleet - but this + // sample keeps them on one Worker for simplicity. + // + // Note the contrast with checkApprovalRequired at the top of this file. Both are one small thing. + // This one touches the outside world and can fail, so it needs an Activity rather than nothing. + // =============================================================================================== + // @@@SNIPSTART samples-java-nexus-walkthrough-notify-requester + @OperationImpl + public OperationHandler notifyRequester() { + return TemporalOperationHandler.create( + (ctx, client, input) -> + client.startActivity( + ApprovalActivities.class, + ApprovalActivities::notifyRequester, + input.getRequester(), + input.getDecision(), + StartActivityOptions.newBuilder() + // Deriving the Activity Id from the request Id keeps a retried Nexus start + // request targeting the same Activity Execution instead of sending a second + // notification. + .setId("notify-" + ctx.getRequestId()) + .setTaskQueue(HandlerWorker.DEFAULT_TASK_QUEUE_NAME) + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .setScheduleToCloseTimeout(Duration.ofMinutes(5)) + .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(3).build()) + .build())); + } + // @@@SNIPEND +} diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalWorkflow.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalWorkflow.java new file mode 100644 index 00000000..c0909356 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalWorkflow.java @@ -0,0 +1,79 @@ +package io.temporal.samples.nexuswalkthrough.handler; + +import io.temporal.samples.nexuswalkthrough.generatedservice.RequestApprovalInput; +import io.temporal.samples.nexuswalkthrough.generatedservice.RequestApprovalOutput; +import io.temporal.samples.nexuswalkthrough.generatedservice.SubmitDecisionInput; +import io.temporal.samples.nexuswalkthrough.generatedservice.SubmitDecisionOutput; +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.UpdateMethod; +import io.temporal.workflow.UpdateValidatorMethod; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +/** + * WALKTHROUGH STEP 4 - The approval Workflow, plus STEP 7 - the message handlers. + * + *

This is an ordinary Temporal Workflow. Nothing in it is Nexus-specific, and it could be + * started directly by a Client instead of through a Nexus Operation. + * + *

What makes it interactive is the message handlers below and a Workflow Id you can predict (see + * {@link ApprovalWorkflowId}), not a special kind of Workflow. + * + *

The types it takes and returns are the generated ones from step 2. Nothing here is + * hand-written, which is what keeps the Workflow and the contract from drifting apart. + */ +@WorkflowInterface +// @@@SNIPSTART samples-java-nexus-walkthrough-approval-workflow +public interface ApprovalWorkflow { + + /** + * The Update handler's name on the wire. The Update-backed Operation has to name the Update + * explicitly when it starts one, so the name is declared once here and reused there rather than + * being spelled as a literal in two places. + */ + String SUBMIT_DECISION_UPDATE = "submitDecision"; + + /** + * STEP 4 - The Workflow method. Its return value is the result of the requestApproval Operation: + * the Operation completes when this Workflow returns, and the caller receives this value through + * the Nexus completion callback. + * + *

Because the Workflow's return value is delivered straight to the caller as the Operation + * result, it has to be the Operation's declared output type. + */ + @WorkflowMethod + RequestApprovalOutput requestApproval(RequestApprovalInput input); + + /** + * STEP 7 - A Signal. Fire-and-forget: the caller gets no result back, which is why a Signal is + * the right message type for a nudge, and why the contract declares no output for it. + */ + @SignalMethod + void remindApprover(); + + /** + * STEP 8 - A Signal that also carries supporting information. Reached through Signal-with-Start, + * so it may be the message that creates this Workflow. + */ + @SignalMethod + void attachContext(String note); + + /** + * STEP 7 - An Update. The caller needs a result back - confirmation that the decision was + * recorded - which is what makes this an Update rather than a Signal. + */ + @UpdateMethod(name = ApprovalWorkflow.SUBMIT_DECISION_UPDATE) + SubmitDecisionOutput submitDecision(SubmitDecisionInput.Decision decision); + + /** + * STEP 7 - The Update's validator. An Update can reject a request before it changes anything, + * which a Signal cannot: a Signal has already been accepted by the time the handler runs. + * + *

Here it rejects a second decision for an approval that has already been decided. Without it + * the later decision would silently overwrite the earlier one. A rejected Update does not appear + * in Event History and does not run the handler. + */ + @UpdateValidatorMethod(updateName = ApprovalWorkflow.SUBMIT_DECISION_UPDATE) + void validateSubmitDecision(SubmitDecisionInput.Decision decision); +} +// @@@SNIPEND diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalWorkflowId.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalWorkflowId.java new file mode 100644 index 00000000..c7600aa8 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalWorkflowId.java @@ -0,0 +1,27 @@ +package io.temporal.samples.nexuswalkthrough.handler; + +/** + * WALKTHROUGH STEP 3 - Give the approval Workflow a stable Id. + * + *

The approval needs a Workflow Id derived from the purchase, not a random one, so that later + * messages can find it. Deriving it from the item id means a caller that knows the item id can + * reach the right Execution without the handler handing out Workflow Ids. + * + *

This also makes the start idempotent: a retried Nexus start request targets the same Workflow + * Id rather than starting a second approval for one purchase. + * + *

It matters again in step 8, where attachApprovalContext may start the approval before + * requestApproval is ever called. Both Operations derive the same Workflow Id from the same item + * id, which is what lets them agree on which Execution they mean. That is why this lives in one + * place instead of being spelled out at each call site. + */ +public final class ApprovalWorkflowId { + + private ApprovalWorkflowId() {} + + // @@@SNIPSTART samples-java-nexus-walkthrough-workflow-id + public static String forItem(String itemId) { + return "approval-" + itemId; + } + // @@@SNIPEND +} diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalWorkflowImpl.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalWorkflowImpl.java new file mode 100644 index 00000000..bc8bd072 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalWorkflowImpl.java @@ -0,0 +1,100 @@ +package io.temporal.samples.nexuswalkthrough.handler; + +import io.temporal.activity.ActivityOptions; +import io.temporal.samples.nexuswalkthrough.generatedservice.RequestApprovalInput; +import io.temporal.samples.nexuswalkthrough.generatedservice.RequestApprovalOutput; +import io.temporal.samples.nexuswalkthrough.generatedservice.SubmitDecisionInput; +import io.temporal.samples.nexuswalkthrough.generatedservice.SubmitDecisionOutput; +import io.temporal.workflow.Workflow; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.slf4j.Logger; + +/** + * WALKTHROUGH STEP 4 - Write the approval Workflow (and STEP 7 - the message handlers). + * + *

The Workflow runs a placeholder Activity that would evaluate whether the request can be + * auto-decided, runs a placeholder Activity that tells a human the request is waiting, blocks until + * a decision arrives, and returns it. + * + *

The blocking step is the reason this is a Workflow rather than an Activity. It may wait weeks, + * across Worker restarts and deployments, and the wait costs nothing while it is idle. + */ +// @@@SNIPSTART samples-java-nexus-walkthrough-approval-workflow-impl +public class ApprovalWorkflowImpl implements ApprovalWorkflow { + + private static final Logger logger = Workflow.getLogger(ApprovalWorkflowImpl.class); + + private final ApprovalActivities activities = + Workflow.newActivityStub( + ApprovalActivities.class, + ActivityOptions.newBuilder().setStartToCloseTimeout(Duration.ofSeconds(10)).build()); + + // Durable intermediate state. This is the other reason the approval is a Workflow: an Activity + // could not hold any of it. + private SubmitDecisionInput.Decision decision; + private int remindersSent; + private final List notes = new ArrayList<>(); + + @Override + public RequestApprovalOutput requestApproval(RequestApprovalInput input) { + // Placeholder. Real logic would apply policy, check limits, or call a risk service. + activities.evaluateAutoDecision(input.getItemId(), input.getAmount()); + + // Placeholder. Real logic would page an approver, open a ticket, or send email. + activities.notifyApproverOfPendingRequest(input.getItemId(), input.getRequester()); + + // Block until submitDecision supplies a decision. This wait is durable and unbounded - the + // Worker can restart and redeploy while it is pending. + Workflow.await(() -> decision != null); + + logger.info( + "Approval for {} decided {} after {} reminder(s) and {} note(s)", + input.getItemId(), + decision.getValue(), + remindersSent, + notes.size()); + + // This return value becomes the result of the requestApproval Nexus Operation, delivered to + // every caller whose completion callback is attached to this Execution. + return new RequestApprovalOutput(Decisions.toRequestApprovalOutput(decision)); + } + + // STEP 7 - Signal handler. Records the nudge and returns nothing. + @Override + public void remindApprover() { + remindersSent++; + logger.info("Approver reminded, {} reminder(s) so far", remindersSent); + } + + // STEP 8 - Signal handler reached through Signal-with-Start. When the note arrives before anyone + // has called requestApproval, the Signal-with-Start creates this Workflow and this handler runs + // on the fresh Execution. + @Override + public void attachContext(String note) { + notes.add(note); + logger.info("Context attached: {}", note); + } + + // STEP 7 - The Update's validator. Runs before the handler and can reject the request without + // changing anything or writing to Event History. Throwing here rejects the Update; the Workflow + // is untouched and the caller's Operation fails. + @Override + public void validateSubmitDecision(SubmitDecisionInput.Decision decision) { + if (this.decision != null) { + throw new IllegalStateException( + "approval already decided " + this.decision.getValue() + ", cannot decide again"); + } + } + + // STEP 7 - Update handler. Records the decision, which satisfies the condition the Workflow + // method is blocked on, and returns confirmation to the caller. The validator above guarantees + // this runs at most once. + @Override + public SubmitDecisionOutput submitDecision(SubmitDecisionInput.Decision decision) { + this.decision = decision; + return new SubmitDecisionOutput(Decisions.toSubmitDecisionOutput(decision), remindersSent); + } +} +// @@@SNIPEND diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/Decisions.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/Decisions.java new file mode 100644 index 00000000..b07b40c5 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/Decisions.java @@ -0,0 +1,52 @@ +package io.temporal.samples.nexuswalkthrough.handler; + +import io.temporal.samples.nexuswalkthrough.generatedservice.NotifyRequesterInput; +import io.temporal.samples.nexuswalkthrough.generatedservice.RequestApprovalOutput; +import io.temporal.samples.nexuswalkthrough.generatedservice.SubmitDecisionInput; +import io.temporal.samples.nexuswalkthrough.generatedservice.SubmitDecisionOutput; + +/** + * Converts a decision between the generated types that carry one. + * + *

The contract declares the same {@code APPROVED | DENIED} value set on four different + * Operations. The generator emits a distinct nested value class for each - {@code + * RequestApprovalOutput.Decision}, {@code SubmitDecisionInput.Decision}, {@code + * SubmitDecisionOutput.Recorded}, {@code NotifyRequesterInput.Decision} - because an enum declared + * inline on a property is scoped to the model that contains it. + * + *

A single shared enum type would be preferable, and the contract cannot express one today: a + * named enum under {@code $defs} is rejected by the generator. Until that is supported, the values + * are carried across type boundaries by their wire string, which is identical in all four. + * + *

Keeping the conversions here rather than at each call site means the Operation implementations + * read as though the shared type existed. + */ +final class Decisions { + + private Decisions() {} + + static RequestApprovalOutput.Decision toRequestApprovalOutput(SubmitDecisionInput.Decision d) { + return require(RequestApprovalOutput.Decision.fromString(d.getValue()), d.getValue()); + } + + static SubmitDecisionOutput.Recorded toSubmitDecisionOutput(SubmitDecisionInput.Decision d) { + return require(SubmitDecisionOutput.Recorded.fromString(d.getValue()), d.getValue()); + } + + static NotifyRequesterInput.Decision toNotifyRequesterInput(RequestApprovalOutput.Decision d) { + return require(NotifyRequesterInput.Decision.fromString(d.getValue()), d.getValue()); + } + + /** + * The generated {@code fromString} returns null for a value the target type does not know. The + * four value sets are identical today, so this cannot happen - but if the contract ever adds a + * value to one Operation and not another, failing here names the problem instead of quietly + * producing an output with a null decision. + */ + private static T require(T converted, String value) { + if (converted == null) { + throw new IllegalStateException("no matching decision constant for wire value " + value); + } + return converted; + } +} diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/HandlerWorker.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/HandlerWorker.java new file mode 100644 index 00000000..19d84365 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/HandlerWorker.java @@ -0,0 +1,37 @@ +package io.temporal.samples.nexuswalkthrough.handler; + +import io.temporal.client.WorkflowClient; +import io.temporal.samples.nexuswalkthrough.options.ClientOptions; +import io.temporal.worker.Worker; +import io.temporal.worker.WorkerFactory; + +/** + * WALKTHROUGH STEP 4 - Run the Worker. + * + *

One Worker hosts the Nexus Service implementation, the Workflow implementation, and the + * Activity implementations. Its Task Queue has to match the Task Queue the Nexus Endpoint targets, + * which is created in step 5. + * + *

A Worker registering a Nexus Service does not have to be the same Worker that runs the backing + * Workflow. Splitting them is a normal choice for larger deployments; this sample keeps one Worker + * so the moving parts stay visible. + */ +public class HandlerWorker { + + public static final String DEFAULT_TASK_QUEUE_NAME = "approval-handler-task-queue"; + + // @@@SNIPSTART samples-java-nexus-walkthrough-handler-worker + public static void main(String[] args) { + WorkflowClient client = ClientOptions.getWorkflowClient(args); + + WorkerFactory factory = WorkerFactory.newInstance(client); + Worker worker = factory.newWorker(DEFAULT_TASK_QUEUE_NAME); + + worker.registerWorkflowImplementationTypes(ApprovalWorkflowImpl.class); + worker.registerActivitiesImplementations(new ApprovalActivitiesImpl()); + worker.registerNexusServiceImplementation(new ApprovalServiceImpl()); + + factory.start(); + } + // @@@SNIPEND +} diff --git a/core/src/main/java/io/temporal/samples/nexuswalkthrough/options/ClientOptions.java b/core/src/main/java/io/temporal/samples/nexuswalkthrough/options/ClientOptions.java new file mode 100644 index 00000000..2e05b391 --- /dev/null +++ b/core/src/main/java/io/temporal/samples/nexuswalkthrough/options/ClientOptions.java @@ -0,0 +1,131 @@ +package io.temporal.samples.nexuswalkthrough.options; + +import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; +import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder; +import io.grpc.netty.shaded.io.netty.handler.ssl.util.InsecureTrustManagerFactory; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.serviceclient.WorkflowServiceStubs; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import javax.net.ssl.SSLException; +import org.apache.commons.cli.*; + +public class ClientOptions { + + public static WorkflowClient getWorkflowClient(String[] args) { + return getWorkflowClient(args, WorkflowClientOptions.newBuilder()); + } + + public static WorkflowClient getWorkflowClient( + String[] args, WorkflowClientOptions.Builder clientOptions) { + Options options = new Options(); + Option targetHostOption = new Option("target-host", true, "Host:port for the Temporal service"); + targetHostOption.setRequired(false); + options.addOption(targetHostOption); + + Option namespaceOption = new Option("namespace", true, "Namespace to connect to"); + namespaceOption.setRequired(false); + options.addOption(namespaceOption); + + Option serverRootCaOption = + new Option("server-root-ca-cert", true, "Optional path to root server CA cert"); + serverRootCaOption.setRequired(false); + options.addOption(serverRootCaOption); + + Option clientCertOption = + new Option( + "client-cert", true, "Optional path to client cert, mutually exclusive with API key"); + clientCertOption.setRequired(false); + options.addOption(clientCertOption); + + Option clientKeyOption = + new Option( + "client-key", true, "Optional path to client key, mutually exclusive with API key"); + clientKeyOption.setRequired(false); + options.addOption(clientKeyOption); + + Option apiKeyOption = + new Option("api-key", true, "Optional API key, mutually exclusive with cert/key"); + apiKeyOption.setRequired(false); + options.addOption(apiKeyOption); + + Option serverNameOption = + new Option( + "server-name", true, "Server name to use for verifying the server's certificate"); + serverNameOption.setRequired(false); + options.addOption(serverNameOption); + + Option insercureSkipVerifyOption = + new Option( + "insecure-skip-verify", + false, + "Skip verification of the server's certificate and host name"); + insercureSkipVerifyOption.setRequired(false); + options.addOption(insercureSkipVerifyOption); + + CommandLineParser parser = new DefaultParser(); + HelpFormatter formatter = new HelpFormatter(); + CommandLine cmd = null; + + try { + cmd = parser.parse(options, args); + } catch (ParseException e) { + System.out.println(e.getMessage()); + formatter.printHelp("utility-name", options); + + System.exit(1); + } + + String targetHost = cmd.getOptionValue("target-host", "localhost:7233"); + String namespace = cmd.getOptionValue("namespace", "default"); + String serverRootCaCert = cmd.getOptionValue("server-root-ca-cert", ""); + String clientCert = cmd.getOptionValue("client-cert", ""); + String clientKey = cmd.getOptionValue("client-key", ""); + String serverName = cmd.getOptionValue("server-name", ""); + boolean insecureSkipVerify = cmd.hasOption("insecure-skip-verify"); + String apiKey = cmd.getOptionValue("api-key", ""); + + // API key and client cert/key are mutually exclusive + if (!apiKey.isEmpty() && (!clientCert.isEmpty() || !clientKey.isEmpty())) { + throw new IllegalArgumentException("API key and client cert/key are mutually exclusive"); + } + WorkflowServiceStubsOptions.Builder serviceStubOptionsBuilder = + WorkflowServiceStubsOptions.newBuilder().setTarget(targetHost); + // Configure TLS if client cert and key are provided + if (!clientCert.isEmpty() || !clientKey.isEmpty()) { + if (clientCert.isEmpty() || clientKey.isEmpty()) { + throw new IllegalArgumentException("Both client-cert and client-key must be provided"); + } + try { + SslContextBuilder sslContext = + SslContextBuilder.forClient() + .keyManager(new FileInputStream(clientCert), new FileInputStream(clientKey)); + if (serverRootCaCert != null && !serverRootCaCert.isEmpty()) { + sslContext.trustManager(new FileInputStream(serverRootCaCert)); + } + if (insecureSkipVerify) { + sslContext.trustManager(InsecureTrustManagerFactory.INSTANCE); + } + serviceStubOptionsBuilder.setSslContext(GrpcSslContexts.configure(sslContext).build()); + } catch (SSLException e) { + throw new RuntimeException(e); + } catch (FileNotFoundException e) { + throw new RuntimeException(e); + } + if (serverName != null && !serverName.isEmpty()) { + serviceStubOptionsBuilder.setChannelInitializer(c -> c.overrideAuthority(serverName)); + } + } + // Configure API key if provided + if (!apiKey.isEmpty()) { + serviceStubOptionsBuilder.setEnableHttps(true); + serviceStubOptionsBuilder.addApiKey(() -> apiKey); + } + + WorkflowServiceStubs service = + WorkflowServiceStubs.newServiceStubs(serviceStubOptionsBuilder.build()); + return WorkflowClient.newInstance(service, clientOptions.setNamespace(namespace).build()); + } +} diff --git a/core/src/test/java/io/temporal/samples/nexuswalkthrough/ApprovalWorkflowTest.java b/core/src/test/java/io/temporal/samples/nexuswalkthrough/ApprovalWorkflowTest.java new file mode 100644 index 00000000..02983b53 --- /dev/null +++ b/core/src/test/java/io/temporal/samples/nexuswalkthrough/ApprovalWorkflowTest.java @@ -0,0 +1,122 @@ +package io.temporal.samples.nexuswalkthrough; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowStub; +import io.temporal.client.WorkflowUpdateException; +import io.temporal.samples.nexuswalkthrough.generatedservice.RequestApprovalInput; +import io.temporal.samples.nexuswalkthrough.generatedservice.RequestApprovalOutput; +import io.temporal.samples.nexuswalkthrough.generatedservice.SubmitDecisionInput; +import io.temporal.samples.nexuswalkthrough.generatedservice.SubmitDecisionOutput; +import io.temporal.samples.nexuswalkthrough.handler.ApprovalActivitiesImpl; +import io.temporal.samples.nexuswalkthrough.handler.ApprovalWorkflow; +import io.temporal.samples.nexuswalkthrough.handler.ApprovalWorkflowId; +import io.temporal.samples.nexuswalkthrough.handler.ApprovalWorkflowImpl; +import io.temporal.testing.TestWorkflowRule; +import org.junit.Rule; +import org.junit.Test; + +/** + * Tests the approval Workflow that backs the requestApproval Nexus Operation. + * + *

The Workflow is an ordinary Temporal Workflow with nothing Nexus-specific in it, which is what + * makes it testable on its own. These tests cover the behavior the walkthrough relies on: the + * Workflow blocks until a decision arrives, counts reminders, and refuses a second decision. + */ +public class ApprovalWorkflowTest { + + @Rule + public TestWorkflowRule testWorkflowRule = + TestWorkflowRule.newBuilder() + .setWorkflowTypes(ApprovalWorkflowImpl.class) + .setActivityImplementations(new ApprovalActivitiesImpl()) + .build(); + + private ApprovalWorkflow newApproval(String itemId) { + return testWorkflowRule + .getWorkflowClient() + .newWorkflowStub( + ApprovalWorkflow.class, + WorkflowOptions.newBuilder() + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setWorkflowId(ApprovalWorkflowId.forItem(itemId)) + .build()); + } + + /** The decision submitted through the Update becomes the Workflow's return value. */ + @Test + public void decisionSubmittedThroughUpdateBecomesTheResult() { + ApprovalWorkflow approval = newApproval("standing-desk"); + WorkflowClient.start( + approval::requestApproval, + new RequestApprovalInput("standing-desk", "dana@example.com", 1250.00)); + + SubmitDecisionOutput ack = + approval.submitDecision(SubmitDecisionInput.Decision.DECISION_APPROVED); + assertEquals("APPROVED", ack.getRecorded().getValue()); + assertEquals(0, ack.getRemindersSent()); + + RequestApprovalOutput result = + WorkflowStub.fromTyped(approval).getResult(RequestApprovalOutput.class); + assertEquals("APPROVED", result.getDecision().getValue()); + } + + /** Reminders are counted while the approval is pending and reported back with the decision. */ + @Test + public void remindersAreCountedAndReportedWithTheDecision() { + ApprovalWorkflow approval = newApproval("monitor-arm"); + WorkflowClient.start( + approval::requestApproval, + new RequestApprovalInput("monitor-arm", "dana@example.com", 900.00)); + + approval.remindApprover(); + approval.remindApprover(); + + SubmitDecisionOutput ack = + approval.submitDecision(SubmitDecisionInput.Decision.DECISION_DENIED); + assertEquals("DENIED", ack.getRecorded().getValue()); + assertEquals(2, ack.getRemindersSent()); + } + + /** + * Context attached before the approval exists is what Signal-with-Start delivers in step 8. Here + * it is sent directly, to check the handler accepts it alongside the rest of the flow. + */ + @Test + public void contextCanBeAttachedWhileTheApprovalIsPending() { + ApprovalWorkflow approval = newApproval("laptop-dock"); + WorkflowClient.start( + approval::requestApproval, + new RequestApprovalInput("laptop-dock", "dana@example.com", 750.00)); + + approval.attachContext("Approved in the Q3 ergonomics budget"); + SubmitDecisionOutput ack = + approval.submitDecision(SubmitDecisionInput.Decision.DECISION_APPROVED); + + assertEquals("APPROVED", ack.getRecorded().getValue()); + } + + /** + * The Update validator rejects a second decision rather than letting it overwrite the first. A + * rejected Update never runs the handler and never reaches Event History. + */ + @Test + public void aSecondDecisionIsRejected() { + ApprovalWorkflow approval = newApproval("desk-lamp"); + WorkflowClient.start( + approval::requestApproval, + new RequestApprovalInput("desk-lamp", "dana@example.com", 600.00)); + + approval.submitDecision(SubmitDecisionInput.Decision.DECISION_APPROVED); + + WorkflowUpdateException failure = + assertThrows( + WorkflowUpdateException.class, + () -> approval.submitDecision(SubmitDecisionInput.Decision.DECISION_DENIED)); + assertTrue(failure.getCause().getMessage().contains("already decided")); + } +}