refactor(server/v2): use net/http for matching logic in auto-gateway (#23390)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+74
-142
@@ -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 ---
|
||||
*/
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user