feat: wire v2 handlers (#22112)

Co-authored-by: Randy Grok <@faulttolerance.net>
Co-authored-by: Julien Robert <julien@rbrt.fr>
This commit is contained in:
Randy Grok 2024-10-16 16:21:18 +02:00 committed by GitHub
parent 4adbd6fadd
commit e666764af6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 402 additions and 1 deletions

View File

@ -22,6 +22,7 @@ require (
cosmossdk.io/store/v2 v2.0.0-00010101000000-000000000000
cosmossdk.io/x/tx v0.13.3
github.com/cosmos/gogoproto v1.7.0
github.com/stretchr/testify v1.9.0
google.golang.org/grpc v1.67.1
google.golang.org/protobuf v1.35.1
)
@ -70,7 +71,7 @@ require (
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/rs/zerolog v1.33.0 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/stretchr/testify v1.9.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect
github.com/tendermint/go-amino v0.16.0 // indirect
github.com/tidwall/btree v1.7.0 // indirect

View File

@ -221,6 +221,8 @@ github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=

View File

@ -0,0 +1,50 @@
package runtime
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
appmodulev2 "cosmossdk.io/core/appmodule/v2"
"cosmossdk.io/core/transaction"
"cosmossdk.io/server/v2/stf"
)
// MockModule implements both HasMsgHandlers and HasQueryHandlers
type MockModule struct {
mock.Mock
appmodulev2.AppModule
}
func (m *MockModule) RegisterMsgHandlers(router appmodulev2.MsgRouter) {
m.Called(router)
}
func (m *MockModule) RegisterQueryHandlers(router appmodulev2.QueryRouter) {
m.Called(router)
}
func TestRegisterServices(t *testing.T) {
mockModule := new(MockModule)
app := &App[transaction.Tx]{
msgRouterBuilder: stf.NewMsgRouterBuilder(),
queryRouterBuilder: stf.NewMsgRouterBuilder(),
}
mm := &MM[transaction.Tx]{
modules: map[string]appmodulev2.AppModule{
"mock": mockModule,
},
}
mockModule.On("RegisterMsgHandlers", app.msgRouterBuilder).Once()
mockModule.On("RegisterQueryHandlers", app.queryRouterBuilder).Once()
err := mm.RegisterServices(app)
assert.NoError(t, err)
mockModule.AssertExpectations(t)
}

View File

@ -0,0 +1,73 @@
# Cosmos SDK REST API
This document describes how to use a service that exposes endpoints based on Cosmos SDK Protobuf message types. Each endpoint responds with data in JSON format.
## General Description
The service allows querying the blockchain using any type of Protobuf message available in the Cosmos SDK application through HTTP `POST` requests. Each endpoint corresponds to a Cosmos SDK protocol message (`proto`), and responses are returned in JSON format.
## Example
### 1. `QueryBalanceRequest`
This endpoint allows querying the balance of an account given an address and a token denomination.
- **URL:** `localhost:8080/cosmos.bank.v2.QueryBalanceRequest`
- **Method:** `POST`
- **Headers:**
- `Content-Type: application/json`
- **Body (JSON):**
```json
{
"address": "<ACCOUNT_ADDRESS>",
"denom": "<TOKEN_DENOMINATION>"
}
```
- `address`: Account address on the Cosmos network.
- `denom`: Token denomination (e.g., `stake`).
- **Request Example:**
```
POST localhost:8080/cosmos.bank.v2.QueryBalanceRequest
Content-Type: application/json
{
"address": "cosmos16tms8tax3ha9exdu7x3maxrvall07yum3rdcu0",
"denom": "stake"
}
```
- **Response Example (JSON):**
```json
{
"balance": {
"denom": "stake",
"amount": "1000000"
}
}
```
The response shows the balance of the specified token for the given account.
## Using Tools
### 1. Using `curl`
To make a request using `curl`, you can run the following command:
```bash
curl -X POST localhost:8080/cosmos.bank.v2.QueryBalanceRequest \
-H "Content-Type: application/json" \
-d '{
"address": "cosmos16tms8tax3ha9exdu7x3maxrvall07yum3rdcu0",
"denom": "stake"
}'
```

View File

@ -0,0 +1,32 @@
package rest
func DefaultConfig() *Config {
return &Config{
Enable: true,
Address: "localhost:8080",
}
}
type CfgOption func(*Config)
// Config defines configuration for the REST server.
type Config struct {
// Enable defines if the REST server should be enabled.
Enable bool `mapstructure:"enable" toml:"enable" comment:"Enable defines if the REST server should be enabled."`
// Address defines the API server to listen on
Address string `mapstructure:"address" toml:"address" comment:"Address defines the REST server address to bind to."`
}
// OverwriteDefaultConfig overwrites the default config with the new config.
func OverwriteDefaultConfig(newCfg *Config) CfgOption {
return func(cfg *Config) {
*cfg = *newCfg
}
}
// Disable the rest server by default (default enabled).
func Disable() CfgOption {
return func(cfg *Config) {
cfg.Enable = false
}
}

View File

@ -0,0 +1,99 @@
package rest
import (
"encoding/json"
"fmt"
"io"
"net/http"
"reflect"
"strings"
"github.com/cosmos/gogoproto/jsonpb"
gogoproto "github.com/cosmos/gogoproto/proto"
"cosmossdk.io/core/transaction"
"cosmossdk.io/server/v2/appmanager"
)
const (
ContentTypeJSON = "application/json"
MaxBodySize = 1 << 20 // 1 MB
)
func NewDefaultHandler[T transaction.Tx](appManager *appmanager.AppManager[T]) http.Handler {
return &DefaultHandler[T]{appManager: appManager}
}
type DefaultHandler[T transaction.Tx] struct {
appManager *appmanager.AppManager[T]
}
func (h *DefaultHandler[T]) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := h.validateMethodIsPOST(r); err != nil {
http.Error(w, err.Error(), http.StatusMethodNotAllowed)
return
}
if err := h.validateContentTypeIsJSON(r); err != nil {
http.Error(w, err.Error(), http.StatusUnsupportedMediaType)
return
}
msg, err := h.createMessage(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
query, err := h.appManager.Query(r.Context(), 0, msg)
if err != nil {
http.Error(w, "Error querying", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", ContentTypeJSON)
if err := json.NewEncoder(w).Encode(query); err != nil {
http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
}
}
// validateMethodIsPOST validates that the request method is POST.
func (h *DefaultHandler[T]) validateMethodIsPOST(r *http.Request) error {
if r.Method != http.MethodPost {
return fmt.Errorf("method not allowed")
}
return nil
}
// validateContentTypeIsJSON validates that the request content type is JSON.
func (h *DefaultHandler[T]) validateContentTypeIsJSON(r *http.Request) error {
contentType := r.Header.Get("Content-Type")
if contentType != ContentTypeJSON {
return fmt.Errorf("unsupported content type, expected %s", ContentTypeJSON)
}
return nil
}
// createMessage creates the message by unmarshalling the request body.
func (h *DefaultHandler[T]) createMessage(r *http.Request) (gogoproto.Message, error) {
path := strings.TrimPrefix(r.URL.Path, "/")
requestType := gogoproto.MessageType(path)
if requestType == nil {
return nil, fmt.Errorf("unknown request type")
}
msg, ok := reflect.New(requestType.Elem()).Interface().(gogoproto.Message)
if !ok {
return nil, fmt.Errorf("failed to create message instance")
}
defer r.Body.Close()
limitedReader := io.LimitReader(r.Body, MaxBodySize)
err := jsonpb.Unmarshal(limitedReader, msg)
if err != nil {
return nil, fmt.Errorf("error parsing body: %w", err)
}
return msg, nil
}

View File

@ -0,0 +1,96 @@
package rest
import (
"context"
"errors"
"fmt"
"net/http"
"cosmossdk.io/core/transaction"
"cosmossdk.io/log"
serverv2 "cosmossdk.io/server/v2"
)
const (
ServerName = "rest-v2"
)
type Server[T transaction.Tx] struct {
logger log.Logger
router *http.ServeMux
httpServer *http.Server
config *Config
cfgOptions []CfgOption
}
func New[T transaction.Tx](cfgOptions ...CfgOption) *Server[T] {
return &Server[T]{
cfgOptions: cfgOptions,
}
}
func (s *Server[T]) Name() string {
return ServerName
}
func (s *Server[T]) Init(appI serverv2.AppI[T], cfg map[string]any, logger log.Logger) error {
s.logger = logger.With(log.ModuleKey, s.Name())
serverCfg := s.Config().(*Config)
if len(cfg) > 0 {
if err := serverv2.UnmarshalSubConfig(cfg, s.Name(), &serverCfg); err != nil {
return fmt.Errorf("failed to unmarshal config: %w", err)
}
}
s.router = http.NewServeMux()
s.router.Handle("/", NewDefaultHandler(appI.GetAppManager()))
s.config = serverCfg
return nil
}
func (s *Server[T]) Start(ctx context.Context) error {
if !s.config.Enable {
s.logger.Info(fmt.Sprintf("%s server is disabled via config", s.Name()))
return nil
}
s.httpServer = &http.Server{
Addr: s.config.Address,
Handler: s.router,
}
s.logger.Info("starting HTTP server", "address", s.config.Address)
if err := s.httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.logger.Error("failed to start HTTP server", "error", err)
return err
}
return nil
}
func (s *Server[T]) Stop(ctx context.Context) error {
if !s.config.Enable {
return nil
}
s.logger.Info("stopping HTTP server")
return s.httpServer.Shutdown(ctx)
}
func (s *Server[T]) Config() any {
if s.config == nil || s.config.Address == "" {
cfg := DefaultConfig()
for _, opt := range s.cfgOptions {
opt(cfg)
}
return cfg
}
return s.config
}

View File

@ -0,0 +1,46 @@
package rest
import (
"testing"
"github.com/stretchr/testify/require"
"cosmossdk.io/core/transaction"
)
func TestServerConfig(t *testing.T) {
testCases := []struct {
name string
setupFunc func() *Config
expectedConfig *Config
}{
{
name: "Default configuration, no custom configuration",
setupFunc: func() *Config {
s := New[transaction.Tx]()
return s.Config().(*Config)
},
expectedConfig: DefaultConfig(),
},
{
name: "Custom configuration",
setupFunc: func() *Config {
s := New[transaction.Tx](func(config *Config) {
config.Enable = false
})
return s.Config().(*Config)
},
expectedConfig: &Config{
Enable: false, // Custom configuration
Address: "localhost:8080",
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
config := tc.setupFunc()
require.Equal(t, tc.expectedConfig, config)
})
}
}

View File

@ -15,6 +15,7 @@ import (
runtimev2 "cosmossdk.io/runtime/v2"
serverv2 "cosmossdk.io/server/v2"
"cosmossdk.io/server/v2/api/grpc"
"cosmossdk.io/server/v2/api/rest"
"cosmossdk.io/server/v2/api/telemetry"
"cosmossdk.io/server/v2/cometbft"
serverstore "cosmossdk.io/server/v2/store"
@ -77,6 +78,7 @@ func initRootCmd[T transaction.Tx](
grpc.New[T](),
serverstore.New[T](),
telemetry.New[T](),
rest.New[T](),
); err != nil {
panic(err)
}