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

refactor(server/v2): auto-gateway improvements #23262

Merged
merged 35 commits into from
Jan 13, 2025
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
0f2ed52
docs
technicallyty Jan 8, 2025
02d0696
refactor: build regular expressions upfront, instead of on the fly
technicallyty Jan 8, 2025
2082711
add support for setting slice of values in query params
technicallyty Jan 9, 2025
7a617c9
godoc
technicallyty Jan 9, 2025
2ff852e
better comment
technicallyty Jan 9, 2025
9ed9087
comment
technicallyty Jan 9, 2025
0f20fcb
comment
technicallyty Jan 9, 2025
8336880
warn on duplicate URI
technicallyty Jan 10, 2025
227b5bd
Merge remote-tracking branch 'origin/main' into technicallyty/23188-g…
technicallyty Jan 10, 2025
ce89c88
simplification overhaul
technicallyty Jan 10, 2025
9f7c085
move code around to be simpler
technicallyty Jan 10, 2025
71256ba
Merge remote-tracking branch 'origin/main' into technicallyty/23188-g…
technicallyty Jan 10, 2025
81491ee
use debug, not info
technicallyty Jan 10, 2025
b0aff3e
return here
technicallyty Jan 10, 2025
bc42211
linter
technicallyty Jan 11, 2025
8813640
fix structured logging format
technicallyty Jan 11, 2025
617de22
update server to handle 'latest' for height header
technicallyty Jan 11, 2025
8223989
add max body size check
technicallyty Jan 11, 2025
887404a
cleanse string from header
technicallyty Jan 11, 2025
3f072b6
maybe increase block height
technicallyty Jan 11, 2025
02deaf9
rework height thing
technicallyty Jan 11, 2025
d909cd9
fix: simple matchers for URIs that are similar to wildcards
technicallyty Jan 13, 2025
c103c74
dont include that in the main mapping though
technicallyty Jan 13, 2025
339e0e7
improve mapping function
technicallyty Jan 13, 2025
2b50c06
lint
technicallyty Jan 13, 2025
db043fe
remove unncessary error check
technicallyty Jan 13, 2025
08d052e
fix error message in test
technicallyty Jan 13, 2025
df4f64c
Merge branch 'main' into technicallyty/23188-gateway-improvements
Jan 13, 2025
8b85a1c
fix error check output
technicallyty Jan 13, 2025
41acd31
Merge branch 'main' into technicallyty/23188-gateway-improvements
Jan 13, 2025
35dd8c2
add support for additonal bindings
technicallyty Jan 13, 2025
4d535e7
construct filter value
technicallyty Jan 13, 2025
60463db
Merge branch 'technicallyty/23188-gateway-improvements' of ssh://gith…
technicallyty Jan 13, 2025
146e568
updated create message function arguments
technicallyty Jan 13, 2025
71dc664
comment
technicallyty Jan 13, 2025
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
11 changes: 11 additions & 0 deletions server/v2/api/grpcgateway/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Package grpcgateway provides a custom http mux that utilizes the global gogoproto registry to match
// grpc gateway requests to query handlers. POST requests with JSON bodies and GET requests with query params are supported.
// Wildcard endpoints (i.e. foo/bar/{baz}), as well as catch-all endpoints (i.e. foo/bar/{baz=**} are supported. Using
// header `x-cosmos-block-height` allows you to specify a height for the query.
//
// The URL matching logic is achieved by building regular expressions from the gateway HTTP annotations. These regular expressions
// are then used to match against incoming requests to the HTTP server.
//
// In cases where the custom http mux is unable to handle the query (i.e. no match found), the request will fall back to the
// ServeMux from github.com/grpc-ecosystem/grpc-gateway/runtime.
package grpcgateway
170 changes: 144 additions & 26 deletions server/v2/api/grpcgateway/interceptor.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
package grpcgateway

