-
Notifications
You must be signed in to change notification settings - Fork 3.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: wire v2 handlers #22112
feat: wire v2 handlers #22112
Changes from 28 commits
233a249
d7202e4
f1d7ebe
75e1924
86d450f
f0af908
22b9f92
45b5d74
a67fcc9
3007e67
b4027dd
85bd55a
f22a135
5d05234
ee8d0a4
40b5bae
52d57cf
a733c83
8d78a2c
8dc09a3
c186af2
2cf4c62
9e6b8fb
ac25a49
9dd5c9f
2b028fd
1c7d32c
506267d
a8a173e
3608feb
c700ad9
db3ac74
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. | ||
tac0turtle marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
## 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" | ||
}' | ||
``` |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
package rest | ||
|
||
func DefaultConfig() *Config { | ||
return &Config{ | ||
Enable: true, | ||
Address: "localhost:8080", | ||
} | ||
} | ||
|
||
type CfgOption func(*Config) | ||
julienrbrt marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
// 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 | ||
} | ||
} |
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,98 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
package rest | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
"encoding/json" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
"fmt" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
"io" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
"net/http" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
"reflect" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
"strings" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
"cosmossdk.io/core/transaction" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
"cosmossdk.io/server/v2/appmanager" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
"github.com/cosmos/gogoproto/jsonpb" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
gogoproto "github.com/cosmos/gogoproto/proto" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Reorganize imports according to Go import conventions To conform with the Uber Go Style Guide, organize imports into three groups separated by blank lines: standard library packages, third-party packages, and local packages. Apply this diff to reorganize the imports: import (
"encoding/json"
"fmt"
"io"
"net/http"
"reflect"
"strings"
+ gogoproto "github.com/cosmos/gogoproto/proto"
+ "github.com/cosmos/gogoproto/jsonpb"
+ "cosmossdk.io/core/transaction"
+ "cosmossdk.io/server/v2/appmanager"
) 📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid exposing internal error details to clients Revealing internal error messages to clients can expose sensitive information. Return a generic error message to the client and log the detailed error internally. Apply this diff to return a generic error message: - http.Error(w, err.Error(), http.StatusBadRequest)
+ http.Error(w, "Bad Request", http.StatusBadRequest) 📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
query, err := h.appManager.Query(r.Context(), 0, msg) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
http.Error(w, "Error querying", http.StatusInternalServerError) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Comment on lines
+48
to
+52
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Log internal errors for better diagnostics In the Apply this diff to log the error internally: if err != nil {
+ log.Printf("Error querying: %v", err)
http.Error(w, "Error querying", http.StatusInternalServerError)
return
} 📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid exposing internal error details when encoding response Including internal error details in the HTTP response may leak sensitive information. Return a generic error message to the client and log the detailed error internally. Apply this diff to return a generic error message: - http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError) 📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
// 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") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Comment on lines
+62
to
+64
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Improve error message for unsupported methods In the Apply this diff to improve the error message: - return fmt.Errorf("method not allowed")
+ return fmt.Errorf(http.StatusText(http.StatusMethodNotAllowed)) 📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Comment on lines
+70
to
+75
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handle Content-Type header with parameters The current validation checks if the Apply this diff to enhance content type validation: -import "strings"
+import (
+ "mime"
+ "strings"
+)
func (h *DefaultHandler[T]) validateContentTypeIsJSON(r *http.Request) error {
contentType := r.Header.Get("Content-Type")
- if contentType != ContentTypeJSON {
+ mediaType, _, err := mime.ParseMediaType(contentType)
+ if err != nil || mediaType != ContentTypeJSON {
return fmt.Errorf("unsupported content type, expected %s", ContentTypeJSON)
}
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
// 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") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Comment on lines
+80
to
+84
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate and sanitize request path Using Consider applying this diff to safely extract the message type: path := strings.TrimPrefix(r.URL.Path, "/")
+if path == "" {
+ return nil, fmt.Errorf("request path cannot be empty")
+}
+if strings.Contains(path, "/") {
+ return nil, fmt.Errorf("invalid request path")
+}
requestType := gogoproto.MessageType(path)
if requestType == nil {
return nil, fmt.Errorf("unknown request type")
} 📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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: %v", err) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return msg, nil | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. super nit, for consistency for the other, can we get the logger error lowercase? |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
package rest | ||
|
||
import ( | ||
"context" | ||
"errors" | ||
"fmt" | ||
"net/http" | ||
|
||
"cosmossdk.io/core/transaction" | ||
"cosmossdk.io/log" | ||
serverv2 "cosmossdk.io/server/v2" | ||
"cosmossdk.io/server/v2/appmanager" | ||
) | ||
|
||
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) | ||
} | ||
} | ||
|
||
var appManager *appmanager.AppManager[T] | ||
appManager = appI.GetAppManager() | ||
|
||
s.router = http.NewServeMux() | ||
s.router.Handle("/", NewDefaultHandler(appManager)) | ||
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{ | ||
randygrok marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Improve grammar in the introduction
The introduction is clear and informative. However, there's a minor grammar issue in the last sentence.
Apply this change to improve the grammar:
📝 Committable suggestion
🧰 Tools
🪛 LanguageTool