grpc-rest 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +23 -0
- data/README.md +128 -0
- data/grpc-rest.gemspec +23 -0
- data/lib/grpc_rest/version.rb +3 -0
- data/lib/grpc_rest.rb +87 -0
- data/protoc-gen-rails/LICENSE +21 -0
- data/protoc-gen-rails/go.mod +21 -0
- data/protoc-gen-rails/go.sum +33 -0
- data/protoc-gen-rails/internal/output.go +122 -0
- data/protoc-gen-rails/internal/parse.go +45 -0
- data/protoc-gen-rails/internal/utils.go +57 -0
- data/protoc-gen-rails/main.go +102 -0
- data/protoc-gen-rails/main_test.go +139 -0
- metadata +84 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 94663645d0e0541b08ccb55ad65c05c1d41391d580793f4f70f5c16925e23f34
|
4
|
+
data.tar.gz: f7f3f7182934adcc2670b3a3d12a29bce8da5896284b4ee78aaa623fa4f2b163
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: ba0669e7cc0343bdc944e5ccddd0adef5f98de23173eb507c964db9cf5394987feac7f11653ce380d4e0f82fa748380646ae7d6df757f86a9fd4de322538ec72
|
7
|
+
data.tar.gz: c4d29996a51dc172765a6c7489a730389d6c85d3bd3a05949cabdddbefa339b8f5166a5c2cdcb3ca263df1fc12221fe8d23873aa812cb50d26f594adec67dc89
|
data/.gitignore
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
# If you prefer the allow list template instead of the deny list, see community template:
|
2
|
+
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
3
|
+
#
|
4
|
+
# Binaries for programs and plugins
|
5
|
+
*.exe
|
6
|
+
*.exe~
|
7
|
+
*.dll
|
8
|
+
*.so
|
9
|
+
*.dylib
|
10
|
+
|
11
|
+
# Test binary, built with `go test -c`
|
12
|
+
*.test
|
13
|
+
|
14
|
+
# Output of the go coverage tool, specifically when used with LiteIDE
|
15
|
+
*.out
|
16
|
+
|
17
|
+
# Dependency directories (remove the comment below to include it)
|
18
|
+
# vendor/
|
19
|
+
|
20
|
+
# Go workspace file
|
21
|
+
go.work
|
22
|
+
|
23
|
+
.idea
|
data/README.md
ADDED
@@ -0,0 +1,128 @@
|
|
1
|
+
# grpc-rest
|
2
|
+
Generate Rails routes and controllers from protobuf files.
|
3
|
+
|
4
|
+
grpc-rest allows you to have a single codebase that serves both gRPC and classic Rails routes, with
|
5
|
+
a single handler (the gRPC handler) for both types of requests. It will add Rails routes to your
|
6
|
+
application that maps JSON requests to gRPC requests, and the gRPC response back to JSON. This is similar to
|
7
|
+
[grpc-gateway](https://github.com/grpc-ecosystem/grpc-gateway), except that rather than proxying the gRPC requests, it simply uses the same handling code to serve both types of requests.
|
8
|
+
|
9
|
+
In order to actually accept both gRPC and REST calls, you will need to start your gRPC server in one process or thread,
|
10
|
+
and start your Rails server in a separate process or thread. They should both be listening on different ports (
|
11
|
+
the default port for Rails is 3000 and for gRPC is 9001).
|
12
|
+
|
13
|
+
With this, you get the automatic client code generation via Swagger and gRPC, the input validation that's automatic with gRPC, and the ease of use of tools like `curl` and Postman with REST.
|
14
|
+
|
15
|
+
## Components
|
16
|
+
|
17
|
+
`grpc-rest` comes with two main components:
|
18
|
+
|
19
|
+
* A protobuf generator plugin that generates Ruby files for Rails routes and controllers.
|
20
|
+
* The Rails library that powers these generated files.
|
21
|
+
|
22
|
+
The protobuf generator uses the same annotations as [grpc-gateway](https://github.com/grpc-ecosystem/grpc-gateway). This also gives you the benefit of being able to generate Swagger files by using protoc.
|
23
|
+
|
24
|
+
## Installation
|
25
|
+
|
26
|
+
First, download `protoc-gen-rails` from the releases page on the right. Ensure it's somewhere in your PATH.
|
27
|
+
|
28
|
+
Then, add the following to your `Gemfile`:
|
29
|
+
|
30
|
+
```ruby
|
31
|
+
gem 'grpc-rest'
|
32
|
+
```
|
33
|
+
|
34
|
+
and run `bundle install`.
|
35
|
+
|
36
|
+
## Example
|
37
|
+
|
38
|
+
Here's an example protobuf file that defines a simple service:
|
39
|
+
|
40
|
+
```protobuf
|
41
|
+
syntax = "proto3";
|
42
|
+
|
43
|
+
package example;
|
44
|
+
|
45
|
+
import "google/api/annotations.proto";
|
46
|
+
|
47
|
+
message ExampleRequest {
|
48
|
+
string name = 1;
|
49
|
+
}
|
50
|
+
|
51
|
+
message ExampleResponse {
|
52
|
+
string message = 1;
|
53
|
+
}
|
54
|
+
|
55
|
+
service ExampleService {
|
56
|
+
rpc GetExample (ExampleRequest) returns (ExampleResponse) {
|
57
|
+
option (google.api.http) = {
|
58
|
+
get: "/example/{name}"
|
59
|
+
};
|
60
|
+
}
|
61
|
+
}
|
62
|
+
```
|
63
|
+
|
64
|
+
First, you need to generate the Ruby files from this. You can do this with plain `protoc`, but it's much easier to handle if you use [buf](https://buf.build/). Here's an example `buf.gen.yaml` file:
|
65
|
+
|
66
|
+
```yaml
|
67
|
+
version: v1
|
68
|
+
managed:
|
69
|
+
enabled: true
|
70
|
+
plugins:
|
71
|
+
- plugin: buf.build/grpc/ruby:v1.56.2
|
72
|
+
out: app/gen
|
73
|
+
opt:
|
74
|
+
- paths=source_relative
|
75
|
+
- plugin: buf.build/protocolbuffers/ruby:v23.0
|
76
|
+
out: app/gen
|
77
|
+
opt:
|
78
|
+
- paths=source_relative
|
79
|
+
- name: rails
|
80
|
+
out: .
|
81
|
+
```
|
82
|
+
|
83
|
+
Then, you can run `buf generate` to generate the Ruby files. This will generate:
|
84
|
+
* the Protobuf Ruby files for grpc, in `app/gen`
|
85
|
+
* A new route file, in `config/routes/grpc.rb`
|
86
|
+
* A new controller file, in `app/controllers`.
|
87
|
+
|
88
|
+
The generated route file will look like this:
|
89
|
+
|
90
|
+
```ruby
|
91
|
+
get "example/:name", to: "example#get_example"
|
92
|
+
```
|
93
|
+
|
94
|
+
and the generated controller will look like this:
|
95
|
+
|
96
|
+
```ruby
|
97
|
+
require 'services/example/example_services_pb'
|
98
|
+
class ExampleServiceController < ActionController::Base
|
99
|
+
protect_from_forgery with: :null_session
|
100
|
+
|
101
|
+
rescue_from Google::Protobuf::TypeError do |e|
|
102
|
+
render json: GrpcRest.error_msg(e)
|
103
|
+
end
|
104
|
+
|
105
|
+
def example
|
106
|
+
grpc_request = Services::Example::ExampleRequest.new
|
107
|
+
GrpcRest.assign_params(grpc_request, "/example/{name}", "", request.parameters)
|
108
|
+
render json: GrpcRest.send_request("Services::Example::ExampleService", "example", grpc_request)
|
109
|
+
end
|
110
|
+
|
111
|
+
end
|
112
|
+
```
|
113
|
+
|
114
|
+
To power it on, all you have to do is add the following to your `config/routes.rb`:
|
115
|
+
|
116
|
+
```ruby
|
117
|
+
Rails.application.routes.draw do
|
118
|
+
draw(:grpc) # Add this line
|
119
|
+
end
|
120
|
+
```
|
121
|
+
|
122
|
+
## Contributing
|
123
|
+
|
124
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/flipp-oss/grpc-rest.
|
125
|
+
|
126
|
+
## License
|
127
|
+
|
128
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/grpc-rest.gemspec
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
lib = File.expand_path('lib', __dir__)
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
5
|
+
require 'grpc_rest/version'
|
6
|
+
|
7
|
+
Gem::Specification.new do |spec|
|
8
|
+
spec.name = 'grpc-rest'
|
9
|
+
spec.version = GrpcRest::VERSION
|
10
|
+
spec.authors = ['Daniel Orner']
|
11
|
+
spec.email = ['daniel.orner@flipp.com']
|
12
|
+
spec.summary = 'Generate Rails controllers and routes from gRPC definitions.'
|
13
|
+
spec.homepage = ''
|
14
|
+
spec.license = 'MIT'
|
15
|
+
|
16
|
+
spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR)
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ['lib']
|
20
|
+
|
21
|
+
spec.add_runtime_dependency('grpc')
|
22
|
+
spec.add_runtime_dependency('rails', '>= 6.0')
|
23
|
+
end
|
data/lib/grpc_rest.rb
ADDED
@@ -0,0 +1,87 @@
|
|
1
|
+
module GrpcRest
|
2
|
+
class << self
|
3
|
+
|
4
|
+
# Gets a sub record from a proto. If it doesn't exist, initialize it and set it to the proto,
|
5
|
+
# then return it.
|
6
|
+
def sub_field(proto, name)
|
7
|
+
existing = proto.public_send(name.to_sym)
|
8
|
+
return existing if existing
|
9
|
+
|
10
|
+
descriptor = proto.class.descriptor.to_a.find { |a| a.name == name }
|
11
|
+
klass = descriptor.submsg_name.split('.').map(&:camelize).join('::').constantize
|
12
|
+
sub_record = klass.new
|
13
|
+
proto.public_send(:"#{name}=", sub_record)
|
14
|
+
sub_record
|
15
|
+
end
|
16
|
+
|
17
|
+
def assign_value(proto, path, value)
|
18
|
+
tokens = path.split('.')
|
19
|
+
tokens[0...-1].each do |path_seg|
|
20
|
+
proto = sub_field(proto, path_seg)
|
21
|
+
end
|
22
|
+
proto.public_send(:"#{tokens.last}=", value)
|
23
|
+
end
|
24
|
+
|
25
|
+
def assign_params(request, param_string, body_string, params)
|
26
|
+
parameters = params.to_h.deep_dup
|
27
|
+
# each instance of {variable} means that we set the corresponding param variable into the
|
28
|
+
# Protobuf request
|
29
|
+
# The variable pattern could have dots which indicate we need to drill into the request
|
30
|
+
# to set it - e.g. {subrecord.foo} means we need to set the value of `request.subrecord.foo` to `params[:foo].`
|
31
|
+
# We can also do simple wildcard replacement if there's a * - for example, {name=something-*}
|
32
|
+
# means we should set `request.name` to "something-#{params[:name]}".
|
33
|
+
param_string.scan(/\{(.*?)}/).each do |match|
|
34
|
+
name, val = match[0].split('=')
|
35
|
+
val = '*' if val.blank?
|
36
|
+
name_tokens = name.split('.')
|
37
|
+
assign_value(request, name, val.gsub('*', parameters.delete(name_tokens.last)))
|
38
|
+
end
|
39
|
+
if body_string.present? && body_string != '*'
|
40
|
+
# we need to "splat" the body parameters into the given sub-record rather than into the top-level.
|
41
|
+
sub_record = sub_field(request, body_string)
|
42
|
+
sub_record.class.descriptor.to_a.map(&:name).each do |key|
|
43
|
+
sub_record.public_send(:"#{key}=", parameters.delete(key))
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
# assign remaining parameters
|
48
|
+
parameters.except('action', 'controller').each do |k, v|
|
49
|
+
assign_value(request, k, v)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def error_msg(error)
|
54
|
+
{
|
55
|
+
code: 3,
|
56
|
+
message: "InvalidArgument: #{error.message}",
|
57
|
+
details: [
|
58
|
+
{
|
59
|
+
backtrace: error.backtrace
|
60
|
+
}
|
61
|
+
]
|
62
|
+
}
|
63
|
+
end
|
64
|
+
|
65
|
+
def send_request(service, method, request)
|
66
|
+
service_obj = service.constantize::Service
|
67
|
+
response = if defined?(Gruf)
|
68
|
+
klass = ::Gruf::Controllers::Base.subclasses.find do |k|
|
69
|
+
k.bound_service == service_obj
|
70
|
+
end
|
71
|
+
ref = service_obj.rpc_descs[method.classify.to_sym]
|
72
|
+
handler = klass.new(
|
73
|
+
method_key: method.to_sym,
|
74
|
+
service: service_obj,
|
75
|
+
rpc_desc: ref,
|
76
|
+
active_call: nil,
|
77
|
+
message: request
|
78
|
+
)
|
79
|
+
handler.send(method.to_sym)
|
80
|
+
else
|
81
|
+
raise 'Non-gruf grpc not implemented yet!'
|
82
|
+
end
|
83
|
+
response.to_h
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2024 Flipp
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module github.com/flipp-oss/protoc-gen-rails
|
2
|
+
|
3
|
+
go 1.22.0
|
4
|
+
|
5
|
+
require (
|
6
|
+
github.com/iancoleman/strcase v0.3.0
|
7
|
+
github.com/stretchr/testify v1.8.4
|
8
|
+
google.golang.org/genproto/googleapis/api v0.0.0-20240125205218-1f4bbc51befe
|
9
|
+
google.golang.org/protobuf v1.32.0
|
10
|
+
)
|
11
|
+
|
12
|
+
require (
|
13
|
+
github.com/davecgh/go-spew v1.1.1 // indirect
|
14
|
+
github.com/google/go-cmp v0.6.0 // indirect
|
15
|
+
github.com/kr/pretty v0.3.1 // indirect
|
16
|
+
github.com/pmezard/go-difflib v1.0.0 // indirect
|
17
|
+
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
18
|
+
google.golang.org/genproto v0.0.0-20240116215550-a9fa1716bcac // indirect
|
19
|
+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
20
|
+
gopkg.in/yaml.v3 v3.0.1 // indirect
|
21
|
+
)
|
@@ -0,0 +1,33 @@
|
|
1
|
+
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
2
|
+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
3
|
+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
4
|
+
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
5
|
+
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
6
|
+
github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI=
|
7
|
+
github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
|
8
|
+
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
9
|
+
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
10
|
+
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
11
|
+
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
12
|
+
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
13
|
+
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
14
|
+
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
15
|
+
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
16
|
+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
17
|
+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
18
|
+
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
19
|
+
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
20
|
+
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
21
|
+
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
22
|
+
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
23
|
+
google.golang.org/genproto v0.0.0-20240116215550-a9fa1716bcac h1:ZL/Teoy/ZGnzyrqK/Optxxp2pmVh+fmJ97slxSRyzUg=
|
24
|
+
google.golang.org/genproto v0.0.0-20240116215550-a9fa1716bcac/go.mod h1:+Rvu7ElI+aLzyDQhpHMFMMltsD6m7nqpuWDd2CwJw3k=
|
25
|
+
google.golang.org/genproto/googleapis/api v0.0.0-20240125205218-1f4bbc51befe h1:0poefMBYvYbs7g5UkjS6HcxBPaTRAmznle9jnxYoAI8=
|
26
|
+
google.golang.org/genproto/googleapis/api v0.0.0-20240125205218-1f4bbc51befe/go.mod h1:4jWUdICTdgc3Ibxmr8nAJiiLHwQBY0UI0XZcEMaFKaA=
|
27
|
+
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
|
28
|
+
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
29
|
+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
30
|
+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
31
|
+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
32
|
+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
33
|
+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
@@ -0,0 +1,122 @@
|
|
1
|
+
package internal
|
2
|
+
|
3
|
+
import (
|
4
|
+
"bytes"
|
5
|
+
"fmt"
|
6
|
+
"github.com/iancoleman/strcase"
|
7
|
+
"google.golang.org/protobuf/types/descriptorpb"
|
8
|
+
"strings"
|
9
|
+
"text/template"
|
10
|
+
)
|
11
|
+
|
12
|
+
type FileResult struct {
|
13
|
+
Name string
|
14
|
+
Content string
|
15
|
+
}
|
16
|
+
|
17
|
+
type controller struct {
|
18
|
+
ControllerName string
|
19
|
+
ServiceFilePath string
|
20
|
+
Methods []method
|
21
|
+
ServiceName string
|
22
|
+
FullServiceName string
|
23
|
+
MethodName string
|
24
|
+
}
|
25
|
+
|
26
|
+
type method struct {
|
27
|
+
Name string
|
28
|
+
RequestType string
|
29
|
+
Path string
|
30
|
+
Body string
|
31
|
+
HttpMethod string
|
32
|
+
}
|
33
|
+
|
34
|
+
type Route struct {
|
35
|
+
MethodName string
|
36
|
+
Path string
|
37
|
+
Controller string
|
38
|
+
HttpMethod string
|
39
|
+
}
|
40
|
+
|
41
|
+
var controllerTemplate = `
|
42
|
+
require 'services/geo_admin/v1/test_services_pb'
|
43
|
+
class {{.ControllerName}}Controller < ActionController::Base
|
44
|
+
protect_from_forgery with: :null_session
|
45
|
+
|
46
|
+
rescue_from Google::Protobuf::TypeError do |e|
|
47
|
+
render json: GrpcRest.error_msg(e)
|
48
|
+
end
|
49
|
+
{{$fullServiceName := .FullServiceName -}}
|
50
|
+
{{range .Methods }}
|
51
|
+
def {{.Name}}
|
52
|
+
grpc_request = {{.RequestType}}.new
|
53
|
+
GrpcRest.assign_params(grpc_request, "{{.Path}}", "{{.Body}}", request.parameters)
|
54
|
+
render json: GrpcRest.send_request("{{$fullServiceName}}", "{{.Name}}", grpc_request)
|
55
|
+
end
|
56
|
+
{{end}}
|
57
|
+
end
|
58
|
+
`
|
59
|
+
|
60
|
+
func ProcessService(service *descriptorpb.ServiceDescriptorProto, pkg string) (FileResult, []Route, error) {
|
61
|
+
var routes []Route
|
62
|
+
data := controller{
|
63
|
+
Methods: []method{},
|
64
|
+
ServiceName: Classify(service.GetName()),
|
65
|
+
ControllerName: Demodulize(service.GetName()),
|
66
|
+
ServiceFilePath: FilePathify(pkg + "." + service.GetName()),
|
67
|
+
FullServiceName: Classify(pkg + "." + service.GetName()),
|
68
|
+
}
|
69
|
+
for _, m := range service.GetMethod() {
|
70
|
+
opts, err := ExtractAPIOptions(m)
|
71
|
+
if err != nil {
|
72
|
+
return FileResult{}, routes, err
|
73
|
+
}
|
74
|
+
httpMethod, path, err := MethodAndPath(opts.Pattern)
|
75
|
+
controllerMethod := method{
|
76
|
+
Name: strcase.ToSnake(m.GetName()),
|
77
|
+
RequestType: Classify(m.GetInputType()),
|
78
|
+
Path: path,
|
79
|
+
HttpMethod: httpMethod,
|
80
|
+
Body: opts.Body,
|
81
|
+
}
|
82
|
+
data.Methods = append(data.Methods, controllerMethod)
|
83
|
+
routes = append(routes, Route{
|
84
|
+
HttpMethod: strings.ToLower(httpMethod),
|
85
|
+
Path: SanitizePath(path),
|
86
|
+
Controller: strcase.ToSnake(data.ControllerName),
|
87
|
+
MethodName: strcase.ToSnake(m.GetName()),
|
88
|
+
})
|
89
|
+
}
|
90
|
+
resultTemplate, err := template.New("controller").Parse(controllerTemplate)
|
91
|
+
if err != nil {
|
92
|
+
return FileResult{}, routes, err
|
93
|
+
}
|
94
|
+
var resultContent bytes.Buffer
|
95
|
+
err = resultTemplate.Execute(&resultContent, data)
|
96
|
+
if err != nil {
|
97
|
+
return FileResult{}, routes, err
|
98
|
+
}
|
99
|
+
return FileResult{
|
100
|
+
Content: resultContent.String(),
|
101
|
+
Name: fmt.Sprintf("app/controllers/%s_controller.rb", strcase.ToSnake(data.ControllerName)),
|
102
|
+
}, routes, nil
|
103
|
+
}
|
104
|
+
|
105
|
+
var routeTemplate = `
|
106
|
+
{{range . -}}
|
107
|
+
{{.HttpMethod}} "{{.Path}}" => "{{.Controller}}#{{.MethodName}}"
|
108
|
+
{{end -}}
|
109
|
+
`
|
110
|
+
|
111
|
+
func OutputRoutes(routes []Route) (string, error) {
|
112
|
+
resultTemplate, err := template.New("routes").Parse(routeTemplate)
|
113
|
+
if err != nil {
|
114
|
+
return "", err
|
115
|
+
}
|
116
|
+
var resultContent bytes.Buffer
|
117
|
+
err = resultTemplate.Execute(&resultContent, routes)
|
118
|
+
if err != nil {
|
119
|
+
return "", err
|
120
|
+
}
|
121
|
+
return resultContent.String(), nil
|
122
|
+
}
|
@@ -0,0 +1,45 @@
|
|
1
|
+
package internal
|
2
|
+
|
3
|
+
import (
|
4
|
+
"fmt"
|
5
|
+
options "google.golang.org/genproto/googleapis/api/annotations"
|
6
|
+
"google.golang.org/protobuf/proto"
|
7
|
+
"google.golang.org/protobuf/types/descriptorpb"
|
8
|
+
)
|
9
|
+
|
10
|
+
func MethodAndPath(pattern any) (string, string, error) {
|
11
|
+
|
12
|
+
switch typedPattern := pattern.(type) {
|
13
|
+
case *options.HttpRule_Get:
|
14
|
+
return "GET", typedPattern.Get, nil
|
15
|
+
case *options.HttpRule_Post:
|
16
|
+
return "POST", typedPattern.Post, nil
|
17
|
+
case *options.HttpRule_Put:
|
18
|
+
return "PUT", typedPattern.Put, nil
|
19
|
+
case *options.HttpRule_Delete:
|
20
|
+
return "DELETE", typedPattern.Delete, nil
|
21
|
+
case *options.HttpRule_Patch:
|
22
|
+
return "PATCH", typedPattern.Patch, nil
|
23
|
+
case *options.HttpRule_Custom:
|
24
|
+
return typedPattern.Custom.Kind, typedPattern.Custom.Path, nil
|
25
|
+
default:
|
26
|
+
return "", "", fmt.Errorf("unknown pattern type %T", pattern)
|
27
|
+
}
|
28
|
+
}
|
29
|
+
|
30
|
+
func ExtractAPIOptions(meth *descriptorpb.MethodDescriptorProto) (*options.HttpRule, error) {
|
31
|
+
if meth.Options == nil {
|
32
|
+
return nil, nil
|
33
|
+
}
|
34
|
+
if !proto.HasExtension(meth.Options, options.E_Http) {
|
35
|
+
return nil, nil
|
36
|
+
}
|
37
|
+
ext := proto.GetExtension(meth.Options, options.E_Http)
|
38
|
+
opts, ok := ext.(*options.HttpRule)
|
39
|
+
if !ok {
|
40
|
+
return nil, fmt.Errorf("extension is %T; want an HttpRule", ext)
|
41
|
+
}
|
42
|
+
return opts, nil
|
43
|
+
}
|
44
|
+
|
45
|
+
|
@@ -0,0 +1,57 @@
|
|
1
|
+
package internal
|
2
|
+
|
3
|
+
import (
|
4
|
+
"fmt"
|
5
|
+
"github.com/iancoleman/strcase"
|
6
|
+
"os"
|
7
|
+
"regexp"
|
8
|
+
"strings"
|
9
|
+
)
|
10
|
+
|
11
|
+
func LogMsg(msg string, args ...any) {
|
12
|
+
fmt.Fprintf(os.Stderr, fmt.Sprintf(msg, args...))
|
13
|
+
fmt.Fprintln(os.Stderr)
|
14
|
+
}
|
15
|
+
|
16
|
+
func FilePathify(s string) string {
|
17
|
+
var result []string
|
18
|
+
s = strings.Trim(s, ".")
|
19
|
+
tokens := strings.Split(s, ".")
|
20
|
+
for _, token := range tokens {
|
21
|
+
result = append(result, strcase.ToSnake(token))
|
22
|
+
}
|
23
|
+
return strings.Join(result, "/")
|
24
|
+
}
|
25
|
+
|
26
|
+
func Classify(s string) string {
|
27
|
+
var result []string
|
28
|
+
s = strings.Trim(s, ".")
|
29
|
+
tokens := strings.Split(s, ".")
|
30
|
+
for _, token := range tokens {
|
31
|
+
result = append(result, strcase.ToCamel(token))
|
32
|
+
}
|
33
|
+
return strings.Join(result, "::")
|
34
|
+
}
|
35
|
+
|
36
|
+
func Demodulize(s string) string {
|
37
|
+
tokens := strings.Split(s, ".")
|
38
|
+
return tokens[len(tokens)-1]
|
39
|
+
}
|
40
|
+
|
41
|
+
func SanitizePath(s string) string {
|
42
|
+
re := regexp.MustCompile("\\{(.*?)}")
|
43
|
+
matches := re.FindAllStringSubmatch(s, -1)
|
44
|
+
for _, match := range matches {
|
45
|
+
repl := match[1]
|
46
|
+
equal := strings.Index(match[1], "=")
|
47
|
+
if equal != -1 {
|
48
|
+
repl = repl[0:equal]
|
49
|
+
}
|
50
|
+
dot := strings.Index(repl, ".")
|
51
|
+
if dot != -1 {
|
52
|
+
repl = repl[dot+1:]
|
53
|
+
}
|
54
|
+
s = strings.Replace(s, match[0], ":"+repl, 1)
|
55
|
+
}
|
56
|
+
return s
|
57
|
+
}
|
@@ -0,0 +1,102 @@
|
|
1
|
+
package main
|
2
|
+
|
3
|
+
import (
|
4
|
+
"fmt"
|
5
|
+
"github.com/flipp-oss/protoc-gen-rails/internal"
|
6
|
+
"google.golang.org/protobuf/proto"
|
7
|
+
"google.golang.org/protobuf/types/descriptorpb"
|
8
|
+
"google.golang.org/protobuf/types/pluginpb"
|
9
|
+
"io"
|
10
|
+
"log"
|
11
|
+
"os"
|
12
|
+
"slices"
|
13
|
+
)
|
14
|
+
|
15
|
+
var routes = []internal.Route{}
|
16
|
+
|
17
|
+
func processService(service *descriptorpb.ServiceDescriptorProto, pkg string) (internal.FileResult, error) {
|
18
|
+
result, serviceRoutes, err := internal.ProcessService(service, pkg)
|
19
|
+
if err != nil {
|
20
|
+
return internal.FileResult{}, err
|
21
|
+
}
|
22
|
+
routes = slices.Concat(routes, serviceRoutes)
|
23
|
+
return result, nil
|
24
|
+
}
|
25
|
+
|
26
|
+
func routeFile() (internal.FileResult, error) {
|
27
|
+
content, err := internal.OutputRoutes(routes)
|
28
|
+
if err != nil {
|
29
|
+
return internal.FileResult{}, err
|
30
|
+
}
|
31
|
+
return internal.FileResult{
|
32
|
+
Name: "config/routes/grpc.rb",
|
33
|
+
Content: content,
|
34
|
+
}, nil
|
35
|
+
}
|
36
|
+
|
37
|
+
func main() {
|
38
|
+
req, err := ReadRequest()
|
39
|
+
if err != nil {
|
40
|
+
log.Fatalf("%s", fmt.Errorf("error reading request: %w", err))
|
41
|
+
}
|
42
|
+
files := []internal.FileResult{}
|
43
|
+
for _, file := range req.GetProtoFile() {
|
44
|
+
for _, service := range file.GetService() {
|
45
|
+
fileResult, err := processService(service, file.GetPackage())
|
46
|
+
if err != nil {
|
47
|
+
log.Fatalf("%s", fmt.Errorf("error processing service %v: %w", service.GetName(), err))
|
48
|
+
}
|
49
|
+
files = append(files, fileResult)
|
50
|
+
}
|
51
|
+
}
|
52
|
+
routeOutput, err := routeFile()
|
53
|
+
if err != nil {
|
54
|
+
log.Fatalf("%s", fmt.Errorf("error processing routes: %w", err))
|
55
|
+
}
|
56
|
+
files = append(files, routeOutput)
|
57
|
+
|
58
|
+
// process registry
|
59
|
+
writeResponse(files)
|
60
|
+
}
|
61
|
+
|
62
|
+
func ReadRequest() (*pluginpb.CodeGeneratorRequest, error) {
|
63
|
+
in, err := io.ReadAll(os.Stdin)
|
64
|
+
if err != nil {
|
65
|
+
return nil, err
|
66
|
+
}
|
67
|
+
req := &pluginpb.CodeGeneratorRequest{}
|
68
|
+
err = proto.Unmarshal(in, req)
|
69
|
+
if err != nil {
|
70
|
+
return nil, err
|
71
|
+
}
|
72
|
+
return req, nil
|
73
|
+
}
|
74
|
+
|
75
|
+
func generateResponse(files []internal.FileResult) *pluginpb.CodeGeneratorResponse {
|
76
|
+
feature := uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL)
|
77
|
+
respFiles := make([]*pluginpb.CodeGeneratorResponse_File, len(files))
|
78
|
+
for i, file := range files {
|
79
|
+
respFiles[i] = &pluginpb.CodeGeneratorResponse_File{
|
80
|
+
Name: &file.Name,
|
81
|
+
Content: &file.Content,
|
82
|
+
}
|
83
|
+
|
84
|
+
}
|
85
|
+
return &pluginpb.CodeGeneratorResponse{
|
86
|
+
SupportedFeatures: &feature,
|
87
|
+
File: respFiles,
|
88
|
+
}
|
89
|
+
}
|
90
|
+
|
91
|
+
func writeResponse(files []internal.FileResult) {
|
92
|
+
response := generateResponse(files)
|
93
|
+
out, err := proto.Marshal(response)
|
94
|
+
if err != nil {
|
95
|
+
log.Fatalf("%s", fmt.Errorf("error marshalling response: %w", err))
|
96
|
+
}
|
97
|
+
_, err = os.Stdout.Write(out)
|
98
|
+
if err != nil {
|
99
|
+
log.Fatalf("%s", fmt.Errorf("error writing response: %w", err))
|
100
|
+
}
|
101
|
+
}
|
102
|
+
|
@@ -0,0 +1,139 @@
|
|
1
|
+
package main
|
2
|
+
|
3
|
+
import (
|
4
|
+
"fmt"
|
5
|
+
"github.com/stretchr/testify/assert"
|
6
|
+
"os"
|
7
|
+
"os/exec"
|
8
|
+
"path/filepath"
|
9
|
+
"strings"
|
10
|
+
"testing"
|
11
|
+
)
|
12
|
+
|
13
|
+
// Overall approach taken from https://github.com/mix-php/mix/blob/master/src/grpc/protoc-gen-mix/plugin_test.go
|
14
|
+
|
15
|
+
// When the environment variable RUN_AS_PROTOC_GEN_AVRO is set, we skip running
|
16
|
+
// tests and instead act as protoc-gen-avro. This allows the test binary to
|
17
|
+
// pass itself to protoc.
|
18
|
+
func init() {
|
19
|
+
if os.Getenv("RUN_AS_PROTOC_GEN_AVRO") != "" {
|
20
|
+
main()
|
21
|
+
os.Exit(0)
|
22
|
+
}
|
23
|
+
}
|
24
|
+
|
25
|
+
func fileNames(directory string, appendDirectory bool) ([]string, error) {
|
26
|
+
files, err := os.ReadDir(directory)
|
27
|
+
if err != nil {
|
28
|
+
return nil, fmt.Errorf("can't read %s directory: %w", directory, err)
|
29
|
+
}
|
30
|
+
var names []string
|
31
|
+
for _, file := range files {
|
32
|
+
if file.IsDir() {
|
33
|
+
continue
|
34
|
+
}
|
35
|
+
if appendDirectory {
|
36
|
+
names = append(names, filepath.Base(directory) + "/" + file.Name())
|
37
|
+
} else {
|
38
|
+
names = append(names, file.Name())
|
39
|
+
}
|
40
|
+
}
|
41
|
+
return names, nil
|
42
|
+
}
|
43
|
+
|
44
|
+
func runTest(t *testing.T, directory string, options map[string]string) {
|
45
|
+
workdir, _ := os.Getwd()
|
46
|
+
tmpdir, err := os.MkdirTemp(workdir, "proto-test.")
|
47
|
+
if err != nil {
|
48
|
+
t.Fatal(err)
|
49
|
+
}
|
50
|
+
defer os.RemoveAll(tmpdir)
|
51
|
+
|
52
|
+
args := []string{
|
53
|
+
"-I.",
|
54
|
+
"--avro_out=" + tmpdir,
|
55
|
+
}
|
56
|
+
names, err := fileNames(workdir + "/testdata", true)
|
57
|
+
if err != nil {
|
58
|
+
t.Fatal(fmt.Errorf("testData fileNames %w", err))
|
59
|
+
}
|
60
|
+
for _, name := range names {
|
61
|
+
args = append(args, name)
|
62
|
+
}
|
63
|
+
for k, v := range options {
|
64
|
+
args = append(args, "--avro_opt=" + k + "=" + v)
|
65
|
+
}
|
66
|
+
protoc(t, args)
|
67
|
+
|
68
|
+
testDir := workdir + "/testdata/" + directory
|
69
|
+
if os.Getenv("UPDATE_SNAPSHOTS") != "" {
|
70
|
+
cmd := exec.Command("/bin/sh", "-c", fmt.Sprintf("cp %v/* %v", tmpdir, testDir))
|
71
|
+
cmd.Run()
|
72
|
+
} else {
|
73
|
+
assertEqualFiles(t, testDir, tmpdir)
|
74
|
+
}
|
75
|
+
}
|
76
|
+
|
77
|
+
func Test_Base(t *testing.T) {
|
78
|
+
runTest(t, "base", map[string]string{})
|
79
|
+
}
|
80
|
+
|
81
|
+
func Test_CollapseFields(t *testing.T) {
|
82
|
+
runTest(t, "collapse_fields", map[string]string{"collapse_fields": "StringList"})
|
83
|
+
}
|
84
|
+
|
85
|
+
func Test_EmitOnly(t *testing.T) {
|
86
|
+
runTest(t, "emit_only", map[string]string{"emit_only": "Widget"})
|
87
|
+
}
|
88
|
+
|
89
|
+
func Test_NamespaceMap(t *testing.T) {
|
90
|
+
runTest(t, "namespace_map", map[string]string{"namespace_map": "testdata:mynamespace"})
|
91
|
+
}
|
92
|
+
|
93
|
+
func Test_PreserveNonStringMaps(t *testing.T) {
|
94
|
+
runTest(t, "preserve_non_string_maps", map[string]string{"preserve_non_string_maps": "true"})
|
95
|
+
}
|
96
|
+
|
97
|
+
func assertEqualFiles(t *testing.T, original, generated string) {
|
98
|
+
names, err := fileNames(original, false)
|
99
|
+
if err != nil {
|
100
|
+
t.Fatal(fmt.Errorf("original fileNames %w", err))
|
101
|
+
}
|
102
|
+
generatedNames, err := fileNames(generated, false)
|
103
|
+
if err != nil {
|
104
|
+
t.Fatal(fmt.Errorf("generated fileNames %w", err))
|
105
|
+
}
|
106
|
+
assert.Equal(t, names, generatedNames)
|
107
|
+
for i, name := range names {
|
108
|
+
originalData, err := os.ReadFile(original + "/" + name)
|
109
|
+
if err != nil {
|
110
|
+
t.Fatal("Can't find original file for comparison")
|
111
|
+
}
|
112
|
+
|
113
|
+
generatedData, err := os.ReadFile(generated + "/" + generatedNames[i])
|
114
|
+
if err != nil {
|
115
|
+
t.Fatal("Can't find generated file for comparison")
|
116
|
+
}
|
117
|
+
r := strings.NewReplacer("\r\n", "", "\n", "")
|
118
|
+
assert.Equal(t, r.Replace(string(originalData)), r.Replace(string(generatedData)))
|
119
|
+
}
|
120
|
+
}
|
121
|
+
|
122
|
+
func protoc(t *testing.T, args []string) {
|
123
|
+
cmd := exec.Command("protoc", "--plugin=protoc-gen-avro=" + os.Args[0])
|
124
|
+
cmd.Args = append(cmd.Args, args...)
|
125
|
+
cmd.Env = append(os.Environ(), "RUN_AS_PROTOC_GEN_AVRO=1")
|
126
|
+
out, err := cmd.CombinedOutput()
|
127
|
+
|
128
|
+
if len(out) > 0 || err != nil {
|
129
|
+
t.Log("RUNNING: ", strings.Join(cmd.Args, " "))
|
130
|
+
}
|
131
|
+
|
132
|
+
if len(out) > 0 {
|
133
|
+
t.Log(string(out))
|
134
|
+
}
|
135
|
+
|
136
|
+
if err != nil {
|
137
|
+
t.Fatalf("protoc: %v", err)
|
138
|
+
}
|
139
|
+
}
|
metadata
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: grpc-rest
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Daniel Orner
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-03-01 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: grpc
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rails
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '6.0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '6.0'
|
41
|
+
description:
|
42
|
+
email:
|
43
|
+
- daniel.orner@flipp.com
|
44
|
+
executables: []
|
45
|
+
extensions: []
|
46
|
+
extra_rdoc_files: []
|
47
|
+
files:
|
48
|
+
- ".gitignore"
|
49
|
+
- README.md
|
50
|
+
- grpc-rest.gemspec
|
51
|
+
- lib/grpc_rest.rb
|
52
|
+
- lib/grpc_rest/version.rb
|
53
|
+
- protoc-gen-rails/LICENSE
|
54
|
+
- protoc-gen-rails/go.mod
|
55
|
+
- protoc-gen-rails/go.sum
|
56
|
+
- protoc-gen-rails/internal/output.go
|
57
|
+
- protoc-gen-rails/internal/parse.go
|
58
|
+
- protoc-gen-rails/internal/utils.go
|
59
|
+
- protoc-gen-rails/main.go
|
60
|
+
- protoc-gen-rails/main_test.go
|
61
|
+
homepage: ''
|
62
|
+
licenses:
|
63
|
+
- MIT
|
64
|
+
metadata: {}
|
65
|
+
post_install_message:
|
66
|
+
rdoc_options: []
|
67
|
+
require_paths:
|
68
|
+
- lib
|
69
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
70
|
+
requirements:
|
71
|
+
- - ">="
|
72
|
+
- !ruby/object:Gem::Version
|
73
|
+
version: '0'
|
74
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
75
|
+
requirements:
|
76
|
+
- - ">="
|
77
|
+
- !ruby/object:Gem::Version
|
78
|
+
version: '0'
|
79
|
+
requirements: []
|
80
|
+
rubygems_version: 3.4.10
|
81
|
+
signing_key:
|
82
|
+
specification_version: 4
|
83
|
+
summary: Generate Rails controllers and routes from gRPC definitions.
|
84
|
+
test_files: []
|