diff --git a/runtime/v2/go.mod b/runtime/v2/go.mod index 74749ef367..44a3c9a9bc 100644 --- a/runtime/v2/go.mod +++ b/runtime/v2/go.mod @@ -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 diff --git a/runtime/v2/go.sum b/runtime/v2/go.sum index b67058f3e1..96f2f2e144 100644 --- a/runtime/v2/go.sum +++ b/runtime/v2/go.sum @@ -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= diff --git a/runtime/v2/services_test.go b/runtime/v2/services_test.go new file mode 100644 index 0000000000..83e16d4e71 --- /dev/null +++ b/runtime/v2/services_test.go @@ -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) +} diff --git a/server/v2/api/rest/README.md b/server/v2/api/rest/README.md new file mode 100644 index 0000000000..dd53f848e5 --- /dev/null +++ b/server/v2/api/rest/README.md @@ -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": "", + "denom": "" + } + ``` + + - `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" + }' +``` \ No newline at end of file diff --git a/server/v2/api/rest/config.go b/server/v2/api/rest/config.go new file mode 100644 index 0000000000..c1e9eb260f --- /dev/null +++ b/server/v2/api/rest/config.go @@ -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 + } +} diff --git a/server/v2/api/rest/handler.go b/server/v2/api/rest/handler.go new file mode 100644 index 0000000000..a5338f35cf --- /dev/null +++ b/server/v2/api/rest/handler.go @@ -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 +} diff --git a/server/v2/api/rest/server.go b/server/v2/api/rest/server.go new file mode 100644 index 0000000000..eaa0fe8b6b --- /dev/null +++ b/server/v2/api/rest/server.go @@ -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 +} diff --git a/server/v2/api/rest/server_test.go b/server/v2/api/rest/server_test.go new file mode 100644 index 0000000000..cec027f5d2 --- /dev/null +++ b/server/v2/api/rest/server_test.go @@ -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) + }) + } +} diff --git a/simapp/v2/simdv2/cmd/commands.go b/simapp/v2/simdv2/cmd/commands.go index 8d4186be8d..4d669f9174 100644 --- a/simapp/v2/simdv2/cmd/commands.go +++ b/simapp/v2/simdv2/cmd/commands.go @@ -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) }