Skip to content
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

Open
wants to merge 29 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
8ad4b56
wip
technicallyty Dec 17, 2024
0bba48a
wip
technicallyty Dec 17, 2024
35f6885
finalize setup of grpc gateway autoreg
technicallyty Dec 18, 2024
ddd164b
didnt mean to commit that
technicallyty Dec 18, 2024
64a8605
comments
technicallyty Dec 18, 2024
5b8a773
unexport things that don't need to be exported
technicallyty Dec 18, 2024
14adff2
add some unit tests
technicallyty Dec 18, 2024
bb2f221
make uri matcher work with query params
technicallyty Dec 18, 2024
e2087d8
support for setting nested struct fields with query params
technicallyty Dec 19, 2024
c32cf97
use status error codes
technicallyty Dec 19, 2024
fddbb50
update distribution test to match error output
technicallyty Dec 19, 2024
e8c93c6
unused import
technicallyty Dec 19, 2024
56fb591
Merge branch 'main' into technicallyty/22715-grpc-auto-registration
technicallyty Dec 19, 2024
7e1d5de
Merge branch 'main' into technicallyty/22715-grpc-auto-registration
technicallyty Dec 19, 2024
a1bee2e
add some debug lines
technicallyty Dec 19, 2024
d3bd509
add returns to remove else cases
technicallyty Dec 19, 2024
dadd86d
comments
technicallyty Dec 19, 2024
2e9285e
add another test case
technicallyty Dec 19, 2024
8369a89
linter
technicallyty Dec 19, 2024
8b5aeb2
linter
technicallyty Dec 19, 2024
ed18cb0
changelog
technicallyty Dec 19, 2024
e3551f0
changelog order
technicallyty Dec 19, 2024
0b79fe4
use strings contains to check no handler error
technicallyty Dec 20, 2024
ea4bdd8
remove todo
technicallyty Dec 20, 2024
eb63921
remove changelog entry
technicallyty Dec 20, 2024
f0c3e47
add changelog to server/v2
technicallyty Dec 20, 2024
176726b
Merge branch 'main' into technicallyty/22715-grpc-auto-registration
technicallyty Dec 20, 2024
e3aeb4d
add error case to create message from json
technicallyty Dec 20, 2024
10d6778
Merge branch 'main' into technicallyty/22715-grpc-auto-registration
technicallyty Dec 20, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions server/v2/api/grpcgateway/interceptor.go
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
}
14 changes: 10 additions & 4 deletions server/v2/api/grpcgateway/server.go
Copy link
Member

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done in remove todo

Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"cosmossdk.io/core/transaction"
"cosmossdk.io/log"
serverv2 "cosmossdk.io/server/v2"
"cosmossdk.io/server/v2/appmanager"
)

var (
Expand All @@ -37,6 +38,7 @@ func New[T transaction.Tx](
logger log.Logger,
config server.ConfigMap,
ir jsonpb.AnyResolver,
appManager appmanager.AppManager[T],
cfgOptions ...CfgOption,
) (*Server[T], error) {
// The default JSON marshaller used by the gRPC-Gateway is unable to marshal non-nullable non-scalar fields.
Expand Down Expand Up @@ -76,7 +78,11 @@ func New[T transaction.Tx](
s.logger = logger.With(log.ModuleKey, s.Name())
s.config = serverCfg
mux := http.NewServeMux()
mux.Handle("/", s.GRPCGatewayRouter)
interceptor, err := newGatewayInterceptor[T](s.GRPCGatewayRouter, appManager)
if err != nil {
return nil, fmt.Errorf("failed to create grpc-gateway interceptor: %w", err)
}
mux.Handle("/", interceptor)

s.server = &http.Server{
Addr: s.config.Address,
Expand Down Expand Up @@ -133,15 +139,15 @@ func (s *Server[T]) Stop(ctx context.Context) error {
return s.server.Shutdown(ctx)
}

// GRPCBlockHeightHeader is the gRPC header for block height.
const GRPCBlockHeightHeader = "x-cosmos-block-height"

// CustomGRPCHeaderMatcher for mapping request headers to
// GRPC metadata.
// HTTP headers that start with 'Grpc-Metadata-' are automatically mapped to
// gRPC metadata after removing prefix 'Grpc-Metadata-'. We can use this
// CustomGRPCHeaderMatcher if headers don't start with `Grpc-Metadata-`
func CustomGRPCHeaderMatcher(key string) (string, bool) {
// GRPCBlockHeightHeader is the gRPC header for block height.
const GRPCBlockHeightHeader = "x-cosmos-block-height"

switch strings.ToLower(key) {
case GRPCBlockHeightHeader:
return GRPCBlockHeightHeader, true
Expand Down
185 changes: 185 additions & 0 deletions server/v2/api/grpcgateway/uri.go
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Validate wildcard usage.
Ensure that special characters in wildcard expansions do not cause misrouting or potential security concerns in parameter injection, particularly if these param values are used downstream without sanitization.


// 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
}
Loading
Loading