import (
"context"
"errors"
"io"
"net/http"
"reflect"
Dismissed Show dismissed Hide dismissed
"regexp"
"strconv"
"strings"

gogoproto "github.com/cosmos/gogoproto/proto"
"github.com/grpc-ecosystem/grpc-gateway/runtime"
"github.com/grpc-ecosystem/grpc-gateway/utilities"
"github.com/mitchellh/mapstructure"
"google.golang.org/genproto/googleapis/api/annotations"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
Expand All @@ -18,18 +25,27 @@ import (
"cosmossdk.io/server/v2/appmanager"
)

const MaxBodySize = 1 << 20 // 1 MB

var _ http.Handler = &gatewayInterceptor[transaction.Tx]{}

// queryMetadata holds information related to handling gateway queries.
type queryMetadata struct {
// queryInputProtoName is the proto name of the query's input type.
queryInputProtoName string
// wildcardKeyNames are the wildcard key names from the query's HTTP annotation.
// for example /foo/bar/{baz}/{qux} would produce []string{"baz", "qux"}
// this is used for building the query's parameter map.
wildcardKeyNames []string
}

// gatewayInterceptor handles routing grpc-gateway queries to the app manager's query router.
type gatewayInterceptor[T transaction.Tx] struct {
logger log.Logger
// 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
matcher uriMatcher

// appManager is used to route queries to the application.
appManager appmanager.AppManager[T]
Expand All @@ -41,57 +57,74 @@ func newGatewayInterceptor[T transaction.Tx](logger log.Logger, gateway *runtime
if err != nil {
return nil, err
}
// convert the mapping to regular expressions for URL matching.
wildcardMatchers, simpleMatchers := createRegexMapping(logger, getMapping)
matcher := uriMatcher{
wildcardURIMatchers: wildcardMatchers,
simpleMatchers: simpleMatchers,
}
return &gatewayInterceptor[T]{
logger: logger,
gateway: gateway,
customEndpointMapping: getMapping,
appManager: am,
logger: logger,
gateway: gateway,
matcher: matcher,
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.
// ServeHTTP implements the http.Handler interface. This method will attempt to match request URIs to its internal mapping
// of gateway HTTP annotations. 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) {
g.logger.Debug("received grpc-gateway request", "request_uri", request.RequestURI)
match := matchURL(request.URL, g.customEndpointMapping)
match := g.matcher.matchURL(request.URL)
if match == nil {
// no match cases fall back to gateway mux.
g.gateway.ServeHTTP(writer, request)
return
}

g.logger.Debug("matched request", "query_input", match.QueryInputName)
_, out := runtime.MarshalerForRequest(g.gateway, request)
var msg gogoproto.Message
var err error

in, out := runtime.MarshalerForRequest(g.gateway, request)

// extract the proto message type.
msgType := gogoproto.MessageType(match.QueryInputName)
msg, ok := reflect.New(msgType.Elem()).Interface().(gogoproto.Message)
if !ok {
runtime.DefaultHTTPProtoErrorHandler(request.Context(), g.gateway, out, writer, request, status.Errorf(codes.Internal, "unable to to create gogoproto message from query input name %s", match.QueryInputName))
return
}

// msg population based on http method.
var inputMsg gogoproto.Message
var err error
switch request.Method {
case http.MethodPost:
msg, err = createMessageFromJSON(match, request)
case http.MethodGet:
msg, err = createMessage(match)
inputMsg, err = g.createMessageFromGetRequest(request.Context(), in, request, msg, match.Params)
Fixed Show fixed Hide fixed
case http.MethodPost:
inputMsg, err = g.createMessageFromPostRequest(request.Context(), in, request, msg)
technicallyty marked this conversation as resolved.
Show resolved Hide resolved
Fixed Show fixed Hide fixed
default:
runtime.DefaultHTTPProtoErrorHandler(request.Context(), g.gateway, out, writer, request, status.Error(codes.Unimplemented, "HTTP method must be POST or GET"))
runtime.DefaultHTTPProtoErrorHandler(request.Context(), g.gateway, out, writer, request, status.Error(codes.InvalidArgument, "HTTP method was not POST or GET"))
return
}
if err != nil {
// the errors returned from the message creation methods return status errors. no need to make one here.
runtime.DefaultHTTPProtoErrorHandler(request.Context(), g.gateway, out, writer, request, err)
return
}

// extract block height header
// get the height from the header.
var height uint64
heightStr := request.Header.Get(GRPCBlockHeightHeader)
if heightStr != "" {
heightStr = strings.Trim(heightStr, `\"`)
if heightStr != "" && heightStr != "latest" {
height, err = strconv.ParseUint(heightStr, 10, 64)
if err != nil {
err = status.Errorf(codes.InvalidArgument, "invalid height: %s", heightStr)
runtime.DefaultHTTPProtoErrorHandler(request.Context(), g.gateway, out, writer, request, err)
runtime.DefaultHTTPProtoErrorHandler(request.Context(), g.gateway, out, writer, request, status.Errorf(codes.InvalidArgument, "invalid height in header: %s", heightStr))
return
}
}

query, err := g.appManager.Query(request.Context(), height, msg)
responseMsg, err := g.appManager.Query(request.Context(), height, inputMsg)
if err != nil {
// if we couldn't find a handler for this request, just fall back to the gateway mux.
if strings.Contains(err.Error(), "no handler") {
Expand All @@ -102,8 +135,54 @@ func (g *gatewayInterceptor[T]) ServeHTTP(writer http.ResponseWriter, request *h
}
return
}

// for no errors, we forward the response.
runtime.ForwardResponseMessage(request.Context(), g.gateway, out, writer, request, query)
runtime.ForwardResponseMessage(request.Context(), g.gateway, out, writer, request, responseMsg)
}

func (g *gatewayInterceptor[T]) createMessageFromPostRequest(_ context.Context, marshaler runtime.Marshaler, req *http.Request, input gogoproto.Message) (gogoproto.Message, error) {
if req.ContentLength > MaxBodySize {
return nil, status.Errorf(codes.InvalidArgument, "request body too large: %d bytes, max=%d", req.ContentLength, MaxBodySize)
}
newReader, err := utilities.IOReaderFactory(req.Body)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "%v", err)
}

if err = marshaler.NewDecoder(newReader()).Decode(input); err != nil && !errors.Is(err, io.EOF) {
return nil, status.Errorf(codes.InvalidArgument, "%v", err)
}

return input, nil
}

func (g *gatewayInterceptor[T]) createMessageFromGetRequest(_ context.Context, _ runtime.Marshaler, req *http.Request, input gogoproto.Message, wildcardValues map[string]string) (gogoproto.Message, error) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

do we need the context.Context argument for these create* functions?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

// decode the path wildcards into the message.
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Result: input,
TagName: "json",
WeaklyTypedInput: true,
})
if err != nil {
return nil, status.Error(codes.Internal, "failed to create message decoder")
}
if err := decoder.Decode(wildcardValues); err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}

