- package client
-
- import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net"
- "net/http"
- "net/url"
- "strings"
- "sync"
- "time"
-
- rpctypes "github.com/tendermint/tendermint/rpc/jsonrpc/types"
- )
-
- const (
- protoHTTP = "http"
- protoHTTPS = "https"
- protoWSS = "wss"
- protoWS = "ws"
- protoTCP = "tcp"
- protoUNIX = "unix"
- )
-
- //-------------------------------------------------------------
-
- // Parsed URL structure
- type parsedURL struct {
- url.URL
-
- isUnixSocket bool
- }
-
- // Parse URL and set defaults
- func newParsedURL(remoteAddr string) (*parsedURL, error) {
- u, err := url.Parse(remoteAddr)
- if err != nil {
- return nil, err
- }
-
- // default to tcp if nothing specified
- if u.Scheme == "" {
- u.Scheme = protoTCP
- }
-
- pu := &parsedURL{
- URL: *u,
- isUnixSocket: false,
- }
-
- if u.Scheme == protoUNIX {
- pu.isUnixSocket = true
- }
-
- return pu, nil
- }
-
- // Change protocol to HTTP for unknown protocols and TCP protocol - useful for RPC connections
- func (u *parsedURL) SetDefaultSchemeHTTP() {
- // protocol to use for http operations, to support both http and https
- switch u.Scheme {
- case protoHTTP, protoHTTPS, protoWS, protoWSS:
- // known protocols not changed
- default:
- // default to http for unknown protocols (ex. tcp)
- u.Scheme = protoHTTP
- }
- }
-
- // Get full address without the protocol - useful for Dialer connections
- func (u parsedURL) GetHostWithPath() string {
- // Remove protocol, userinfo and # fragment, assume opaque is empty
- return u.Host + u.EscapedPath()
- }
-
- // Get a trimmed address - useful for WS connections
- func (u parsedURL) GetTrimmedHostWithPath() string {
- // if it's not an unix socket we return the normal URL
- if !u.isUnixSocket {
- return u.GetHostWithPath()
- }
- // if it's a unix socket we replace the host slashes with a period
- // this is because otherwise the http.Client would think that the
- // domain is invalid.
- return strings.ReplaceAll(u.GetHostWithPath(), "/", ".")
- }
-
- // GetDialAddress returns the endpoint to dial for the parsed URL
- func (u parsedURL) GetDialAddress() string {
- // if it's not a unix socket we return the host, example: localhost:443
- if !u.isUnixSocket {
- return u.Host
- }
- // otherwise we return the path of the unix socket, ex /tmp/socket
- return u.GetHostWithPath()
- }
-
- // Get a trimmed address with protocol - useful as address in RPC connections
- func (u parsedURL) GetTrimmedURL() string {
- return u.Scheme + "://" + u.GetTrimmedHostWithPath()
- }
-
- //-------------------------------------------------------------
-
- // HTTPClient is a common interface for JSON-RPC HTTP clients.
- type HTTPClient interface {
- // Call calls the given method with the params and returns a result.
- Call(ctx context.Context, method string, params map[string]interface{}, result interface{}) (interface{}, error)
- }
-
- // Caller implementers can facilitate calling the JSON-RPC endpoint.
- type Caller interface {
- Call(ctx context.Context, method string, params map[string]interface{}, result interface{}) (interface{}, error)
- }
-
- //-------------------------------------------------------------
-
- // Client is a JSON-RPC client, which sends POST HTTP requests to the
- // remote server.
- //
- // Client is safe for concurrent use by multiple goroutines.
- type Client struct {
- address string
- username string
- password string
-
- client *http.Client
-
- mtx sync.Mutex
- nextReqID int
- }
-
- var _ HTTPClient = (*Client)(nil)
-
- // Both Client and RequestBatch can facilitate calls to the JSON
- // RPC endpoint.
- var _ Caller = (*Client)(nil)
- var _ Caller = (*RequestBatch)(nil)
-
- // New returns a Client pointed at the given address.
- // An error is returned on invalid remote. The function panics when remote is nil.
- func New(remote string) (*Client, error) {
- httpClient, err := DefaultHTTPClient(remote)
- if err != nil {
- return nil, err
- }
- return NewWithHTTPClient(remote, httpClient)
- }
-
- // NewWithHTTPClient returns a Client pointed at the given address using a
- // custom http client. An error is returned on invalid remote. The function
- // panics when client is nil.
- func NewWithHTTPClient(remote string, c *http.Client) (*Client, error) {
- if c == nil {
- return nil, errors.New("nil client")
- }
-
- parsedURL, err := newParsedURL(remote)
- if err != nil {
- return nil, fmt.Errorf("invalid remote %s: %s", remote, err)
- }
-
- parsedURL.SetDefaultSchemeHTTP()
-
- address := parsedURL.GetTrimmedURL()
- username := parsedURL.User.Username()
- password, _ := parsedURL.User.Password()
-
- rpcClient := &Client{
- address: address,
- username: username,
- password: password,
- client: c,
- }
-
- return rpcClient, nil
- }
-
- // Call issues a POST HTTP request. Requests are JSON encoded. Content-Type:
- // application/json.
- func (c *Client) Call(
- ctx context.Context,
- method string,
- params map[string]interface{},
- result interface{},
- ) (interface{}, error) {
- id := c.nextRequestID()
-
- request, err := rpctypes.MapToRequest(id, method, params)
- if err != nil {
- return nil, fmt.Errorf("failed to encode params: %w", err)
- }
-
- requestBytes, err := json.Marshal(request)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal request: %w", err)
- }
-
- requestBuf := bytes.NewBuffer(requestBytes)
- httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, c.address, requestBuf)
- if err != nil {
- return nil, fmt.Errorf("request setup failed: %w", err)
- }
-
- httpRequest.Header.Set("Content-Type", "application/json")
-
- if c.username != "" || c.password != "" {
- httpRequest.SetBasicAuth(c.username, c.password)
- }
-
- httpResponse, err := c.client.Do(httpRequest)
- if err != nil {
- return nil, err
- }
-
- defer httpResponse.Body.Close()
-
- responseBytes, err := io.ReadAll(httpResponse.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
- }
-
- return unmarshalResponseBytes(responseBytes, id, result)
- }
-
- // NewRequestBatch starts a batch of requests for this client.
- func (c *Client) NewRequestBatch() *RequestBatch {
- return &RequestBatch{
- requests: make([]*jsonRPCBufferedRequest, 0),
- client: c,
- }
- }
-
- func (c *Client) sendBatch(ctx context.Context, requests []*jsonRPCBufferedRequest) ([]interface{}, error) {
- reqs := make([]rpctypes.RPCRequest, 0, len(requests))
- results := make([]interface{}, 0, len(requests))
- for _, req := range requests {
- reqs = append(reqs, req.request)
- results = append(results, req.result)
- }
-
- // serialize the array of requests into a single JSON object
- requestBytes, err := json.Marshal(reqs)
- if err != nil {
- return nil, fmt.Errorf("json marshal: %w", err)
- }
-
- httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, c.address, bytes.NewBuffer(requestBytes))
- if err != nil {
- return nil, fmt.Errorf("new request: %w", err)
- }
-
- httpRequest.Header.Set("Content-Type", "application/json")
-
- if c.username != "" || c.password != "" {
- httpRequest.SetBasicAuth(c.username, c.password)
- }
-
- httpResponse, err := c.client.Do(httpRequest)
- if err != nil {
- return nil, fmt.Errorf("post: %w", err)
- }
-
- defer httpResponse.Body.Close()
-
- responseBytes, err := io.ReadAll(httpResponse.Body)
- if err != nil {
- return nil, fmt.Errorf("read response body: %w", err)
- }
-
- // collect ids to check responses IDs in unmarshalResponseBytesArray
- ids := make([]rpctypes.JSONRPCIntID, len(requests))
- for i, req := range requests {
- ids[i] = req.request.ID.(rpctypes.JSONRPCIntID)
- }
-
- return unmarshalResponseBytesArray(responseBytes, ids, results)
- }
-
- func (c *Client) nextRequestID() rpctypes.JSONRPCIntID {
- c.mtx.Lock()
- id := c.nextReqID
- c.nextReqID++
- c.mtx.Unlock()
- return rpctypes.JSONRPCIntID(id)
- }
-
- //------------------------------------------------------------------------------------
-
- // jsonRPCBufferedRequest encapsulates a single buffered request, as well as its
- // anticipated response structure.
- type jsonRPCBufferedRequest struct {
- request rpctypes.RPCRequest
- result interface{} // The result will be deserialized into this object.
- }
-
- // RequestBatch allows us to buffer multiple request/response structures
- // into a single batch request. Note that this batch acts like a FIFO queue, and
- // is thread-safe.
- type RequestBatch struct {
- client *Client
-
- mtx sync.Mutex
- requests []*jsonRPCBufferedRequest
- }
-
- // Count returns the number of enqueued requests waiting to be sent.
- func (b *RequestBatch) Count() int {
- b.mtx.Lock()
- defer b.mtx.Unlock()
- return len(b.requests)
- }
-
- func (b *RequestBatch) enqueue(req *jsonRPCBufferedRequest) {
- b.mtx.Lock()
- defer b.mtx.Unlock()
- b.requests = append(b.requests, req)
- }
-
- // Clear empties out the request batch.
- func (b *RequestBatch) Clear() int {
- b.mtx.Lock()
- defer b.mtx.Unlock()
- return b.clear()
- }
-
- func (b *RequestBatch) clear() int {
- count := len(b.requests)
- b.requests = make([]*jsonRPCBufferedRequest, 0)
- return count
- }
-
- // Send will attempt to send the current batch of enqueued requests, and then
- // will clear out the requests once done. On success, this returns the
- // deserialized list of results from each of the enqueued requests.
- func (b *RequestBatch) Send(ctx context.Context) ([]interface{}, error) {
- b.mtx.Lock()
- defer func() {
- b.clear()
- b.mtx.Unlock()
- }()
- return b.client.sendBatch(ctx, b.requests)
- }
-
- // Call enqueues a request to call the given RPC method with the specified
- // parameters, in the same way that the `Client.Call` function would.
- func (b *RequestBatch) Call(
- _ context.Context,
- method string,
- params map[string]interface{},
- result interface{},
- ) (interface{}, error) {
- id := b.client.nextRequestID()
- request, err := rpctypes.MapToRequest(id, method, params)
- if err != nil {
- return nil, err
- }
- b.enqueue(&jsonRPCBufferedRequest{request: request, result: result})
- return result, nil
- }
-
- //-------------------------------------------------------------
-
- func makeHTTPDialer(remoteAddr string) (func(string, string) (net.Conn, error), error) {
- u, err := newParsedURL(remoteAddr)
- if err != nil {
- return nil, err
- }
-
- protocol := u.Scheme
- padding := u.Scheme
-
- // accept http(s) as an alias for tcp
- switch protocol {
- case protoHTTP, protoHTTPS:
- protocol = protoTCP
- }
-
- dialFn := func(proto, addr string) (net.Conn, error) {
- var timeout = 10 * time.Second
- if !u.isUnixSocket && strings.LastIndex(u.Host, ":") == -1 {
- u.Host = fmt.Sprintf("%s:%s", u.Host, padding)
- return net.DialTimeout(protocol, u.GetDialAddress(), timeout)
- }
-
- return net.DialTimeout(protocol, u.GetDialAddress(), timeout)
- }
-
- return dialFn, nil
- }
-
- // DefaultHTTPClient is used to create an http client with some default parameters.
- // We overwrite the http.Client.Dial so we can do http over tcp or unix.
- // remoteAddr should be fully featured (eg. with tcp:// or unix://).
- // An error will be returned in case of invalid remoteAddr.
- func DefaultHTTPClient(remoteAddr string) (*http.Client, error) {
- dialFn, err := makeHTTPDialer(remoteAddr)
- if err != nil {
- return nil, err
- }
-
- client := &http.Client{
- Transport: &http.Transport{
- // Set to true to prevent GZIP-bomb DoS attacks
- DisableCompression: true,
- Dial: dialFn,
- },
- }
-
- return client, nil
- }
|