-
Notifications
You must be signed in to change notification settings - Fork 3.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(server/v2)!: grpcgateway autoregistration #22941
base: main
Are you sure you want to change the base?
Changes from 9 commits
8ad4b56
0bba48a
35f6885
ddd164b
64a8605
5b8a773
14adff2
bb2f221
e2087d8
c32cf97
fddbb50
e8c93c6
56fb591
7e1d5de
a1bee2e
d3bd509
dadd86d
2e9285e
8369a89
8b5aeb2
ed18cb0
e3551f0
0b79fe4
ea4bdd8
eb63921
f0c3e47
176726b
e3aeb4d
10d6778
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
package grpcgateway | ||
|
||
import ( | ||
"errors" | ||
"net/http" | ||
"strconv" | ||
|
||
gogoproto "github.com/cosmos/gogoproto/proto" | ||
"github.com/grpc-ecosystem/grpc-gateway/runtime" | ||
"google.golang.org/genproto/googleapis/api/annotations" | ||
"google.golang.org/protobuf/reflect/protoreflect" | ||
|
||
"google.golang.org/protobuf/proto" | ||
|
||
"cosmossdk.io/core/transaction" | ||
"cosmossdk.io/server/v2/appmanager" | ||
) | ||
|
||
var _ http.Handler = &gatewayInterceptor[transaction.Tx]{} | ||
|
||
// gatewayInterceptor handles routing grpc-gateway queries to the app manager's query router. | ||
type gatewayInterceptor[T transaction.Tx] struct { | ||
// gateway is the fallback grpc gateway mux handler. | ||
gateway *runtime.ServeMux | ||
|
||
// customEndpointMapping is a mapping of custom GET options on proto RPC handlers, to the fully qualified method name. | ||
// | ||
// example: /cosmos/bank/v1beta1/denoms_metadata -> cosmos.bank.v1beta1.Query.DenomsMetadata | ||
customEndpointMapping map[string]string | ||
|
||
// appManager is used to route queries to the application. | ||
appManager appmanager.AppManager[T] | ||
} | ||
|
||
// newGatewayInterceptor creates a new gatewayInterceptor. | ||
func newGatewayInterceptor[T transaction.Tx](gateway *runtime.ServeMux, am appmanager.AppManager[T]) (*gatewayInterceptor[T], error) { | ||
getMapping, err := getHTTPGetAnnotationMapping() | ||
if err != nil { | ||
return nil, err | ||
} | ||
return &gatewayInterceptor[T]{ | ||
gateway: gateway, | ||
customEndpointMapping: getMapping, | ||
appManager: am, | ||
}, nil | ||
} | ||
|
||
// ServeHTTP implements the http.Handler interface. This function will attempt to match http requests to the | ||
// interceptors internal mapping of http annotations to query request type names. | ||
// If no match can be made, it falls back to the runtime gateway server mux. | ||
func (g *gatewayInterceptor[T]) ServeHTTP(writer http.ResponseWriter, request *http.Request) { | ||
match := matchURL(request.URL, g.customEndpointMapping) | ||
if match != nil { | ||
_, out := runtime.MarshalerForRequest(g.gateway, request) | ||
var msg gogoproto.Message | ||
var err error | ||
|
||
switch request.Method { | ||
case http.MethodPost: | ||
msg, err = createMessageFromJSON(match, request) | ||
case http.MethodGet: | ||
msg, err = createMessage(match) | ||
default: | ||
runtime.DefaultHTTPProtoErrorHandler(request.Context(), g.gateway, out, writer, request, errors.New(http.StatusText(http.StatusMethodNotAllowed))) | ||
return | ||
} | ||
if err != nil { | ||
runtime.DefaultHTTPProtoErrorHandler(request.Context(), g.gateway, out, writer, request, err) | ||
return | ||
} | ||
|
||
var height uint64 | ||
heightStr := request.Header.Get(GRPCBlockHeightHeader) | ||
if heightStr != "" { | ||
height, err = strconv.ParseUint(heightStr, 10, 64) | ||
if err != nil { | ||
runtime.DefaultHTTPProtoErrorHandler(request.Context(), g.gateway, out, writer, request, err) | ||
return | ||
} | ||
} | ||
|
||
query, err := g.appManager.Query(request.Context(), height, msg) | ||
if err != nil { | ||
runtime.DefaultHTTPProtoErrorHandler(request.Context(), g.gateway, out, writer, request, err) | ||
return | ||
} | ||
runtime.ForwardResponseMessage(request.Context(), g.gateway, out, writer, request, query) | ||
} else { | ||
g.gateway.ServeHTTP(writer, request) | ||
} | ||
} | ||
|
||
// getHTTPGetAnnotationMapping returns a mapping of proto query input type full name to its RPC method's HTTP GET annotation. | ||
// | ||
// example: "/cosmos/auth/v1beta1/account_info/{address}":"cosmos.auth.v1beta1.Query.AccountInfo" | ||
func getHTTPGetAnnotationMapping() (map[string]string, error) { | ||
protoFiles, err := gogoproto.MergedRegistry() | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
httpGets := make(map[string]string) | ||
protoFiles.RangeFiles(func(fd protoreflect.FileDescriptor) bool { | ||
for i := 0; i < fd.Services().Len(); i++ { | ||
// Get the service descriptor | ||
sd := fd.Services().Get(i) | ||
|
||
for j := 0; j < sd.Methods().Len(); j++ { | ||
// Get the method descriptor | ||
md := sd.Methods().Get(j) | ||
|
||
httpOption := proto.GetExtension(md.Options(), annotations.E_Http) | ||
if httpOption == nil { | ||
continue | ||
} | ||
|
||
httpRule, ok := httpOption.(*annotations.HttpRule) | ||
if !ok || httpRule == nil { | ||
continue | ||
} | ||
if httpRule.GetGet() == "" { | ||
continue | ||
} | ||
|
||
httpGets[httpRule.GetGet()] = string(md.Input().FullName()) | ||
} | ||
} | ||
return true | ||
}) | ||
|
||
return httpGets, nil | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,185 @@ | ||
package grpcgateway | ||
|
||
import ( | ||
"fmt" | ||
"io" | ||
"net/http" | ||
"net/url" | ||
"reflect" | ||
Check notice Code scanning / CodeQL Sensitive package import Note
Certain system packages contain functions which may be a possible source of non-determinism
|
||
"regexp" | ||
"strings" | ||
|
||
"github.com/cosmos/gogoproto/jsonpb" | ||
gogoproto "github.com/cosmos/gogoproto/proto" | ||
"github.com/mitchellh/mapstructure" | ||
) | ||
|
||
const maxBodySize = 1 << 20 // 1 MB | ||
|
||
// uriMatch contains information related to a URI match. | ||
type uriMatch struct { | ||
// QueryInputName is the fully qualified name of the proto input type of the query rpc method. | ||
QueryInputName string | ||
|
||
// Params are any wildcard params found in the request. | ||
// | ||
// example: foo/bar/{baz} - foo/bar/qux -> {baz: qux} | ||
Params map[string]string | ||
} | ||
|
||
// HasParams reports whether the uriMatch has any params. | ||
func (uri uriMatch) HasParams() bool { | ||
return len(uri.Params) > 0 | ||
} | ||
|
||
// matchURL attempts to find a match for the given URL. | ||
// NOTE: if no match is found, nil is returned. | ||
func matchURL(u *url.URL, getPatternToQueryInputName map[string]string) *uriMatch { | ||
uriPath := strings.TrimRight(u.Path, "/") | ||
queryParams := u.Query() | ||
|
||
params := make(map[string]string) | ||
for key, vals := range queryParams { | ||
if len(vals) > 0 { | ||
// url.Values contains a slice for the values as you are able to specify a key multiple times in URL. | ||
// example: https://localhost:9090/do/something?color=red&color=blue&color=green | ||
// We will just take the first value in the slice. | ||
params[key] = vals[0] | ||
} | ||
} | ||
|
||
// for simple cases where there are no wildcards, we can just do a map lookup. | ||
if inputName, ok := getPatternToQueryInputName[uriPath]; ok { | ||
return &uriMatch{ | ||
QueryInputName: inputName, | ||
Params: params, | ||
} | ||
} | ||
|
||
// attempt to find a match in the pattern map. | ||
for getPattern, queryInputName := range getPatternToQueryInputName { | ||
getPattern = strings.TrimRight(getPattern, "/") | ||
|
||
regexPattern, wildcardNames := patternToRegex(getPattern) | ||
|
||
regex := regexp.MustCompile(regexPattern) | ||
matches := regex.FindStringSubmatch(uriPath) | ||
|
||
if matches != nil && len(matches) > 1 { | ||
// first match is the full string, subsequent matches are capture groups | ||
for i, name := range wildcardNames { | ||
params[name] = matches[i+1] | ||
} | ||
|
||
return &uriMatch{ | ||
QueryInputName: queryInputName, | ||
Params: params, | ||
} | ||
} | ||
} | ||
|
||
return nil | ||
} | ||
|
||
// patternToRegex converts a URI pattern with wildcards to a regex pattern. | ||
// Returns the regex pattern and a slice of wildcard names in order | ||
func patternToRegex(pattern string) (string, []string) { | ||
escaped := regexp.QuoteMeta(pattern) | ||
var wildcardNames []string | ||
|
||
// extract and replace {param=**} patterns | ||
r1 := regexp.MustCompile(`\\\{([^}]+?)=\\\*\\\*\\}`) | ||
escaped = r1.ReplaceAllStringFunc(escaped, func(match string) string { | ||
// extract wildcard name without the =** suffix | ||
name := regexp.MustCompile(`\\\{(.+?)=`).FindStringSubmatch(match)[1] | ||
wildcardNames = append(wildcardNames, name) | ||
return "(.+)" | ||
}) | ||
|
||
// extract and replace {param} patterns | ||
r2 := regexp.MustCompile(`\\\{([^}]+)\\}`) | ||
escaped = r2.ReplaceAllStringFunc(escaped, func(match string) string { | ||
// extract wildcard name from the curl braces {}. | ||
name := regexp.MustCompile(`\\\{(.*?)\\}`).FindStringSubmatch(match)[1] | ||
wildcardNames = append(wildcardNames, name) | ||
return "([^/]+)" | ||
}) | ||
|
||
return "^" + escaped + "$", wildcardNames | ||
} | ||
Comment on lines
+87
to
+112
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate wildcard usage. |
||
|
||
// createMessageFromJSON creates a message from the uriMatch given the JSON body in the http request. | ||
func createMessageFromJSON(match *uriMatch, r *http.Request) (gogoproto.Message, error) { | ||
requestType := gogoproto.MessageType(match.QueryInputName) | ||
if requestType == nil { | ||
return nil, fmt.Errorf("unknown request type") | ||
} | ||
|
||
msg, ok := reflect.New(requestType.Elem()).Interface().(gogoproto.Message) | ||
if !ok { | ||
return nil, fmt.Errorf("failed to create message instance") | ||
} | ||
|
||
defer r.Body.Close() | ||
limitedReader := io.LimitReader(r.Body, maxBodySize) | ||
err := jsonpb.Unmarshal(limitedReader, msg) | ||
if err != nil { | ||
return nil, fmt.Errorf("error parsing body: %w", err) | ||
} | ||
|
||
return msg, nil | ||
|
||
} | ||
|
||
// createMessage creates a message from the given uriMatch. If the match has params, the message will be populated | ||
// with the value of those params. Otherwise, an empty message is returned. | ||
func createMessage(match *uriMatch) (gogoproto.Message, error) { | ||
requestType := gogoproto.MessageType(match.QueryInputName) | ||
if requestType == nil { | ||
return nil, fmt.Errorf("unknown request type") | ||
} | ||
|
||
msg, ok := reflect.New(requestType.Elem()).Interface().(gogoproto.Message) | ||
if !ok { | ||
return nil, fmt.Errorf("failed to create message instance") | ||
} | ||
|
||
// if the uri match has params, we need to populate the message with the values of those params. | ||
if match.HasParams() { | ||
// convert flat params map to nested structure | ||
nestedParams := make(map[string]any) | ||
for key, value := range match.Params { | ||
parts := strings.Split(key, ".") | ||
current := nestedParams | ||
|
||
// step through nested levels | ||
for i, part := range parts { | ||
if i == len(parts)-1 { | ||
// Last part - set the value | ||
current[part] = value | ||
} else { | ||
// continue nestedness | ||
if _, exists := current[part]; !exists { | ||
current[part] = make(map[string]any) | ||
} | ||
current = current[part].(map[string]any) | ||
} | ||
} | ||
} | ||
Comment on lines
+153
to
+170
Check warning Code scanning / CodeQL Iteration over map Warning
Iteration over map may be a possible source of non-determinism
|
||
|
||
// Configure decoder to handle the nested structure | ||
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ | ||
Result: msg, | ||
TagName: "json", // Use json tags as they're simpler | ||
WeaklyTypedInput: true, | ||
}) | ||
if err != nil { | ||
return nil, fmt.Errorf("failed to create decoder: %w", err) | ||
} | ||
|
||
if err := decoder.Decode(nestedParams); err != nil { | ||
return nil, fmt.Errorf("failed to decode params: %w", err) | ||
} | ||
} | ||
return msg, nil | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit, can you remove the
// TODO: register the gRPC-Gateway routes
from this file? As this does that.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done in remove todo