diff --git a/docs/encyclopedia/nexus/nexus-client-code-generator.mdx b/docs/encyclopedia/nexus/nexus-client-code-generator.mdx new file mode 100644 index 0000000000..b3784fa3ff --- /dev/null +++ b/docs/encyclopedia/nexus/nexus-client-code-generator.mdx @@ -0,0 +1,400 @@ +--- +id: nexus-client-code-generator +title: Nexus Client Code Generator +sidebar_label: Nexus Client Code Generator +description: The Nexus Client Code Generator turns one schema into typed models, runtime validators, and Nexus Service definitions for Go, Java, Python, and TypeScript. +toc_max_heading_level: 4 +slug: /nexus/client-code-generator +keywords: + - nexus client code generator + - nexus code generation + - nexus service definition + - service contract + - json schema + - schema validation + - generated models +tags: + - Nexus + - Concepts +--- + +A [Nexus Service](/nexus/services) is a contract meant to be shared across team boundaries. +Those teams often work in different languages, so the same request and response types get hand-written once per SDK. +Hand-written copies drift: a field is required on one side and optional on the other, a bound is enforced by the caller but not the handler. + +The **Nexus Client Code Generator** removes those copies. +You describe your types and [Nexus Operations](/nexus/operations) once in a definition file, and the generator emits the equivalent library code for Go, Java, Python, and TypeScript. +The generator is a command-line tool named `nexgen`, distributed from the [temporalio/nex-gen](https://github.com/temporalio/nex-gen) repository. + +:::caution + +`nexgen` is pre-release software, currently at version 0.2.1. +The supported schema subset, command-line options, and emitted code may change incompatibly before a stable release. +It is not yet published to any package registry, so you build it from source as described in [Install the generator](#install-the-generator). + +::: + +## What the generator produces + +For every type in your definition file, the generator emits three things per language. + +- **A typed model.** An idiomatic struct, class, interface, or dataclass, with doc comments carried over from the schema. +- **A shared runtime validator.** One validator per type, used when a value is parsed off the wire and again when it is serialized onto the wire, so a payload cannot enter or leave your service in a shape the contract forbids. +- **A [Nexus Service Contract](/glossary#nexus-service-contract) definition.** The generated Service and Operation declarations you register on a Worker and call from a caller Workflow. + +Constraint failures do not surface one at a time. +They aggregate into a single native error listing every violation, each naming the offending field and the bound it broke. +A handler maps that error to a `BAD_REQUEST` [Nexus error](/nexus/error-handling), so a malformed request tells the caller everything that was wrong with it in one response. + +The supported schema subset is deliberately strict. +Anything ambiguous, or anything that cannot be expressed identically in all four languages, is rejected when you run the generator, with a diagnostic explaining how to express it instead. +The generator prefers to fail loudly at generation time over emitting code that behaves differently in one language than another. + +## Supported languages + +`nexgen` generates Go, Java, Python, and TypeScript. + +## Definition files + +Types are modeled with [JSON Schema 2020-12](https://json-schema.org/draft/2020-12). +A definition file comes in two flavors. + +**Pure JSON Schema.** The root of the document is itself a type, and reusable types live under `$defs`. +Use this when you only need data models shared across languages, with no Service or Operation declarations. + +**Nexus document.** Add a root `nexusrpc: "1.0.0"` marker to enable a `services` section. +The root becomes an envelope: Services and their Operations sit at the top level, and your types live under `$defs`. + +The examples on this page use `samples/schemas/chat.nexusrpc.yaml` from the repository, abbreviated here: + +```yaml +nexusrpc: '1.0.0' +$schema: https://json-schema.org/draft/2020-12/schema +services: + ChatService: + fqn: example.chat.v1.ChatService + description: Send messages and look up rooms. + operations: + sendMessage: + description: Post a message to a room. + input: { $ref: '#/$defs/SendMessageInput' } + output: { $ref: '#/$defs/SendMessageOutput' } + getRoom: + description: Look up a room by id. + input: + type: object + additionalProperties: false + properties: + roomId: { type: string } + required: [roomId] + output: { $ref: '#/$defs/Room' } + ping: + description: Liveness probe. +$defs: + SendMessageInput: + type: object + additionalProperties: false + properties: + roomId: { type: string } + message: { $ref: '#/$defs/Message' } + required: [roomId, message] + SendMessageOutput: + type: object + additionalProperties: false + properties: + messageId: { type: string } + required: [messageId] +``` + +`fqn` is the wire name of the Service, the name callers reference when executing an Operation. + +An Operation's `input` and `output` are each optional. +The `ping` Operation above declares neither, which generates an Operation that takes and returns nothing. +When present, each must be an object type, so that a field can be added later without breaking the wire format. + +The repository holds four example definitions under [`samples/schemas/`](https://github.com/temporalio/nex-gen/tree/main/samples/schemas): +[`chat.nexusrpc.yaml`](https://github.com/temporalio/nex-gen/blob/main/samples/schemas/chat.nexusrpc.yaml), +the feature-diverse [`showcase.nexusrpc.yaml`](https://github.com/temporalio/nex-gen/blob/main/samples/schemas/showcase.nexusrpc.yaml), +the pure-schema [`temporal.yaml`](https://github.com/temporalio/nex-gen/blob/main/samples/schemas/temporal.yaml), +and a multi-file closure under [`kb/`](https://github.com/temporalio/nex-gen/tree/main/samples/schemas/kb) showing how types split across files resolve through `$ref`. +The `kb/` closure starts at [`kb.nexusrpc.yaml`](https://github.com/temporalio/nex-gen/blob/main/samples/schemas/kb/kb.nexusrpc.yaml) and pulls in types from its [`content/`](https://github.com/temporalio/nex-gen/tree/main/samples/schemas/kb/content) and [`tree/`](https://github.com/temporalio/nex-gen/tree/main/samples/schemas/kb/tree) subdirectories. + +## Install the generator + +Build the `nexgen` binary from source with a Rust toolchain: + +```bash +git clone https://github.com/temporalio/nex-gen.git +cd nex-gen +cargo build --release +``` + +The binary lands at `target/release/nexgen`. +Confirm it works and check which targets your build supports: + +```bash +./target/release/nexgen --version +./target/release/nexgen --help +``` + +## Generate code + +Every language uses the same shape: `nexgen ... --output `. +Inputs are positional and may be files or directories, so you can pass a whole multi-file schema closure. Some languages have extra flags. + +:::note + +The output directory name becomes the generated package or module name. +Name it after your domain, such as `chat`, not after the language. +Pointing `--output` at a directory named `go` produces `package go`, which is not valid Go. + +::: + +### Go + +```bash +nexgen go samples/schemas/chat.nexusrpc.yaml --output ./chat +``` + +Place the output directory inside your Go module. +The package name is the directory name, so the example above generates `package chat` in `./chat/chat.go` alongside `./chat/definitions.go`. + +### Java + +Java requires `--package-name`, and its last dot-separated segment must match the `--output` directory name: + +```bash +nexgen java samples/schemas/chat.nexusrpc.yaml \ + --output ./src/main/java/com/example/chat \ + --package-name com.example.chat +``` + +If the two disagree, generation stops and tells you how to reconcile them: + +``` +`--package-name com.example.wrong` must end with the output directory name `chat`, +but its last segment is `wrong`; point `--output` at a directory named `wrong` or +change the package's last segment to `chat` +``` + +### Python + +```bash +nexgen python samples/schemas/chat.nexusrpc.yaml --output ./chat +``` + +This writes an importable package: `models.py`, `services.py`, and an `__init__.py` that re-exports both. +Generated models are [Pydantic](https://docs.pydantic.dev/) models, so your Worker and Client must use the Pydantic Data Converter described in [Use Pydantic models](/develop/python/data-handling/data-conversion#use-pydantic-models). + +### TypeScript + +```bash +nexgen ts samples/schemas/chat.nexusrpc.yaml --output ./chat +``` + +TypeScript accepts `--date-time-types` to choose how temporal `format` fields are represented in memory: + +```bash +nexgen ts samples/schemas/temporal.yaml --output ./chat --date-time-types temporal +``` + +- `string` (the default) keeps every temporal field as the RFC 3339 string that appears on the wire. + It has no runtime dependency and round-trips losslessly, but you parse and compare the strings yourself. +- `date` maps `date-time` fields to a JavaScript `Date`. + This is lossy: a `Date` is a UTC instant, so the original offset is folded away and precision is capped at milliseconds. +- `temporal` maps to the TC39 Temporal API, preserving offset and sub-second precision, and requires the `Temporal` global or a polyfill. + +## Use the generated code + +The generated Service definition is a normal Nexus Service definition. +You register it on a Worker and call it from a caller Workflow exactly as described in your SDK's Nexus guide. +What differs per language is how the validator gets invoked. + +| SDK | How validation reaches the wire | Extra step | +| ---------- | ----------------------------------------------------------- | ---------- | +| Go | Generated `MarshalJSON` and `UnmarshalJSON` on each model | None | +| Java | Generated Jackson serializer and deserializer on each model | None | +| Python | Pydantic model validation | [Use the Pydantic data converter](/develop/python/data-handling/data-conversion#use-pydantic-models) | +| TypeScript | Generated mapper classes | Call the mapper yourself | + +In Go, Java, and Python the validator sits in the serialization hook the Temporal data converter already calls, so validation happens on its own once the models are in use. +TypeScript requires an explicit call, covered in [Validate payloads in TypeScript](#validate-payloads-in-typescript). + +### Go + +The generated `ChatService` value carries the Service name and one typed Operation reference per Operation. +Register handlers on a Worker: + +```go +service := nexus.NewService(chat.ChatService.ServiceName) + +sendMessage := nexus.NewSyncOperation(chat.ChatService.SendMessage.Name(), + func(ctx context.Context, input chat.SendMessageInput, _ nexus.StartOperationOptions) (chat.SendMessageOutput, error) { + return chat.SendMessageOutput{MessageId: store(input)}, nil + }) + +if err := service.Register(sendMessage); err != nil { + return err +} +w.RegisterNexusService(service) +``` + +Call it from a caller Workflow, passing the generated Operation reference so the SDK type-checks the request and response: + +```go +client := workflow.NewNexusClient("chat-endpoint", chat.ChatService.ServiceName) + +var output chat.SendMessageOutput +err := client.ExecuteOperation( + ctx, + chat.ChatService.SendMessage, + chat.SendMessageInput{RoomId: "r1", Message: chat.Message{Kind: "text", Body: "hi"}}, + workflow.NexusOperationOptions{}, +).Get(ctx, &output) +``` + +### Java + +The generator emits `ChatService` as an interface annotated with `@Service`, with one `@Operation` method per Operation. +On the handler side, write a separate implementation class that points at the generated interface with `@ServiceImpl`, and return an `OperationHandler` from each `@OperationImpl` method: + +```java +@ServiceImpl(service = ChatService.class) +public final class ChatServiceImpl { + @OperationImpl + public OperationHandler sendMessage() { + return OperationHandler.sync((ctx, details, input) -> new SendMessageOutput(store(input))); + } +} +``` + +Register it on a Worker with `worker.registerNexusServiceImplementation(new ChatServiceImpl())`. + +On the caller side, the same interface works directly as a Workflow stub: + +```java +ChatService chat = Workflow.newNexusServiceStub( + ChatService.class, + NexusServiceOptions.newBuilder() + .setEndpoint("chat-endpoint") + .setOperationOptions(NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(10)) + .build()) + .build()); + +SendMessageOutput output = chat.sendMessage(new SendMessageInput("r1", message)); +``` + +### Python + +The generator emits `ChatService` as a `@service`-decorated class whose attributes are typed `Operation` declarations. +Bind a handler to it: + +```python +@service_handler(service=ChatService) +class ChatServiceHandler: + @sync_operation + async def send_message( + self, ctx: StartOperationContext, input: SendMessageInput + ) -> SendMessageOutput: + return SendMessageOutput(messageId=store(input)) +``` + +Pass the handler to your Worker as `nexus_service_handlers=[ChatServiceHandler()]`, then call it from a caller Workflow: + +```python +client = workflow.create_nexus_client(service=ChatService, endpoint="chat-endpoint") + +output = await client.execute_operation( + ChatService.send_message, + SendMessageInput(roomId="r1", message=Message(kind="text", body="hi")), +) +``` + +Generated Python fields are snake_case with the wire name as an alias. +Construct models with either name, and read them with the snake_case attribute: `SendMessageInput(roomId="r1", ...)` constructs, and `output.message_id` reads. + +### TypeScript + +The generator emits a `chatService` Service definition plus, for each type, an interface and a companion `Mapper` class: + +```typescript +export const chatService = nexus.service('example.chat.v1.ChatService', { + sendMessage: nexus.operation({ name: 'SendMessage' }), + getRoom: nexus.operation({ name: 'GetRoom' }), + ping: nexus.operation({ name: 'Ping' }), +}); +``` + +Register a handler against that definition with `nexus.serviceHandler(chatService, { ... })`, and create a caller with `workflow.createNexusServiceClient({ service: chatService, endpoint: 'chat-endpoint' })`. + +#### Validate payloads in TypeScript + +:::caution + +In TypeScript the generated validator only runs when you call the mapper. +No generated payload converter exists, so nothing calls it for you. + +::: + +Each generated type comes with a mapper exposing two methods. +`fromIntermediate` validates an untrusted plain value and returns the typed model. +`toIntermediate` validates a model and returns its plain wire form. +Call them at both edges of every Operation, on the handler side and the caller side: + +```typescript +const handler = nexus.serviceHandler(chatService, { + async sendMessage(_ctx, input) { + const request = new SendMessageInputMapper().fromIntermediate(input); + const output = { messageId: await store(request) }; + return new SendMessageOutputMapper().toIntermediate(output) as SendMessageOutput; + }, +}); +``` + +The cast on the return value is expected: `toIntermediate` returns `unknown`, because its result is a plain wire value rather than the model type the Operation declares. + +Skipping the mapper is the failure to watch for, because nothing reports it. +The value handed to your handler is typed as the model, since `nexus.operation` declares it that way, but at runtime it is only whatever was deserialized. +A handler that ignores the mapper compiles, type-checks, and returns correct results for valid payloads, while enforcing none of the constraints in your schema. + +When a payload does violate the contract, `fromIntermediate` throws a `ValidationError` carrying every violation at once: + +``` +ValidationError: 2 validation error(s): roomId: required; message.body: expected string +``` + +The error also exposes a `violations` array of `{ path, reason }` objects, so a handler can convert it into a `BAD_REQUEST` Nexus error with the full list intact. + +## Schema defaults + +A `default` in your schema is applied when reading, and is never written back to the wire. +The field stays optional in the generated model, and each language exposes the default differently. + +- **Go** and **Java** generate an accessor: `PriorityOrDefault()` and `getPriorityOrDefault()`. +- **Python** applies the default through Pydantic, so reading the attribute returns it. +- **TypeScript** exports a module-level constant, such as `DEFAULT_PRIORITY`, that you apply yourself with `value.priority ?? DEFAULT_PRIORITY`. + +## Supported schema features + +The generator implements a curated subset of JSON Schema 2020-12 chosen so that every accepted construct lowers identically into all four languages. + +Fully supported: `properties`, `required`, `default`, `minProperties` and `maxProperties`, `dependentRequired`, string and numeric bounds, `items`, `minItems` and `maxItems`, `minContains` and `maxContains`, `allOf`, the recognized nullable pattern `oneOf: [{type: T}, {type: "null"}]`, and the `title`, `description`, and `deprecated` annotations. + +Partially supported: `type` (single-string form only), `additionalProperties`, `propertyNames`, `const` and `enum` (scalars only), `format`, `pattern` (a portable RE2-safe subset), `multipleOf`, `contentEncoding`, `uniqueItems`, `contains`, `oneOf` (branches must be separable by a decidable selector), and `$ref` with `$defs` (local files only). + +Deliberately rejected, because they have no coherent typed lowering across all four languages: `anyOf`, `not`, `if`/`then`/`else`, `dependentSchemas`, `prefixItems`, `unevaluatedProperties`, `unevaluatedItems`, `contentMediaType`, and `contentSchema`. + +For the current per-keyword support table, see the [nex-gen README](https://github.com/temporalio/nex-gen#supported-json-schema-features). + +:::tip RESOURCES + +- [temporalio/nex-gen](https://github.com/temporalio/nex-gen) for the generator, its README, and the example schemas. +- [Nexus Services](/nexus/services) for the Service contract concept. +- Nexus feature guides for registering Services and calling Operations: + [Go](/develop/go/nexus/feature-guide) | + [Java](/develop/java/nexus/feature-guide) | + [Python](/develop/python/nexus/feature-guide) | + [TypeScript](/develop/typescript/nexus/feature-guide) + +::: diff --git a/sidebars.js b/sidebars.js index 7f751809b1..0a6e729ebe 100644 --- a/sidebars.js +++ b/sidebars.js @@ -1948,6 +1948,7 @@ module.exports = { }, items: [ 'encyclopedia/nexus/nexus-services', + 'encyclopedia/nexus/nexus-client-code-generator', 'encyclopedia/nexus/nexus-operations', 'encyclopedia/nexus/standalone-nexus-operation', 'encyclopedia/nexus/nexus-endpoints',