diff --git a/server/v2/api/grpcgateway/config.go b/server/v2/api/grpcgateway/config.go index 773eeb4d58..659e2257c8 100644 --- a/server/v2/api/grpcgateway/config.go +++ b/server/v2/api/grpcgateway/config.go @@ -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) diff --git a/server/v2/api/grpcgateway/doc.go b/server/v2/api/grpcgateway/doc.go index cbdce577f2..3d419b2c34 100644 --- a/server/v2/api/grpcgateway/doc.go +++ b/server/v2/api/grpcgateway/doc.go @@ -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 diff --git a/server/v2/api/grpcgateway/handler.go b/server/v2/api/grpcgateway/handler.go new file mode 100644 index 0000000000..1df6f3d005 --- /dev/null +++ b/server/v2/api/grpcgateway/handler.go @@ -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 +} diff --git a/server/v2/api/grpcgateway/interceptor_test.go b/server/v2/api/grpcgateway/handler_test.go similarity index 53% rename from server/v2/api/grpcgateway/interceptor_test.go rename to server/v2/api/grpcgateway/handler_test.go index 80512f2c21..72545ef7ef 100644 --- a/server/v2/api/grpcgateway/interceptor_test.go +++ b/server/v2/api/grpcgateway/handler_test.go @@ -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 --- */ diff --git a/server/v2/api/grpcgateway/interceptor.go b/server/v2/api/grpcgateway/interceptor.go deleted file mode 100644 index ee8fc598ca..0000000000 --- a/server/v2/api/grpcgateway/interceptor.go +++ /dev/null @@ -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 -} diff --git a/server/v2/api/grpcgateway/server.go b/server/v2/api/grpcgateway/server.go index 59fb1b2ff1..938ecfb66a 100644 --- a/server/v2/api/grpcgateway/server.go +++ b/server/v2/api/grpcgateway/server.go @@ -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, diff --git a/server/v2/api/grpcgateway/uri.go b/server/v2/api/grpcgateway/uri.go deleted file mode 100644 index f5ebc25668..0000000000 --- a/server/v2/api/grpcgateway/uri.go +++ /dev/null @@ -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 -} diff --git a/server/v2/api/grpcgateway/uri_test.go b/server/v2/api/grpcgateway/uri_test.go deleted file mode 100644 index 24d51fd59e..0000000000 --- a/server/v2/api/grpcgateway/uri_test.go +++ /dev/null @@ -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) - } - }) - } -}