grpc-rest 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/.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: []
|