refactor(server/v2): use net/http for matching logic in auto-gateway (#23390)

This commit is contained in:
Tyler
2025-01-15 20:17:25 +00:00
committed by GitHub
parent bbf813ccda
commit 2488a33af8
8 changed files with 387 additions and 701 deletions
+4 -4
View File
@@ -8,11 +8,11 @@ func DefaultConfig() *Config {
}
type Config struct {
// Enable defines if the gRPC-gateway should be enabled.
Enable bool `mapstructure:"enable" toml:"enable" comment:"Enable defines if the gRPC-gateway should be enabled."`
// Enable defines if the gRPC-Gateway should be enabled.
Enable bool `mapstructure:"enable" toml:"enable" comment:"Enable defines if the gRPC-Gateway should be enabled."`
// Address defines the address the gRPC-gateway server binds to.
Address string `mapstructure:"address" toml:"address" comment:"Address defines the address the gRPC-gateway server binds to."`
// Address defines the address the gRPC-Gateway server binds to.
Address string `mapstructure:"address" toml:"address" comment:"Address defines the address the gRPC-Gateway server binds to."`
}
type CfgOption func(*Config)
+3 -8
View File
@@ -1,11 +1,6 @@
// 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.
// Package grpcgateway utilizes the global gogoproto registry to create dynamic query handlers on net/http's mux router.
//
// 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.
// Header `x-cosmos-block-height` allows you to specify a height for the query.
//
// 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.
// Requests that do not have a dynamic handler registered will be routed to the canonical gRPC-Gateway mux.
package grpcgateway
+303
View File
@@ -0,0 +1,303 @@
package grpcgateway
import (
"bytes"
"errors"
"fmt"
"io"
"maps"
"net/http"
"reflect"
"regexp"
"slices"
"strconv"
"strings"
gogoproto "github.com/cosmos/gogoproto/proto"
"github.com/grpc-ecosystem/grpc-gateway/runtime"
"github.com/grpc-ecosystem/grpc-gateway/utilities"
"google.golang.org/genproto/googleapis/api/annotations"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"cosmossdk.io/core/transaction"
"cosmossdk.io/log"
"cosmossdk.io/server/v2/appmanager"
)
const MaxBodySize = 1 << 20 // 1 MB
var (
_ http.Handler = &protoHandler[transaction.Tx]{}
wildcardRegex = regexp.MustCompile(`\{([^}]*)\}`)
)
// queryMetadata holds information related to handling gateway queries.
type queryMetadata struct {
// queryInputProtoName is the proto name of the query's input type.
msg gogoproto.Message
// 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 path parameter map.
wildcardKeyNames []string
}
// mountHTTPRoutes registers handlers for from proto HTTP annotations to the http.ServeMux, using runtime.ServeMux as a fallback/
// last ditch effort router.
func mountHTTPRoutes[T transaction.Tx](logger log.Logger, httpMux *http.ServeMux, fallbackRouter *runtime.ServeMux, am appmanager.AppManager[T]) error {
annotationMapping, err := newHTTPAnnotationMapping()
if err != nil {
return err
}
annotationToMetadata, err := annotationsToQueryMetadata(annotationMapping)
if err != nil {
return err
}
registerMethods[T](logger, httpMux, am, fallbackRouter, annotationToMetadata)
return nil
}
// registerMethods registers the endpoints specified in the annotation mapping to the http.ServeMux.
func registerMethods[T transaction.Tx](logger log.Logger, mux *http.ServeMux, am appmanager.AppManager[T], fallbackRouter *runtime.ServeMux, annotationToMetadata map[string]queryMetadata) {
// register the fallback handler. this will run if the mux isn't able to get a match from the registrations below.
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fallbackRouter.ServeHTTP(w, r)
})
// register in deterministic order. we do this because of the problem mentioned below, and different nodes could
// end up with one version of the handler or the other.
uris := slices.Sorted(maps.Keys(annotationToMetadata))
for _, uri := range uris {
queryMD := annotationToMetadata[uri]
// we need to wrap this in a panic handler because cosmos SDK proto stubs contains a duplicate annotation
// that causes the registration to panic.
func(u string, qMD queryMetadata) {
defer func() {
if err := recover(); err != nil {
logger.Warn("duplicate HTTP annotation detected", "error", err)
}
}()
mux.Handle(u, &protoHandler[T]{
msg: qMD.msg,
fallbackRouter: fallbackRouter,
appManager: am,
wildcardKeyNames: qMD.wildcardKeyNames,
})
}(uri, queryMD)
}
}
// protoHandler handles turning data in http.Request to the gogoproto.Message
type protoHandler[T transaction.Tx] struct {
// msg is the gogoproto message type.
msg gogoproto.Message
// wildcardKeyNames are the wildcard key names, if any, specified in the http annotation. (i.e. /foo/bar/{baz})
wildcardKeyNames []string
// fallbackRouter is the canonical gRPC gateway runtime.ServeMux, used as a fallback if the query does not have a handler in AppManager.
fallbackRouter *runtime.ServeMux
// appManager is used to route queries.
appManager appmanager.AppManager[T]
}
func (p *protoHandler[T]) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
in, out := runtime.MarshalerForRequest(p.fallbackRouter, request)
// we clone here as handlers are concurrent and using p.msg would trample.
msg := gogoproto.Clone(p.msg)
// extract path parameters.
params := make(map[string]string)
for _, wildcardKeyName := range p.wildcardKeyNames {
params[wildcardKeyName] = request.PathValue(wildcardKeyName)
}
inputMsg, err := p.populateMessage(request, in, msg, params)
if err != nil {
// the errors returned from the message creation return status errors. no need to make one here.
runtime.HTTPError(request.Context(), p.fallbackRouter, out, writer, request, err)
return
}
// get the height from the header.
var height uint64
heightStr := request.Header.Get(GRPCBlockHeightHeader)
heightStr = strings.Trim(heightStr, `\"`)
if heightStr != "" && heightStr != "latest" {
height, err = strconv.ParseUint(heightStr, 10, 64)
if err != nil {
runtime.HTTPError(request.Context(), p.fallbackRouter, out, writer, request, status.Errorf(codes.InvalidArgument, "invalid height in header: %s", heightStr))
return
}
}
responseMsg, err := p.appManager.Query(request.Context(), height, inputMsg)
if err != nil {
// if we couldn't find a handler for this request, just fall back to the fallbackRouter.
if strings.Contains(err.Error(), "no handler") {
p.fallbackRouter.ServeHTTP(writer, request)
} else {
// for all other errors, we just return the error.
runtime.HTTPError(request.Context(), p.fallbackRouter, out, writer, request, err)
}
return
}
runtime.ForwardResponseMessage(request.Context(), p.fallbackRouter, out, writer, request, responseMsg)
}
func (p *protoHandler[T]) populateMessage(req *http.Request, marshaler runtime.Marshaler, input gogoproto.Message, pathParams map[string]string) (gogoproto.Message, error) {
// see if we have path params to populate the message with.
if len(pathParams) > 0 {
for pathKey, pathValue := range pathParams {
if err := runtime.PopulateFieldFromPath(input, pathKey, pathValue); err != nil {
return nil, status.Error(codes.InvalidArgument, fmt.Errorf("failed to populate field %s with value %s: %w", pathKey, pathValue, err).Error())
}
}
}
// handle query parameters.
if err := req.ParseForm(); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "%v", err)
}
filter := filterFromPathParams(pathParams)
err := runtime.PopulateQueryParameters(input, req.Form, filter)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "%v", err)
}
// see if we have a body to unmarshal.
if req.ContentLength > 0 {
if req.ContentLength > MaxBodySize {
return nil, status.Errorf(codes.InvalidArgument, "request body too large: %d bytes, max=%d", req.ContentLength, MaxBodySize)
}
// this block of code ensures that the body can be re-read. this is needed as if the query fails in the
// app's query handler, we need to pass the request back to the fallbackRouter, which needs to be able to
// read the body again.
bodyBytes, err := io.ReadAll(req.Body)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "%v", err)
}
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
if err = marshaler.NewDecoder(bytes.NewReader(bodyBytes)).Decode(input); err != nil && !errors.Is(err, io.EOF) {
return nil, status.Errorf(codes.InvalidArgument, "%v", err)
}
}
return input, nil
}
func filterFromPathParams(pathParams map[string]string) *utilities.DoubleArray {
var prefixPaths [][]string
for k := range pathParams {
prefixPaths = append(prefixPaths, []string{k})
}
return utilities.NewDoubleArray(prefixPaths)
}
// newHTTPAnnotationMapping returns a mapping of RPC Method HTTP GET annotation to the RPC Handler's Request Input type full name.
//
// example: "/cosmos/auth/v1beta1/account_info/{address}":"cosmos.auth.v1beta1.Query.AccountInfo"
func newHTTPAnnotationMapping() (map[string]string, error) {
protoFiles, err := gogoproto.MergedRegistry()
if err != nil {
return nil, err
}
annotationToQueryInputName := make(map[string]string)
protoFiles.RangeFiles(func(fd protoreflect.FileDescriptor) bool {
for i := 0; i < fd.Services().Len(); i++ {
serviceDesc := fd.Services().Get(i)
for j := 0; j < serviceDesc.Methods().Len(); j++ {
methodDesc := serviceDesc.Methods().Get(j)
httpExtension := proto.GetExtension(methodDesc.Options(), annotations.E_Http)
if httpExtension == nil {
continue
}
httpRule, ok := httpExtension.(*annotations.HttpRule)
if !ok || httpRule == nil {
continue
}
queryInputName := string(methodDesc.Input().FullName())
httpRules := append(httpRule.GetAdditionalBindings(), httpRule)
for _, rule := range httpRules {
if httpAnnotation := rule.GetGet(); httpAnnotation != "" {
annotationToQueryInputName[fixCatchAll(httpAnnotation)] = queryInputName
}
if httpAnnotation := rule.GetPost(); httpAnnotation != "" {
annotationToQueryInputName[fixCatchAll(httpAnnotation)] = queryInputName
}
if httpAnnotation := rule.GetPut(); httpAnnotation != "" {
annotationToQueryInputName[fixCatchAll(httpAnnotation)] = queryInputName
}
if httpAnnotation := rule.GetPatch(); httpAnnotation != "" {
annotationToQueryInputName[fixCatchAll(httpAnnotation)] = queryInputName
}
if httpAnnotation := rule.GetDelete(); httpAnnotation != "" {
annotationToQueryInputName[fixCatchAll(httpAnnotation)] = queryInputName
}
}
}
}
return true
})
return annotationToQueryInputName, nil
}
var catchAllRegex = regexp.MustCompile(`\{([^=]+)=\*\*\}`)
// fixCatchAll replaces grpc gateway catch all syntax with net/http syntax.
//
// {foo=**} -> {foo...}
func fixCatchAll(uri string) string {
return catchAllRegex.ReplaceAllString(uri, `{$1...}`)
}
// annotationsToQueryMetadata takes annotations and creates a mapping of URIs to queryMetadata.
func annotationsToQueryMetadata(annotations map[string]string) (map[string]queryMetadata, error) {
annotationToMetadata := make(map[string]queryMetadata)
for uri, queryInputName := range annotations {
// extract the proto message type.
msgType := gogoproto.MessageType(queryInputName)
if msgType == nil {
continue
}
msg, ok := reflect.New(msgType.Elem()).Interface().(gogoproto.Message)
if !ok {
return nil, fmt.Errorf("query input type %q does not implement gogoproto.Message", queryInputName)
}
annotationToMetadata[uri] = queryMetadata{
msg: msg,
wildcardKeyNames: extractWildcardKeyNames(uri),
}
}
return annotationToMetadata, nil
}
// extractWildcardKeyNames extracts the wildcard key names from the uri annotation.
//
// example:
// "/hello/{world}" -> []string{"world"}
// "/hello/{world}/and/{friends} -> []string{"world", "friends"}
// "/hello/world" -> []string{}
func extractWildcardKeyNames(uri string) []string {
matches := wildcardRegex.FindAllStringSubmatch(uri, -1)
var extracted []string
for _, match := range matches {
// match[0] is the full string including braces (i.e. "{bar}")
// match[1] is the captured group (i.e. "bar")
// we also need to handle the catch-all case with URI's like "bar..." and
// transform them to just "bar".
extracted = append(extracted, strings.TrimRight(match[1], "."))
}
return extracted
}
@@ -2,7 +2,6 @@ package grpcgateway
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
@@ -14,65 +13,72 @@ import (
"google.golang.org/grpc/status"
"cosmossdk.io/core/transaction"
"cosmossdk.io/log"
)
func Test_createRegexMapping(t *testing.T) {
func Test_fixCatchAll(t *testing.T) {
tests := []struct {
name string
annotations map[string]string
expectedRegex int
expectedSimple int
wantWarn bool
name string
uri string
want string
}{
{
name: "no annotations should not warn",
name: "replaces catch all",
uri: "/foo/bar/{baz=**}",
want: "/foo/bar/{baz...}",
},
{
name: "expected correct amount of regex and simple matchers",
annotations: map[string]string{
"/foo/bar/baz": "",
"/foo/{bar}/baz": "",
"/foo/bar/bell": "",
},
expectedRegex: 1,
expectedSimple: 2,
name: "returns original",
uri: "/foo/bar/baz",
want: "/foo/bar/baz",
},
{
name: "different annotations should not warn",
annotations: map[string]string{
"/foo/bar/{baz}": "",
"/crypto/{currency}": "",
},
expectedRegex: 2,
},
{
name: "duplicate annotations should warn",
annotations: map[string]string{
"/hello/{world}": "",
"/hello/{developers}": "",
},
expectedRegex: 2,
wantWarn: true,
name: "doesn't tamper with normal wildcard",
uri: "/foo/{baz}",
want: "/foo/{baz}",
},
}
buf := bytes.NewBuffer(nil)
logger := log.NewLogger(buf)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
regex, simple := createRegexMapping(logger, tt.annotations)
if tt.wantWarn {
require.NotEmpty(t, buf.String())
} else {
require.Empty(t, buf.String())
}
require.Equal(t, tt.expectedRegex, len(regex))
require.Equal(t, tt.expectedSimple, len(simple))
require.Equal(t, tt.want, fixCatchAll(tt.uri))
})
}
}
func TestCreateMessageFromGetRequest(t *testing.T) {
func Test_extractWildcardKeyNames(t *testing.T) {
tests := []struct {
name string
uri string
want []string
}{
{
name: "single",
uri: "/foo/bar/{baz}",
want: []string{"baz"},
},
{
name: "multiple",
uri: "/foo/{bar}/baz/{buzz}",
want: []string{"bar", "buzz"},
},
{
name: "catch-all wildcard",
uri: "/foo/{buzz...}",
want: []string{"buzz"},
},
{
name: "none",
uri: "/foo/bar",
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, extractWildcardKeyNames(tt.uri))
})
}
}
func TestPopulateMessage(t *testing.T) {
gogoproto.RegisterType(&DummyProto{}, dummyProtoName)
testCases := []struct {
@@ -112,6 +118,27 @@ func TestCreateMessageFromGetRequest(t *testing.T) {
},
wantErr: false,
},
{
name: "simple query params and body",
request: func() *http.Request {
body := `{"denoms": ["hello", "there"]}`
req := httptest.NewRequest(
http.MethodGet,
"/foo", // this doesn't really matter
bytes.NewReader([]byte(body)),
)
return req
},
wildcardValues: map[string]string{
"foo": "wildFooValue", // from path wildcard e.g. /dummy/{foo}
},
expected: &DummyProto{
Foo: "wildFooValue",
Denoms: []string{"hello", "there"},
},
wantErr: false,
},
{
name: "invalid integer in query param",
request: func() *http.Request {
@@ -148,17 +175,17 @@ func TestCreateMessageFromGetRequest(t *testing.T) {
},
}
// We only need a minimal gatewayInterceptor instance to call createMessageFromGetRequest,
// We only need a minimal gatewayInterceptor instance to call populateMessage,
// so it's fine to leave most fields nil for this unit test.
g := &gatewayInterceptor[transaction.Tx]{}
g := &protoHandler[transaction.Tx]{}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := tc.request()
inputMsg := &DummyProto{}
gotMsg, err := g.createMessageFromGetRequest(
gotMsg, err := g.populateMessage(
req,
&runtime.JSONPb{},
inputMsg,
tc.wildcardValues,
)
@@ -177,101 +204,6 @@ func TestCreateMessageFromGetRequest(t *testing.T) {
}
}
func TestCreateMessageFromPostRequest(t *testing.T) {
gogoproto.RegisterType(&DummyProto{}, dummyProtoName)
gogoproto.RegisterType(&Pagination{}, "pagination")
gogoproto.RegisterType(&Nested{}, "nested")
testCases := []struct {
name string
body any
wantErr bool
errCode codes.Code
expected *DummyProto
}{
{
name: "valid JSON body with nested fields",
body: map[string]any{
"foo": "postFoo",
"bar": true,
"baz": 42,
"denoms": []string{"atom", "osmo"},
"page": map[string]any{
"limit": 100,
"nest": map[string]any{
"foo": 999,
},
},
},
wantErr: false,
expected: &DummyProto{
Foo: "postFoo",
Bar: true,
Baz: 42,
Denoms: []string{"atom", "osmo"},
Page: &Pagination{
Limit: 100,
Nest: &Nested{
Foo: 999,
},
},
},
},
{
name: "invalid JSON structure",
// Provide a broken JSON string:
body: `{"foo": "bad json", "extra": "not closed"`,
wantErr: true,
errCode: codes.InvalidArgument,
},
{
name: "empty JSON object",
body: map[string]any{},
wantErr: false,
expected: &DummyProto{}, // all fields remain zeroed
},
}
g := &gatewayInterceptor[transaction.Tx]{}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var reqBody []byte
switch typedBody := tc.body.(type) {
case string:
// This might be invalid JSON we intentionally want to test
reqBody = []byte(typedBody)
default:
// Marshal the given any into JSON
b, err := json.Marshal(typedBody)
require.NoError(t, err, "failed to marshal test body to JSON")
reqBody = b
}
req := httptest.NewRequest(http.MethodPost, "/dummy", bytes.NewReader(reqBody))
inputMsg := &DummyProto{}
gotMsg, err := g.createMessageFromPostRequest(
&runtime.JSONPb{}, // JSONPb marshaler
req,
inputMsg,
)
if tc.wantErr {
require.Error(t, err, "expected an error but got none")
// Optionally verify the gRPC status code
st, ok := status.FromError(err)
if ok && tc.errCode != codes.OK {
require.Equal(t, tc.errCode, st.Code())
}
} else {
require.NoError(t, err, "did not expect an error")
require.Equal(t, tc.expected, gotMsg)
}
})
}
}
/*
--- Testing Types ---
*/
-280
View File
@@ -1,280 +0,0 @@
package grpcgateway
import (
"bytes"
"errors"
"io"
"net/http"
"reflect"
"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"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"cosmossdk.io/core/transaction"
"cosmossdk.io/log"
"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
matcher uriMatcher
// appManager is used to route queries to the application.
appManager appmanager.AppManager[T]
}
// newGatewayInterceptor creates a new gatewayInterceptor.
func newGatewayInterceptor[T transaction.Tx](logger log.Logger, gateway *runtime.ServeMux, am appmanager.AppManager[T]) (*gatewayInterceptor[T], error) {
getMapping, err := getHTTPGetAnnotationMapping()
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,
matcher: matcher,
appManager: am,
}, nil
}
// 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 := 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)
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.HTTPError(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.MethodGet:
inputMsg, err = g.createMessageFromGetRequest(request, msg, match.Params)
case http.MethodPost:
inputMsg, err = g.createMessageFromPostRequest(in, request, msg)
default:
runtime.HTTPError(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.HTTPError(request.Context(), g.gateway, out, writer, request, err)
return
}
// get the height from the header.
var height uint64
heightStr := request.Header.Get(GRPCBlockHeightHeader)
heightStr = strings.Trim(heightStr, `\"`)
if heightStr != "" && heightStr != "latest" {
height, err = strconv.ParseUint(heightStr, 10, 64)
if err != nil {
runtime.HTTPError(request.Context(), g.gateway, out, writer, request, status.Errorf(codes.InvalidArgument, "invalid height in header: %s", heightStr))
return
}
}
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") {
g.gateway.ServeHTTP(writer, request)
} else {
// for all other errors, we just return the error.
runtime.HTTPError(request.Context(), g.gateway, out, writer, request, err)
}
return
}
// for no errors, we forward the response.
runtime.ForwardResponseMessage(request.Context(), g.gateway, out, writer, request, responseMsg)
}
func (g *gatewayInterceptor[T]) createMessageFromPostRequest(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)
}
// this block of code ensures that the body can be re-read. this is needed as if the query fails in the
// app's query handler, we need to pass the request back to the canonical gateway, which needs to be able to
// read the body again.
bodyBytes, err := io.ReadAll(req.Body)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "%v", err)
}
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
if err = marshaler.NewDecoder(bytes.NewReader(bodyBytes)).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(req *http.Request, input gogoproto.Message, wildcardValues map[string]string) (gogoproto.Message, error) {
// 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)
}
filter := filterFromPathParams(wildcardValues)
err = runtime.PopulateQueryParameters(input, req.Form, filter)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "%v", err)
}
return input, err
}
func filterFromPathParams(pathParams map[string]string) *utilities.DoubleArray {
var prefixPaths [][]string
for k := range pathParams {
prefixPaths = append(prefixPaths, []string{k})
}
return utilities.NewDoubleArray(prefixPaths)
}
// getHTTPGetAnnotationMapping returns a mapping of RPC Method HTTP GET annotation to the RPC Handler's Request Input type full name.
//
// 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
}
annotationToQueryInputName := make(map[string]string)
protoFiles.RangeFiles(func(fd protoreflect.FileDescriptor) bool {
for i := 0; i < fd.Services().Len(); i++ {
serviceDesc := fd.Services().Get(i)
for j := 0; j < serviceDesc.Methods().Len(); j++ {
methodDesc := serviceDesc.Methods().Get(j)
httpExtension := proto.GetExtension(methodDesc.Options(), annotations.E_Http)
if httpExtension == nil {
continue
}
httpRule, ok := httpExtension.(*annotations.HttpRule)
if !ok || httpRule == nil {
continue
}
queryInputName := string(methodDesc.Input().FullName())
httpRules := append(httpRule.GetAdditionalBindings(), httpRule)
for _, rule := range httpRules {
if httpAnnotation := rule.GetGet(); httpAnnotation != "" {
annotationToQueryInputName[httpAnnotation] = queryInputName
}
if httpAnnotation := rule.GetPost(); httpAnnotation != "" {
annotationToQueryInputName[httpAnnotation] = queryInputName
}
}
}
}
return true
})
return annotationToQueryInputName, 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)
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
}
+3 -4
View File
@@ -33,7 +33,7 @@ type Server[T transaction.Tx] struct {
GRPCGatewayRouter *runtime.ServeMux
}
// New creates a new gRPC-gateway server.
// New creates a new gRPC-Gateway server.
func New[T transaction.Tx](
logger log.Logger,
config server.ConfigMap,
@@ -76,11 +76,10 @@ func New[T transaction.Tx](
s.logger = logger.With(log.ModuleKey, s.Name())
s.config = serverCfg
mux := http.NewServeMux()
interceptor, err := newGatewayInterceptor[T](logger, s.GRPCGatewayRouter, appManager)
err := mountHTTPRoutes[T](logger, mux, s.GRPCGatewayRouter, appManager)
if err != nil {
return nil, fmt.Errorf("failed to create grpc-gateway interceptor: %w", err)
return nil, fmt.Errorf("failed to register gRPC gateway annotations: %w", err)
}
mux.Handle("/", interceptor)
s.server = &http.Server{
Addr: s.config.Address,
-91
View File
@@ -1,91 +0,0 @@
package grpcgateway
import (
"net/url"
"regexp"
"strings"
)
// uriMatcher provides functionality to match HTTP request URIs.
type uriMatcher struct {
// wildcardURIMatchers are used for complex URIs that involve wildcards (i.e. /foo/{bar}/baz)
wildcardURIMatchers map[*regexp.Regexp]queryMetadata
// simpleMatchers are used for simple URI's that have no wildcards (i.e. /foo/bar/baz).
simpleMatchers map[string]queryMetadata
}
// 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/hello = {"baz": "hello"}
Params map[string]string
}
// matchURL attempts to find a match for the given URL.
// NOTE: if no match is found, nil is returned.
func (m uriMatcher) matchURL(u *url.URL) *uriMatch {
uriPath := strings.TrimRight(u.Path, "/")
params := make(map[string]string)
// see if we can get a simple match first.
if qmd, ok := m.simpleMatchers[uriPath]; ok {
return &uriMatch{
QueryInputName: qmd.queryInputProtoName,
Params: params,
}
}
// try the complex matchers.
for reg, qmd := range m.wildcardURIMatchers {
matches := reg.FindStringSubmatch(uriPath)
switch {
case len(matches) == 1:
return &uriMatch{
QueryInputName: qmd.queryInputProtoName,
Params: params,
}
case len(matches) > 1:
// first match is the URI, subsequent matches are the wild card values.
for i, name := range qmd.wildcardKeyNames {
params[name] = matches[i+1]
}
return &uriMatch{
QueryInputName: qmd.queryInputProtoName,
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
}
-172
View File
@@ -1,172 +0,0 @@
package grpcgateway
import (
"net/url"
"os"
"regexp"
"testing"
"github.com/stretchr/testify/require"
"cosmossdk.io/log"
)
func TestMatchURI(t *testing.T) {
testCases := []struct {
name string
uri string
mapping map[string]string
expected *uriMatch
}{
{
name: "simple match, no wildcards",
uri: "https://localhost:8080/foo/bar",
mapping: map[string]string{"/foo/bar": "query.Bank"},
expected: &uriMatch{QueryInputName: "query.Bank", Params: map[string]string{}},
},
{
name: "match with wildcard similar to simple match - simple",
uri: "https://localhost:8080/bank/supply/latest",
mapping: map[string]string{
"/bank/supply/{height}": "queryBankHeight",
"/bank/supply/latest": "queryBankLatest",
},
expected: &uriMatch{QueryInputName: "queryBankLatest", Params: map[string]string{}},
},
{
name: "match with wildcard similar to simple match - wildcard",
uri: "https://localhost:8080/bank/supply/52",
mapping: map[string]string{
"/bank/supply/{height}": "queryBankHeight",
"/bank/supply/latest": "queryBankLatest",
},
expected: &uriMatch{QueryInputName: "queryBankHeight", Params: map[string]string{"height": "52"}},
},
{
name: "wildcard match at the end",
uri: "https://localhost:8080/foo/bar/buzz",
mapping: map[string]string{"/foo/bar/{baz}": "bar"},
expected: &uriMatch{
QueryInputName: "bar",
Params: map[string]string{"baz": "buzz"},
},
},
{
name: "wildcard match in the middle",
uri: "https://localhost:8080/foo/buzz/bar",
mapping: map[string]string{"/foo/{baz}/bar": "bar"},
expected: &uriMatch{
QueryInputName: "bar",
Params: map[string]string{"baz": "buzz"},
},
},
{
name: "multiple wild cards",
uri: "https://localhost:8080/foo/bar/baz/buzz",
mapping: map[string]string{"/foo/bar/{q1}/{q2}": "bar"},
expected: &uriMatch{
QueryInputName: "bar",
Params: map[string]string{"q1": "baz", "q2": "buzz"},
},
},
{
name: "catch-all wildcard",
uri: "https://localhost:8080/foo/bar/ibc/token/stuff",
mapping: map[string]string{"/foo/bar/{ibc_token=**}": "bar"},
expected: &uriMatch{
QueryInputName: "bar",
Params: map[string]string{"ibc_token": "ibc/token/stuff"},
},
},
{
name: "no match should return nil",
uri: "https://localhost:8080/foo/bar",
mapping: map[string]string{"/bar/foo": "bar"},
expected: nil,
},
}
logger := log.NewLogger(os.Stdout)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
u, err := url.Parse(tc.uri)
require.NoError(t, err)
regexpMatchers, simpleMatchers := createRegexMapping(logger, tc.mapping)
matcher := uriMatcher{
wildcardURIMatchers: regexpMatchers,
simpleMatchers: simpleMatchers,
}
actual := matcher.matchURL(u)
require.Equal(t, tc.expected, actual)
})
}
}
func Test_patternToRegex(t *testing.T) {
tests := []struct {
name string
pattern string
wildcards []string
wildcardValues []string
shouldMatch string
shouldNotMatch []string
}{
{
name: "simple match, no wildcards",
pattern: "/foo/bar/baz",
shouldMatch: "/foo/bar/baz",
shouldNotMatch: []string{"/foo/bar", "/foo", "/foo/bar/baz/boo"},
},
{
name: "match with wildcard",
pattern: "/foo/bar/{baz}",
wildcards: []string{"baz"},
shouldMatch: "/foo/bar/hello",
wildcardValues: []string{"hello"},
shouldNotMatch: []string{"/foo/bar", "/foo/bar/baz/boo"},
},
{
name: "match with multiple wildcards",
pattern: "/foo/{bar}/{baz}/meow",
wildcards: []string{"bar", "baz"},
shouldMatch: "/foo/hello/world/meow",
wildcardValues: []string{"hello", "world"},
shouldNotMatch: []string{"/foo/bar/baz/boo", "/foo/bar/baz"},
},
{
name: "match catch-all wildcard",
pattern: `/foo/bar/{baz=**}`,
wildcards: []string{"baz"},
shouldMatch: `/foo/bar/this/is/a/long/wildcard`,
wildcardValues: []string{"this/is/a/long/wildcard"},
shouldNotMatch: []string{"/foo/bar", "/foo", "/foo/baz/bar/long/wild/card"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
regString, wildcards := patternToRegex(tt.pattern)
// should produce the same wildcard keys
require.Equal(t, tt.wildcards, wildcards)
reg := regexp.MustCompile(regString)
// handle the "should match" case.
matches := reg.FindStringSubmatch(tt.shouldMatch)
require.True(t, len(matches) > 0) // there should always be a match.
// when matches > 1, this means we got wildcard values to handle. the test should have wildcard values.
if len(matches) > 1 {
require.Greater(t, len(tt.wildcardValues), 0)
}
// matches[0] is the URL, everything else should be those wildcard values.
if len(tt.wildcardValues) > 0 {
require.Equal(t, matches[1:], tt.wildcardValues)
}
// should never match these.
for _, notMatch := range tt.shouldNotMatch {
require.Len(t, reg.FindStringSubmatch(notMatch), 0)
}
})
}
}