feat(server/v2)!: grpcgateway autoregistration (#22941)

This commit is contained in:
Tyler
2025-01-06 08:12:42 +00:00
committed by GitHub
parent 23595c82d9
commit 884a7a51c3
8 changed files with 616 additions and 17 deletions
+4
View File
@@ -22,6 +22,10 @@ Each entry must include the Github issue reference in the following format:
## [Unreleased]
### Features
* [#22715](https://github.com/cosmos/cosmos-sdk/pull/22941) Add custom HTTP handler for grpc-gateway that removes the need to manually register grpc-gateway services.
## [v2.0.0-beta.1](https://github.com/cosmos/cosmos-sdk/releases/tag/server/v2.0.0-beta.1)
Initial tag of `cosmossdk.io/server/v2`.
+145
View File
@@ -0,0 +1,145 @@
package grpcgateway
import (
"net/http"
"strconv"
"strings"
gogoproto "github.com/cosmos/gogoproto/proto"
"github.com/grpc-ecosystem/grpc-gateway/runtime"
"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"
)
var _ http.Handler = &gatewayInterceptor[transaction.Tx]{}
// gatewayInterceptor handles routing grpc-gateway queries to the app manager's query router.
type gatewayInterceptor[T transaction.Tx] struct {
logger log.Logger
// gateway is the fallback grpc gateway mux handler.
gateway *runtime.ServeMux
// customEndpointMapping is a mapping of custom GET options on proto RPC handlers, to the fully qualified method name.
//
// example: /cosmos/bank/v1beta1/denoms_metadata -> cosmos.bank.v1beta1.Query.DenomsMetadata
customEndpointMapping map[string]string
// 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
}
return &gatewayInterceptor[T]{
logger: logger,
gateway: gateway,
customEndpointMapping: getMapping,
appManager: am,
}, nil
}
// ServeHTTP implements the http.Handler interface. This function will attempt to match http requests to the
// interceptors internal mapping of http annotations to query request type names.
// If no match can be made, it falls back to the runtime gateway server mux.
func (g *gatewayInterceptor[T]) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
g.logger.Debug("received grpc-gateway request", "request_uri", request.RequestURI)
match := matchURL(request.URL, g.customEndpointMapping)
if match == nil {
// no match cases fall back to gateway mux.
g.gateway.ServeHTTP(writer, request)
return
}
g.logger.Debug("matched request", "query_input", match.QueryInputName)
_, out := runtime.MarshalerForRequest(g.gateway, request)
var msg gogoproto.Message
var err error
switch request.Method {
case http.MethodPost:
msg, err = createMessageFromJSON(match, request)
case http.MethodGet:
msg, err = createMessage(match)
default:
runtime.DefaultHTTPProtoErrorHandler(request.Context(), g.gateway, out, writer, request, status.Error(codes.Unimplemented, "HTTP method must be POST or GET"))
return
}
if err != nil {
runtime.DefaultHTTPProtoErrorHandler(request.Context(), g.gateway, out, writer, request, err)
return
}
// extract block height header
var height uint64
heightStr := request.Header.Get(GRPCBlockHeightHeader)
if heightStr != "" {
height, err = strconv.ParseUint(heightStr, 10, 64)
if err != nil {
err = status.Errorf(codes.InvalidArgument, "invalid height: %s", heightStr)
runtime.DefaultHTTPProtoErrorHandler(request.Context(), g.gateway, out, writer, request, err)
return
}
}
query, err := g.appManager.Query(request.Context(), height, msg)
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.DefaultHTTPProtoErrorHandler(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, query)
}
// 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
}
httpGets := 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)
httpAnnotation := proto.GetExtension(methodDesc.Options(), annotations.E_Http)
if httpAnnotation == nil {
continue
}
httpRule, ok := httpAnnotation.(*annotations.HttpRule)
if !ok || httpRule == nil {
continue
}
if httpRule.GetGet() == "" {
continue
}
httpGets[httpRule.GetGet()] = string(methodDesc.Input().FullName())
}
}
return true
})
return httpGets, nil
}
+10 -6
View File
@@ -14,6 +14,7 @@ import (
"cosmossdk.io/core/transaction"
"cosmossdk.io/log"
serverv2 "cosmossdk.io/server/v2"
"cosmossdk.io/server/v2/appmanager"
)
var (
@@ -37,6 +38,7 @@ func New[T transaction.Tx](
logger log.Logger,
config server.ConfigMap,
ir jsonpb.AnyResolver,
appManager appmanager.AppManager[T],
cfgOptions ...CfgOption,
) (*Server[T], error) {
// The default JSON marshaller used by the gRPC-Gateway is unable to marshal non-nullable non-scalar fields.
@@ -71,12 +73,14 @@ func New[T transaction.Tx](
}
}
// TODO: register the gRPC-Gateway routes
s.logger = logger.With(log.ModuleKey, s.Name())
s.config = serverCfg
mux := http.NewServeMux()
mux.Handle("/", s.GRPCGatewayRouter)
interceptor, err := newGatewayInterceptor[T](logger, s.GRPCGatewayRouter, appManager)
if err != nil {
return nil, fmt.Errorf("failed to create grpc-gateway interceptor: %w", err)
}
mux.Handle("/", interceptor)
s.server = &http.Server{
Addr: s.config.Address,
@@ -133,15 +137,15 @@ func (s *Server[T]) Stop(ctx context.Context) error {
return s.server.Shutdown(ctx)
}
// GRPCBlockHeightHeader is the gRPC header for block height.
const GRPCBlockHeightHeader = "x-cosmos-block-height"
// CustomGRPCHeaderMatcher for mapping request headers to
// GRPC metadata.
// HTTP headers that start with 'Grpc-Metadata-' are automatically mapped to
// gRPC metadata after removing prefix 'Grpc-Metadata-'. We can use this
// CustomGRPCHeaderMatcher if headers don't start with `Grpc-Metadata-`
func CustomGRPCHeaderMatcher(key string) (string, bool) {
// GRPCBlockHeightHeader is the gRPC header for block height.
const GRPCBlockHeightHeader = "x-cosmos-block-height"
switch strings.ToLower(key) {
case GRPCBlockHeightHeader:
return GRPCBlockHeightHeader, true
+187
View File
@@ -0,0 +1,187 @@
package grpcgateway
import (
"io"
"net/http"
"net/url"
"reflect"
"regexp"
"strings"
"github.com/cosmos/gogoproto/jsonpb"
gogoproto "github.com/cosmos/gogoproto/proto"
"github.com/mitchellh/mapstructure"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const maxBodySize = 1 << 20 // 1 MB
// uriMatch contains information related to a URI match.
type uriMatch struct {
// QueryInputName is the fully qualified name of the proto input type of the query rpc method.
QueryInputName string
// Params are any wildcard/query params found in the request.
//
// example:
// - foo/bar/{baz} - foo/bar/qux -> {baz: qux}
// - foo/bar?baz=qux - foo/bar -> {baz: qux}
Params map[string]string
}
// HasParams reports whether the uriMatch has any params.
func (uri uriMatch) HasParams() bool {
return len(uri.Params) > 0
}
// matchURL attempts to find a match for the given URL.
// NOTE: if no match is found, nil is returned.
func matchURL(u *url.URL, getPatternToQueryInputName map[string]string) *uriMatch {
uriPath := strings.TrimRight(u.Path, "/")
queryParams := u.Query()
params := make(map[string]string)
for key, vals := range queryParams {
if len(vals) > 0 {
// url.Values contains a slice for the values as you are able to specify a key multiple times in URL.
// example: https://localhost:9090/do/something?color=red&color=blue&color=green
// We will just take the first value in the slice.
params[key] = vals[0]
}
}
// for simple cases where there are no wildcards, we can just do a map lookup.
if inputName, ok := getPatternToQueryInputName[uriPath]; ok {
return &uriMatch{
QueryInputName: inputName,
Params: params,
}
}
// attempt to find a match in the pattern map.
for getPattern, queryInputName := range getPatternToQueryInputName {
getPattern = strings.TrimRight(getPattern, "/")
regexPattern, wildcardNames := patternToRegex(getPattern)
regex := regexp.MustCompile(regexPattern)
matches := regex.FindStringSubmatch(uriPath)
if len(matches) > 1 {
// first match is the full string, subsequent matches are capture groups
for i, name := range wildcardNames {
params[name] = matches[i+1]
}
return &uriMatch{
QueryInputName: queryInputName,
Params: params,
}
}
}
return nil
}
// patternToRegex converts a URI pattern with wildcards to a regex pattern.
// Returns the regex pattern and a slice of wildcard names in order
func patternToRegex(pattern string) (string, []string) {
escaped := regexp.QuoteMeta(pattern)
var wildcardNames []string
// extract and replace {param=**} patterns
r1 := regexp.MustCompile(`\\\{([^}]+?)=\\\*\\\*\\}`)
escaped = r1.ReplaceAllStringFunc(escaped, func(match string) string {
// extract wildcard name without the =** suffix
name := regexp.MustCompile(`\\\{(.+?)=`).FindStringSubmatch(match)[1]
wildcardNames = append(wildcardNames, name)
return "(.+)"
})
// extract and replace {param} patterns
r2 := regexp.MustCompile(`\\\{([^}]+)\\}`)
escaped = r2.ReplaceAllStringFunc(escaped, func(match string) string {
// extract wildcard name from the curl braces {}.
name := regexp.MustCompile(`\\\{(.*?)\\}`).FindStringSubmatch(match)[1]
wildcardNames = append(wildcardNames, name)
return "([^/]+)"
})
return "^" + escaped + "$", wildcardNames
}
// createMessageFromJSON creates a message from the uriMatch given the JSON body in the http request.
func createMessageFromJSON(match *uriMatch, r *http.Request) (gogoproto.Message, error) {
requestType := gogoproto.MessageType(match.QueryInputName)
if requestType == nil {
return nil, status.Error(codes.InvalidArgument, "invalid request type")
}
msg, ok := reflect.New(requestType.Elem()).Interface().(gogoproto.Message)
if !ok {
return nil, status.Error(codes.Internal, "failed to cast to proto message")
}
defer r.Body.Close()
limitedReader := io.LimitReader(r.Body, maxBodySize)
err := jsonpb.Unmarshal(limitedReader, msg)
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
return msg, nil
}
// createMessage creates a message from the given uriMatch. If the match has params, the message will be populated
// with the value of those params. Otherwise, an empty message is returned.
func createMessage(match *uriMatch) (gogoproto.Message, error) {
requestType := gogoproto.MessageType(match.QueryInputName)
if requestType == nil {
return nil, status.Error(codes.InvalidArgument, "unknown request type")
}
msg, ok := reflect.New(requestType.Elem()).Interface().(gogoproto.Message)
if !ok {
return nil, status.Error(codes.Internal, "failed to create message instance")
}
// if the uri match has params, we need to populate the message with the values of those params.
if match.HasParams() {
// convert flat params map to nested structure
nestedParams := make(map[string]any)
for key, value := range match.Params {
parts := strings.Split(key, ".")
current := nestedParams
// step through nested levels
for i, part := range parts {
if i == len(parts)-1 {
// Last part - set the value
current[part] = value
} else {
// continue nestedness
if _, exists := current[part]; !exists {
current[part] = make(map[string]any)
}
current = current[part].(map[string]any)
}
}
}
// Configure decoder to handle the nested structure
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Result: msg,
TagName: "json", // Use json tags as they're simpler
WeaklyTypedInput: true,
})
if err != nil {
return nil, status.Error(codes.Internal, "failed to create message instance")
}
if err := decoder.Decode(nestedParams); err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
}
return msg, nil
}
+264
View File
@@ -0,0 +1,264 @@
package grpcgateway
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/url"
"testing"
gogoproto "github.com/cosmos/gogoproto/proto"
"github.com/stretchr/testify/require"
)
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 query parameters",
uri: "https://localhost:8080/foo/bar?baz=qux",
mapping: map[string]string{"/foo/bar": "query.Bank"},
expected: &uriMatch{QueryInputName: "query.Bank", Params: map[string]string{"baz": "qux"}},
},
{
name: "match with multiple query parameters",
uri: "https://localhost:8080/foo/bar?baz=qux&foo=/msg.type.bank.send",
mapping: map[string]string{"/foo/bar": "query.Bank"},
expected: &uriMatch{QueryInputName: "query.Bank", Params: map[string]string{"baz": "qux", "foo": "/msg.type.bank.send"}},
},
{
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,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
u, err := url.Parse(tc.uri)
require.NoError(t, err)
actual := matchURL(u, tc.mapping)
require.Equal(t, tc.expected, actual)
})
}
}
func TestURIMatch_HasParams(t *testing.T) {
u := uriMatch{Params: map[string]string{"foo": "bar"}}
require.True(t, u.HasParams())
u = uriMatch{}
require.False(t, u.HasParams())
}
type Nested struct {
Foo int `protobuf:"varint,1,opt,name=foo,proto3" json:"foo,omitempty"`
}
type Pagination struct {
Limit int `protobuf:"varint,1,opt,name=limit,proto3" json:"limit,omitempty"`
Nest *Nested `protobuf:"bytes,2,opt,name=nest,proto3" json:"nest,omitempty"`
}
const dummyProtoName = "dummy"
type DummyProto struct {
Foo string `protobuf:"bytes,1,opt,name=foo,proto3" json:"foo,omitempty"`
Bar bool `protobuf:"varint,2,opt,name=bar,proto3" json:"bar,omitempty"`
Baz int `protobuf:"varint,3,opt,name=baz,proto3" json:"baz,omitempty"`
Page *Pagination `protobuf:"bytes,4,opt,name=page,proto3" json:"page,omitempty"`
}
func (d DummyProto) Reset() {}
func (d DummyProto) String() string { return dummyProtoName }
func (d DummyProto) ProtoMessage() {}
func TestCreateMessage(t *testing.T) {
gogoproto.RegisterType(&DummyProto{}, dummyProtoName)
testCases := []struct {
name string
uri uriMatch
expected gogoproto.Message
expErr bool
}{
{
name: "simple, empty message",
uri: uriMatch{QueryInputName: dummyProtoName},
expected: &DummyProto{},
},
{
name: "message with params",
uri: uriMatch{
QueryInputName: dummyProtoName,
Params: map[string]string{"foo": "blah", "bar": "true", "baz": "1352"},
},
expected: &DummyProto{
Foo: "blah",
Bar: true,
Baz: 1352,
},
},
{
name: "message with nested params",
uri: uriMatch{
QueryInputName: dummyProtoName,
Params: map[string]string{"foo": "blah", "bar": "true", "baz": "1352", "page.limit": "3"},
},
expected: &DummyProto{
Foo: "blah",
Bar: true,
Baz: 1352,
Page: &Pagination{Limit: 3},
},
},
{
name: "message with multi nested params",
uri: uriMatch{
QueryInputName: dummyProtoName,
Params: map[string]string{"foo": "blah", "bar": "true", "baz": "1352", "page.limit": "3", "page.nest.foo": "5"},
},
expected: &DummyProto{
Foo: "blah",
Bar: true,
Baz: 1352,
Page: &Pagination{Limit: 3, Nest: &Nested{Foo: 5}},
},
},
{
name: "invalid params should error out",
uri: uriMatch{
QueryInputName: dummyProtoName,
Params: map[string]string{"foo": "blah", "bar": "235235", "baz": "true"},
},
expErr: true,
},
{
name: "unknown input type",
uri: uriMatch{
QueryInputName: "foobar",
},
expErr: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
actual, err := createMessage(&tc.uri)
if tc.expErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tc.expected, actual)
}
})
}
}
func TestCreateMessageFromJson(t *testing.T) {
gogoproto.RegisterType(&DummyProto{}, dummyProtoName)
testCases := []struct {
name string
uri uriMatch
request func() *http.Request
expected gogoproto.Message
expErr bool
}{
{
name: "simple, empty message",
uri: uriMatch{QueryInputName: dummyProtoName},
request: func() *http.Request {
return &http.Request{Body: io.NopCloser(bytes.NewReader([]byte("{}")))}
},
expected: &DummyProto{},
},
{
name: "message with json input",
uri: uriMatch{QueryInputName: dummyProtoName},
request: func() *http.Request {
d := DummyProto{
Foo: "hello",
Bar: true,
Baz: 320,
}
bz, err := json.Marshal(d)
require.NoError(t, err)
return &http.Request{Body: io.NopCloser(bytes.NewReader(bz))}
},
expected: &DummyProto{
Foo: "hello",
Bar: true,
Baz: 320,
},
},
{
name: "message with invalid json",
uri: uriMatch{QueryInputName: dummyProtoName},
request: func() *http.Request {
return &http.Request{Body: io.NopCloser(bytes.NewReader([]byte(`{"foo":12,dfi3}"`)))}
},
expErr: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
actual, err := createMessageFromJSON(&tc.uri, tc.request())
if tc.expErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tc.expected, actual)
}
})
}
}
+1 -1
View File
@@ -32,6 +32,7 @@ require (
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.10.0
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1
google.golang.org/grpc v1.68.1
google.golang.org/protobuf v1.36.1
)
@@ -109,7 +110,6 @@ require (
golang.org/x/text v0.21.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+1 -7
View File
@@ -30,7 +30,6 @@ import (
"github.com/cosmos/cosmos-sdk/client/rpc"
sdktelemetry "github.com/cosmos/cosmos-sdk/telemetry"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/cosmos/cosmos-sdk/types/module"
txtypes "github.com/cosmos/cosmos-sdk/types/tx"
"github.com/cosmos/cosmos-sdk/version"
authcmd "github.com/cosmos/cosmos-sdk/x/auth/client/cli"
@@ -157,6 +156,7 @@ func InitRootCmd[T transaction.Tx](
logger,
deps.GlobalConfig,
simApp.InterfaceRegistry(),
simApp.App.AppManager,
)
if err != nil {
return nil, err
@@ -278,10 +278,4 @@ func registerGRPCGatewayRoutes[T transaction.Tx](
cmtservice.RegisterGRPCGatewayRoutes(deps.ClientContext, server.GRPCGatewayRouter)
_ = nodeservice.RegisterServiceHandlerClient(context.Background(), server.GRPCGatewayRouter, nodeservice.NewServiceClient(deps.ClientContext))
_ = txtypes.RegisterServiceHandlerClient(context.Background(), server.GRPCGatewayRouter, txtypes.NewServiceClient(deps.ClientContext))
for _, mod := range deps.ModuleManager.Modules() {
if gmod, ok := mod.(module.HasGRPCGateway); ok {
gmod.RegisterGRPCGatewayRoutes(deps.ClientContext, server.GRPCGatewayRouter)
}
}
}
+4 -3
View File
@@ -179,20 +179,21 @@ func TestDistrValidatorGRPCQueries(t *testing.T) {
// test validator slashes grpc endpoint
slashURL := baseurl + `/cosmos/distribution/v1beta1/validators/%s/slashes`
invalidHeightOutput := `{"code":3, "message":"strconv.ParseUint: parsing \"-3\": invalid syntax", "details":[]}`
invalidStartingHeightOutput := `{"code":3, "message":"1 error(s) decoding:\n\n* cannot parse 'starting_height' as uint: strconv.ParseUint: parsing \"-3\": invalid syntax", "details":[]}`
invalidEndingHeightOutput := `{"code":3, "message":"1 error(s) decoding:\n\n* cannot parse 'ending_height' as uint: strconv.ParseUint: parsing \"-3\": invalid syntax", "details":[]}`
slashTestCases := []systest.RestTestCase{
{
Name: "invalid start height",
Url: fmt.Sprintf(slashURL+`?starting_height=%s&ending_height=%s`, valOperAddr, "-3", "3"),
ExpCode: http.StatusBadRequest,
ExpOut: invalidHeightOutput,
ExpOut: invalidStartingHeightOutput,
},
{
Name: "invalid end height",
Url: fmt.Sprintf(slashURL+`?starting_height=%s&ending_height=%s`, valOperAddr, "1", "-3"),
ExpCode: http.StatusBadRequest,
ExpOut: invalidHeightOutput,
ExpOut: invalidEndingHeightOutput,
},
{
Name: "valid request get slashes",