diff --git a/CHANGELOG.md b/CHANGELOG.md index 95887875..f429b916 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,10 +18,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Go proto-backed operation models now use SDK transfer-type converters instead + of eager conversion at the operation call site. Conversion errors surface on + the returned future. Generated converters use + `converter.NewContextualTransferTypeConverter`. The Go samples pin the + squash-merged experimental SDK API from temporalio/sdk-go#2703. + ### Deprecated ### Breaking Changes +- Go models with generated transfer-type converters require workflow context and + cannot be used as top-level workflow return values or activity arguments. +- Go generation rejects `@nexus.omit` fields used as resource constructor + arguments. - .NET generic type parameters now use the C# `T` prefix. For example, the authored `output-t` alias generates `TOutput` instead of `OutputT`. diff --git a/GUIDE.md b/GUIDE.md index d5d988df..e643e050 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -619,6 +619,20 @@ code that converts between the public model and its protobuf transfer type. Generated callers continue to accept and return the public model; conversion is performed by the target SDK or generated operation helpers. +For Go, generated proto-backed operation inputs and outputs implement +`converter.ValueWithTransferTypeConverter`. The SDK converts these models during +payload conversion using `converter.NewContextualTransferTypeConverter`; the generated +method returns both the converter and its construction error. Models and transfer +types use non-pointer type arguments, with a value receiver on the model. + +Go conversion requires a `workflow.Context` to access the workflow's data +converter. Using these models outside a workflow, including as a workflow's +top-level return value or an activity argument, fails with `can only be converted +inside a workflow`. Override-converter types and resource-return or +output-transform results continue to use generated operation helpers instead of +model transfer-type converters. Nexus conversion errors surface on the returned +future. An `@nexus.omit` field cannot supply a Go resource constructor argument. + ### Transfer-Type Conversion ```wit diff --git a/advanced/samples/go/go.mod b/advanced/samples/go/go.mod index 188936fe..27cbe994 100644 --- a/advanced/samples/go/go.mod +++ b/advanced/samples/go/go.mod @@ -6,7 +6,7 @@ require ( github.com/nexus-rpc/sdk-go v0.7.0 github.com/stretchr/testify v1.10.0 go.temporal.io/api v1.63.5 - go.temporal.io/sdk v1.48.1-0.20260828193328-cfdc74a4e624 + go.temporal.io/sdk v1.49.1-0.20261006234944-b7c855cd3699 google.golang.org/protobuf v1.36.11 ) diff --git a/advanced/samples/go/go.sum b/advanced/samples/go/go.sum index 202b9855..e32ee5a8 100644 --- a/advanced/samples/go/go.sum +++ b/advanced/samples/go/go.sum @@ -59,8 +59,8 @@ go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/ go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.temporal.io/api v1.63.5 h1:c11+kPYHkXXL3UiShPdbMD+xtvqGsbTibUA9ypmiCa4= go.temporal.io/api v1.63.5/go.mod h1:SrlW2JMwVlDP4nRWSNznUFqnSHd+YeMDS1BkYo63HCQ= -go.temporal.io/sdk v1.48.1-0.20260828193328-cfdc74a4e624 h1:OOTiDG3gJhzPhqn1YebEGqU5ic1rH/Am5uvOmEk1jL4= -go.temporal.io/sdk v1.48.1-0.20260828193328-cfdc74a4e624/go.mod h1:tr/G47tBsjORurCbixYoMDy2Lqiks6lIKS5+kI9jd40= +go.temporal.io/sdk v1.49.1-0.20261006234944-b7c855cd3699 h1:H0lzIOKvMyITmdudf0Aon4wcMGbDBPQw3YyVsZABU2I= +go.temporal.io/sdk v1.49.1-0.20261006234944-b7c855cd3699/go.mod h1:xP0FulN5JJSfisESUP60LlWsrKz2tLSStGjVdk8r5cc= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= diff --git a/advanced/samples/go/startworkflow/startworkflowservice.go b/advanced/samples/go/startworkflow/startworkflowservice.go index d661f65f..af3f6da8 100644 --- a/advanced/samples/go/startworkflow/startworkflowservice.go +++ b/advanced/samples/go/startworkflow/startworkflowservice.go @@ -3,10 +3,13 @@ package startworkflow import ( + "context" + "errors" "time" common "go.temporal.io/api/common/v1" workflowservice "go.temporal.io/api/workflowservice/v1" + "go.temporal.io/sdk/converter" "go.temporal.io/sdk/workflow" ) @@ -17,9 +20,10 @@ type startWorkflowRequest struct { WorkflowID string TaskQueue string WorkflowStartDelay *time.Duration + namespace string } -func (m startWorkflowRequest) toProto(ctx workflow.Context) (*workflowservice.StartWorkflowExecutionRequest, error) { +func startWorkflowRequestToProto(ctx workflow.Context, m *startWorkflowRequest) (*workflowservice.StartWorkflowExecutionRequest, error) { message := &workflowservice.StartWorkflowExecutionRequest{} { converted, err := workflowTypeToProto(ctx, &m.Workflow) @@ -43,50 +47,65 @@ func (m startWorkflowRequest) toProto(ctx workflow.Context) (*workflowservice.St } message.WorkflowStartDelay = converted } - message.Namespace = workflow.GetInfo(ctx).Namespace + message.Namespace = m.namespace return message, nil } -func startWorkflowRequestFromProto(ctx workflow.Context, proto *workflowservice.StartWorkflowExecutionRequest) (startWorkflowRequest, error) { - value := startWorkflowRequest{} +func startWorkflowRequestFromProto(ctx workflow.Context, proto *workflowservice.StartWorkflowExecutionRequest, out *startWorkflowRequest) error { + *out = startWorkflowRequest{} { converted, err := workflowTypeFromProto(ctx, proto.GetWorkflowType()) if err != nil { - return value, err + return err } if converted != nil { - value.Workflow = *converted + out.Workflow = *converted } } - value.WorkflowID = proto.GetWorkflowId() + out.WorkflowID = proto.GetWorkflowId() { converted, err := taskQueueFromProto(ctx, proto.GetTaskQueue()) if err != nil { - return value, err + return err } if converted != nil { - value.TaskQueue = *converted + out.TaskQueue = *converted } } { converted, err := durationFromProto(ctx, proto.GetWorkflowStartDelay()) if err != nil { - return value, err + return err } - value.WorkflowStartDelay = converted + out.WorkflowStartDelay = converted } - return value, nil + out.namespace = proto.GetNamespace() + return nil +} + +func (startWorkflowRequest) TransferTypeConverter() (converter.TransferTypeConverter, error) { + return converter.NewContextualTransferTypeConverter( + func(context.Context, *startWorkflowRequest) (*workflowservice.StartWorkflowExecutionRequest, error) { + return nil, errors.New("nexgen: transfer type converter outside a workflow") + }, + func(context.Context, *workflowservice.StartWorkflowExecutionRequest, *startWorkflowRequest) error { + return errors.New("nexgen: transfer type converter outside a workflow") + }, + startWorkflowRequestToProto, + startWorkflowRequestFromProto, + ) } type cancelWorkflowRequest struct { WorkflowExecution WorkflowExecution Reason *string + namespace string } -func (m cancelWorkflowRequest) toProto(ctx workflow.Context) (*workflowservice.RequestCancelWorkflowExecutionRequest, error) { +func cancelWorkflowRequestToProto(ctx workflow.Context, m *cancelWorkflowRequest) (*workflowservice.RequestCancelWorkflowExecutionRequest, error) { message := &workflowservice.RequestCancelWorkflowExecutionRequest{} { - converted, err := m.WorkflowExecution.toProto(ctx) + converted, err := workflowExecutionToProto(ctx, &m.WorkflowExecution) if err != nil { return nil, err } @@ -95,31 +114,41 @@ func (m cancelWorkflowRequest) toProto(ctx workflow.Context) (*workflowservice.R if m.Reason != nil { message.Reason = (*m.Reason) } - message.Namespace = workflow.GetInfo(ctx).Namespace + message.Namespace = m.namespace return message, nil } -func cancelWorkflowRequestFromProto(ctx workflow.Context, proto *workflowservice.RequestCancelWorkflowExecutionRequest) (cancelWorkflowRequest, error) { - value := cancelWorkflowRequest{} - { - converted, err := workflowExecutionFromProto(ctx, proto.GetWorkflowExecution()) - if err != nil { - return value, err - } - value.WorkflowExecution = converted +func cancelWorkflowRequestFromProto(ctx workflow.Context, proto *workflowservice.RequestCancelWorkflowExecutionRequest, out *cancelWorkflowRequest) error { + *out = cancelWorkflowRequest{} + if err := workflowExecutionFromProto(ctx, proto.GetWorkflowExecution(), &out.WorkflowExecution); err != nil { + return err } { converted := proto.GetReason() - value.Reason = &converted + out.Reason = &converted } - return value, nil + out.namespace = proto.GetNamespace() + return nil +} + +func (cancelWorkflowRequest) TransferTypeConverter() (converter.TransferTypeConverter, error) { + return converter.NewContextualTransferTypeConverter( + func(context.Context, *cancelWorkflowRequest) (*workflowservice.RequestCancelWorkflowExecutionRequest, error) { + return nil, errors.New("nexgen: transfer type converter outside a workflow") + }, + func(context.Context, *workflowservice.RequestCancelWorkflowExecutionRequest, *cancelWorkflowRequest) error { + return errors.New("nexgen: transfer type converter outside a workflow") + }, + cancelWorkflowRequestToProto, + cancelWorkflowRequestFromProto, + ) } type startWorkflowResult struct { RunID *string } -func (m startWorkflowResult) toProto(ctx workflow.Context) (*workflowservice.StartWorkflowExecutionResponse, error) { +func startWorkflowResultToProto(ctx workflow.Context, m *startWorkflowResult) (*workflowservice.StartWorkflowExecutionResponse, error) { message := &workflowservice.StartWorkflowExecutionResponse{} if m.RunID != nil { message.RunId = (*m.RunID) @@ -127,13 +156,13 @@ func (m startWorkflowResult) toProto(ctx workflow.Context) (*workflowservice.Sta return message, nil } -func startWorkflowResultFromProto(ctx workflow.Context, proto *workflowservice.StartWorkflowExecutionResponse) (startWorkflowResult, error) { - value := startWorkflowResult{} +func startWorkflowResultFromProto(ctx workflow.Context, proto *workflowservice.StartWorkflowExecutionResponse, out *startWorkflowResult) error { + *out = startWorkflowResult{} { converted := proto.GetRunId() - value.RunID = &converted + out.RunID = &converted } - return value, nil + return nil } // --- Resources --- @@ -178,14 +207,9 @@ func (u *StartedWorkflow) GetResult(ctx workflow.Context) workflow.Future { // --- Operations (internal) --- func startWorkflow(ctx workflow.Context, request startWorkflowRequest) workflow.Future { - requestProto, err := request.toProto(ctx) - if err != nil { - result, resultSettable := workflow.NewFuture(ctx) - resultSettable.SetError(err) - return result - } + request.namespace = workflow.GetInfo(ctx).Namespace c := workflow.NewNexusClient("temporal-system", "StartWorkflowService") - fut := c.ExecuteOperation(ctx, "StartWorkflow", requestProto, workflow.NexusOperationOptions{}) + fut := c.ExecuteOperation(ctx, "StartWorkflow", request, workflow.NexusOperationOptions{}) result, resultSettable := workflow.NewFuture(ctx) workflow.Go(ctx, func(ctx workflow.Context) { var result workflowservice.StartWorkflowExecutionResponse @@ -193,21 +217,16 @@ func startWorkflow(ctx workflow.Context, request startWorkflowRequest) workflow. resultSettable.SetError(err) return } - value := NewStartedWorkflow(requestProto.GetNamespace(), request.WorkflowID, result.GetRunId()) + value := NewStartedWorkflow(request.namespace, request.WorkflowID, result.GetRunId()) resultSettable.Set(value, nil) }) return result } func restartWorkflow(ctx workflow.Context, request startWorkflowRequest) workflow.Future { - requestProto, err := request.toProto(ctx) - if err != nil { - result, resultSettable := workflow.NewFuture(ctx) - resultSettable.SetError(err) - return result - } + request.namespace = workflow.GetInfo(ctx).Namespace c := workflow.NewNexusClient("temporal-system", "StartWorkflowService") - fut := c.ExecuteOperation(ctx, "RestartWorkflow", requestProto, workflow.NexusOperationOptions{}) + fut := c.ExecuteOperation(ctx, "RestartWorkflow", request, workflow.NexusOperationOptions{}) result, resultSettable := workflow.NewFuture(ctx) workflow.Go(ctx, func(ctx workflow.Context) { var result workflowservice.StartWorkflowExecutionResponse @@ -215,30 +234,20 @@ func restartWorkflow(ctx workflow.Context, request startWorkflowRequest) workflo resultSettable.SetError(err) return } - value := NewStartedWorkflow(requestProto.GetNamespace(), request.WorkflowID, result.GetRunId()) + value := NewStartedWorkflow(request.namespace, request.WorkflowID, result.GetRunId()) resultSettable.Set(value, nil) }) return result } func cancelWorkflow(ctx workflow.Context, request cancelWorkflowRequest) workflow.Future { - requestProto, err := request.toProto(ctx) - if err != nil { - result, resultSettable := workflow.NewFuture(ctx) - resultSettable.SetError(err) - return result - } + request.namespace = workflow.GetInfo(ctx).Namespace c := workflow.NewNexusClient("temporal-system", "StartWorkflowService") - fut := c.ExecuteOperation(ctx, "CancelWorkflow", requestProto, workflow.NexusOperationOptions{}) + fut := c.ExecuteOperation(ctx, "CancelWorkflow", request, workflow.NexusOperationOptions{}) result, resultSettable := workflow.NewFuture(ctx) workflow.Go(ctx, func(ctx workflow.Context) { - var result workflowservice.RequestCancelWorkflowExecutionResponse - if err := fut.Get(ctx, &result); err != nil { - resultSettable.SetError(err) - return - } - value, err := cancelWorkflowResponseFromProto(ctx, &result) - if err != nil { + var value CancelWorkflowResponse + if err := fut.Get(ctx, &value); err != nil { resultSettable.SetError(err) return } @@ -256,7 +265,7 @@ type WorkflowExecution struct { RunID *string } -func (m WorkflowExecution) toProto(ctx workflow.Context) (*common.WorkflowExecution, error) { +func workflowExecutionToProto(ctx workflow.Context, m *WorkflowExecution) (*common.WorkflowExecution, error) { message := &common.WorkflowExecution{} message.WorkflowId = m.WorkflowID if m.RunID != nil { @@ -265,27 +274,40 @@ func (m WorkflowExecution) toProto(ctx workflow.Context) (*common.WorkflowExecut return message, nil } -func workflowExecutionFromProto(ctx workflow.Context, proto *common.WorkflowExecution) (WorkflowExecution, error) { - value := WorkflowExecution{} - value.WorkflowID = proto.GetWorkflowId() +func workflowExecutionFromProto(ctx workflow.Context, proto *common.WorkflowExecution, out *WorkflowExecution) error { + *out = WorkflowExecution{} + out.WorkflowID = proto.GetWorkflowId() { converted := proto.GetRunId() - value.RunID = &converted + out.RunID = &converted } - return value, nil + return nil } type CancelWorkflowResponse struct { } -func (m CancelWorkflowResponse) toProto(ctx workflow.Context) (*workflowservice.RequestCancelWorkflowExecutionResponse, error) { +func cancelWorkflowResponseToProto(ctx workflow.Context, m *CancelWorkflowResponse) (*workflowservice.RequestCancelWorkflowExecutionResponse, error) { message := &workflowservice.RequestCancelWorkflowExecutionResponse{} return message, nil } -func cancelWorkflowResponseFromProto(ctx workflow.Context, proto *workflowservice.RequestCancelWorkflowExecutionResponse) (CancelWorkflowResponse, error) { - value := CancelWorkflowResponse{} - return value, nil +func cancelWorkflowResponseFromProto(ctx workflow.Context, proto *workflowservice.RequestCancelWorkflowExecutionResponse, out *CancelWorkflowResponse) error { + *out = CancelWorkflowResponse{} + return nil +} + +func (CancelWorkflowResponse) TransferTypeConverter() (converter.TransferTypeConverter, error) { + return converter.NewContextualTransferTypeConverter( + func(context.Context, *CancelWorkflowResponse) (*workflowservice.RequestCancelWorkflowExecutionResponse, error) { + return nil, errors.New("nexgen: transfer type converter outside a workflow") + }, + func(context.Context, *workflowservice.RequestCancelWorkflowExecutionResponse, *CancelWorkflowResponse) error { + return errors.New("nexgen: transfer type converter outside a workflow") + }, + cancelWorkflowResponseToProto, + cancelWorkflowResponseFromProto, + ) } type StartWorkflowOptions struct { diff --git a/advanced/samples/go/tests/typeroundtrip_test.go b/advanced/samples/go/tests/typeroundtrip_test.go index bc35d726..62f18862 100644 --- a/advanced/samples/go/tests/typeroundtrip_test.go +++ b/advanced/samples/go/tests/typeroundtrip_test.go @@ -186,15 +186,19 @@ func (s *TypeRoundtripIntegrationSuite) TestFailureOperationPreservesAbsence() { func (s *TypeRoundtripIntegrationSuite) TestActivityOptionsOperationRequiredOnly() { policy := temporal.RetryPolicy{MaximumAttempts: 5} - s.env.ExecuteWorkflow(func(ctx workflow.Context) (*tr.ActivityOptions, error) { - var result tr.ActivityOptions - return &result, tr.ActivityOptionsOperation(ctx, tr.ActivityOptionsOperationOptions{RetryPolicy: policy}).Get(ctx, &result) + // The model is returned inside a wrapper: a proto-backed model carries a + // transfer-type converter that only runs inside a workflow, so it cannot be + // a workflow's top-level return value. + s.env.ExecuteWorkflow(func(ctx workflow.Context) (*typeRoundtripResults, error) { + var results typeRoundtripResults + return &results, tr.ActivityOptionsOperation(ctx, tr.ActivityOptionsOperationOptions{RetryPolicy: policy}).Get(ctx, &results.ActivityOption) }) s.True(s.env.IsWorkflowCompleted()) s.NoError(s.env.GetWorkflowError()) - var result tr.ActivityOptions - s.NoError(s.env.GetWorkflowResult(&result)) + var results typeRoundtripResults + s.NoError(s.env.GetWorkflowResult(&results)) + result := results.ActivityOption s.Equal(int32(5), result.RetryPolicy.MaximumAttempts) // Optional fields that were never supplied remain absent after the round // trip, which is distinct from present fields containing their zero value. diff --git a/advanced/samples/go/tests/workflowservice_test.go b/advanced/samples/go/tests/workflowservice_test.go index a53bc2a6..3e3e5a04 100644 --- a/advanced/samples/go/tests/workflowservice_test.go +++ b/advanced/samples/go/tests/workflowservice_test.go @@ -36,6 +36,49 @@ func (c emptyPayloadsDataConverter) ToPayloads(values ...interface{}) (*common.P return c.DataConverter.ToPayloads(values...) } +// recordedEncode captures one value handed to the data converter along with the +// serialization context that was active at the time. +type recordedEncode struct { + Context converter.SerializationContext + Value any +} + +// recordingDataConverter records every value it is asked to encode. It also +// implements [converter.DataConverterWithSerializationContext] so that the +// recorded entries show which serialization context the SDK applied. +type recordingDataConverter struct { + converter.DataConverter + serializationContext converter.SerializationContext + recorded *[]recordedEncode +} + +func newRecordingDataConverter() recordingDataConverter { + return recordingDataConverter{ + DataConverter: converter.GetDefaultDataConverter(), + recorded: &[]recordedEncode{}, + } +} + +func (c recordingDataConverter) WithSerializationContext(ctx converter.SerializationContext) converter.DataConverter { + return recordingDataConverter{ + DataConverter: converter.WithDataConverterSerializationContext(c.DataConverter, ctx), + serializationContext: ctx, + recorded: c.recorded, + } +} + +func (c recordingDataConverter) ToPayload(value any) (*common.Payload, error) { + *c.recorded = append(*c.recorded, recordedEncode{c.serializationContext, value}) + return c.DataConverter.ToPayload(value) +} + +func (c recordingDataConverter) ToPayloads(values ...any) (*common.Payloads, error) { + for _, value := range values { + *c.recorded = append(*c.recorded, recordedEncode{c.serializationContext, value}) + } + return c.DataConverter.ToPayloads(values...) +} + type WorkflowServiceIntegrationSuite struct { suite.Suite testsuite.WorkflowTestSuite @@ -180,12 +223,12 @@ func (s *WorkflowServiceIntegrationSuite) TestCanceledContextDoesNotScheduleOper s.Empty(s.calls) } -func (s *WorkflowServiceIntegrationSuite) TestConversionFailureReturnsReadyFuture() { +func (s *WorkflowServiceIntegrationSuite) TestConversionFailureSurfacesOnTheFuture() { + // Model->proto conversion now runs inside the SDK's payload converter, so a + // conversion failure resolves the operation future with an error instead of + // failing synchronously before the operation is scheduled. s.env.ExecuteWorkflow(func(ctx workflow.Context) error { fut := ws.SignalWithStartWorkflow(ctx, ws.SignalWithStartWorkflowOptions{ID: "workflow-id", Memo: map[string]any{"invalid": func() {}}}, "wake-up", "signal-value", signalWithStartWorkflow, "workflow-input") - if !fut.IsReady() { - return errors.New("conversion failure future is not ready") - } if err := fut.Get(ctx, nil); err == nil { return errors.New("conversion failure future returned no error") } @@ -195,3 +238,83 @@ func (s *WorkflowServiceIntegrationSuite) TestConversionFailureReturnsReadyFutur s.NoError(s.env.GetWorkflowError()) s.Empty(s.calls) } + +// TestModelIsConvertedInsideThePayloadConverter is the acceptance test for the +// transfer-type port. The generated code no longer builds the proto itself; +// the SDK's transfer-type machinery does it during payload conversion. The +// observable consequence is that the model's inner user payloads (Args, +// SignalArgs) are encoded by the data converter -- and under the serialization +// context -- that the SDK selected for the Nexus operation. +func (s *WorkflowServiceIntegrationSuite) TestModelIsConvertedInsideThePayloadConverter() { + recorder := newRecordingDataConverter() + s.env.SetDataConverter(recorder) + + s.env.ExecuteWorkflow(func(ctx workflow.Context) error { + return ws.SignalWithStartWorkflow( + ctx, + ws.SignalWithStartWorkflowOptions{ID: "target-workflow-id", TaskQueue: "my-task-queue"}, + "wake-up", + "signal-value", + "ExampleWorkflow", + "workflow-input", + ).Get(ctx, nil) + }) + + s.True(s.env.IsWorkflowCompleted()) + s.NoError(s.env.GetWorkflowError()) + s.Require().Len(s.calls, 1) + + recorded := *recorder.recorded + indexOf := func(match func(recordedEncode) bool) int { + for i, entry := range recorded { + if match(entry) { + return i + } + } + return -1 + } + stringAt := func(want string) int { + return indexOf(func(entry recordedEncode) bool { + value, ok := entry.Value.(string) + return ok && value == want + }) + } + + workflowArg := stringAt("workflow-input") + signalArg := stringAt("signal-value") + request := indexOf(func(entry recordedEncode) bool { + _, ok := entry.Value.(*workflowservicepb.SignalWithStartWorkflowExecutionRequest) + return ok + }) + + s.Require().NotEqual(-1, workflowArg, "workflow argument was not encoded by the SDK's data converter") + s.Require().NotEqual(-1, signalArg, "signal argument was not encoded by the SDK's data converter") + s.Require().NotEqual(-1, request, "the SDK's data converter never received the transfer value") + + // The inner user payloads are encoded while the SDK converts the model, + // i.e. strictly before the resulting proto reaches the data converter. + s.Less(workflowArg, request) + s.Less(signalArg, request) + + // The transfer value -- the proto envelope -- is encoded under the Nexus + // operation's serialization context. + s.Equal( + converter.NexusSerializationContext{ + Endpoint: "__temporal_system", + Service: workflowServiceName, + Operation: "SignalWithStartWorkflowExecution", + }, + recorded[request].Context, + ) + + // The inner user payloads are encoded under the workflow serialization + // context reachable from the workflow.Context handed to the transfer + // converter. That context is the *calling* workflow's today; redirecting it + // to the target workflow is what @nexus.serialization-context will do. + callerContext := converter.WorkflowSerializationContext{ + Namespace: "default-test-namespace", + WorkflowID: "default-test-workflow-id", + } + s.Equal(callerContext, recorded[workflowArg].Context) + s.Equal(callerContext, recorded[signalArg].Context) +} diff --git a/advanced/samples/go/typeroundtrip/typeroundtripservice.go b/advanced/samples/go/typeroundtrip/typeroundtripservice.go index d899f968..1145ba0d 100644 --- a/advanced/samples/go/typeroundtrip/typeroundtripservice.go +++ b/advanced/samples/go/typeroundtrip/typeroundtripservice.go @@ -3,10 +3,13 @@ package typeroundtrip import ( + "context" + "errors" "time" activity "go.temporal.io/api/activity/v1" command "go.temporal.io/api/command/v1" + "go.temporal.io/sdk/converter" "go.temporal.io/sdk/temporal" "go.temporal.io/sdk/workflow" ) @@ -14,23 +17,12 @@ import ( // --- Operations (internal) --- func activityOptionsOperation(ctx workflow.Context, request ActivityOptions) workflow.Future { - requestProto, err := request.toProto(ctx) - if err != nil { - result, resultSettable := workflow.NewFuture(ctx) - resultSettable.SetError(err) - return result - } c := workflow.NewNexusClient("temporal-system", "TypeRoundtripService") - fut := c.ExecuteOperation(ctx, "ActivityOptionsOperation", requestProto, workflow.NexusOperationOptions{}) + fut := c.ExecuteOperation(ctx, "ActivityOptionsOperation", request, workflow.NexusOperationOptions{}) result, resultSettable := workflow.NewFuture(ctx) workflow.Go(ctx, func(ctx workflow.Context) { - var result activity.ActivityOptions - if err := fut.Get(ctx, &result); err != nil { - resultSettable.SetError(err) - return - } - value, err := activityOptionsFromProto(ctx, &result) - if err != nil { + var value ActivityOptions + if err := fut.Get(ctx, &value); err != nil { resultSettable.SetError(err) return } @@ -40,23 +32,12 @@ func activityOptionsOperation(ctx workflow.Context, request ActivityOptions) wor } func failureOperation(ctx workflow.Context, request FailureContainer) workflow.Future { - requestProto, err := request.toProto(ctx) - if err != nil { - result, resultSettable := workflow.NewFuture(ctx) - resultSettable.SetError(err) - return result - } c := workflow.NewNexusClient("temporal-system", "TypeRoundtripService") - fut := c.ExecuteOperation(ctx, "FailureOperation", requestProto, workflow.NexusOperationOptions{}) + fut := c.ExecuteOperation(ctx, "FailureOperation", request, workflow.NexusOperationOptions{}) result, resultSettable := workflow.NewFuture(ctx) workflow.Go(ctx, func(ctx workflow.Context) { - var result command.FailWorkflowExecutionCommandAttributes - if err := fut.Get(ctx, &result); err != nil { - resultSettable.SetError(err) - return - } - value, err := failureContainerFromProto(ctx, &result) - if err != nil { + var value FailureContainer + if err := fut.Get(ctx, &value); err != nil { resultSettable.SetError(err) return } @@ -78,7 +59,7 @@ type ActivityOptions struct { Priority *temporal.Priority } -func (m ActivityOptions) toProto(ctx workflow.Context) (*activity.ActivityOptions, error) { +func activityOptionsToProto(ctx workflow.Context, m *ActivityOptions) (*activity.ActivityOptions, error) { message := &activity.ActivityOptions{} { converted, err := taskQueueToProto(ctx, m.TaskQueue) @@ -111,39 +92,52 @@ func (m ActivityOptions) toProto(ctx workflow.Context) (*activity.ActivityOption return message, nil } -func activityOptionsFromProto(ctx workflow.Context, proto *activity.ActivityOptions) (ActivityOptions, error) { - value := ActivityOptions{} +func activityOptionsFromProto(ctx workflow.Context, proto *activity.ActivityOptions, out *ActivityOptions) error { + *out = ActivityOptions{} { converted, err := taskQueueFromProto(ctx, proto.GetTaskQueue()) if err != nil { - return value, err + return err } - value.TaskQueue = converted + out.TaskQueue = converted } { converted, err := retryPolicyFromProto(ctx, proto.GetRetryPolicy()) if err != nil { - return value, err + return err } if converted != nil { - value.RetryPolicy = *converted + out.RetryPolicy = *converted } } { converted, err := durationFromProto(ctx, proto.GetScheduleToCloseTimeout()) if err != nil { - return value, err + return err } - value.ScheduleToCloseTimeout = converted + out.ScheduleToCloseTimeout = converted } { converted, err := priorityFromProto(ctx, proto.GetPriority()) if err != nil { - return value, err + return err } - value.Priority = converted + out.Priority = converted } - return value, nil + return nil +} + +func (ActivityOptions) TransferTypeConverter() (converter.TransferTypeConverter, error) { + return converter.NewContextualTransferTypeConverter( + func(context.Context, *ActivityOptions) (*activity.ActivityOptions, error) { + return nil, errors.New("nexgen: transfer type converter outside a workflow") + }, + func(context.Context, *activity.ActivityOptions, *ActivityOptions) error { + return errors.New("nexgen: transfer type converter outside a workflow") + }, + activityOptionsToProto, + activityOptionsFromProto, + ) } type FailureContainer struct { @@ -151,7 +145,7 @@ type FailureContainer struct { Failure error } -func (m FailureContainer) toProto(ctx workflow.Context) (*command.FailWorkflowExecutionCommandAttributes, error) { +func failureContainerToProto(ctx workflow.Context, m *FailureContainer) (*command.FailWorkflowExecutionCommandAttributes, error) { message := &command.FailWorkflowExecutionCommandAttributes{} { converted, err := failureToProto(ctx, m.Failure) @@ -163,16 +157,29 @@ func (m FailureContainer) toProto(ctx workflow.Context) (*command.FailWorkflowEx return message, nil } -func failureContainerFromProto(ctx workflow.Context, proto *command.FailWorkflowExecutionCommandAttributes) (FailureContainer, error) { - value := FailureContainer{} +func failureContainerFromProto(ctx workflow.Context, proto *command.FailWorkflowExecutionCommandAttributes, out *FailureContainer) error { + *out = FailureContainer{} { converted, err := failureFromProto(ctx, proto.GetFailure()) if err != nil { - return value, err + return err } - value.Failure = converted + out.Failure = converted } - return value, nil + return nil +} + +func (FailureContainer) TransferTypeConverter() (converter.TransferTypeConverter, error) { + return converter.NewContextualTransferTypeConverter( + func(context.Context, *FailureContainer) (*command.FailWorkflowExecutionCommandAttributes, error) { + return nil, errors.New("nexgen: transfer type converter outside a workflow") + }, + func(context.Context, *command.FailWorkflowExecutionCommandAttributes, *FailureContainer) error { + return errors.New("nexgen: transfer type converter outside a workflow") + }, + failureContainerToProto, + failureContainerFromProto, + ) } type ActivityOptionsOperationOptions struct { diff --git a/advanced/samples/go/workflowservice/workflowservice.go b/advanced/samples/go/workflowservice/workflowservice.go index c061a94b..5a5e31d0 100644 --- a/advanced/samples/go/workflowservice/workflowservice.go +++ b/advanced/samples/go/workflowservice/workflowservice.go @@ -3,6 +3,8 @@ package workflowservice import ( + "context" + "errors" "fmt" "reflect" "runtime" @@ -13,6 +15,7 @@ import ( sdk "go.temporal.io/api/sdk/v1" workflowservice "go.temporal.io/api/workflowservice/v1" "go.temporal.io/sdk/client" + "go.temporal.io/sdk/converter" "go.temporal.io/sdk/internal" "go.temporal.io/sdk/temporal" "go.temporal.io/sdk/workflow" @@ -41,9 +44,10 @@ type signalWithStartWorkflowRequest struct { StartDelay *time.Duration UserMetadata *UserMetadata Headers map[string]any + namespace string } -func (m signalWithStartWorkflowRequest) toProto(ctx workflow.Context) (*workflowservice.SignalWithStartWorkflowExecutionRequest, error) { +func signalWithStartWorkflowRequestToProto(ctx workflow.Context, m *signalWithStartWorkflowRequest) (*workflowservice.SignalWithStartWorkflowExecutionRequest, error) { message := &workflowservice.SignalWithStartWorkflowExecutionRequest{} { converted, err := workflowTypeToProto(ctx, &m.Workflow) @@ -148,7 +152,7 @@ func (m signalWithStartWorkflowRequest) toProto(ctx workflow.Context) (*workflow message.WorkflowStartDelay = converted } if m.UserMetadata != nil { - converted, err := (*m.UserMetadata).toProto(ctx) + converted, err := userMetadataToProto(ctx, m.UserMetadata) if err != nil { return nil, err } @@ -161,162 +165,165 @@ func (m signalWithStartWorkflowRequest) toProto(ctx workflow.Context) (*workflow } message.Header = converted } - message.Namespace = workflow.GetInfo(ctx).Namespace + message.Namespace = m.namespace return message, nil } -func signalWithStartWorkflowRequestFromProto(ctx workflow.Context, proto *workflowservice.SignalWithStartWorkflowExecutionRequest) (signalWithStartWorkflowRequest, error) { - value := signalWithStartWorkflowRequest{} +func signalWithStartWorkflowRequestFromProto(ctx workflow.Context, proto *workflowservice.SignalWithStartWorkflowExecutionRequest, out *signalWithStartWorkflowRequest) error { + *out = signalWithStartWorkflowRequest{} { converted, err := workflowTypeFromProto(ctx, proto.GetWorkflowType()) if err != nil { - return value, err + return err } if converted != nil { - value.Workflow = *converted + out.Workflow = *converted } } { converted, err := payloadsFromProto(ctx, proto.GetInput()) if err != nil { - return value, err + return err } - value.Args = converted + out.Args = converted } - value.ID = proto.GetWorkflowId() + out.ID = proto.GetWorkflowId() { converted, err := taskQueueFromProto(ctx, proto.GetTaskQueue()) if err != nil { - return value, err + return err } if converted != nil { - value.TaskQueue = *converted + out.TaskQueue = *converted } } - value.Signal = proto.GetSignalName() + out.Signal = proto.GetSignalName() { converted, err := payloadsFromProto(ctx, proto.GetSignalInput()) if err != nil { - return value, err + return err } - value.SignalArgs = converted + out.SignalArgs = converted } { converted, err := durationFromProto(ctx, proto.GetWorkflowExecutionTimeout()) if err != nil { - return value, err + return err } - value.WorkflowExecutionTimeout = converted + out.WorkflowExecutionTimeout = converted } { converted, err := durationFromProto(ctx, proto.GetWorkflowRunTimeout()) if err != nil { - return value, err + return err } - value.WorkflowRunTimeout = converted + out.WorkflowRunTimeout = converted } { converted, err := durationFromProto(ctx, proto.GetWorkflowTaskTimeout()) if err != nil { - return value, err + return err } - value.WorkflowTaskTimeout = converted + out.WorkflowTaskTimeout = converted } { converted := enums.WorkflowIdReusePolicy(int32(proto.GetWorkflowIdReusePolicy())) - value.WorkflowIDReusePolicy = &converted + out.WorkflowIDReusePolicy = &converted } { converted := enums.WorkflowIdConflictPolicy(int32(proto.GetWorkflowIdConflictPolicy())) - value.WorkflowIDConflictPolicy = &converted + out.WorkflowIDConflictPolicy = &converted } { converted, err := retryPolicyFromProto(ctx, proto.GetRetryPolicy()) if err != nil { - return value, err + return err } - value.RetryPolicy = converted + out.RetryPolicy = converted } { converted := proto.GetCronSchedule() - value.CronSchedule = &converted + out.CronSchedule = &converted } { converted, err := memoFromProto(ctx, proto.GetMemo()) if err != nil { - return value, err + return err } - value.Memo = converted + out.Memo = converted } { converted, err := searchAttributesFromProto(ctx, proto.GetSearchAttributes()) if err != nil { - return value, err + return err } if converted != nil { - value.TypedSearchAttributes = *converted + out.TypedSearchAttributes = *converted } } { converted, err := priorityFromProto(ctx, proto.GetPriority()) if err != nil { - return value, err + return err } - value.Priority = converted + out.Priority = converted } { converted, err := versioningOverrideFromProto(ctx, proto.GetVersioningOverride()) if err != nil { - return value, err + return err } if converted != nil { - value.VersioningOverride = *converted + out.VersioningOverride = *converted } } { converted, err := durationFromProto(ctx, proto.GetWorkflowStartDelay()) if err != nil { - return value, err + return err } - value.StartDelay = converted + out.StartDelay = converted } if proto.GetUserMetadata() != nil { - converted, err := userMetadataFromProto(ctx, proto.GetUserMetadata()) - if err != nil { - return value, err + out.UserMetadata = new(UserMetadata) + if err := userMetadataFromProto(ctx, proto.GetUserMetadata(), out.UserMetadata); err != nil { + return err } - value.UserMetadata = &converted } { converted, err := headerFromProto(ctx, proto.GetHeader()) if err != nil { - return value, err + return err } - value.Headers = converted + out.Headers = converted } - return value, nil + out.namespace = proto.GetNamespace() + return nil +} + +func (signalWithStartWorkflowRequest) TransferTypeConverter() (converter.TransferTypeConverter, error) { + return converter.NewContextualTransferTypeConverter( + func(context.Context, *signalWithStartWorkflowRequest) (*workflowservice.SignalWithStartWorkflowExecutionRequest, error) { + return nil, errors.New("nexgen: transfer type converter outside a workflow") + }, + func(context.Context, *workflowservice.SignalWithStartWorkflowExecutionRequest, *signalWithStartWorkflowRequest) error { + return errors.New("nexgen: transfer type converter outside a workflow") + }, + signalWithStartWorkflowRequestToProto, + signalWithStartWorkflowRequestFromProto, + ) } // --- Operations (internal) --- func signalWithStartWorkflow(ctx workflow.Context, request signalWithStartWorkflowRequest) workflow.Future { - requestProto, err := request.toProto(ctx) - if err != nil { - result, resultSettable := workflow.NewFuture(ctx) - resultSettable.SetError(err) - return result - } + request.namespace = workflow.GetInfo(ctx).Namespace c := internal.NewSystemNexusClient("temporal.api.workflowservice.v1.WorkflowService") - fut := c.ExecuteOperation(ctx, "SignalWithStartWorkflowExecution", requestProto, workflow.NexusOperationOptions{}) + fut := c.ExecuteOperation(ctx, "SignalWithStartWorkflowExecution", request, workflow.NexusOperationOptions{}) result, resultSettable := workflow.NewFuture(ctx) workflow.Go(ctx, func(ctx workflow.Context) { - var result workflowservice.SignalWithStartWorkflowExecutionResponse - if err := fut.Get(ctx, &result); err != nil { - resultSettable.SetError(err) - return - } - value, err := signalWithStartWorkflowResponseFromProto(ctx, &result) - if err != nil { + var value SignalWithStartWorkflowResponse + if err := fut.Get(ctx, &value); err != nil { resultSettable.SetError(err) return } @@ -341,7 +348,7 @@ type UserMetadata struct { StaticDetails string } -func (m UserMetadata) toProto(ctx workflow.Context) (*sdk.UserMetadata, error) { +func userMetadataToProto(ctx workflow.Context, m *UserMetadata) (*sdk.UserMetadata, error) { message := &sdk.UserMetadata{} { converted, err := payloadToProto(ctx, m.StaticSummary) @@ -360,31 +367,31 @@ func (m UserMetadata) toProto(ctx workflow.Context) (*sdk.UserMetadata, error) { return message, nil } -func userMetadataFromProto(ctx workflow.Context, proto *sdk.UserMetadata) (UserMetadata, error) { - value := UserMetadata{} +func userMetadataFromProto(ctx workflow.Context, proto *sdk.UserMetadata, out *UserMetadata) error { + *out = UserMetadata{} if proto.GetSummary() != nil { converted, err := payloadFromProto(ctx, proto.GetSummary()) if err != nil { - return value, err + return err } typed, ok := converted.(string) if !ok { - return value, fmt.Errorf("nexgen decoded field StaticSummary has unexpected type %T", converted) + return fmt.Errorf("nexgen decoded field StaticSummary has unexpected type %T", converted) } - value.StaticSummary = typed + out.StaticSummary = typed } if proto.GetDetails() != nil { converted, err := payloadFromProto(ctx, proto.GetDetails()) if err != nil { - return value, err + return err } typed, ok := converted.(string) if !ok { - return value, fmt.Errorf("nexgen decoded field StaticDetails has unexpected type %T", converted) + return fmt.Errorf("nexgen decoded field StaticDetails has unexpected type %T", converted) } - value.StaticDetails = typed + out.StaticDetails = typed } - return value, nil + return nil } type SignalWithStartWorkflowResponse struct { @@ -398,7 +405,7 @@ type SignalWithStartWorkflowResponse struct { Started *bool } -func (m SignalWithStartWorkflowResponse) toProto(ctx workflow.Context) (*workflowservice.SignalWithStartWorkflowExecutionResponse, error) { +func signalWithStartWorkflowResponseToProto(ctx workflow.Context, m *SignalWithStartWorkflowResponse) (*workflowservice.SignalWithStartWorkflowExecutionResponse, error) { message := &workflowservice.SignalWithStartWorkflowExecutionResponse{} if m.RunID != nil { message.RunId = (*m.RunID) @@ -409,17 +416,30 @@ func (m SignalWithStartWorkflowResponse) toProto(ctx workflow.Context) (*workflo return message, nil } -func signalWithStartWorkflowResponseFromProto(ctx workflow.Context, proto *workflowservice.SignalWithStartWorkflowExecutionResponse) (SignalWithStartWorkflowResponse, error) { - value := SignalWithStartWorkflowResponse{} +func signalWithStartWorkflowResponseFromProto(ctx workflow.Context, proto *workflowservice.SignalWithStartWorkflowExecutionResponse, out *SignalWithStartWorkflowResponse) error { + *out = SignalWithStartWorkflowResponse{} { converted := proto.GetRunId() - value.RunID = &converted + out.RunID = &converted } { converted := proto.GetStarted() - value.Started = &converted + out.Started = &converted } - return value, nil + return nil +} + +func (SignalWithStartWorkflowResponse) TransferTypeConverter() (converter.TransferTypeConverter, error) { + return converter.NewContextualTransferTypeConverter( + func(context.Context, *SignalWithStartWorkflowResponse) (*workflowservice.SignalWithStartWorkflowExecutionResponse, error) { + return nil, errors.New("nexgen: transfer type converter outside a workflow") + }, + func(context.Context, *workflowservice.SignalWithStartWorkflowExecutionResponse, *SignalWithStartWorkflowResponse) error { + return errors.New("nexgen: transfer type converter outside a workflow") + }, + signalWithStartWorkflowResponseToProto, + signalWithStartWorkflowResponseFromProto, + ) } type SignalWithStartWorkflowOptions struct { diff --git a/src/generator/go.rs b/src/generator/go.rs index 7b41c06f..f10b599e 100644 --- a/src/generator/go.rs +++ b/src/generator/go.rs @@ -316,7 +316,7 @@ fn record_message_key(record: &RecordSpec) -> &str { .unwrap_or(record.full_name.as_str()) } -fn record_for_message<'a>( +pub(in crate::generator) fn record_for_message<'a>( api_plan: &'a PlannedSpec, message: &PlannedMessageType, ) -> Option<&'a RecordSpec> { @@ -602,6 +602,28 @@ impl GoPackageContext { ) } + pub(in crate::generator) fn transfer_type_converter_type(&self) -> String { + self.qualified_expr( + "go.temporal.io/sdk/converter", + "converter.TransferTypeConverter", + ) + } + + pub(in crate::generator) fn new_transfer_type_converter(&self) -> String { + self.qualified_expr( + "go.temporal.io/sdk/converter", + "converter.NewContextualTransferTypeConverter", + ) + } + + pub(in crate::generator) fn context_context_type(&self) -> String { + self.qualified_expr("context", "context.Context") + } + + pub(in crate::generator) fn errors_new(&self) -> String { + self.qualified_expr("errors", "errors.New") + } + fn qualified_expr(&self, import_path: &str, code_expr: &str) -> String { if self.is_self_import(import_path) { self.unqualify_self_expr(code_expr) @@ -968,9 +990,18 @@ impl GoExternalModels { } } - fn render_model_wire_methods(&self, output: &mut String, key: &str, model: &RenderedModel) { + fn render_model_converters(&self, output: &mut String, key: &str, model: &RenderedModel) { if let Self::Proto(backend) = self { - backend.render_model_wire_methods(output, key, model); + backend.render_model_converters(output, key, model); + } + } + + /// Whether any model emits a `converter.TransferTypeConverter`, which pulls + /// in the `context` and `errors` standard-library imports. + fn renders_transfer_type_converters(&self) -> bool { + match self { + Self::Proto(backend) => backend.renders_transfer_type_converters(), + Self::Json(_) => false, } } @@ -1139,6 +1170,18 @@ impl<'a> ApiPlanner<'a> { self.imports.insert("runtime".to_string()); self.imports.insert("strings".to_string()); } + // Transfer-type converters reject non-workflow conversion inline. + if self.external_models.renders_transfer_type_converters() { + if !self.package.is_self_import("go.temporal.io/sdk/converter") { + self.imports + .insert("go.temporal.io/sdk/converter".to_string()); + } + for import in ["context", "errors"] { + if !self.package.is_self_import(import) { + self.imports.insert(import.to_string()); + } + } + } let model_fragments = self.external_models.render_models()?; self.imports.extend(model_fragments.imports); // The Nexus service definition (operation references) is emitted in @@ -1616,8 +1659,19 @@ impl<'a> ApiPlanner<'a> { let planned_fields = planned_model .model_fields() .map(|(field_name, field)| { - planned_field(planned_model, field_name, field, self.api_plan) + ( + planned_field(planned_model, field_name, field, self.api_plan), + false, + ) }) + .chain(planned_model.sourced_fields().map( + |(field_name, field, _source_expr)| { + ( + planned_field(planned_model, field_name, field, self.api_plan), + true, + ) + }, + )) .collect::>(); (planned_model.name.clone(), parameters, planned_fields) }) @@ -1655,7 +1709,7 @@ impl<'a> ApiPlanner<'a> { let fields = planned_fields .iter() - .map(|planned_field| self.build_field(planned_field)) + .map(|(planned_field, sourced)| self.build_field(planned_field, *sourced)) .collect::>>()?; self.models @@ -1665,8 +1719,12 @@ impl<'a> ApiPlanner<'a> { Ok(()) } - fn build_field(&mut self, field: &PlannedField) -> Result { - let field_name = go_field_name(&field.authored_name); + fn build_field(&mut self, field: &PlannedField, sourced: bool) -> Result { + let field_name = if sourced { + go_unexported_name(&go_field_name(&field.authored_name)) + } else { + go_field_name(&field.authored_name) + }; let annotated_go_type = field .flattened_annotation_override @@ -1710,6 +1768,7 @@ impl<'a> ApiPlanner<'a> { .map(str::to_string), go_type, required: field.required, + sourced, }) } @@ -2077,6 +2136,10 @@ pub(in crate::generator) struct RenderedField { /// Whether the field is required in the WIT definition. Rendered as a /// leading `// Required.` godoc comment on the generated Go struct field. pub(in crate::generator) required: bool, + /// Whether this field carries a `@nexus.source` value. Sourced fields are + /// rendered as unexported struct fields, populated by generated code at + /// the operation call site, and never documented. + pub(in crate::generator) sourced: bool, } /// The result of resolving a [`PlannedValueType`] to a Go type expression. @@ -2112,11 +2175,10 @@ impl GoVisibility { } fn rewrite_go_expr(&self, expr: &str) -> String { - if self.type_name_replacements.is_empty() && !expr.contains(".ToProto()") { + if self.type_name_replacements.is_empty() { return expr.to_string(); } - let expr = expr.replace(".ToProto()", ".toProto()"); let bytes = expr.as_bytes(); let mut output = String::with_capacity(expr.len()); let mut index = 0; @@ -2742,6 +2804,7 @@ fn ensure_generic_tuple( doc: None, go_type: format!("T{}", index + 1), required: true, + sourced: false, }) .collect(); models.insert( @@ -2770,12 +2833,14 @@ fn ensure_generic_result(models: &mut IndexMap) { doc: None, go_type: "T".to_string(), required: false, + sourced: false, }, RenderedField { name: "Error".to_string(), doc: None, go_type: "E".to_string(), required: false, + sourced: false, }, ], }, @@ -3672,7 +3737,7 @@ fn render_model( output.push_str("}\n"); } else { for field in &model.fields { - if public { + if public && !field.sourced { render_field_doc_comment( output, "\t", @@ -3690,7 +3755,7 @@ fn render_model( output.push_str("}\n"); } - external_models.render_model_wire_methods(output, key, model); + external_models.render_model_converters(output, key, model); } /// Renders a WIT resource as a Go struct with its constructor fields. diff --git a/src/generator/proto/go.rs b/src/generator/proto/go.rs index 7dee5fe4..884a5814 100644 --- a/src/generator/proto/go.rs +++ b/src/generator/proto/go.rs @@ -12,7 +12,7 @@ use crate::planning::{ PlannedFamily, PlannedProtoType, PlannedResource, PlannedResourceField, PlannedSpec, PlannedType, PlannedWireFieldBinding, }; -use crate::spec::{ExternalTypeSpec, RecordFieldSpec, RecordFieldVisibility, RecordSpec}; +use crate::spec::{ExternalTypeSpec, RecordFieldVisibility, RecordSpec}; use crate::generator::go::{ GoPackageContext, PlannedEnumType, PlannedFieldKind, PlannedMessageSource, PlannedMessageType, @@ -20,8 +20,9 @@ use crate::generator::go::{ PlannedValueType, RenderedModel, RenderedService, go_authored_type_annotation, go_field_name, go_replacement_type_name, go_string_literal, go_unexported_name, new_nexus_client_expr, operation_output, planned_field, planned_field_kind, planned_message_type, - public_default_punning_zero_for_field, record_for_model_key, render_operation_future_adapter, - render_operation_future_return_type, resolve_resource_field_kind, split_go_type_decl_name, + public_default_punning_zero_for_field, record_for_message, record_for_model_key, + render_operation_future_adapter, render_operation_future_return_type, + resolve_resource_field_kind, split_go_type_decl_name, }; #[derive(Debug, Default)] @@ -38,8 +39,11 @@ pub(in crate::generator) struct GoValueConversion { /// The structural shape of the conversion, which determines how the line /// builders handle pointers and dereferencing. pub(in crate::generator) kind: GoConversionKind, - /// Produces the native expression from a proto expression. - pub(in crate::generator) from_proto: Box String>, + /// Produces a decode call or expression. Generated model calls also take + /// the destination pointer; expression converters ignore that argument. + pub(in crate::generator) from_proto: Box String>, + /// Native type to declare when decoding a generated model into a local. + pub(in crate::generator) decoded_model_type: Option, /// Produces the proto expression from a native expression. pub(in crate::generator) to_proto: Box String>, /// Whether the conversion expression returns `(value, error)`. @@ -50,6 +54,27 @@ pub(in crate::generator) struct GoValueConversion { pub(in crate::generator) from_proto_returns_pointer: bool, } +impl GoValueConversion { + /// Decodes a fallible conversion into a fresh local named `converted`. + fn checked_from_proto_lines(&self, expr: &str, error_return: &str) -> Vec { + let call = (self.from_proto)(expr, "&converted"); + let mut lines = if let Some(native_type) = &self.decoded_model_type { + vec![ + format!("var converted {native_type}"), + format!("if err := {call}; err != nil {{"), + ] + } else { + vec![ + format!("converted, err := {call}"), + "if err != nil {".to_string(), + ] + }; + lines.push(format!("\t{error_return}")); + lines.push("}".to_string()); + lines + } +} + /// Classifies a value conversion so the line builders know how to bridge /// pointer/value mismatches between the native field and the converter. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -65,9 +90,9 @@ pub(in crate::generator) enum GoConversionKind { /// `ToProto(*Native) *Proto`, each returning `nil` for `nil` input. The /// converter owns nil passthrough; the caller supplies/consumes pointers. OverrideConverter, - /// A generated model converter: `FromProto(*Proto) Model` (value result) - /// and `(Model) toProto(ctx) *Proto` (value receiver). The native side is a - /// value, the proto side is a pointer. + /// A generated model converter: `modelFromProto(ctx, *Proto, *Model) error` + /// and `modelToProto(ctx, *Model) (*Proto, error)`. Optional fields are + /// guarded by the caller rather than relying on nil passthrough. ModelConverter, } @@ -83,6 +108,10 @@ pub(in crate::generator) struct ModelBackend { imports: RefCell, proto_models: BTreeMap, wire_models: RefCell>, + /// Models that appear as a top-level Nexus operation input or output, and + /// therefore need a [`converter.TransferTypeConverter`] so the SDK performs + /// model<->proto conversion inside the payload converter. + transfer_models: RefCell>, } impl ModelBackend { @@ -92,6 +121,7 @@ impl ModelBackend { imports: RefCell::new(GoImportCollector::default()), proto_models: BTreeMap::new(), wire_models: RefCell::new(BTreeMap::new()), + transfer_models: RefCell::new(BTreeSet::new()), } } @@ -161,7 +191,46 @@ impl ModelBackend { self.wire_models.borrow_mut().insert(key, wire); } - pub(in crate::generator) fn render_model_wire_methods( + /// Marks a top-level operation message as needing a transfer-type + /// converter, rejecting generic models: the converter is a package-level + /// singleton instantiated with concrete type arguments, which a generic + /// model cannot provide. + fn mark_transfer_model_checked( + &self, + api_plan: &PlannedSpec, + message: &PlannedMessageType, + operation_name: &str, + direction: &str, + ) -> Result<()> { + let key = &message.info.full_name; + if self.model_proto_info(key).is_none() { + return Ok(()); + } + if let Some(record) = record_for_message(api_plan, message) + && !api_plan + .record_type_parameters(&record.full_name, Language::Go) + .is_empty() + { + return Err(Error::UnsupportedGoProtoConversion { + context: format!("operation `{operation_name}` {direction}"), + reason: format!( + "model `{}` is generic; Go transfer-type converters are package-level \ + singletons and cannot be instantiated per type argument", + record.name + ), + }); + } + self.transfer_models.borrow_mut().insert(key.to_string()); + Ok(()) + } + + /// Whether any transfer-type converter will be emitted. Drives the + /// `context`/`errors` imports. + pub(in crate::generator) fn renders_transfer_type_converters(&self) -> bool { + !self.transfer_models.borrow().is_empty() + } + + pub(in crate::generator) fn render_model_converters( &self, output: &mut String, key: &str, @@ -171,7 +240,10 @@ impl ModelBackend { let Some(wire) = wire_models.get(key) else { return; }; - render_model_wire_methods(output, model, wire, &self.package); + render_model_converters(output, model, wire, &self.package); + if self.transfer_models.borrow().contains(key) { + render_model_transfer_type_converter(output, model, wire, &self.package); + } } } @@ -219,7 +291,8 @@ impl ExternalModelBackend for ModelBackend { match model_type { PlannedValueType::Scalar(_) => Some(Ok(GoValueConversion { kind: GoConversionKind::Scalar, - from_proto: Box::new(|expr| expr.to_string()), + from_proto: Box::new(|expr, _| expr.to_string()), + decoded_model_type: None, to_proto: Box::new(|expr| expr.to_string()), fallible: false, to_proto_takes_pointer: false, @@ -361,7 +434,8 @@ fn go_enum_conversion( let native_for_cast = native_type.clone(); Ok(GoValueConversion { kind: GoConversionKind::Enum, - from_proto: Box::new(move |expr| format!("{native_for_cast}(int32({expr}))")), + from_proto: Box::new(move |expr, _| format!("{native_for_cast}(int32({expr}))")), + decoded_model_type: None, to_proto: Box::new(move |expr| format!("{proto_type}({expr})")), fallible: false, to_proto_takes_pointer: false, @@ -380,7 +454,8 @@ fn go_message_conversion( let native_is_nilable_value = crate::generator::go::go_type_is_nilable(&type_name); return Ok(GoValueConversion { kind: GoConversionKind::OverrideConverter, - from_proto: Box::new(move |expr| format!("{from}(ctx, {expr})")), + from_proto: Box::new(move |expr, _| format!("{from}(ctx, {expr})")), + decoded_model_type: None, to_proto: Box::new(move |expr| format!("{to}(ctx, {expr})")), fallible: true, to_proto_takes_pointer: !native_is_nilable_value, @@ -394,7 +469,8 @@ fn go_message_conversion( let to = go_default_to_proto_name(&message.info.full_name); return Ok(GoValueConversion { kind: GoConversionKind::OverrideConverter, - from_proto: Box::new(move |expr| format!("{from}(ctx, {expr})")), + from_proto: Box::new(move |expr, _| format!("{from}(ctx, {expr})")), + decoded_model_type: None, to_proto: Box::new(move |expr| format!("{to}(ctx, {expr})")), fallible: true, to_proto_takes_pointer: true, @@ -410,13 +486,16 @@ fn go_message_conversion( } if message.source == PlannedMessageSource::Proto { - let from_proto = format!("{}FromProto", go_unexported_name(&message.model_name)); + let base = go_unexported_name(&message.model_name); + let from_proto = format!("{base}FromProto"); + let to_proto = format!("{base}ToProto"); return Ok(GoValueConversion { kind: GoConversionKind::ModelConverter, - from_proto: Box::new(move |expr| format!("{from_proto}(ctx, {expr})")), - to_proto: Box::new(|expr| format!("{expr}.toProto(ctx)")), + from_proto: Box::new(move |expr, out| format!("{from_proto}(ctx, {expr}, {out})")), + decoded_model_type: Some(message.model_name.clone()), + to_proto: Box::new(move |expr| format!("{to_proto}(ctx, {expr})")), fallible: true, - to_proto_takes_pointer: false, + to_proto_takes_pointer: true, from_proto_returns_pointer: false, }); } @@ -537,11 +616,8 @@ pub(in crate::generator) struct RenderedModelWire { /// The Go proto type expression (e.g. `"common.ActivityOptions"`) the /// model converts to/from, qualified with the resolved import alias. pub(in crate::generator) proto_type: String, - /// Field conversions in rendered model field order. + /// Field conversions in rendered model field order, sourced fields last. pub(in crate::generator) field_conversions: Vec, - /// Sourced fields (write-only, value derived from a source expression) - /// emitted in `ToProto` after the regular fields. - pub(in crate::generator) sourced_fields: Vec, } /// Per-field proto conversion metadata, carrying the lines/expressions needed @@ -556,21 +632,27 @@ pub(in crate::generator) struct RenderedFieldConversion { pub(in crate::generator) from_proto_lines: Vec, } -/// A sourced field rendered for `ToProto`. -#[derive(Debug)] -pub(in crate::generator) struct RenderedSourcedField { - pub(in crate::generator) to_proto_lines: Vec, -} - /// Wire serialization binding for an operation's request and response, /// allowing the generated operation function to convert native values to/from /// proto before/after the SDK call. #[derive(Debug)] pub(in crate::generator) struct OperationBinding { /// Expression converting the native `request` value to its proto form. - input_to_proto: String, + /// `None` when the request is a generated model carrying a transfer-type + /// converter -- the SDK then performs the conversion inside the payload + /// converter, and the model is passed to `ExecuteOperation` as-is. + input_to_proto: Option, + /// Assignments that populate the request model's `@nexus.source` fields + /// from their source expressions, emitted at the top of the operation + /// function where `ctx` is in scope. + input_sourced_assignments: Vec, /// Proto type expression for the response, or `None` for void operations. output_proto_type: Option, + /// Go type of the generated model the response decodes into directly, + /// when that model carries a transfer-type converter. `None` when the + /// response is decoded as a proto and converted in generated code + /// (override converters, output transforms, resource returns). + output_model_type: Option, /// Expression converting the proto response (bound to `&result`) to its /// native form. `None` for void operations. output_from_proto: Option, @@ -644,12 +726,11 @@ impl ModelBackend { .get(&full_name) .map(|model| model.fields.iter().map(|f| f.go_type.clone()).collect()) .unwrap_or_default(); - let (field_conversions, sourced_fields) = match planned_record { - Some(planned_model) => ( - build_field_conversions(planned_model, &native_field_types, api_plan, self)?, - build_sourced_conversions(planned_model, api_plan, self)?, - ), - None => (Vec::new(), Vec::new()), + let field_conversions = match planned_record { + Some(planned_model) => { + build_field_conversions(planned_model, &native_field_types, api_plan, self)? + } + None => Vec::new(), }; self.set_model_wire( @@ -657,7 +738,6 @@ impl ModelBackend { RenderedModelWire { proto_type, field_conversions, - sourced_fields, }, ); } @@ -690,13 +770,20 @@ impl ModelBackend { else { continue; }; - // Override converters take a pointer to the native value; generated - // model converters use a value receiver (`request.toProto(ctx)`). - let input_arg = match input_conv.kind { - GoConversionKind::OverrideConverter => "&request".to_string(), - _ => "request".to_string(), + // Generated models carry a transfer-type converter, so the SDK + // converts them to proto inside the payload converter. Anything + // else (hand-written override converters over types nexgen does + // not own) still converts eagerly here. + let input_to_proto = if input_conv.kind == GoConversionKind::ModelConverter { + self.mark_transfer_model_checked(api_plan, &input, &planned_op.name, "input")?; + None + } else { + let input_arg = match input_conv.kind { + GoConversionKind::OverrideConverter => "&request".to_string(), + _ => "request".to_string(), + }; + Some((input_conv.to_proto)(&input_arg)) }; - let input_to_proto = (input_conv.to_proto)(&input_arg); let has_go_output_transform = planned_op .output_transform @@ -722,6 +809,7 @@ impl ModelBackend { planned_service.name.as_str(), resource_return, resource, + record_for_message(api_plan, &input), api_plan, self, &self.package, @@ -730,16 +818,26 @@ impl ModelBackend { None }; - let (output_proto_type, output_from_proto, output_returns_pointer) = match &output { + let ( + output_proto_type, + output_model_type, + output_from_proto, + output_returns_pointer, + ) = match &output { PlannedOperationOutput::Message(output) => { if planned_op.data.output_resource_return.is_some() { let Some(proto_type) = operation_message_proto_type(output, self) else { continue; }; - (Some(proto_type), None, false) + (Some(proto_type), None, None, false) } else if has_go_output_transform { - (operation_message_proto_type(output, self), None, false) + ( + operation_message_proto_type(output, self), + None, + None, + false, + ) } else { match operation_message_binding( output, @@ -747,24 +845,57 @@ impl ModelBackend { "output", self, )? { + Some((proto_type, conv)) + if conv.kind == GoConversionKind::ModelConverter => + { + self.mark_transfer_model_checked( + api_plan, + output, + &planned_op.name, + "output", + )?; + // The model decodes directly; the SDK + // runs the transfer converter for us. + let model_type = rendered_op + .output_type + .clone() + .unwrap_or_else(|| output.model_name.clone()); + (Some(proto_type), Some(model_type), None, false) + } Some((proto_type, conv)) => { // `result` is declared as a proto value; // converters take a pointer to the proto message. - let from = (conv.from_proto)("&result"); + let from = (conv.from_proto)("&result", ""); let returns_pointer = conv.from_proto_returns_pointer; - (Some(proto_type), Some(from), returns_pointer) + (Some(proto_type), None, Some(from), returns_pointer) } None => continue, } } } PlannedOperationOutput::Resource { .. } => continue, - PlannedOperationOutput::None => (None, None, false), + PlannedOperationOutput::None => (None, None, None, false), }; + let input_sourced_assignments = record_for_message(api_plan, &input) + .map(|planned_model| { + planned_model + .sourced_fields() + .map(|(field_name, _, source_expr)| { + format!( + "request.{} = {source_expr}", + go_model_field_name(field_name, true) + ) + }) + .collect::>() + }) + .unwrap_or_default(); + rendered_op.wire_binding = Some(OperationBinding { input_to_proto, + input_sourced_assignments, output_proto_type, + output_model_type, output_from_proto, output_returns_pointer, resource_return, @@ -843,16 +974,29 @@ pub(in crate::generator) fn render_operation_function_proto( render_operation_future_return_type(output, package); output.push_str(" {\n"); - output.push_str("\trequestProto, err := "); - output.push_str(&binding.input_to_proto); - output.push('\n'); - output.push_str("\tif err != nil {\n"); - output.push_str("\t\tresult, resultSettable := "); - output.push_str(&package.new_future()); - output.push_str("(ctx)\n"); - output.push_str("\t\tresultSettable.SetError(err)\n"); - output.push_str("\t\treturn result\n"); - output.push_str("\t}\n"); + for assignment in &binding.input_sourced_assignments { + output.push('\t'); + output.push_str(assignment); + output.push('\n'); + } + let input_arg = match binding.input_to_proto.as_deref() { + Some(input_to_proto) => { + output.push_str("\trequestProto, err := "); + output.push_str(input_to_proto); + output.push('\n'); + output.push_str("\tif err != nil {\n"); + output.push_str("\t\tresult, resultSettable := "); + output.push_str(&package.new_future()); + output.push_str("(ctx)\n"); + output.push_str("\t\tresultSettable.SetError(err)\n"); + output.push_str("\t\treturn result\n"); + output.push_str("\t}\n"); + "requestProto" + } + // The request model implements `workflow.ValueWithTransferTypeConverter`, + // so the SDK converts it to proto while encoding the payload. + None => "request", + }; let endpoint = service .endpoint .as_deref() @@ -862,7 +1006,8 @@ pub(in crate::generator) fn render_operation_function_proto( output.push('\n'); output.push_str("\tfut := c.ExecuteOperation(ctx, "); output.push_str(&operation_name); - output.push_str(", requestProto"); + output.push_str(", "); + output.push_str(input_arg); output.push_str(", "); output.push_str(&package.nexus_operation_options()); output.push_str(")\n"); @@ -900,6 +1045,19 @@ pub(in crate::generator) fn render_operation_function_proto( output.push_str("\t\t\treturn\n"); output.push_str("\t\t}\n"); }); + } else if let Some(model_type) = binding.output_model_type.as_deref() { + // The response model implements `workflow.ValueWithTransferTypeConverter`, + // so the SDK decodes the proto payload and runs the transfer converter + // before handing us the model. + render_operation_future_adapter(output, package, model_type, false, |output| { + output.push_str("\t\tvar value "); + output.push_str(model_type); + output.push('\n'); + output.push_str("\t\tif err := fut.Get(ctx, &value); err != nil {\n"); + output.push_str("\t\t\tresultSettable.SetError(err)\n"); + output.push_str("\t\t\treturn\n"); + output.push_str("\t\t}\n"); + }); } else if let (Some(output_type), Some(proto_value_type), Some(from_proto)) = ( operation.output_type.as_ref(), binding @@ -1009,6 +1167,7 @@ fn build_rendered_resource_return( service_name: &str, resource_return: &PlannedOperationResourceReturn, resource: &PlannedResource, + input_record: Option<&RecordSpec>, api_plan: &PlannedSpec, backend: &ModelBackend, package: &GoPackageContext, @@ -1035,27 +1194,27 @@ fn build_rendered_resource_return( proto_field_name, hidden, } => { - if *hidden { - let (lines, expr) = resource_return_proto_field_source( - field, - "requestProto", - proto_field_name, - api_plan, - backend, - package, - ) - .map_err(|reason| Error::UnsupportedGoProtoConversion { + // Sourced request fields live on the generated model as + // unexported fields (populated at the call site), so they read + // the same way as public fields. + let is_sourced = input_record + .is_some_and(|record| record.field_source(proto_field_name).is_some()); + if *hidden && !is_sourced { + return Err(Error::UnsupportedGoProtoConversion { context: format!( "resource return field `{}.{}`", resource_return.resource_type_name, binding.field_name ), - reason, - })?; - local_lines.extend(lines); - (expr, false) - } else { - (format!("request.{}", go_field_name(field_name)), true) + reason: format!( + "request field `{field_name}` is omitted from the generated model, \ + so it cannot be read back when constructing the resource" + ), + }); } + ( + format!("request.{}", go_model_field_name(field_name, is_sourced)), + true, + ) } ResolvedResourceBindingSource::ResultField { proto_field_name, .. @@ -1141,7 +1300,7 @@ fn resource_return_proto_field_source( } PlannedFieldKind::Repeated(value) => { let conversion = go_value_conversion(value, api_plan, backend, package)?; - let converted = (conversion.from_proto)("item"); + let converted = (conversion.from_proto)("item", "&converted"); let mut lines = vec![ format!("var {local} {native_type}"), format!("for _, item := range {getter} {{"), @@ -1176,10 +1335,12 @@ fn resource_return_proto_field_source( } _ => { if conversion.fallible { - lines.push(format!("\tconverted, err := {converted}")); - lines.push("\tif err != nil {".to_string()); - lines.push("\t\treturn nil, err".to_string()); - lines.push("\t}".to_string()); + lines.extend( + conversion + .checked_from_proto_lines("item", "return nil, err") + .into_iter() + .map(|line| format!("\t{line}")), + ); lines.push(format!("\t{local} = append({local}, converted)")); } else { lines.push(format!("\t{local} = append({local}, {converted})")); @@ -1191,7 +1352,7 @@ fn resource_return_proto_field_source( } PlannedFieldKind::Map { key: _, value } => { let conversion = go_value_conversion(value, api_plan, backend, package)?; - let converted = (conversion.from_proto)("v"); + let converted = (conversion.from_proto)("v", "&converted"); let mut lines = vec![ format!("var {local} {native_type}"), format!("if len({getter}) > 0 {{"), @@ -1228,10 +1389,12 @@ fn resource_return_proto_field_source( } _ => { if conversion.fallible { - lines.push(format!("\t\tconverted, err := {converted}")); - lines.push("\t\tif err != nil {".to_string()); - lines.push("\t\t\treturn nil, err".to_string()); - lines.push("\t\t}".to_string()); + lines.extend( + conversion + .checked_from_proto_lines("v", "return nil, err") + .into_iter() + .map(|line| format!("\t\t{line}")), + ); lines.push(format!("\t\t{local}[k] = converted")); } else { lines.push(format!("\t\t{local}[k] = {converted}")); @@ -1254,7 +1417,7 @@ fn resource_return_singular_proto_source( native_type: &str, error_return: &str, ) -> (Vec, String) { - let converted = (conversion.from_proto)(getter); + let converted = (conversion.from_proto)(getter, "&converted"); let uses_pointer = field.optional && native_type.starts_with('*'); if matches!( @@ -1335,33 +1498,33 @@ fn resource_return_singular_proto_source( } GoConversionKind::ModelConverter => { if uses_pointer { + let value_local = format!("{local}Value"); + let call = (conversion.from_proto)(getter, &format!("&{value_local}")); let mut lines = vec![ format!("var {local} {native_type}"), format!("if {getter} != nil {{"), + format!( + "\tvar {value_local} {}", + native_type.trim_start_matches('*') + ), + format!("\tif err := {call}; err != nil {{"), + format!("\t\t{error_return}"), + "\t}".to_string(), ]; - if conversion.fallible { - lines.push(format!("\tconverted, err := {converted}")); - lines.push("\tif err != nil {".to_string()); - lines.push(format!("\t\t{error_return}")); - lines.push("\t}".to_string()); - } else { - lines.push(format!("\tconverted := {converted}")); - } - lines.push(format!("\t{local} = &converted")); + lines.push(format!("\t{local} = &{value_local}")); lines.push("}".to_string()); (lines, local.to_string()) - } else if conversion.fallible { + } else { + let call = (conversion.from_proto)(getter, &format!("&{local}")); ( vec![ - format!("{local}, err := {converted}"), - "if err != nil {".to_string(), + format!("var {local} {native_type}"), + format!("if err := {call}; err != nil {{"), format!("\t{error_return}"), "}".to_string(), ], local.to_string(), ) - } else { - (vec![format!("{local} := {converted}")], local.to_string()) } } GoConversionKind::Scalar | GoConversionKind::Enum => { @@ -1400,7 +1563,8 @@ fn resource_return_local_name(field_name: &str) -> String { } /// Builds per-field conversion metadata for a proto-backed model, in field -/// declaration order (matching the rendered struct fields). +/// declaration order (matching the rendered struct fields). Sourced fields are +/// appended last, matching the order the Go backend renders them in. fn build_field_conversions( planned_model: &RecordSpec, native_field_types: &[String], @@ -1409,22 +1573,27 @@ fn build_field_conversions( ) -> Result> { planned_model .model_fields() + .map(|(field_name, field)| (field_name, field, false)) + .chain( + planned_model + .sourced_fields() + .map(|(field_name, field, _)| (field_name, field, true)), + ) .enumerate() - .map(|(index, (field_name, field))| { + .map(|(index, (field_name, field, sourced))| { let planned_field = planned_field(planned_model, field_name, field, api_plan); let native_go_type = native_field_types .get(index) .map(String::as_str) .unwrap_or(""); - build_field_conversion(&planned_field, native_go_type, api_plan, backend).map_err( - |reason| Error::UnsupportedGoProtoConversion { + build_field_conversion(&planned_field, native_go_type, sourced, api_plan, backend) + .map_err(|reason| Error::UnsupportedGoProtoConversion { context: format!( "field `{}.{}`", planned_model.name, planned_field.authored_name ), reason, - }, - ) + }) }) .collect() } @@ -1432,11 +1601,12 @@ fn build_field_conversions( fn build_field_conversion( field: &crate::generator::go::PlannedField, native_go_type: &str, + sourced: bool, api_plan: &PlannedSpec, backend: &ModelBackend, ) -> GoConversionResult { let proto_field = go_proto_field_name(&field.proto_name); - let go_field = go_field_name(&field.authored_name); + let go_field = go_model_field_name(&field.authored_name, sourced); let receiver = format!("m.{go_field}"); match &field.kind { @@ -1455,7 +1625,7 @@ fn build_field_conversion( &proto_field, &go_field, field_is_pointer, - "return value, err", + "return err", ); if field .flattened_annotation_override @@ -1468,18 +1638,18 @@ fn build_field_conversion( format!("if proto.Get{proto_field}() != nil {{"), format!( "\tconverted, err := {}", - (conversion.from_proto)(&format!("proto.Get{proto_field}()")) + (conversion.from_proto)(&format!("proto.Get{proto_field}()"), "") ), "\tif err != nil {".to_string(), - "\t\treturn value, err".to_string(), + "\t\treturn err".to_string(), "\t}".to_string(), format!("\ttyped, ok := converted.({native_go_type})"), "\tif !ok {".to_string(), format!( - "\t\treturn value, fmt.Errorf(\"nexgen decoded field {go_field} has unexpected type %T\", converted)" + "\t\treturn fmt.Errorf(\"nexgen decoded field {go_field} has unexpected type %T\", converted)" ), "\t}".to_string(), - format!("\tvalue.{go_field} = typed"), + format!("\tout.{go_field} = typed"), "}".to_string(), ]; } @@ -1492,12 +1662,8 @@ fn build_field_conversion( let conversion = go_value_conversion(value, api_plan, backend, &backend.package)?; let to_lines = repeated_to_proto_lines(&conversion, &receiver, &proto_field, "return nil, err"); - let from_lines = repeated_from_proto_lines( - &conversion, - &proto_field, - &go_field, - "return value, err", - ); + let from_lines = + repeated_from_proto_lines(&conversion, &proto_field, &go_field, "return err"); Ok(RenderedFieldConversion { to_proto_lines: to_lines, from_proto_lines: from_lines, @@ -1524,7 +1690,7 @@ fn build_field_conversion( native_go_type, &proto_field, &go_field, - "return value, err", + "return err", ); Ok(RenderedFieldConversion { to_proto_lines: to_lines, @@ -1534,123 +1700,64 @@ fn build_field_conversion( } } -/// Builds `ToProto` lines for sourced (write-only) fields. -fn build_sourced_conversions( - planned_model: &RecordSpec, - api_plan: &PlannedSpec, - backend: &ModelBackend, -) -> Result> { - planned_model - .sourced_fields() - .map(|(field_name, field, source_expr)| { - build_sourced_conversion(field_name, field, source_expr, api_plan, backend).map_err( - |reason| Error::UnsupportedGoProtoConversion { - context: format!("sourced field `{}.{}`", planned_model.name, field_name), - reason, - }, - ) - }) - .collect() +/// Renders the transfer-type converter for a model used as a top-level Nexus +/// operation input or output. +/// +/// The converter tells the Temporal Go SDK how to turn the model into its proto +/// transfer value *inside* the payload converter, rather than in generated code +/// before the call. This matters because the model's user-payload fields +/// (workflow args, signal args, memo) must be encoded by the converter the SDK +/// has selected for the operation, not by the caller's. +/// +/// Only the workflow-context variants are implemented: encoding needs a +/// `workflow.Context` to reach the workflow's data converter, and there is no +/// `context.Context`-flavoured equivalent in the SDK. The other variants return +/// an error rather than silently falling back to the default converter. +fn render_model_transfer_type_converter( + output: &mut String, + model: &RenderedModel, + wire: &RenderedModelWire, + package: &GoPackageContext, +) { + let (model_ident, _) = split_go_type_decl_name(&model.name); + let proto_value_type = wire.proto_type.trim_start_matches('*'); + let base = go_unexported_name(model_ident); + let from_proto_fn = format!("{base}FromProto"); + let to_proto_fn = format!("{base}ToProto"); + + let new_converter = package.new_transfer_type_converter(); + let converter_type = package.transfer_type_converter_type(); + let context_context = package.context_context_type(); + let errors_new = package.errors_new(); + + // A value receiver opts in both `T` and `*T`. The SDK caches the returned + // converter by model type, so no generated package-level cache is needed. + output.push('\n'); + output.push_str(&format!( + "func ({model_ident}) TransferTypeConverter() ({converter_type}, error) {{\n" + )); + output.push_str(&format!( + "\treturn {new_converter}(\n" + )); + output.push_str(&format!( + "\t\tfunc({context_context}, *{model_ident}) (*{proto_value_type}, error) {{\n\t\t\treturn nil, {errors_new}(\"nexgen: transfer type converter outside a workflow\")\n\t\t}},\n" + )); + output.push_str(&format!( + "\t\tfunc({context_context}, *{proto_value_type}, *{model_ident}) error {{\n\t\t\treturn {errors_new}(\"nexgen: transfer type converter outside a workflow\")\n\t\t}},\n" + )); + output.push_str(&format!("\t\t{to_proto_fn},\n\t\t{from_proto_fn},\n")); + output.push_str("\t)\n}\n"); } -fn build_sourced_conversion( - proto_name: &str, - field: &RecordFieldSpec, - source_expr: &str, - api_plan: &PlannedSpec, - backend: &ModelBackend, -) -> GoConversionResult { - let proto_field = go_proto_field_name(proto_name); - match &planned_field_kind(&field.field_type, api_plan) { - PlannedFieldKind::Singular(value) => { - let conversion = go_value_conversion(value, api_plan, backend, &backend.package)?; - match conversion.kind { - GoConversionKind::OverrideConverter => { - let arg = if conversion.to_proto_takes_pointer { - "&sourced" - } else { - "sourced" - }; - let converted = (conversion.to_proto)(arg); - let mut to_proto_lines = vec![format!("sourced := {source_expr}")]; - if conversion.fallible { - to_proto_lines.extend([ - format!("converted, err := {converted}"), - "if err != nil {".to_string(), - "\treturn nil, err".to_string(), - "}".to_string(), - format!("message.{proto_field} = converted"), - ]); - } else { - to_proto_lines.push(format!("message.{proto_field} = {converted}")); - } - Ok(RenderedSourcedField { to_proto_lines }) - } - _ => { - let converted = (conversion.to_proto)(source_expr); - if conversion.fallible { - Ok(RenderedSourcedField { - to_proto_lines: vec![ - format!("converted, err := {converted}"), - "if err != nil {".to_string(), - "\treturn nil, err".to_string(), - "}".to_string(), - format!("message.{proto_field} = converted"), - ], - }) - } else { - Ok(RenderedSourcedField { - to_proto_lines: vec![format!("message.{proto_field} = {converted}")], - }) - } - } - } - } - PlannedFieldKind::Repeated(value) => { - let conversion = go_value_conversion(value, api_plan, backend, &backend.package)?; - let converted = match conversion.kind { - GoConversionKind::OverrideConverter if conversion.to_proto_takes_pointer => { - (conversion.to_proto)("&item") - } - _ => (conversion.to_proto)("item"), - }; - let mut to_proto_lines = vec![format!("for _, item := range {source_expr} {{")]; - if conversion.fallible { - to_proto_lines.push(format!("\tconverted, err := {converted}")); - to_proto_lines.push("\tif err != nil {".to_string()); - to_proto_lines.push("\t\treturn nil, err".to_string()); - to_proto_lines.push("\t}".to_string()); - to_proto_lines.push(format!( - "\tmessage.{proto_field} = append(message.{proto_field}, converted)" - )); - } else { - to_proto_lines.push(format!( - "\tmessage.{proto_field} = append(message.{proto_field}, {converted})" - )); - } - to_proto_lines.push("}".to_string()); - Ok(RenderedSourcedField { to_proto_lines }) - } - PlannedFieldKind::Map { key, value } => { - let conversion = go_value_conversion(value, api_plan, backend, &backend.package)?; - let key_type = backend - .value_proto_type(key) - .map_err(|reason| format!("map key: {reason}"))?; - let value_type = backend - .value_proto_type(value) - .map_err(|reason| format!("map value: {reason}"))?; - let proto_map_type = format!("map[{key_type}]{value_type}"); - let local = format!("sourced{proto_field}"); - let mut to_proto_lines = vec![format!("{local} := {source_expr}")]; - to_proto_lines.extend(map_to_proto_lines( - &conversion, - &proto_map_type, - &local, - &proto_field, - "return nil, err", - )); - Ok(RenderedSourcedField { to_proto_lines }) - } +/// Go struct field name for a model field. Sourced fields are rendered as +/// unexported fields so callers outside the generated package cannot set them; +/// generated operation code populates them at the call site. +fn go_model_field_name(authored_name: &str, sourced: bool) -> String { + let name = go_field_name(authored_name); + if sourced { + go_unexported_name(&name) + } else { + name } } @@ -1699,7 +1806,12 @@ fn singular_to_proto_lines( } GoConversionKind::Scalar | GoConversionKind::Enum | GoConversionKind::ModelConverter => { if field_is_pointer { - let converted = (conversion.to_proto)(&format!("(*{receiver})")); + let arg = if conversion.to_proto_takes_pointer { + receiver.to_string() + } else { + format!("(*{receiver})") + }; + let converted = (conversion.to_proto)(&arg); if conversion.fallible { vec![ format!("if {receiver} != nil {{"), @@ -1718,7 +1830,12 @@ fn singular_to_proto_lines( ] } } else { - let converted = (conversion.to_proto)(receiver); + let arg = if conversion.to_proto_takes_pointer { + format!("&{receiver}") + } else { + receiver.to_string() + }; + let converted = (conversion.to_proto)(&arg); if conversion.fallible { checked_assign(converted) } else { @@ -1729,7 +1846,7 @@ fn singular_to_proto_lines( } } -/// `FromProto` lines for a singular field, assigning into `value.`. +/// `FromProto` lines for a singular field, assigning into `out.`. fn singular_from_proto_lines( conversion: &GoValueConversion, proto_field: &str, @@ -1740,7 +1857,7 @@ fn singular_from_proto_lines( let getter = format!("proto.Get{proto_field}()"); match conversion.kind { GoConversionKind::OverrideConverter => { - let converted = (conversion.from_proto)(&getter); + let converted = (conversion.from_proto)(&getter, ""); if !conversion.from_proto_returns_pointer { if conversion.fallible { vec![ @@ -1750,9 +1867,9 @@ fn singular_from_proto_lines( format!("\t\t{error_return}"), "\t}".to_string(), if field_is_pointer { - format!("\tvalue.{go_field} = &converted") + format!("\tout.{go_field} = &converted") } else { - format!("\tvalue.{go_field} = converted") + format!("\tout.{go_field} = converted") }, "}".to_string(), ] @@ -1761,11 +1878,11 @@ fn singular_from_proto_lines( vec![ "{".to_string(), format!("\tconverted := {converted}"), - format!("\tvalue.{go_field} = &converted"), + format!("\tout.{go_field} = &converted"), "}".to_string(), ] } else { - vec![format!("value.{go_field} = {converted}")] + vec![format!("out.{go_field} = {converted}")] } } } else if field_is_pointer { @@ -1776,11 +1893,11 @@ fn singular_from_proto_lines( "\tif err != nil {".to_string(), format!("\t\t{error_return}"), "\t}".to_string(), - format!("\tvalue.{go_field} = converted"), + format!("\tout.{go_field} = converted"), "}".to_string(), ] } else { - vec![format!("value.{go_field} = {converted}")] + vec![format!("out.{go_field} = {converted}")] } } else if conversion.fallible { vec![ @@ -1790,64 +1907,50 @@ fn singular_from_proto_lines( format!("\t\t{error_return}"), "\t}".to_string(), "\tif converted != nil {".to_string(), - format!("\t\tvalue.{go_field} = *converted"), + format!("\t\tout.{go_field} = *converted"), "\t}".to_string(), "}".to_string(), ] } else { vec![ format!("if converted := {converted}; converted != nil {{"), - format!("\tvalue.{go_field} = *converted"), + format!("\tout.{go_field} = *converted"), "}".to_string(), ] } } GoConversionKind::ModelConverter => { - let converted = (conversion.from_proto)(&getter); if field_is_pointer { - if conversion.fallible { - vec![ - format!("if {getter} != nil {{"), - format!("\tconverted, err := {converted}"), - "\tif err != nil {".to_string(), - format!("\t\t{error_return}"), - "\t}".to_string(), - format!("\tvalue.{go_field} = &converted"), - "}".to_string(), - ] - } else { - vec![ - format!("if {getter} != nil {{"), - format!("\tconverted := {converted}"), - format!("\tvalue.{go_field} = &converted"), - "}".to_string(), - ] - } - } else if conversion.fallible { + let native_type = conversion.decoded_model_type.as_ref().unwrap(); + let call = (conversion.from_proto)(&getter, &format!("out.{go_field}")); vec![ - "{".to_string(), - format!("\tconverted, err := {converted}"), - "\tif err != nil {".to_string(), + format!("if {getter} != nil {{"), + format!("\tout.{go_field} = new({native_type})"), + format!("\tif err := {call}; err != nil {{"), format!("\t\t{error_return}"), "\t}".to_string(), - format!("\tvalue.{go_field} = converted"), "}".to_string(), ] } else { - vec![format!("value.{go_field} = {converted}")] + let call = (conversion.from_proto)(&getter, &format!("&out.{go_field}")); + vec![ + format!("if err := {call}; err != nil {{"), + format!("\t{error_return}"), + "}".to_string(), + ] } } GoConversionKind::Scalar | GoConversionKind::Enum => { - let converted = (conversion.from_proto)(&getter); + let converted = (conversion.from_proto)(&getter, ""); if field_is_pointer { vec![ "{".to_string(), format!("\tconverted := {converted}"), - format!("\tvalue.{go_field} = &converted"), + format!("\tout.{go_field} = &converted"), "}".to_string(), ] } else { - vec![format!("value.{go_field} = {converted}")] + vec![format!("out.{go_field} = {converted}")] } } } @@ -1860,11 +1963,10 @@ fn repeated_to_proto_lines( proto_field: &str, error_return: &str, ) -> Vec { - let converted = match conversion.kind { - GoConversionKind::OverrideConverter if conversion.to_proto_takes_pointer => { - (conversion.to_proto)("&item") - } - _ => (conversion.to_proto)("item"), + let converted = if conversion.to_proto_takes_pointer { + (conversion.to_proto)("&item") + } else { + (conversion.to_proto)("item") }; let mut lines = vec![format!("for _, item := range {receiver} {{")]; if conversion.fallible { @@ -1891,7 +1993,7 @@ fn repeated_from_proto_lines( go_field: &str, error_return: &str, ) -> Vec { - let converted = (conversion.from_proto)("item"); + let converted = (conversion.from_proto)("item", "&converted"); let mut lines = vec![format!("for _, item := range proto.Get{proto_field}() {{")]; if conversion.kind == GoConversionKind::OverrideConverter && conversion.from_proto_returns_pointer @@ -1903,7 +2005,7 @@ fn repeated_from_proto_lines( lines.push("\t}".to_string()); lines.push("\tif converted != nil {".to_string()); lines.push(format!( - "\t\tvalue.{go_field} = append(value.{go_field}, *converted)" + "\t\tout.{go_field} = append(out.{go_field}, *converted)" )); lines.push("\t}".to_string()); } else if conversion.kind == GoConversionKind::OverrideConverter @@ -1913,20 +2015,22 @@ fn repeated_from_proto_lines( "\tif converted := {converted}; converted != nil {{" )); lines.push(format!( - "\t\tvalue.{go_field} = append(value.{go_field}, *converted)" + "\t\tout.{go_field} = append(out.{go_field}, *converted)" )); lines.push("\t}".to_string()); } else if conversion.fallible { - lines.push(format!("\tconverted, err := {converted}")); - lines.push("\tif err != nil {".to_string()); - lines.push(format!("\t\t{error_return}")); - lines.push("\t}".to_string()); + lines.extend( + conversion + .checked_from_proto_lines("item", error_return) + .into_iter() + .map(|line| format!("\t{line}")), + ); lines.push(format!( - "\tvalue.{go_field} = append(value.{go_field}, converted)" + "\tout.{go_field} = append(out.{go_field}, converted)" )); } else { lines.push(format!( - "\tvalue.{go_field} = append(value.{go_field}, {converted})" + "\tout.{go_field} = append(out.{go_field}, {converted})" )); } lines.push("}".to_string()); @@ -1942,11 +2046,10 @@ fn map_to_proto_lines( proto_field: &str, error_return: &str, ) -> Vec { - let converted = match conversion.kind { - GoConversionKind::OverrideConverter if conversion.to_proto_takes_pointer => { - (conversion.to_proto)("&v") - } - _ => (conversion.to_proto)("v"), + let converted = if conversion.to_proto_takes_pointer { + (conversion.to_proto)("&v") + } else { + (conversion.to_proto)("v") }; let mut lines = vec![ format!("if len({receiver}) > 0 {{"), @@ -1977,10 +2080,10 @@ fn map_from_proto_lines( error_return: &str, ) -> Vec { let getter = format!("proto.Get{proto_field}()"); - let converted = (conversion.from_proto)("v"); + let converted = (conversion.from_proto)("v", "&converted"); let mut lines = vec![ format!("if len({getter}) > 0 {{"), - format!("\tvalue.{go_field} = make({native_map_type}, len({getter}))"), + format!("\tout.{go_field} = make({native_map_type}, len({getter}))"), format!("\tfor k, v := range {getter} {{"), ]; match conversion.kind { @@ -1991,9 +2094,9 @@ fn map_from_proto_lines( lines.push("\t\tif err != nil {".to_string()); lines.push(format!("\t\t\t{error_return}")); lines.push("\t\t}".to_string()); - lines.push(format!("\t\tvalue.{go_field}[k] = converted")); + lines.push(format!("\t\tout.{go_field}[k] = converted")); } else { - lines.push(format!("\t\tvalue.{go_field}[k] = {converted}")); + lines.push(format!("\t\tout.{go_field}[k] = {converted}")); } } else { if conversion.fallible { @@ -2007,19 +2110,21 @@ fn map_from_proto_lines( "\t\tif converted := {converted}; converted != nil {{" )); } - lines.push(format!("\t\t\tvalue.{go_field}[k] = *converted")); + lines.push(format!("\t\t\tout.{go_field}[k] = *converted")); lines.push("\t\t}".to_string()); } } _ => { if conversion.fallible { - lines.push(format!("\t\tconverted, err := {converted}")); - lines.push("\t\tif err != nil {".to_string()); - lines.push(format!("\t\t\t{error_return}")); - lines.push("\t\t}".to_string()); - lines.push(format!("\t\tvalue.{go_field}[k] = converted")); + lines.extend( + conversion + .checked_from_proto_lines("v", error_return) + .into_iter() + .map(|line| format!("\t\t{line}")), + ); + lines.push(format!("\t\tout.{go_field}[k] = converted")); } else { - lines.push(format!("\t\tvalue.{go_field}[k] = {converted}")); + lines.push(format!("\t\tout.{go_field}[k] = {converted}")); } } } @@ -2028,21 +2133,24 @@ fn map_from_proto_lines( lines } -/// Renders the `toProto` method and unexported from-proto constructor for a -/// proto-backed model. -fn render_model_wire_methods( +/// Renders the unexported conversion functions for a proto-backed model. +fn render_model_converters( output: &mut String, model: &RenderedModel, wire: &RenderedModelWire, package: &GoPackageContext, ) { let proto_value_type = wire.proto_type.trim_start_matches('*'); + let (model_ident, _) = split_go_type_decl_name(&model.name); + let base = go_unexported_name(model_ident); output.push('\n'); - output.push_str("func (m "); - output.push_str(&model.name); - output.push_str(") toProto(ctx "); + output.push_str("func "); + output.push_str(&base); + output.push_str("ToProto(ctx "); output.push_str(&package.workflow_context_type()); + output.push_str(", m *"); + output.push_str(&model.name); output.push_str(") ("); output.push_str(&wire.proto_type); output.push_str(", error) {\n"); @@ -2056,28 +2164,20 @@ fn render_model_wire_methods( output.push('\n'); } } - for sourced in &wire.sourced_fields { - for line in &sourced.to_proto_lines { - output.push('\t'); - output.push_str(line); - output.push('\n'); - } - } output.push_str("\treturn message, nil\n"); output.push_str("}\n"); output.push('\n'); output.push_str("func "); - let (model_ident, _) = split_go_type_decl_name(&model.name); - output.push_str(&go_unexported_name(model_ident)); + output.push_str(&base); output.push_str("FromProto(ctx "); output.push_str(&package.workflow_context_type()); output.push_str(", proto "); output.push_str(&wire.proto_type); - output.push_str(") ("); + output.push_str(", out *"); output.push_str(&model.name); - output.push_str(", error) {\n"); - output.push_str("\tvalue := "); + output.push_str(") error {\n"); + output.push_str("\t*out = "); output.push_str(&model.name); output.push_str("{}\n"); for conversion in &wire.field_conversions { @@ -2087,6 +2187,6 @@ fn render_model_wire_methods( output.push('\n'); } } - output.push_str("\treturn value, nil\n"); + output.push_str("\treturn nil\n"); output.push_str("}\n"); } diff --git a/tests/generate_go.rs b/tests/generate_go.rs index f4f3d9f0..70647343 100644 --- a/tests/generate_go.rs +++ b/tests/generate_go.rs @@ -601,13 +601,20 @@ interface namespace-service { ) .unwrap(); - // The sourced map is bound to a field-unique local, evaluated once, and - // copied into a properly typed proto map. - assert!(rendered.contains("sourcedData := NamespaceData()")); - assert!(rendered.contains("if len(sourcedData) > 0 {")); - assert!(rendered.contains("message.Data = make(map[string]string, len(sourcedData))")); - assert!(rendered.contains("for k, v := range sourcedData {")); + // Sourced fields live on the model as unexported fields, populated by the + // operation function where `workflow.Context` is in scope. + assert!( + rendered + .contains("type namespaceInfo struct {\n\tName *string\n\tdata map[string]string\n}") + ); + assert!(rendered.contains("\trequest.data = NamespaceData()\n")); + // The sourced map is then converted like any other model field, and it now + // round-trips back out of the proto as well. + assert!(rendered.contains("if len(m.data) > 0 {")); + assert!(rendered.contains("message.Data = make(map[string]string, len(m.data))")); + assert!(rendered.contains("for k, v := range m.data {")); assert!(rendered.contains("message.Data[k] = v")); + assert!(rendered.contains("out.data = make(map[string]string, len(proto.GetData()))")); fs::remove_dir_all(temp_dir).unwrap(); } @@ -1369,10 +1376,10 @@ fn go_type_roundtrip_generates_proto_conversions() { assert!(rendered.contains("ScheduleToCloseTimeout *time.Duration")); assert!(rendered.contains("Priority *temporal.Priority")); - // Generated model gets a context-aware ToProto method targeting the proto - // message type and returning conversion errors. + // Generated model gets a context-aware encoding function that accepts a + // pointer and returns the proto message and conversion errors. assert!(rendered - .contains("func (m ActivityOptions) toProto(ctx workflow.Context) (*activity.ActivityOptions, error) {")); + .contains("func activityOptionsToProto(ctx workflow.Context, m *ActivityOptions) (*activity.ActivityOptions, error) {")); assert!(rendered.contains("message := &activity.ActivityOptions{}")); assert!(rendered.contains( "type ActivityOptions struct {\n\t// TaskQueue - Optional.\n\tTaskQueue *string" @@ -1389,25 +1396,37 @@ fn go_type_roundtrip_generates_proto_conversions() { assert!(rendered.contains("converted, err := durationToProto(ctx, m.ScheduleToCloseTimeout)")); assert!(rendered.contains("return message, nil")); - // Generated model gets a context-aware FromProto constructor. Optional + // Generated model gets a context-aware FromProto output callback. Optional // override fields assign the converter's pointer result directly; the // required field is dereferenced with a nil guard. assert!(rendered.contains( - "func activityOptionsFromProto(ctx workflow.Context, proto *activity.ActivityOptions) (ActivityOptions, error) {" + "func activityOptionsFromProto(ctx workflow.Context, proto *activity.ActivityOptions, out *ActivityOptions) error {" )); assert!(rendered.contains("converted, err := taskQueueFromProto(ctx, proto.GetTaskQueue())")); assert!( rendered.contains("converted, err := retryPolicyFromProto(ctx, proto.GetRetryPolicy())") ); - assert!(rendered.contains("value.RetryPolicy = *converted")); - // Operation functions convert the request to proto before the SDK call and - // decode the proto response afterwards. - assert!(rendered.contains("requestProto, err := request.toProto(ctx)")); + assert!(rendered.contains("out.RetryPolicy = *converted")); + // The model constructs a transfer-type converter, so the SDK + // runs model<->proto conversion inside the payload converter. + assert!(rendered.contains( + "return converter.NewContextualTransferTypeConverter(" + )); assert!(rendered.contains( - "fut := c.ExecuteOperation(ctx, \"ActivityOptionsOperation\", requestProto, workflow.NexusOperationOptions{})" + "func (ActivityOptions) TransferTypeConverter() (converter.TransferTypeConverter, error) {\n\treturn converter.NewContextualTransferTypeConverter" + )); + assert!(rendered.contains("\t\tactivityOptionsToProto,\n\t\tactivityOptionsFromProto,\n")); + assert!(rendered.contains("\t*out = ActivityOptions{}\n")); + assert!(!rendered.contains("\tvalue := ActivityOptions{}")); + // Operation functions therefore hand the model straight to the SDK and + // decode the response back into a model. + assert!(!rendered.contains(".toProto(ctx)")); + assert!(rendered.contains( + "fut := c.ExecuteOperation(ctx, \"ActivityOptionsOperation\", request, workflow.NexusOperationOptions{})" + )); + assert!(rendered.contains( + "\t\tvar value ActivityOptions\n\t\tif err := fut.Get(ctx, &value); err != nil {" )); - assert!(rendered.contains("var result activity.ActivityOptions")); - assert!(rendered.contains("value, err := activityOptionsFromProto(ctx, &result)")); // The hand-written support fragment is emitted alongside the generated // service file with the pointer-in/pointer-out converter contract. @@ -1422,25 +1441,346 @@ fn go_type_roundtrip_generates_proto_conversions() { } #[test] -fn go_proto_resource_return_converts_request_and_constructs_resource() { +fn go_transfer_type_converter_defers_model_conversion_to_the_sdk() { let root = project_root(); let rendered = generate_to_string_with_inputs( nexgen::language::Language::Go, - &example_input_paths(&root, "start-workflow"), + &example_input_paths(&root, "workflow-service"), &[descriptor_path(&root)], ) .unwrap(); - assert!(rendered.contains("\trequestProto, err := request.toProto(ctx)\n")); + // Sourced fields become unexported model fields so they survive payload + // conversion without widening the public API. + assert!(rendered.contains("\tnamespace string\n")); + assert!(rendered.contains("\tmessage.Namespace = m.namespace\n")); + assert!(rendered.contains("\tout.namespace = proto.GetNamespace()\n")); + assert!(rendered.contains("\trequest.namespace = workflow.GetInfo(ctx).Namespace\n")); + + // Construction happens in the method; the SDK owns caching by model type. + assert!(rendered.contains( + "return converter.NewContextualTransferTypeConverter(" + )); + // Value receiver, so both the model and a pointer to it opt in. + assert!(rendered.contains( + "func (signalWithStartWorkflowRequest) TransferTypeConverter() (converter.TransferTypeConverter, error) {\n\treturn converter.NewContextualTransferTypeConverter" + )); + + // Conversion needs a workflow.Context, so the context-free and + // context.Context variants fail loudly instead of silently misconverting. + assert!(rendered.contains( + "\t\tfunc(context.Context, *signalWithStartWorkflowRequest) (*workflowservice.SignalWithStartWorkflowExecutionRequest, error) {\n\t\t\treturn nil, errors.New(\"nexgen: transfer type converter outside a workflow\")\n\t\t},\n" + )); assert!(rendered.contains( - "\tif err != nil {\n\t\tresult, resultSettable := workflow.NewFuture(ctx)\n\t\tresultSettable.SetError(err)\n\t\treturn result\n\t}\n" + "\t\tfunc(context.Context, *workflowservice.SignalWithStartWorkflowExecutionRequest, *signalWithStartWorkflowRequest) error {\n\t\t\treturn errors.New(\"nexgen: transfer type converter outside a workflow\")\n\t\t},\n" )); assert!(rendered.contains( - "fut := c.ExecuteOperation(ctx, \"StartWorkflow\", requestProto, workflow.NexusOperationOptions{})" + "\t\tsignalWithStartWorkflowRequestToProto,\n\t\tsignalWithStartWorkflowRequestFromProto,\n" )); + + // Operation responses decode into the model, not the proto. + assert!(rendered.contains( + "func (SignalWithStartWorkflowResponse) TransferTypeConverter() (converter.TransferTypeConverter, error) {" + )); + assert!(rendered.contains("\t\tvar value SignalWithStartWorkflowResponse\n")); + assert!(!rendered.contains("requestProto")); + + // Inline non-workflow callbacks need `context` and `errors`. + assert!(rendered.contains("\t\"context\"\n\t\"errors\"\n")); + assert!(!rendered.contains("transferTypeConverterOutsideWorkflow")); + assert!(!rendered.contains("NeedsWorkflowContext")); + assert!(!rendered.contains("TransferTypeConverterErr")); + assert!(!rendered.contains("var signalWithStartWorkflowRequestTransferTypeConverter")); +} + +#[test] +fn go_nested_model_encoding_bridges_pointer_arguments() { + use prost::Message; + use prost_types::field_descriptor_proto::{Label, Type}; + use prost_types::{ + DescriptorProto, FieldDescriptorProto, FileDescriptorProto, FileDescriptorSet, FileOptions, + MessageOptions, + }; + + let temp_dir = unique_output_path("go-nested-models"); + fs::create_dir_all(&temp_dir).unwrap(); + let field = |name: &str, number, label: Label, kind: Type, type_name: Option<&str>| { + FieldDescriptorProto { + name: Some(name.into()), + number: Some(number), + label: Some(label as i32), + r#type: Some(kind as i32), + type_name: type_name.map(str::to_string), + ..Default::default() + } + }; + let descriptors = FileDescriptorSet { + file: vec![FileDescriptorProto { + name: Some("nested.proto".into()), + package: Some("test".into()), + syntax: Some("proto3".into()), + options: Some(FileOptions { + go_package: Some("example.com/test/wire;wire".into()), + ..Default::default() + }), + message_type: vec![ + DescriptorProto { + name: Some("Child".into()), + field: vec![field("name", 1, Label::Optional, Type::String, None)], + ..Default::default() + }, + DescriptorProto { + name: Some("Parent".into()), + field: vec![ + field( + "required", + 1, + Label::Optional, + Type::Message, + Some(".test.Child"), + ), + field( + "optional", + 2, + Label::Optional, + Type::Message, + Some(".test.Child"), + ), + field( + "children", + 3, + Label::Repeated, + Type::Message, + Some(".test.Child"), + ), + field( + "entries", + 4, + Label::Repeated, + Type::Message, + Some(".test.Parent.EntriesEntry"), + ), + ], + nested_type: vec![DescriptorProto { + name: Some("EntriesEntry".into()), + field: vec![ + field("key", 1, Label::Optional, Type::String, None), + field( + "value", + 2, + Label::Optional, + Type::Message, + Some(".test.Child"), + ), + ], + options: Some(MessageOptions { + map_entry: Some(true), + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }, + ], + ..Default::default() + }], + }; + let descriptor_path = temp_dir.join("nested.bin"); + fs::write(&descriptor_path, descriptors.encode_to_vec()).unwrap(); + let input_path = temp_dir.join("nested.wit"); + fs::write( + &input_path, + r#"package test:nested; + +interface models { + /// @nexus.proto "test.Child" + record child { + name: string, + } + /// @nexus.proto "test.Parent" + record parent { + required: child, + optional: option, + children: option>, + entries: option>, + } + echo: func(request: parent) -> parent; +} + +world api { + export models; +} +"#, + ) + .unwrap(); + let output_path = temp_dir.join("output"); + generate_to_file(&GenerateRequest { + config: nexgen::nexgen_config::NexgenConfig { + mode: nexgen::generator::GenerationMode::NativeApi, + ..Default::default() + }, + language: nexgen::language::Language::Go, + input_paths: vec![input_path], + support_paths: Vec::new(), + descriptor_paths: vec![descriptor_path], + output_path: output_path.clone(), + format: false, + java_package_name: None, + ts_date_time_types: Default::default(), + }) + .unwrap(); + let files = read_go_output_files(&output_path); + let rendered = files.values().cloned().collect::>().join("\n"); + assert!(!files.contains_key(&PathBuf::from("support.go"))); + let root = project_root(); + let module = fs::read_to_string(root.join("advanced/samples/go/go.mod")).unwrap(); + fs::write( + temp_dir.join("go.mod"), + module.replace("go.temporal.io/sdk/advanced/samples/go", "example.com/test"), + ) + .unwrap(); + fs::copy( + root.join("advanced/samples/go/go.sum"), + temp_dir.join("go.sum"), + ) + .unwrap(); + fs::create_dir(temp_dir.join("wire")).unwrap(); + fs::write( + temp_dir.join("wire/wire.go"), + include_str!("fixtures/go_nested_wire.go"), + ) + .unwrap(); + fs::write( + output_path.join("conversion_test.go"), + include_str!("fixtures/go_nested_conversion_test.go"), + ) + .unwrap(); + let status = Command::new("go") + .args(["test", "./output"]) + .current_dir(&temp_dir) + .status() + .unwrap(); + fs::remove_dir_all(temp_dir).unwrap(); + assert!(status.success(), "standalone nested Go model tests failed"); + + assert!( + rendered + .contains("func childToProto(ctx workflow.Context, m *Child) (*wire.Child, error) {"), + "{rendered}" + ); + assert!(rendered.contains( + "func childFromProto(ctx workflow.Context, proto *wire.Child, out *Child) error {" + )); + assert!(rendered.contains("converted, err := childToProto(ctx, &m.Required)")); + assert!( + rendered.contains( + "if m.Optional != nil {\n\t\tconverted, err := childToProto(ctx, m.Optional)" + ) + ); + assert!(rendered.contains( + "for _, item := range m.Children {\n\t\tconverted, err := childToProto(ctx, &item)" + )); + assert!( + rendered.contains( + "for k, v := range m.Entries {\n\t\t\tconverted, err := childToProto(ctx, &v)" + ) + ); + assert!(rendered.contains( + "if proto.GetOptional() != nil {\n\t\tout.Optional = new(Child)\n\t\tif err := childFromProto(ctx, proto.GetOptional(), out.Optional); err != nil {" + )); + assert!(rendered.contains("out.Optional = new(Child)")); + assert!(rendered.contains( + "if err := childFromProto(ctx, proto.GetRequired(), &out.Required); err != nil {" + )); + assert!(rendered.contains( + "for _, item := range proto.GetChildren() {\n\t\tvar converted Child\n\t\tif err := childFromProto(ctx, item, &converted); err != nil {" + )); + assert!(rendered.contains( + "for k, v := range proto.GetEntries() {\n\t\t\tvar converted Child\n\t\t\tif err := childFromProto(ctx, v, &converted); err != nil {" + )); + assert!(rendered.contains("\t*out = Parent{}\n")); + assert!(rendered.contains("out.Children = append(out.Children, converted)")); + assert!(rendered.contains("out.Entries[k] = converted")); + assert!(!rendered.contains("return value, err")); + assert!(!rendered.contains(".toProto(")); +} + +#[test] +fn go_model_decode_uses_direct_output_and_shared_support_callbacks() { + let root = project_root(); + let temp_dir = unique_output_path("go-model-decode"); + fs::create_dir_all(&temp_dir).unwrap(); + let input_path = temp_dir.join("api.wit"); + fs::write( + &input_path, + r#"package test:decode; +interface service { + use nexus:temporal-types/model@1.0.0.{user-metadata}; + echo: func(request: user-metadata) -> user-metadata; +} +world api { + export service; +} +"#, + ) + .unwrap(); + let output_path = temp_dir.join("output"); + generate_to_file(&GenerateRequest { + config: nexgen::nexgen_config::NexgenConfig { + mode: nexgen::generator::GenerationMode::NativeApi, + ..Default::default() + }, + language: nexgen::language::Language::Go, + input_paths: vec![input_path, linked_inputs_path(&root)], + support_paths: Vec::new(), + descriptor_paths: vec![descriptor_path(&root)], + output_path: output_path.clone(), + format: true, + java_package_name: None, + ts_date_time_types: Default::default(), + }) + .unwrap(); + for name in ["go.mod", "go.sum"] { + fs::copy( + root.join("advanced/samples/go").join(name), + temp_dir.join(name), + ) + .unwrap(); + } + fs::write( + output_path.join("conversion_test.go"), + include_str!("fixtures/go_model_conversion_test.go"), + ) + .unwrap(); + let status = Command::new("go") + .args(["test", "./output"]) + .current_dir(&temp_dir) + .status() + .unwrap(); + fs::remove_dir_all(temp_dir).unwrap(); + assert!(status.success(), "generated Go model runtime tests failed"); +} + +#[test] +fn go_proto_resource_return_converts_request_and_constructs_resource() { + let root = project_root(); + let rendered = generate_to_string_with_inputs( + nexgen::language::Language::Go, + &example_input_paths(&root, "start-workflow"), + &[descriptor_path(&root)], + ) + .unwrap(); + + assert!(rendered.contains("\trequest.namespace = workflow.GetInfo(ctx).Namespace\n")); + assert!(rendered.contains( + "fut := c.ExecuteOperation(ctx, \"StartWorkflow\", request, workflow.NexusOperationOptions{})" + )); + // Resource returns keep decoding the raw proto response: the planner emits + // no model for the resource type, so there is nothing to decode into. assert!(rendered.contains("\tvar result workflowservice.StartWorkflowExecutionResponse\n")); + // Sourced constructor arguments are read off the model instead of the proto. assert!(rendered.contains( - "value := NewStartedWorkflow(requestProto.GetNamespace(), request.WorkflowID, result.GetRunId())" + "value := NewStartedWorkflow(request.namespace, request.WorkflowID, result.GetRunId())" )); assert!(rendered.contains( "type StartedWorkflow struct {\n\t// Namespace - Required.\n\tNamespace string\n\t// WorkflowID - Required.\n\tWorkflowID string\n\t// RunID - Optional.\n\tRunID *string\n}" @@ -1449,7 +1789,7 @@ fn go_proto_resource_return_converts_request_and_constructs_resource() { "func NewStartedWorkflow(namespace string, workflowID string, runID string) StartedWorkflow" )); assert!(rendered.contains( - "fut := c.ExecuteOperation(ctx, \"RestartWorkflow\", requestProto, workflow.NexusOperationOptions{})" + "fut := c.ExecuteOperation(ctx, \"RestartWorkflow\", request, workflow.NexusOperationOptions{})" )); assert!(rendered.contains("func StartWorkflow(")); assert!(rendered.contains( @@ -1528,10 +1868,7 @@ interface workflow-service { ) .unwrap(); - assert!( - rendered - .contains("value := NewSignalResult(requestProto.GetNamespace(), result.GetStarted())") - ); + assert!(rendered.contains("value := NewSignalResult(request.namespace, result.GetStarted())")); fs::remove_dir_all(temp_dir).unwrap(); }