connectrpc 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +501 -0
- data/docs/design.md +504 -0
- data/docs/development.md +566 -0
- data/docs/grpc-web.md +165 -0
- data/docs/grpc.md +75 -0
- data/docs/rbs.md +469 -0
- data/docs/streaming.md +251 -0
- data/exe/protoc-gen-connectrpc-ruby +23 -0
- data/lib/connectrpc/bindings/descriptors/status.bin +15 -0
- data/lib/connectrpc/bindings/google_protobuf.rb +92 -0
- data/lib/connectrpc/cancellation.rb +220 -0
- data/lib/connectrpc/client.rb +260 -0
- data/lib/connectrpc/compression.rb +120 -0
- data/lib/connectrpc/deadline.rb +81 -0
- data/lib/connectrpc/error.rb +72 -0
- data/lib/connectrpc/generator/names.rb +105 -0
- data/lib/connectrpc/generator/rbs_renderer.rb +93 -0
- data/lib/connectrpc/generator/renderer.rb +115 -0
- data/lib/connectrpc/generator/runtime_names.rb +84 -0
- data/lib/connectrpc/generator/schema.rb +218 -0
- data/lib/connectrpc/generator/streaming_methods.rb +29 -0
- data/lib/connectrpc/generator.rb +42 -0
- data/lib/connectrpc/get_query.rb +159 -0
- data/lib/connectrpc/grpc.rb +200 -0
- data/lib/connectrpc/grpc_client.rb +23 -0
- data/lib/connectrpc/grpc_server.rb +158 -0
- data/lib/connectrpc/grpc_web.rb +131 -0
- data/lib/connectrpc/grpc_web_cors.rb +135 -0
- data/lib/connectrpc/grpc_web_io.rb +156 -0
- data/lib/connectrpc/grpc_web_server.rb +207 -0
- data/lib/connectrpc/instrumentation.rb +216 -0
- data/lib/connectrpc/message_io.rb +47 -0
- data/lib/connectrpc/metadata.rb +105 -0
- data/lib/connectrpc/method.rb +68 -0
- data/lib/connectrpc/protocol.rb +189 -0
- data/lib/connectrpc/server.rb +398 -0
- data/lib/connectrpc/streaming/client.rb +104 -0
- data/lib/connectrpc/streaming/client_execution.rb +87 -0
- data/lib/connectrpc/streaming/client_scope.rb +105 -0
- data/lib/connectrpc/streaming/framing.rb +123 -0
- data/lib/connectrpc/streaming/framing_io.rb +61 -0
- data/lib/connectrpc/streaming/method.rb +33 -0
- data/lib/connectrpc/streaming/server.rb +122 -0
- data/lib/connectrpc/streaming/server_execution.rb +88 -0
- data/lib/connectrpc/streaming/server_terminal.rb +70 -0
- data/lib/connectrpc/streaming/session.rb +143 -0
- data/lib/connectrpc/streaming/session_completion.rb +134 -0
- data/lib/connectrpc/streaming/session_io.rb +107 -0
- data/lib/connectrpc/streaming/session_messages.rb +119 -0
- data/lib/connectrpc/streaming/strategy.rb +95 -0
- data/lib/connectrpc/streaming/streams.rb +116 -0
- data/lib/connectrpc/streaming/terminal.rb +52 -0
- data/lib/connectrpc/transports/net_http.rb +246 -0
- data/lib/connectrpc/version.rb +5 -0
- data/lib/connectrpc.rb +38 -0
- data/rbs/google_protobuf_4_36/runtime.rbs +41 -0
- data/sig/connectrpc.rbs +772 -0
- data/sig/manifest.yaml +2 -0
- data/third_party/README.md +16 -0
- data/third_party/google/LICENSE +201 -0
- data/third_party/google/rpc/status.proto +48 -0
- data/third_party/protobuf-LICENSE +32 -0
- metadata +106 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
module Generator
|
|
5
|
+
class RBSRenderer
|
|
6
|
+
include StreamingMethods
|
|
7
|
+
|
|
8
|
+
def initialize(schema, file)
|
|
9
|
+
@schema = schema
|
|
10
|
+
@file = file
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def render
|
|
14
|
+
@lines = ["# Generated by protoc-gen-connectrpc-ruby. DO NOT EDIT!", "# source: #{@file.name}", ""]
|
|
15
|
+
namespace = Names.namespace(@file)
|
|
16
|
+
namespace.each_with_index { |name, index| line(index, "module #{name}") }
|
|
17
|
+
@file.service.each { |service| render_service(service, namespace.size) }
|
|
18
|
+
namespace.size.times { |index| line(namespace.size - index - 1, "end") }
|
|
19
|
+
"#{@lines.join("\n")}\n"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def line(depth, text)
|
|
25
|
+
@lines << ((" " * depth) + text)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def types(method)
|
|
29
|
+
[method.input_type, method.output_type].map { |type| @schema.ruby_types.fetch(type) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def handler(method)
|
|
33
|
+
request, reply = types(method)
|
|
34
|
+
case declaration_type(method)
|
|
35
|
+
when "ServerStreamMethod"
|
|
36
|
+
"(^(#{request}, ::ConnectRPC::ResponseWriter[#{reply}], ::ConnectRPC::StreamingContext) -> void) | " \
|
|
37
|
+
"::ConnectRPC::_ServerStreamHandler[#{request}, #{reply}]"
|
|
38
|
+
when "ClientStreamMethod"
|
|
39
|
+
"(^(::ConnectRPC::RequestReader[#{request}], ::ConnectRPC::StreamingContext) -> #{reply}) | " \
|
|
40
|
+
"::ConnectRPC::_ClientStreamHandler[#{request}, #{reply}]"
|
|
41
|
+
when "BidiStreamMethod"
|
|
42
|
+
"(^(::ConnectRPC::ServerDuplex[#{request}, #{reply}], ::ConnectRPC::StreamingContext) -> void) | " \
|
|
43
|
+
"::ConnectRPC::_BidiStreamHandler[#{request}, #{reply}]"
|
|
44
|
+
else
|
|
45
|
+
"(^(#{request}, ::ConnectRPC::Context) -> #{reply}) | ::ConnectRPC::_Handler[#{request}, #{reply}]"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def render_client_signature(method, depth)
|
|
50
|
+
request, reply = types(method)
|
|
51
|
+
options = "?metadata: ::ConnectRPC::metadata_input, ?timeout: ::Numeric?, " \
|
|
52
|
+
"?cancellation: ::ConnectRPC::CancellationToken?"
|
|
53
|
+
name = Names.rpc(method.name)
|
|
54
|
+
case declaration_type(method)
|
|
55
|
+
when "ServerStreamMethod"
|
|
56
|
+
line(depth + 2,
|
|
57
|
+
"def #{name}: (#{request} request, #{options}) " \
|
|
58
|
+
"{ (::ConnectRPC::ResponseReader[#{reply}]) -> void } -> nil")
|
|
59
|
+
when "ClientStreamMethod"
|
|
60
|
+
line(depth + 2, "def #{name}: (#{options}) { (::ConnectRPC::RequestWriter[#{request}]) -> void } " \
|
|
61
|
+
"-> ::ConnectRPC::Response[#{reply}]")
|
|
62
|
+
when "BidiStreamMethod"
|
|
63
|
+
line(depth + 2,
|
|
64
|
+
"def #{name}: (#{options}) { (::ConnectRPC::ClientDuplex[#{request}, #{reply}]) -> void } -> nil")
|
|
65
|
+
else
|
|
66
|
+
line(depth + 2, "def #{name}: (#{request} request, #{options}) -> ::ConnectRPC::Response[#{reply}]")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def render_service(service, depth)
|
|
71
|
+
line(depth, "module #{Names.rubify(service.name)}")
|
|
72
|
+
methods = service["method"]
|
|
73
|
+
methods.each do |method|
|
|
74
|
+
line(depth + 1,
|
|
75
|
+
"#{Names.rpc(method.name).upcase}: " \
|
|
76
|
+
"::ConnectRPC::#{declaration_type(method)}[#{types(method).join(', ')}]")
|
|
77
|
+
end
|
|
78
|
+
line(depth + 1, "SERVICE: ::ConnectRPC::Service")
|
|
79
|
+
line(depth + 1, "class Client")
|
|
80
|
+
line(depth + 2, "def initialize: (client: ::ConnectRPC::Client) -> void")
|
|
81
|
+
methods.each { |method| render_client_signature(method, depth) }
|
|
82
|
+
line(depth + 1, "end")
|
|
83
|
+
keywords = methods.map { |method| "#{Names.rpc(method.name)}: (#{handler(method)})" }.join(", ")
|
|
84
|
+
values = methods.map do |method|
|
|
85
|
+
"::ConnectRPC::#{handler_type(method)}[#{types(method).join(', ')}]"
|
|
86
|
+
end.uniq
|
|
87
|
+
line(depth + 1,
|
|
88
|
+
"def self.handlers: (#{keywords}) -> ::Hash[::String, #{values.empty? ? 'bot' : values.join(' | ')}]")
|
|
89
|
+
line(depth, "end")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
module Generator
|
|
5
|
+
class Renderer
|
|
6
|
+
include StreamingMethods
|
|
7
|
+
|
|
8
|
+
def initialize(schema, file)
|
|
9
|
+
@schema = schema
|
|
10
|
+
@file = file
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def render
|
|
14
|
+
@lines = ["# frozen_string_literal: true", "", "# Generated by protoc-gen-connectrpc-ruby. DO NOT EDIT!",
|
|
15
|
+
"# source: #{@file.name}", "", 'require "connectrpc"']
|
|
16
|
+
required_files.each { |name| @lines << "require #{"#{Names.stem(name)}_pb".dump}" }
|
|
17
|
+
@lines << ""
|
|
18
|
+
namespace = Names.namespace(@file)
|
|
19
|
+
namespace.each_with_index { |name, index| line(index, "module #{name}") }
|
|
20
|
+
@file.service.each_with_index do |service, index|
|
|
21
|
+
line(namespace.size, "") if index.positive?
|
|
22
|
+
render_service(service, namespace.size)
|
|
23
|
+
end
|
|
24
|
+
namespace.size.times { |index| line(namespace.size - index - 1, "end") }
|
|
25
|
+
"#{@lines.join("\n")}\n"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def required_files
|
|
31
|
+
methods = @file.service.flat_map { |service| service["method"] }
|
|
32
|
+
types = methods.flat_map { |method| [method.input_type, method.output_type] }
|
|
33
|
+
[@file.name, *types.map { |type| @schema.types.fetch(type).first.name }.uniq.sort].uniq
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def line(depth, text)
|
|
37
|
+
@lines << (text.empty? ? "" : (" " * depth) + text)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def render_service(service, depth)
|
|
41
|
+
line(depth, "module #{Names.rubify(service.name)}")
|
|
42
|
+
depth += 1
|
|
43
|
+
methods = service["method"].map { |method| [method, Names.rpc(method.name)] }
|
|
44
|
+
constants = ["SERVICE", "Client", *methods.map { |_method, ruby| ruby.upcase }]
|
|
45
|
+
line(depth, "%w[#{constants.join(' ')}].each do |name|")
|
|
46
|
+
line(depth + 1, "next unless const_defined?(name, false)")
|
|
47
|
+
line(depth, "")
|
|
48
|
+
line(depth + 1, "raise ::ArgumentError, \"Generated service constant already defined: \#{self}::\#{name}\"")
|
|
49
|
+
line(depth, "end")
|
|
50
|
+
methods.each { |method, ruby| render_method(service, method, ruby, depth) }
|
|
51
|
+
name = [@file.package, service.name].reject(&:empty?).join(".")
|
|
52
|
+
line(depth, "")
|
|
53
|
+
line(depth, "SERVICE = ::ConnectRPC::Service.new(")
|
|
54
|
+
line(depth + 1, "name: #{name.dump}, methods: [#{methods.map { |_m, ruby| ruby.upcase }.join(', ')}]")
|
|
55
|
+
line(depth, ")")
|
|
56
|
+
render_client(methods, depth)
|
|
57
|
+
render_handlers(methods, depth)
|
|
58
|
+
line(depth - 1, "end")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def render_method(service, method, ruby, depth)
|
|
62
|
+
name = [@file.package, service.name].reject(&:empty?).join(".")
|
|
63
|
+
line(depth, "")
|
|
64
|
+
line(depth, "#{ruby.upcase} = ::ConnectRPC::#{declaration_type(method)}.new(")
|
|
65
|
+
line(depth + 1, "service: #{name.dump}, name: #{method.name.dump},")
|
|
66
|
+
render_type("request_type", method.input_type, depth + 1, comma: true)
|
|
67
|
+
idempotency = { NO_SIDE_EFFECTS: :no_side_effects, IDEMPOTENT: :idempotent }[method.options&.idempotency_level]
|
|
68
|
+
render_type("response_type", method.output_type, depth + 1, comma: !idempotency.nil?)
|
|
69
|
+
line(depth + 1, "idempotency: :#{idempotency}") if idempotency
|
|
70
|
+
line(depth, ")")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def render_type(keyword, type, depth, comma:)
|
|
74
|
+
line(depth, "#{keyword}: ::Google::Protobuf::DescriptorPool.generated_pool.lookup(")
|
|
75
|
+
line(depth + 1, type.delete_prefix(".").dump)
|
|
76
|
+
line(depth, ").msgclass#{',' if comma}")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def render_client(methods, depth)
|
|
80
|
+
line(depth, "")
|
|
81
|
+
line(depth, "class Client")
|
|
82
|
+
line(depth + 1, "def initialize(client:)")
|
|
83
|
+
line(depth + 2, "@client = client")
|
|
84
|
+
line(depth + 1, "end")
|
|
85
|
+
methods.each do |entry|
|
|
86
|
+
method, ruby = entry
|
|
87
|
+
line(depth, "")
|
|
88
|
+
if method.client_streaming || method.server_streaming
|
|
89
|
+
render_streaming_client(method, ruby, depth)
|
|
90
|
+
else
|
|
91
|
+
line(depth + 1, "def #{ruby}(request, **options)")
|
|
92
|
+
line(depth + 2, "@client.call(#{ruby.upcase}, request, **options)")
|
|
93
|
+
end
|
|
94
|
+
line(depth + 1, "end")
|
|
95
|
+
end
|
|
96
|
+
line(depth, "end")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def render_handlers(methods, depth)
|
|
100
|
+
keywords = methods.map { |_method, ruby| "#{ruby}:" }.join(", ")
|
|
101
|
+
line(depth, "")
|
|
102
|
+
line(depth, "def self.handlers#{"(#{keywords})" unless methods.empty?}")
|
|
103
|
+
methods.each do |entry|
|
|
104
|
+
ruby = entry.last
|
|
105
|
+
line(depth + 1,
|
|
106
|
+
"raise ::ArgumentError, #{"#{ruby} must respond to call".dump} unless #{ruby}.respond_to?(:call)")
|
|
107
|
+
end
|
|
108
|
+
line(depth, "") unless methods.empty?
|
|
109
|
+
entries = methods.map { |_method, ruby| "#{ruby.upcase}.path => #{ruby}" }.join(", ")
|
|
110
|
+
line(depth + 1, methods.empty? ? "{}" : "{ #{entries} }")
|
|
111
|
+
line(depth, "end")
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
module Generator
|
|
5
|
+
module RuntimeNames
|
|
6
|
+
# google-protobuf 4.36.2: protobuf.rb, message_exts.rb, repeated_field.rb,
|
|
7
|
+
# internal/object_cache.rb and ext/google/protobuf_c/{defs,map,protobuf}.c.
|
|
8
|
+
# Protect helper subtrees too; package modules cannot reopen their classes.
|
|
9
|
+
PROTOBUF_RUNTIME = %w[
|
|
10
|
+
AbstractMessage Descriptor DescriptorPool EnumDescriptor Error FieldDescriptor FileDescriptor
|
|
11
|
+
FFI IMPLEMENTATION Internal Map MessageExts MethodDescriptor OneofDescriptor ParseError PREFER_FFI
|
|
12
|
+
RepeatedField ServiceDescriptor TypeError UnexpectedStructType
|
|
13
|
+
].freeze
|
|
14
|
+
RUNTIME_PACKAGES = %w[Google Google::Protobuf Google::Protobuf::Compiler].freeze
|
|
15
|
+
# Concrete assignments in the pinned gem's official *_pb.rb files. These
|
|
16
|
+
# namespaces belong to those proto definitions, including nested types.
|
|
17
|
+
PROTOBUF_FILES = {
|
|
18
|
+
"google/protobuf/any.proto" => %w[Any],
|
|
19
|
+
"google/protobuf/api.proto" => %w[Api Method Mixin],
|
|
20
|
+
"google/protobuf/descriptor.proto" => %w[
|
|
21
|
+
FileDescriptorSet FileDescriptorProto DescriptorProto ExtensionRangeOptions FieldDescriptorProto
|
|
22
|
+
OneofDescriptorProto EnumDescriptorProto EnumValueDescriptorProto ServiceDescriptorProto MethodDescriptorProto
|
|
23
|
+
FileOptions MessageOptions FieldOptions OneofOptions EnumOptions EnumValueOptions ServiceOptions MethodOptions
|
|
24
|
+
UninterpretedOption FeatureSet FeatureSetDefaults SourceCodeInfo GeneratedCodeInfo Edition SymbolVisibility
|
|
25
|
+
],
|
|
26
|
+
"google/protobuf/duration.proto" => %w[Duration],
|
|
27
|
+
"google/protobuf/empty.proto" => %w[Empty],
|
|
28
|
+
"google/protobuf/field_mask.proto" => %w[FieldMask],
|
|
29
|
+
"google/protobuf/compiler/plugin.proto" => %w[
|
|
30
|
+
Compiler::Version Compiler::CodeGeneratorRequest Compiler::CodeGeneratorResponse
|
|
31
|
+
],
|
|
32
|
+
"google/protobuf/source_context.proto" => %w[SourceContext],
|
|
33
|
+
"google/protobuf/struct.proto" => %w[Struct Value ListValue NullValue],
|
|
34
|
+
"google/protobuf/timestamp.proto" => %w[Timestamp],
|
|
35
|
+
"google/protobuf/type.proto" => %w[Type Field Enum EnumValue Option Syntax],
|
|
36
|
+
"google/protobuf/wrappers.proto" => %w[
|
|
37
|
+
DoubleValue FloatValue Int64Value UInt64Value Int32Value UInt32Value BoolValue StringValue BytesValue
|
|
38
|
+
]
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
def self.validate!(parts, kind, origin, file)
|
|
42
|
+
name = parts.join("::")
|
|
43
|
+
if reserved_root?(parts, kind)
|
|
44
|
+
raise InvalidDescriptor, "Reserved top-level Ruby constant #{parts.first} for #{file}: #{origin}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
protected_name = protected_runtime(parts, kind) || protected_protobuf_type(parts, kind, origin, file)
|
|
48
|
+
return unless protected_name
|
|
49
|
+
|
|
50
|
+
raise InvalidDescriptor, "Reserved runtime Ruby constant #{protected_name} at #{name} for #{file}: #{origin}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.protected_runtime(parts, kind)
|
|
54
|
+
name = parts.join("::")
|
|
55
|
+
return name if RUNTIME_PACKAGES.include?(name) && kind != :package
|
|
56
|
+
return unless parts.take(2) == %w[Google Protobuf] && PROTOBUF_RUNTIME.include?(parts[2])
|
|
57
|
+
|
|
58
|
+
parts.take(3).join("::")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.reserved_root?(parts, kind)
|
|
62
|
+
Names::TOP_LEVEL_RESERVED.include?(parts.first) &&
|
|
63
|
+
!(parts.first == "Google" && (parts.size > 1 || kind == :package))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.protected_protobuf_type(parts, kind, origin, file)
|
|
67
|
+
return unless parts.take(2) == %w[Google Protobuf]
|
|
68
|
+
|
|
69
|
+
suffix = parts.drop(2).take(parts[2] == "Compiler" ? 2 : 1).join("::")
|
|
70
|
+
source, = PROTOBUF_FILES.find { |_source, names| names.include?(suffix) }
|
|
71
|
+
return unless source
|
|
72
|
+
|
|
73
|
+
return if source == file && official_type?(parts, kind, origin)
|
|
74
|
+
|
|
75
|
+
"Google::Protobuf::#{suffix}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.official_type?(parts, kind, origin)
|
|
79
|
+
official = "google.protobuf.#{parts.drop(2).join('.').sub(/\ACompiler\./, 'compiler.')}"
|
|
80
|
+
%i[message enum].include?(kind) && origin == official
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
module Generator
|
|
5
|
+
class Schema
|
|
6
|
+
attr_reader :targets, :types, :ruby_types, :rbs
|
|
7
|
+
|
|
8
|
+
def initialize(request)
|
|
9
|
+
unless ["", "rbs=true"].include?(request.parameter)
|
|
10
|
+
raise InvalidDescriptor, "Invalid plugin parameters #{request.parameter.inspect}; expected rbs=true or empty"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
@rbs = request.parameter == "rbs=true"
|
|
14
|
+
|
|
15
|
+
@files = unique_files(request.proto_file)
|
|
16
|
+
@targets = target_files(request.file_to_generate)
|
|
17
|
+
@types = {}
|
|
18
|
+
@ruby_types = {}
|
|
19
|
+
@symbols = {}
|
|
20
|
+
@namespaces = Hash.new { |hash, key| hash[key] = [] }
|
|
21
|
+
@files.each_value { |file| index_file(file) }
|
|
22
|
+
targets.each { |file| validate_target(file) }
|
|
23
|
+
validate_namespaces
|
|
24
|
+
validate_outputs
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def unique_files(files)
|
|
30
|
+
files.each_with_object({}) do |file, result|
|
|
31
|
+
Names.path!(file.name)
|
|
32
|
+
raise InvalidDescriptor, "Duplicate proto file #{file.name}" if result.key?(file.name)
|
|
33
|
+
|
|
34
|
+
result[file.name] = file
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def target_files(names)
|
|
39
|
+
raise InvalidDescriptor, "Duplicate generation target" unless names.uniq.size == names.size
|
|
40
|
+
|
|
41
|
+
names.map do |name|
|
|
42
|
+
@files.fetch(name) { raise InvalidDescriptor, "Missing generation target #{name.inspect}" }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def symbol!(name, description)
|
|
47
|
+
previous = @symbols[name]
|
|
48
|
+
raise InvalidDescriptor, "Duplicate proto symbol #{name}: #{previous} and #{description}" if previous
|
|
49
|
+
|
|
50
|
+
@symbols[name] = description
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def node(parts, kind, origin, file)
|
|
54
|
+
parts.each { |part| Names.constant!(part, origin) }
|
|
55
|
+
@namespaces[parts] << [kind, origin, file.name]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def index_file(file)
|
|
59
|
+
unless file.package.empty? || file.package.split(".", -1).all? { |part| Names::IDENTIFIER.match?(part) }
|
|
60
|
+
raise InvalidDescriptor, "Invalid package #{file.package.inspect} in #{file.name}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
namespace = Names.namespace(file)
|
|
64
|
+
namespace.each_index do |index|
|
|
65
|
+
# Repeated declarations of the same package are legitimate.
|
|
66
|
+
node(namespace.take(index + 1), :package, "package #{namespace.take(index + 1).join('::')}", file)
|
|
67
|
+
end
|
|
68
|
+
file.dependency.each do |name|
|
|
69
|
+
raise InvalidDescriptor, "Missing dependency #{name} in #{file.name}" unless @files.key?(name)
|
|
70
|
+
end
|
|
71
|
+
index_types(file.message_type, file.enum_type, file.package, namespace, file)
|
|
72
|
+
file.service.each do |service|
|
|
73
|
+
Names.identifier!(service.name, file.name)
|
|
74
|
+
full = qualified(file.package, service.name)
|
|
75
|
+
symbol!(full, file.name)
|
|
76
|
+
service["method"].each do |method|
|
|
77
|
+
Names.identifier!(method.name, full)
|
|
78
|
+
symbol!(qualified(full, method.name), file.name)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def index_types(messages, enums, prefix, namespace, file)
|
|
84
|
+
messages.each do |message|
|
|
85
|
+
Names.identifier!(message.name, file.name)
|
|
86
|
+
full = qualified(prefix, message.name)
|
|
87
|
+
symbol!(full, file.name)
|
|
88
|
+
@types[".#{full}"] = [file, message]
|
|
89
|
+
next if message.options&.map_entry
|
|
90
|
+
|
|
91
|
+
parts = namespace + [Names.rubify(message.name)]
|
|
92
|
+
@ruby_types[".#{full}"] = "::#{parts.join('::')}"
|
|
93
|
+
node(parts, :message, full, file)
|
|
94
|
+
index_types(message.nested_type, message.enum_type, full, parts, file)
|
|
95
|
+
end
|
|
96
|
+
enums.each do |enum|
|
|
97
|
+
Names.identifier!(enum.name, file.name)
|
|
98
|
+
full = qualified(prefix, enum.name)
|
|
99
|
+
symbol!(full, file.name)
|
|
100
|
+
node(namespace + [Names.rubify(enum.name)], :enum, full, file)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def validate_target(file)
|
|
105
|
+
unless ["", "proto2", "proto3"].include?(file.syntax) && !file.has_edition?
|
|
106
|
+
raise InvalidDescriptor, "Unsupported syntax/editions in #{file.name}; use proto2 or proto3"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
file.service.each do |service|
|
|
110
|
+
origin = "#{file.name}: #{qualified(file.package, service.name)}"
|
|
111
|
+
parts = Names.namespace(file) + [Names.rubify(service.name)]
|
|
112
|
+
node(parts, :service, origin, file)
|
|
113
|
+
node(parts + ["SERVICE"], :generated, "#{origin} SERVICE", file)
|
|
114
|
+
node(parts + ["Client"], :generated, "#{origin} Client", file)
|
|
115
|
+
methods = {}
|
|
116
|
+
service["method"].each do |method|
|
|
117
|
+
validate_method(method, origin)
|
|
118
|
+
ruby = Names.rpc!(method.name, origin)
|
|
119
|
+
if methods.key?(ruby)
|
|
120
|
+
raise InvalidDescriptor, "Ruby RPC collision in #{origin}: #{methods[ruby]} and #{method.name} (#{ruby})"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
methods[ruby] = method.name
|
|
124
|
+
node(parts + [ruby.upcase], :generated, "#{origin}.#{method.name}", file)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def validate_method(method, origin)
|
|
130
|
+
unless [nil, :IDEMPOTENCY_UNKNOWN, :NO_SIDE_EFFECTS, :IDEMPOTENT].include?(method.options&.idempotency_level)
|
|
131
|
+
raise InvalidDescriptor, "Invalid idempotency level for #{origin}.#{method.name}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
[method.input_type, method.output_type].each do |name|
|
|
135
|
+
entry = types[name]
|
|
136
|
+
unless entry && !entry.last.options&.map_entry
|
|
137
|
+
raise InvalidDescriptor, "Unresolved or non-message type #{name.inspect} for #{origin}.#{method.name}"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def validate_namespaces
|
|
143
|
+
loaded = loaded_files
|
|
144
|
+
loaded_names = loaded.map(&:name)
|
|
145
|
+
relevant = loaded.flat_map do |file|
|
|
146
|
+
namespace = Names.namespace(file)
|
|
147
|
+
if namespace.empty?
|
|
148
|
+
[*file.message_type, *file.enum_type, *file.service].map { |type| [Names.rubify(type.name)] }
|
|
149
|
+
else
|
|
150
|
+
[namespace]
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
@namespaces.each do |parts, declarations|
|
|
154
|
+
next unless relevant.any? { |prefix| parts.take(prefix.size) == prefix || prefix.take(parts.size) == parts }
|
|
155
|
+
|
|
156
|
+
declarations.each do |kind, origin, file|
|
|
157
|
+
RuntimeNames.validate!(parts, kind, origin, file) if loaded_names.include?(file)
|
|
158
|
+
end
|
|
159
|
+
unique = declarations.map { |kind, origin, _file| [kind, origin] }.uniq
|
|
160
|
+
next if unique.size == 1
|
|
161
|
+
next if unique.all? { |kind, _origin| kind == :package }
|
|
162
|
+
|
|
163
|
+
raise InvalidDescriptor,
|
|
164
|
+
"Ruby namespace collision at #{parts.join('::')}: #{unique.map(&:last).join(' and ')}"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Official target _pb files require their imports, including public imports.
|
|
169
|
+
# Include explicitly resolved type files too for defensive indirect requests.
|
|
170
|
+
def loaded_files
|
|
171
|
+
required = targets.dup
|
|
172
|
+
targets.each do |file|
|
|
173
|
+
file.service.each do |service|
|
|
174
|
+
service["method"].each do |method|
|
|
175
|
+
[method.input_type, method.output_type].each { |type| required << types.fetch(type).first }
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
loaded = {}
|
|
180
|
+
until required.empty?
|
|
181
|
+
file = required.pop
|
|
182
|
+
next if loaded.key?(file.name)
|
|
183
|
+
|
|
184
|
+
loaded[file.name] = file
|
|
185
|
+
file.dependency.each { |name| required << @files.fetch(name) }
|
|
186
|
+
end
|
|
187
|
+
loaded.values
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def validate_outputs
|
|
191
|
+
outputs = {}
|
|
192
|
+
@files.each_value do |file|
|
|
193
|
+
output = "#{Names.stem(file.name)}_pb.rb"
|
|
194
|
+
if outputs[output]
|
|
195
|
+
raise InvalidDescriptor,
|
|
196
|
+
"Output collision: #{outputs[output]} and #{file.name} -> #{output}"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
outputs[output] = file.name
|
|
200
|
+
end
|
|
201
|
+
targets.reject { |file| file.service.empty? }.each do |file|
|
|
202
|
+
extensions = rbs ? %w[rb rbs] : %w[rb]
|
|
203
|
+
extensions.each do |extension|
|
|
204
|
+
output = "#{Names.stem(file.name)}_connect.#{extension}"
|
|
205
|
+
if outputs[output]
|
|
206
|
+
raise InvalidDescriptor,
|
|
207
|
+
"Output collision: #{outputs[output]} and #{file.name} -> #{output}"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
outputs[output] = file.name
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def qualified(prefix, name) = [prefix, name].reject(&:empty?).join(".")
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
module Generator
|
|
5
|
+
module StreamingMethods
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def declaration_type(method)
|
|
9
|
+
return method.server_streaming ? "BidiStreamMethod" : "ClientStreamMethod" if method.client_streaming
|
|
10
|
+
|
|
11
|
+
method.server_streaming ? "ServerStreamMethod" : "Method"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def handler_type(method)
|
|
15
|
+
name = declaration_type(method)
|
|
16
|
+
name == "Method" ? "_Handler" : "_#{name.delete_suffix('Method')}Handler"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def render_streaming_client(method, ruby, depth)
|
|
20
|
+
operation = { "BidiStreamMethod" => "bidi_stream", "ClientStreamMethod" => "client_stream",
|
|
21
|
+
"ServerStreamMethod" => "server_stream" }.fetch(declaration_type(method))
|
|
22
|
+
request = method.client_streaming ? "" : "request, "
|
|
23
|
+
line(depth + 1, "def #{ruby}(#{request}**options, &)")
|
|
24
|
+
line(depth + 2, "@client.#{operation}(#{ruby.upcase}, #{request}**options, &)")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
private_constant :StreamingMethods
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
gem "google-protobuf", ">= 4.36.2", "< 5"
|
|
5
|
+
require "google/protobuf/plugin_pb"
|
|
6
|
+
rescue LoadError => e
|
|
7
|
+
raise LoadError, "ConnectRPC generator requires google-protobuf >= 4.36.2, < 5: #{e.message}"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
require_relative "generator/names"
|
|
11
|
+
require_relative "generator/runtime_names"
|
|
12
|
+
require_relative "generator/schema"
|
|
13
|
+
require_relative "generator/streaming_methods"
|
|
14
|
+
require_relative "generator/renderer"
|
|
15
|
+
require_relative "generator/rbs_renderer"
|
|
16
|
+
|
|
17
|
+
module ConnectRPC
|
|
18
|
+
module Generator
|
|
19
|
+
class InvalidDescriptor < StandardError; end
|
|
20
|
+
|
|
21
|
+
# Pure request -> response. Expected descriptor errors use the protoc error
|
|
22
|
+
# field; unexpected implementation failures must remain visible to callers.
|
|
23
|
+
def self.generate(request)
|
|
24
|
+
schema = Schema.new(request)
|
|
25
|
+
files = schema.targets.reject { |file| file.service.empty? }.flat_map { |file| render_files(schema, file) }
|
|
26
|
+
Google::Protobuf::Compiler::CodeGeneratorResponse.new(supported_features: 1, file: files)
|
|
27
|
+
rescue InvalidDescriptor => e
|
|
28
|
+
Google::Protobuf::Compiler::CodeGeneratorResponse.new(error: e.message)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.render_files(schema, file)
|
|
32
|
+
renderers = [[Renderer, "rb"]]
|
|
33
|
+
renderers << [RBSRenderer, "rbs"] if schema.rbs
|
|
34
|
+
renderers.map do |renderer, extension|
|
|
35
|
+
Google::Protobuf::Compiler::CodeGeneratorResponse::File.new(
|
|
36
|
+
name: "#{Names.stem(file.name)}_connect.#{extension}", content: renderer.new(schema, file).render
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
private_class_method :render_files
|
|
41
|
+
end
|
|
42
|
+
end
|