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.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +501 -0
  4. data/docs/design.md +504 -0
  5. data/docs/development.md +566 -0
  6. data/docs/grpc-web.md +165 -0
  7. data/docs/grpc.md +75 -0
  8. data/docs/rbs.md +469 -0
  9. data/docs/streaming.md +251 -0
  10. data/exe/protoc-gen-connectrpc-ruby +23 -0
  11. data/lib/connectrpc/bindings/descriptors/status.bin +15 -0
  12. data/lib/connectrpc/bindings/google_protobuf.rb +92 -0
  13. data/lib/connectrpc/cancellation.rb +220 -0
  14. data/lib/connectrpc/client.rb +260 -0
  15. data/lib/connectrpc/compression.rb +120 -0
  16. data/lib/connectrpc/deadline.rb +81 -0
  17. data/lib/connectrpc/error.rb +72 -0
  18. data/lib/connectrpc/generator/names.rb +105 -0
  19. data/lib/connectrpc/generator/rbs_renderer.rb +93 -0
  20. data/lib/connectrpc/generator/renderer.rb +115 -0
  21. data/lib/connectrpc/generator/runtime_names.rb +84 -0
  22. data/lib/connectrpc/generator/schema.rb +218 -0
  23. data/lib/connectrpc/generator/streaming_methods.rb +29 -0
  24. data/lib/connectrpc/generator.rb +42 -0
  25. data/lib/connectrpc/get_query.rb +159 -0
  26. data/lib/connectrpc/grpc.rb +200 -0
  27. data/lib/connectrpc/grpc_client.rb +23 -0
  28. data/lib/connectrpc/grpc_server.rb +158 -0
  29. data/lib/connectrpc/grpc_web.rb +131 -0
  30. data/lib/connectrpc/grpc_web_cors.rb +135 -0
  31. data/lib/connectrpc/grpc_web_io.rb +156 -0
  32. data/lib/connectrpc/grpc_web_server.rb +207 -0
  33. data/lib/connectrpc/instrumentation.rb +216 -0
  34. data/lib/connectrpc/message_io.rb +47 -0
  35. data/lib/connectrpc/metadata.rb +105 -0
  36. data/lib/connectrpc/method.rb +68 -0
  37. data/lib/connectrpc/protocol.rb +189 -0
  38. data/lib/connectrpc/server.rb +398 -0
  39. data/lib/connectrpc/streaming/client.rb +104 -0
  40. data/lib/connectrpc/streaming/client_execution.rb +87 -0
  41. data/lib/connectrpc/streaming/client_scope.rb +105 -0
  42. data/lib/connectrpc/streaming/framing.rb +123 -0
  43. data/lib/connectrpc/streaming/framing_io.rb +61 -0
  44. data/lib/connectrpc/streaming/method.rb +33 -0
  45. data/lib/connectrpc/streaming/server.rb +122 -0
  46. data/lib/connectrpc/streaming/server_execution.rb +88 -0
  47. data/lib/connectrpc/streaming/server_terminal.rb +70 -0
  48. data/lib/connectrpc/streaming/session.rb +143 -0
  49. data/lib/connectrpc/streaming/session_completion.rb +134 -0
  50. data/lib/connectrpc/streaming/session_io.rb +107 -0
  51. data/lib/connectrpc/streaming/session_messages.rb +119 -0
  52. data/lib/connectrpc/streaming/strategy.rb +95 -0
  53. data/lib/connectrpc/streaming/streams.rb +116 -0
  54. data/lib/connectrpc/streaming/terminal.rb +52 -0
  55. data/lib/connectrpc/transports/net_http.rb +246 -0
  56. data/lib/connectrpc/version.rb +5 -0
  57. data/lib/connectrpc.rb +38 -0
  58. data/rbs/google_protobuf_4_36/runtime.rbs +41 -0
  59. data/sig/connectrpc.rbs +772 -0
  60. data/sig/manifest.yaml +2 -0
  61. data/third_party/README.md +16 -0
  62. data/third_party/google/LICENSE +201 -0
  63. data/third_party/google/rpc/status.proto +48 -0
  64. data/third_party/protobuf-LICENSE +32 -0
  65. 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