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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ######
Expand Down Expand Up @@ -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:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't we have the same make target for google? Seems like we can sync both. But probably don't sync google as part of this PR if there are any changes there, better to do that separately.

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..."
Expand All @@ -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..."
Expand Down
13 changes: 9 additions & 4 deletions buf.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions buf.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
196 changes: 196 additions & 0 deletions cmd/protoc-gen-nexus-rpc-yaml/generator.go
Original file line number Diff line number Diff line change
@@ -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()
}
10 changes: 10 additions & 0 deletions cmd/protoc-gen-nexus-rpc-yaml/go.mod
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions cmd/protoc-gen-nexus-rpc-yaml/go.sum
Original file line number Diff line number Diff line change
@@ -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=
14 changes: 14 additions & 0 deletions cmd/protoc-gen-nexus-rpc-yaml/main.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
15 changes: 15 additions & 0 deletions nexus/nexusrpc.langs.yaml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions nexus/nexusrpc.yaml
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions nexusannotations/v1/options.proto
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 4 additions & 1 deletion temporal/api/workflowservice/v1/service.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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: "*"
Expand Down
Loading