diff --git a/Makefile b/Makefile index b4b33bdb5..4a1b4055f 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ ci-build: install proto http-api-docs install: grpc-install api-linter-install buf-install # Run all linters and compile proto files. -proto: grpc http-api-docs +proto: sync-nexus-annotations grpc http-api-docs nexusrpc-yaml ######################################################################## ##### Variables ###### @@ -95,6 +95,11 @@ buf-install: printf $(COLOR) "Install/update buf..." go install github.com/bufbuild/buf/cmd/buf@v1.27.0 +##### Sync external proto dependencies ##### +sync-nexus-annotations: + printf $(COLOR) "Sync nexusannotations from buf.build/temporalio/nexus-annotations..." + buf export buf.build/temporalio/nexus-annotations --output . + ##### Linters ##### api-linter: printf $(COLOR) "Run api-linter..." @@ -116,6 +121,18 @@ buf-breaking: @printf $(COLOR) "Run buf breaking changes check against master branch..." @(cd $(PROTO_ROOT) && buf breaking --against 'https://github.com/temporalio/api.git#branch=master') +nexusrpc-yaml: nexusrpc-yaml-install + printf $(COLOR) "Generate nexus/nexusrpc.yaml and nexus/nexusrpc.langs.yaml..." + mkdir -p nexus + protoc -I $(PROTO_ROOT) \ + --nexus-rpc-yaml_out=. \ + temporal/api/workflowservice/v1/* \ + temporal/api/operatorservice/v1/* + +nexusrpc-yaml-install: + printf $(COLOR) "Install protoc-gen-nexus-rpc-yaml..." + cd cmd/protoc-gen-nexus-rpc-yaml && go install . + ##### Clean ##### clean: printf $(COLOR) "Delete generated go files..." diff --git a/buf.lock b/buf.lock index fccbfb89d..f43352bf2 100644 --- a/buf.lock +++ b/buf.lock @@ -4,10 +4,15 @@ deps: - remote: buf.build owner: googleapis repository: googleapis - commit: 28151c0d0a1641bf938a7672c500e01d - digest: shake256:49215edf8ef57f7863004539deff8834cfb2195113f0b890dd1f67815d9353e28e668019165b9d872395871eeafcbab3ccfdb2b5f11734d3cca95be9e8d139de + commit: 004180b77378443887d3b55cabc00384 + digest: shake256:d26c7c2fd95f0873761af33ca4a0c0d92c8577122b6feb74eb3b0a57ebe47a98ab24a209a0e91945ac4c77204e9da0c2de0020b2cedc27bdbcdea6c431eec69b - remote: buf.build owner: grpc-ecosystem repository: grpc-gateway - commit: 048ae6ff94ca4476b3225904b1078fad - digest: shake256:e5250bf2d999516c02206d757502b902e406f35c099d0e869dc3e4f923f6870fe0805a9974c27df0695462937eae90cd4d9db90bb9a03489412560baa74a87b6 + commit: 6467306b4f624747aaf6266762ee7a1c + digest: shake256:833d648b99b9d2c18b6882ef41aaeb113e76fc38de20dda810c588d133846e6593b4da71b388bcd921b1c7ab41c7acf8f106663d7301ae9e82ceab22cf64b1b7 + - remote: buf.build + owner: temporalio + repository: nexus-annotations + commit: 599b78404fbe4e78b833d527a1d0da40 + digest: shake256:1f41ef11ccbf31d7318b0fe1915550ba6567c99dc94694d60b117fc1ffc756290ba9766c58b403986f079e2b861b42538e5f8cf0495f744cd390d223b81854ca diff --git a/buf.yaml b/buf.yaml index e984c1439..6b9d9eb57 100644 --- a/buf.yaml +++ b/buf.yaml @@ -3,11 +3,14 @@ name: buf.build/temporalio/api deps: - buf.build/grpc-ecosystem/grpc-gateway - buf.build/googleapis/googleapis + - buf.build/temporalio/nexus-annotations build: excludes: # Buf won't accept a local dependency on the google protos but we need them # to run api-linter, so just tell buf it ignore it - google + # Same for nexusannotations - local copy for api-linter, BSR dep for buf + - nexusannotations breaking: use: - WIRE_JSON diff --git a/cmd/protoc-gen-nexus-rpc-yaml/generator.go b/cmd/protoc-gen-nexus-rpc-yaml/generator.go new file mode 100644 index 000000000..53fa78fe5 --- /dev/null +++ b/cmd/protoc-gen-nexus-rpc-yaml/generator.go @@ -0,0 +1,196 @@ +package main + +import ( + "slices" + "sort" + "strings" + + nexusannotationsv1 "github.com/nexus-rpc/nexus-proto-annotations/go/nexusannotations/v1" + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/descriptorpb" + "gopkg.in/yaml.v3" +) + +func generate(gen *protogen.Plugin) error { + nexusDoc := newDoc() + langsDoc := newDoc() + + for _, f := range gen.Files { + if !f.Generate { + continue + } + for _, svc := range f.Services { + for _, m := range svc.Methods { + opts, ok := m.Desc.Options().(*descriptorpb.MethodOptions) + if !ok || opts == nil { + continue + } + if !proto.HasExtension(opts, nexusannotationsv1.E_Operation) { + continue + } + opOpts := proto.GetExtension(opts, nexusannotationsv1.E_Operation).(*nexusannotationsv1.OperationOptions) + if !slices.Contains(opOpts.GetTags(), "exposed") { + continue + } + + svcName := string(svc.Desc.Name()) + methodName := string(m.Desc.Name()) + + addOperation(nexusDoc, svcName, methodName, + map[string]string{"$ref": openAPIRef(m.Input.Desc)}, + map[string]string{"$ref": openAPIRef(m.Output.Desc)}, + ) + + addOperation(langsDoc, svcName, methodName, + langRefs(f.Desc, m.Input.Desc), + langRefs(f.Desc, m.Output.Desc), + ) + } + } + } + + if err := writeFile(gen, "nexus/nexusrpc.yaml", nexusDoc); err != nil { + return err + } + return writeFile(gen, "nexus/nexusrpc.langs.yaml", langsDoc) +} + +// openAPIRef returns the nexus-rpc-gen multi-file $ref string for a message type, +// referencing the openapiv3.yaml components/schemas entry. The path is relative to nexus/. +// +// Schema key convention used by protoc-gen-openapi (v3): +// +// {MessageName} (no package prefix) +// +// e.g. message "SignalWithStartWorkflowExecutionRequest" +// → "../openapi/openapiv3.yaml#/components/schemas/SignalWithStartWorkflowExecutionRequest" +func openAPIRef(msg protoreflect.MessageDescriptor) string { + return "../openapi/openapiv3.yaml#/components/schemas/" + string(msg.Name()) +} + +// langRefs builds the map of language-specific type refs for a message, +// derived from proto file-level options. Only keys with non-empty values are included. +// Order is canonical: go → java → ruby → csharp. +func langRefs(file protoreflect.FileDescriptor, msg protoreflect.MessageDescriptor) map[string]string { + opts, ok := file.Options().(*descriptorpb.FileOptions) + if !ok || opts == nil { + return nil + } + name := string(msg.Name()) + refs := make(map[string]string) + + if pkg := opts.GetGoPackage(); pkg != "" { + // strip the ";alias" suffix (e.g. "go.temporal.io/api/workflowservice/v1;workflowservice") + pkg = strings.SplitN(pkg, ";", 2)[0] + refs["$goRef"] = pkg + "." + name + } + if pkg := opts.GetJavaPackage(); pkg != "" { + refs["$javaRef"] = pkg + "." + name + } + if pkg := opts.GetRubyPackage(); pkg != "" { + refs["$rubyRef"] = pkg + "::" + name + } + if pkg := opts.GetCsharpNamespace(); pkg != "" { + refs["$csharpRef"] = pkg + "." + name + } + if len(refs) == 0 { + return nil + } + return refs +} + +// newDoc creates a yaml.Node document with the "nexusrpc: 1.0.0" header +// and an empty "services" mapping node, returned as a *yaml.Node (document node). +func newDoc() *yaml.Node { + doc := &yaml.Node{Kind: yaml.DocumentNode} + root := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + doc.Content = []*yaml.Node{root} + + root.Content = append(root.Content, + scalarNode("nexusrpc"), + scalarNode("1.0.0"), + scalarNode("services"), + &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}, + ) + return doc +} + +// servicesNode returns the "services" mapping node from a doc created by newDoc. +func servicesNode(doc *yaml.Node) *yaml.Node { + root := doc.Content[0] + for i := 0; i < len(root.Content)-1; i += 2 { + if root.Content[i].Value == "services" { + return root.Content[i+1] + } + } + panic("services node not found") +} + +// addOperation inserts a service → operation → {input, output} entry into doc. +// Services and operations are inserted in the order first encountered. +func addOperation(doc *yaml.Node, svcName, methodName string, input, output map[string]string) { + svcs := servicesNode(doc) + + // find or create service node + var svcOps *yaml.Node + for i := 0; i < len(svcs.Content)-1; i += 2 { + if svcs.Content[i].Value == svcName { + // find "operations" within service mapping + svcMap := svcs.Content[i+1] + for j := 0; j < len(svcMap.Content)-1; j += 2 { + if svcMap.Content[j].Value == "operations" { + svcOps = svcMap.Content[j+1] + } + } + } + } + if svcOps == nil { + svcMap := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + svcOps = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + svcMap.Content = append(svcMap.Content, + scalarNode("operations"), + svcOps, + ) + svcs.Content = append(svcs.Content, scalarNode(svcName), svcMap) + } + + // build operation node + opNode := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + if len(input) > 0 { + opNode.Content = append(opNode.Content, scalarNode("input"), mapNode(input)) + } + if len(output) > 0 { + opNode.Content = append(opNode.Content, scalarNode("output"), mapNode(output)) + } + svcOps.Content = append(svcOps.Content, scalarNode(methodName), opNode) +} + +// mapNode serializes a map[string]string as a yaml mapping node with keys in sorted order. +func mapNode(m map[string]string) *yaml.Node { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + node := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + for _, k := range keys { + node.Content = append(node.Content, scalarNode(k), scalarNode(m[k])) + } + return node +} + +func scalarNode(value string) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value} +} + +func writeFile(gen *protogen.Plugin, name string, doc *yaml.Node) error { + f := gen.NewGeneratedFile(name, "") + enc := yaml.NewEncoder(f) + enc.SetIndent(2) + if err := enc.Encode(doc); err != nil { + return err + } + return enc.Close() +} diff --git a/cmd/protoc-gen-nexus-rpc-yaml/go.mod b/cmd/protoc-gen-nexus-rpc-yaml/go.mod new file mode 100644 index 000000000..610f91712 --- /dev/null +++ b/cmd/protoc-gen-nexus-rpc-yaml/go.mod @@ -0,0 +1,10 @@ +module github.com/temporalio/api/cmd/protoc-gen-nexus-rpc-yaml + +go 1.25.4 + +require ( + google.golang.org/protobuf v1.36.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require github.com/nexus-rpc/nexus-proto-annotations v0.0.0-20260330194009-e558d6edaf84 diff --git a/cmd/protoc-gen-nexus-rpc-yaml/go.sum b/cmd/protoc-gen-nexus-rpc-yaml/go.sum new file mode 100644 index 000000000..f9fd894ce --- /dev/null +++ b/cmd/protoc-gen-nexus-rpc-yaml/go.sum @@ -0,0 +1,12 @@ +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/nexus-rpc/nexus-proto-annotations v0.0.0-20260330194009-e558d6edaf84 h1:SWHt3Coj0VvF0Km1A0wlY+IjnHKsjQLgO29io84r3wY= +github.com/nexus-rpc/nexus-proto-annotations v0.0.0-20260330194009-e558d6edaf84/go.mod h1:n3UjF1bPCW8llR8tHvbxJ+27yPWrhpo8w/Yg1IOuY0Y= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= +google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cmd/protoc-gen-nexus-rpc-yaml/main.go b/cmd/protoc-gen-nexus-rpc-yaml/main.go new file mode 100644 index 000000000..9fadd0fb1 --- /dev/null +++ b/cmd/protoc-gen-nexus-rpc-yaml/main.go @@ -0,0 +1,14 @@ +// protoc-gen-nexus-rpc-yaml is a protoc plugin that generates nexus/nexusrpc.yaml +// and nexus/nexusrpc.langs.yaml from proto service methods annotated with +// option (nexusannotations.v1.operation).tags = "exposed". +package main + +import ( + "google.golang.org/protobuf/compiler/protogen" +) + +func main() { + protogen.Options{}.Run(func(gen *protogen.Plugin) error { + return generate(gen) + }) +} diff --git a/nexus/nexusrpc.langs.yaml b/nexus/nexusrpc.langs.yaml new file mode 100644 index 000000000..2262cf57d --- /dev/null +++ b/nexus/nexusrpc.langs.yaml @@ -0,0 +1,15 @@ +nexusrpc: 1.0.0 +services: + WorkflowService: + operations: + SignalWithStartWorkflowExecution: + input: + $csharpRef: Temporalio.Api.WorkflowService.V1.SignalWithStartWorkflowExecutionRequest + $goRef: go.temporal.io/api/workflowservice/v1.SignalWithStartWorkflowExecutionRequest + $javaRef: io.temporal.api.workflowservice.v1.SignalWithStartWorkflowExecutionRequest + $rubyRef: Temporalio::Api::WorkflowService::V1::SignalWithStartWorkflowExecutionRequest + output: + $csharpRef: Temporalio.Api.WorkflowService.V1.SignalWithStartWorkflowExecutionResponse + $goRef: go.temporal.io/api/workflowservice/v1.SignalWithStartWorkflowExecutionResponse + $javaRef: io.temporal.api.workflowservice.v1.SignalWithStartWorkflowExecutionResponse + $rubyRef: Temporalio::Api::WorkflowService::V1::SignalWithStartWorkflowExecutionResponse diff --git a/nexus/nexusrpc.yaml b/nexus/nexusrpc.yaml new file mode 100644 index 000000000..bbfafb33a --- /dev/null +++ b/nexus/nexusrpc.yaml @@ -0,0 +1,9 @@ +nexusrpc: 1.0.0 +services: + WorkflowService: + operations: + SignalWithStartWorkflowExecution: + input: + $ref: ../openapi/openapiv3.yaml#/components/schemas/SignalWithStartWorkflowExecutionRequest + output: + $ref: ../openapi/openapiv3.yaml#/components/schemas/SignalWithStartWorkflowExecutionResponse diff --git a/nexusannotations/v1/options.proto b/nexusannotations/v1/options.proto new file mode 100644 index 000000000..e137896bb --- /dev/null +++ b/nexusannotations/v1/options.proto @@ -0,0 +1,29 @@ +syntax = "proto3"; + +package nexusannotations.v1; + +import "google/protobuf/descriptor.proto"; + +option go_package = "github.com/nexus-rpc/nexus-proto-annotations/go/nexusannotations/v1"; + +extend google.protobuf.ServiceOptions { + optional ServiceOptions service = 8233; +} + +extend google.protobuf.MethodOptions { + optional OperationOptions operation = 8234; +} + +message OperationOptions { + // Nexus operation name (defaults to proto method name). + string name = 1; + // Tags to attach to the operation. Used by code generators to include and exclude operations. + repeated string tags = 2; +} + +message ServiceOptions { + // Nexus service name (defaults to proto service full name). + string name = 1; + // Tags to attach to the service. Used by code generators to include and exclude services. + repeated string tags = 2; +} diff --git a/temporal/api/workflowservice/v1/service.proto b/temporal/api/workflowservice/v1/service.proto index 18e579c2c..cfe44d4ef 100644 --- a/temporal/api/workflowservice/v1/service.proto +++ b/temporal/api/workflowservice/v1/service.proto @@ -10,9 +10,10 @@ option ruby_package = "Temporalio::Api::WorkflowService::V1"; option csharp_namespace = "Temporalio.Api.WorkflowService.V1"; -import "temporal/api/workflowservice/v1/request_response.proto"; import "google/api/annotations.proto"; +import "nexusannotations/v1/options.proto"; import "temporal/api/protometa/v1/annotations.proto"; +import "temporal/api/workflowservice/v1/request_response.proto"; // WorkflowService API defines how Temporal SDKs and other clients interact with the Temporal server // to create and interact with workflows and activities. @@ -487,6 +488,8 @@ service WorkflowService { // (-- api-linter: core::0136::prepositions=disabled // aip.dev/not-precedent: "With" is used to indicate combined operation. --) rpc SignalWithStartWorkflowExecution (SignalWithStartWorkflowExecutionRequest) returns (SignalWithStartWorkflowExecutionResponse) { + option (nexusannotations.v1.operation).tags = "exposed"; + option (google.api.http) = { post: "/namespaces/{namespace}/workflows/{workflow_id}/signal-with-start/{signal_name}" body: "*"