if err = req.ParseForm(); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "%v", err)
}

// im not really sure what this filter is for, but defaulting it like so doesn't seem to break anything.
// pb.gw.go code uses it in a few queries, but it's not clear what actual behavior is gained from this.
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we should either remove this comment/logic or find the real reason we are including this code. Let's not add code we don't understand

Copy link
Contributor Author

Choose a reason for hiding this comment

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

whoops, forgot to get back to that one. updated in construct filter value

filter := &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)}
err = runtime.PopulateQueryParameters(input, req.Form, filter)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "%v", err)
}

return input, err
}

// getHTTPGetAnnotationMapping returns a mapping of RPC Method HTTP GET annotation to the RPC Handler's Request Input type full name.
Expand All @@ -121,7 +200,6 @@ func getHTTPGetAnnotationMapping() (map[string]string, error) {
serviceDesc := fd.Services().Get(i)
for j := 0; j < serviceDesc.Methods().Len(); j++ {
methodDesc := serviceDesc.Methods().Get(j)

httpAnnotation := proto.GetExtension(methodDesc.Options(), annotations.E_Http)
if httpAnnotation == nil {
continue
Expand All @@ -143,3 +221,43 @@ func getHTTPGetAnnotationMapping() (map[string]string, error) {

return httpGets, nil
}

// createRegexMapping converts the annotationMapping (HTTP annotation -> query input type name) to a
// map of regular expressions for that HTTP annotation pattern, to queryMetadata.
func createRegexMapping(logger log.Logger, annotationMapping map[string]string) (map[*regexp.Regexp]queryMetadata, map[string]queryMetadata) {
wildcardMatchers := make(map[*regexp.Regexp]queryMetadata)
// seen patterns is a map of URI patterns to annotations. for simple queries (no wildcards) the annotation is used
// for the key.
seenPatterns := make(map[string]string)
simpleMatchers := make(map[string]queryMetadata)

for annotation, queryInputName := range annotationMapping {
pattern, wildcardNames := patternToRegex(annotation)
technicallyty marked this conversation as resolved.
Show resolved Hide resolved
if len(wildcardNames) == 0 {
if otherAnnotation, ok := seenPatterns[annotation]; ok {
// TODO: eventually we want this to error, but there is currently a duplicate in the protobuf.
// see: https://github.com/cosmos/cosmos-sdk/issues/23281
logger.Warn("duplicate HTTP annotation found", "annotation1", annotation, "annotation2", otherAnnotation, "query_input_name", queryInputName)
}
simpleMatchers[annotation] = queryMetadata{
queryInputProtoName: queryInputName,
wildcardKeyNames: nil,
}
seenPatterns[annotation] = annotation
} else {
reg := regexp.MustCompile(pattern)
if otherAnnotation, ok := seenPatterns[pattern]; ok {
// TODO: eventually we want this to error, but there is currently a duplicate in the protobuf.
// see: https://github.com/cosmos/cosmos-sdk/issues/23281
logger.Warn("duplicate HTTP annotation found", "annotation1", annotation, "annotation2", otherAnnotation, "query_input_name", queryInputName)
}
wildcardMatchers[reg] = queryMetadata{
queryInputProtoName: queryInputName,
wildcardKeyNames: wildcardNames,
}
seenPatterns[pattern] = annotation

}
}
return wildcardMatchers, simpleMatchers
technicallyty marked this conversation as resolved.
Show resolved Hide resolved
}
Loading
Loading