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:
parent
4adbd6fadd
commit
e666764af6
@ -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
|
||||
|
||||
@ -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=
|
||||
|
||||
50
runtime/v2/services_test.go
Normal file
50
runtime/v2/services_test.go
Normal 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)
|
||||
}
|
||||
73
server/v2/api/rest/README.md
Normal file
73
server/v2/api/rest/README.md
Normal 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"
|
||||
}'
|
||||
```
|
||||
32
server/v2/api/rest/config.go
Normal file
32
server/v2/api/rest/config.go
Normal 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
|
||||
}
|
||||
}
|
||||
99
server/v2/api/rest/handler.go
Normal file
99
server/v2/api/rest/handler.go
Normal 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
|
||||
}
|
||||
96
server/v2/api/rest/server.go
Normal file
96
server/v2/api/rest/server.go
Normal 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
|
||||
}
|
||||
46
server/v2/api/rest/server_test.go
Normal file
46
server/v2/api/rest/server_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user