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 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: []