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
29 changes: 28 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,28 @@ 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/temporal-json-schema-models-nexusrpc.yaml and nexus/temporal-proto-models-nexusrpc.yaml..."
mkdir -p nexus
protoc -I $(PROTO_ROOT) \
--nexusrpc_opt=openapi_ref_prefix=../openapi/openapiv3.yaml#/components/schemas/ \
--nexusrpc_opt=nexusrpc_out=nexus/temporal-json-schema-models-nexusrpc.yaml \
--nexusrpc_opt=nexusrpc_langs_out=nexus/temporal-proto-models-nexusrpc.yaml \
--nexusrpc_opt=python_package_prefix=temporalio.api \
--nexusrpc_opt=typescript_package_prefix=@temporalio/api \
--nexusrpc_opt=include_operation_tags=exposed \
--nexusrpc_out=. \
temporal/api/workflowservice/v1/* \
temporal/api/operatorservice/v1/*

nexusrpc-yaml-install:
# TODO seankane: after merging nexus-rpc/nexus-rpc-gen#36 update this to install from `main` instead of branch
printf $(COLOR) "Install protoc-gen-nexusrpc from nexus-rpc/nexus-rpc-gen@spk/protoc-plugin..."
@NEXUSRPC_TMP=$$(mktemp -d) && \
git clone --quiet --depth=1 --branch=spk/protoc-plugin https://github.com/nexus-rpc/nexus-rpc-gen.git $$NEXUSRPC_TMP && \
cd $$NEXUSRPC_TMP/tools/protoc-gen-nexusrpc && go install ./cmd/protoc-gen-nexusrpc && \
rm -rf $$NEXUSRPC_TMP

##### 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
302 changes: 302 additions & 0 deletions cmd/protoc-gen-nexus-rpc-yaml/generator.go
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.

This should go in nexus-rpc-gen eventually but it's okay if it starts out in this repo.

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.

I'm fine with what you have here because it gets the job done but the task was to add a proper tool that anybody can use, not something that is specific to our API repo.

Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
package main

import (
"fmt"
"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"
)

// params holds the parsed protoc plugin options.
// Passed via --nexus-rpc-yaml_opt=key=value (multiple opts are comma-joined by protoc).
//
// - openapi_ref_prefix: optional. Prefix prepended to the message name to form the
// JSON Schema $ref in nexusrpc.yaml. If empty, nexusrpc.yaml is not written.
// Example: "../openapi/openapiv3.yaml#/components/schemas/"
//
// - nexusrpc_out: optional. Output path for nexusrpc.yaml (relative to --nexus-rpc-yaml_out dir).
// If empty, nexusrpc.yaml is not written. Requires openapi_ref_prefix to also be set.
// Example: "nexus/nexusrpc.yaml"
//
// - nexusrpc_langs_out: optional. Output path for nexusrpc.langs.yaml.
// If empty, nexusrpc.langs.yaml is not written.
// Example: "nexus/nexusrpc.langs.yaml"
//
// - python_package_prefix: optional. Dot-separated package prefix for $pythonRef.
// The last two path segments of the go_package ({service}/v{n}) are appended.
// Example: "temporalio.api" → "temporalio.api.workflowservice.v1.TypeName"
// If empty, $pythonRef is omitted.
//
// - typescript_package_prefix: optional. Scoped package prefix for $typescriptRef.
// The last two path segments of the go_package ({service}/v{n}) are appended.
// Example: "@temporalio/api" → "@temporalio/api/workflowservice/v1.TypeName"
// If empty, $typescriptRef is omitted.
//
// - include_operation_tags: optional, repeatable. Only include operations whose tags
// contain at least one of these values. If empty, all annotated operations are included
// (subject to exclude_operation_tags). Specify multiple times for multiple tags.
// Example: include_operation_tags=exposed
//
// - exclude_operation_tags: optional, repeatable. Exclude operations whose tags contain
// any of these values. Applied after include_operation_tags.
// Example: exclude_operation_tags=internal
type params struct {
openAPIRefPrefix string
nexusrpcOut string
nexusrpcLangsOut string
pythonPackagePrefix string
typescriptPackagePrefix string
includeOperationTags []string
excludeOperationTags []string
}

// parseParams parses the comma-separated key=value parameter string provided by protoc.
func parseParams(raw string) (params, error) {
var p params
if raw == "" {
return p, nil
}
for kv := range strings.SplitSeq(raw, ",") {
key, value, ok := strings.Cut(kv, "=")
if !ok {
return p, fmt.Errorf("invalid parameter %q: expected key=value", kv)
}
switch key {
case "openapi_ref_prefix":
p.openAPIRefPrefix = value
case "nexusrpc_out":
p.nexusrpcOut = value
case "nexusrpc_langs_out":
p.nexusrpcLangsOut = value
case "python_package_prefix":
p.pythonPackagePrefix = value
case "typescript_package_prefix":
p.typescriptPackagePrefix = value
case "include_operation_tags":
p.includeOperationTags = append(p.includeOperationTags, value)
case "exclude_operation_tags":
p.excludeOperationTags = append(p.excludeOperationTags, value)
default:
return p, fmt.Errorf("unknown parameter %q", key)
}
}
return p, nil
}

// shouldIncludeOperation returns true if the method's nexus operation tags pass
// the include/exclude filters. Mirrors the logic from protoc-gen-go-nexus:
// 1. Method must have the nexus operation extension set.
// 2. If includeOperationTags is non-empty, at least one of the method's tags must match.
// 3. If excludeOperationTags is non-empty, none of the method's tags may match.
func shouldIncludeOperation(p params, m *protogen.Method) bool {
opts, ok := m.Desc.Options().(*descriptorpb.MethodOptions)
if !ok || opts == nil {
return false
}
if !proto.HasExtension(opts, nexusannotationsv1.E_Operation) {
return false
}
tags := proto.GetExtension(opts, nexusannotationsv1.E_Operation).(*nexusannotationsv1.OperationOptions).GetTags()
if len(p.includeOperationTags) > 0 && !slices.ContainsFunc(p.includeOperationTags, func(t string) bool {
return slices.Contains(tags, t)
}) {
return false
}
return !slices.ContainsFunc(p.excludeOperationTags, func(t string) bool {
return slices.Contains(tags, t)
})
}

func generate(gen *protogen.Plugin) error {
p, err := parseParams(gen.Request.GetParameter())
if err != nil {
return err
}

nexusDoc := newDoc()
langsDoc := newDoc()
hasOps := false

for _, f := range gen.Files {
if !f.Generate {
continue
}
for _, svc := range f.Services {
for _, m := range svc.Methods {
if !shouldIncludeOperation(p, m) {
continue
}

svcName := string(svc.Desc.Name())
methodName := string(m.Desc.Name())
hasOps = true

if p.openAPIRefPrefix != "" {
addOperation(nexusDoc, svcName, methodName,
map[string]string{"$ref": p.openAPIRefPrefix + string(m.Input.Desc.Name())},
map[string]string{"$ref": p.openAPIRefPrefix + string(m.Output.Desc.Name())},
)
}

addOperation(langsDoc, svcName, methodName,
langRefs(p, f.Desc, m.Input.Desc),
langRefs(p, f.Desc, m.Output.Desc),
)
}
}
}

if !hasOps {
return nil
}

if p.openAPIRefPrefix != "" && p.nexusrpcOut != "" {
if err := writeFile(gen, p.nexusrpcOut, nexusDoc); err != nil {
return err
}
}
if p.nexusrpcLangsOut != "" {
return writeFile(gen, p.nexusrpcLangsOut, langsDoc)
}
return nil
}

// langRefs builds the map of language-specific type refs for a message.
//
// Go, Java, dotnet, and Ruby refs are derived from proto file-level package options.
// Python and TypeScript refs require the corresponding prefix params to be set; if
// empty they are omitted. Both use the last two path segments of go_package
// ({service}/v{n}), dropping any intermediate grouping directory.
func langRefs(p params, 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

segments := strings.Split(pkg, "/")
if len(segments) >= 2 {
tail := segments[len(segments)-2] + "/" + segments[len(segments)-1]
if p.pythonPackagePrefix != "" {
dotTail := strings.ReplaceAll(tail, "/", ".")
refs["$pythonRef"] = p.pythonPackagePrefix + "." + dotTail + "." + name
}
if p.typescriptPackagePrefix != "" {
refs["$typescriptRef"] = p.typescriptPackagePrefix + "/" + tail + "." + 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["$dotnetRef"] = 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.
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)

var svcOps *yaml.Node
for i := 0; i < len(svcs.Content)-1; i += 2 {
if svcs.Content[i].Value == svcName {
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)
}

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()
}
Loading
Loading