Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Expand Down
220 changes: 220 additions & 0 deletions core/src/main/java/io/temporal/samples/nexuswalkthrough/README.md
Original file line number Diff line number Diff line change
@@ -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 <caller-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.
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading