grpc-rest 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,3 @@
1
+ module GrpcRest
2
+ VERSION = "0.1.0"
3
+ 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: []