From c9f581011b748ed6bbae3b1b0bc420ff50cc90f2 Mon Sep 17 00:00:00 2001 From: Chris Marslender Date: Thu, 19 Sep 2024 14:36:32 -0500 Subject: [PATCH] Add a public http client for use with rpc compliant public (no-cert) servers --- pkg/publichttpclient/httpclient.go | 236 +++++++++++++++++++++++++++++ pkg/rpc/client.go | 6 + pkg/rpc/clientoptions.go | 7 + pkg/rpc/readme.md | 27 ++++ 4 files changed, 276 insertions(+) create mode 100644 pkg/publichttpclient/httpclient.go diff --git a/pkg/publichttpclient/httpclient.go b/pkg/publichttpclient/httpclient.go new file mode 100644 index 0000000..ed6c852 --- /dev/null +++ b/pkg/publichttpclient/httpclient.go @@ -0,0 +1,236 @@ +package publichttpclient + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "time" + + "github.com/google/go-querystring/query" + "github.com/google/uuid" + + "github.com/chia-network/go-chia-libs/pkg/httpclient" + "github.com/chia-network/go-chia-libs/pkg/rpcinterface" +) + +// HTTPClient connects to Chia RPC via standard HTTP requests +type HTTPClient struct { + baseURL *url.URL + logger *slog.Logger + + // If set > 0, will configure http requests with a cache + cacheValidTime time.Duration + + // Request timeout + Timeout time.Duration + + nodeClient *http.Client +} + +// NewHTTPClient returns a new HTTP client that satisfies the rpcinterface.Client interface for use with public RPC services +func NewHTTPClient(options ...rpcinterface.ClientOptionFunc) (*HTTPClient, error) { + c := &HTTPClient{ + logger: slog.New(rpcinterface.SlogInfo()), + + Timeout: 10 * time.Second, // Default, overridable with client option + } + + // Sets the default host. Can be overridden by client options + err := c.SetBaseURL(&url.URL{ + Scheme: "https", + Host: "localhost", + }) + if err != nil { + return nil, err + } + + for _, fn := range options { + if fn == nil { + continue + } + if err := fn(c); err != nil { + return nil, err + } + } + + return c, nil +} + +// SetBaseURL sets the base URL for API requests to a custom endpoint. +func (c *HTTPClient) SetBaseURL(url *url.URL) error { + c.baseURL = url + + return nil +} + +// SetLogHandler sets a slog compatible log handler +func (c *HTTPClient) SetLogHandler(handler slog.Handler) { + c.logger = slog.New(handler) +} + +// SetCacheValidTime sets how long cache should be valid for +func (c *HTTPClient) SetCacheValidTime(validTime time.Duration) { + c.cacheValidTime = validTime +} + +// NewRequest creates an RPC request for the specified service +func (c *HTTPClient) NewRequest(service rpcinterface.ServiceType, rpcEndpoint rpcinterface.Endpoint, opt interface{}) (*rpcinterface.Request, error) { + // Always POST + // Supporting it as a variable in case that changes in the future, it can be passed in instead + method := http.MethodPost + + u := *c.baseURL + + u.RawPath = fmt.Sprintf("/%s", rpcEndpoint) + u.Path = fmt.Sprintf("/%s", rpcEndpoint) + + // Create a request specific headers map. + reqHeaders := make(http.Header) + reqHeaders.Set("Accept", "application/json") + + var body []byte + var err error + switch { + case method == http.MethodPost || method == http.MethodPut: + reqHeaders.Set("Content-Type", "application/json") + + // Always need at least an empty json object in the body + if opt == nil { + body = []byte(`{}`) + } else { + body, err = json.Marshal(opt) + if err != nil { + return nil, err + } + } + case opt != nil: + q, err := query.Values(opt) + if err != nil { + return nil, err + } + u.RawQuery = q.Encode() + } + + req, err := http.NewRequest(method, u.String(), bytes.NewReader(body)) + if err != nil { + return nil, err + } + + // Set the request specific headers. + for k, v := range reqHeaders { + req.Header[k] = v + } + + return &rpcinterface.Request{ + Service: service, + Request: req, + }, nil +} + +// Do sends an RPC request and returns the RPC response. +func (c *HTTPClient) Do(req *rpcinterface.Request, v interface{}) (*http.Response, error) { + client, err := c.httpClientForService(req.Service) + if err != nil { + return nil, err + } + + resp, err := client.Do(req.Request) + if err != nil { + return nil, err + } + + if v != nil { + if w, ok := v.(io.Writer); ok { + _, err = io.Copy(w, resp.Body) + } else { + err = json.NewDecoder(resp.Body).Decode(v) + } + } + + return resp, err +} + +func (c *HTTPClient) generateHTTPClientForService(service rpcinterface.ServiceType) (*http.Client, error) { + var transport http.RoundTripper + + transport = &http.Transport{} + + if c.cacheValidTime > 0 { + transport = httpclient.NewCachedTransport(c.cacheValidTime, transport) + } + + client := &http.Client{ + Transport: transport, + Timeout: c.Timeout, + } + + return client, nil +} + +// httpClientForService returns the proper http client to use with the service +func (c *HTTPClient) httpClientForService(service rpcinterface.ServiceType) (*http.Client, error) { + var ( + client *http.Client + err error + ) + + switch service { + case rpcinterface.ServiceDaemon: + return nil, fmt.Errorf("daemon RPC calls must be made with the websocket client") + case rpcinterface.ServiceFullNode: + if c.nodeClient == nil { + c.nodeClient, err = c.generateHTTPClientForService(rpcinterface.ServiceFullNode) + if err != nil { + return nil, err + } + } + client = c.nodeClient + } + + if client == nil { + return nil, fmt.Errorf("unknown service") + } + + return client, nil +} + +// The following are here to satisfy the interface, but are not used by the HTTP client + +// SubscribeSelf does not apply to the HTTP Client +func (c *HTTPClient) SubscribeSelf() error { + return fmt.Errorf("subscriptions are not supported on the HTTP client - websockets are required for subscriptions") +} + +// Subscribe does not apply to the HTTP Client +// Not applicable on the HTTP connection +func (c *HTTPClient) Subscribe(service string) error { + return fmt.Errorf("subscriptions are not supported on the HTTP client - websockets are required for subscriptions") +} + +// AddHandler does not apply to HTTP Client +func (c *HTTPClient) AddHandler(handler rpcinterface.WebsocketResponseHandler) (uuid.UUID, error) { + return uuid.Nil, fmt.Errorf("handlers are not supported on the HTTP client - reponses are returned directly from the calling functions") +} + +// RemoveHandler does not apply to HTTP Client +func (c *HTTPClient) RemoveHandler(handlerID uuid.UUID) {} + +// AddDisconnectHandler does not apply to the HTTP Client +func (c *HTTPClient) AddDisconnectHandler(onDisconnect rpcinterface.DisconnectHandler) {} + +// AddReconnectHandler does not apply to the HTTP Client +func (c *HTTPClient) AddReconnectHandler(onReconnect rpcinterface.ReconnectHandler) {} + +// SetSyncMode does not apply to the HTTP Client +func (c *HTTPClient) SetSyncMode() { + c.logger.Debug("Sync mode is default for HTTP client. SetSyncMode call is ignored") +} + +// SetAsyncMode does not apply to the HTTP Client +func (c *HTTPClient) SetAsyncMode() { + c.logger.Debug("Async mode is not applicable to the HTTP client. SetAsyncMode call is ignored") +} diff --git a/pkg/rpc/client.go b/pkg/rpc/client.go index ad811ab..be28104 100644 --- a/pkg/rpc/client.go +++ b/pkg/rpc/client.go @@ -7,6 +7,7 @@ import ( "github.com/chia-network/go-chia-libs/pkg/config" "github.com/chia-network/go-chia-libs/pkg/httpclient" + "github.com/chia-network/go-chia-libs/pkg/publichttpclient" "github.com/chia-network/go-chia-libs/pkg/rpcinterface" "github.com/chia-network/go-chia-libs/pkg/websocketclient" ) @@ -37,6 +38,9 @@ const ( // ConnectionModeWebsocket uses websockets for requests to the RPC server ConnectionModeWebsocket + + // ConnectionModePublicHTTP is for use with public http(s) servers that don't require cert auth but otherwise mirror the RPCs + ConnectionModePublicHTTP ) // NewClient returns a new RPC Client @@ -56,6 +60,8 @@ func NewClient(connectionMode ConnectionMode, configOption rpcinterface.ConfigOp activeClient, err = httpclient.NewHTTPClient(cfg, options...) case ConnectionModeWebsocket: activeClient, err = websocketclient.NewWebsocketClient(cfg, options...) + case ConnectionModePublicHTTP: + activeClient, err = publichttpclient.NewHTTPClient(options...) } if err != nil { return nil, err diff --git a/pkg/rpc/clientoptions.go b/pkg/rpc/clientoptions.go index 3bbc27b..d900d02 100644 --- a/pkg/rpc/clientoptions.go +++ b/pkg/rpc/clientoptions.go @@ -25,6 +25,13 @@ func WithManualConfig(cfg config.ChiaConfig) rpcinterface.ConfigOptionFunc { } } +// WithPublicConfig client option func for using public HTTP(s) servers +func WithPublicConfig() rpcinterface.ConfigOptionFunc { + return func() (*config.ChiaConfig, error) { + return &config.ChiaConfig{}, nil + } +} + // WithSyncWebsocket is a helper to making the client and calling SetSyncMode to set the client to sync mode by default func WithSyncWebsocket() rpcinterface.ClientOptionFunc { return func(c rpcinterface.Client) error { diff --git a/pkg/rpc/readme.md b/pkg/rpc/readme.md index 8bfcc1e..fedc1b3 100644 --- a/pkg/rpc/readme.md +++ b/pkg/rpc/readme.md @@ -54,6 +54,33 @@ func main() { } ``` +### Public HTTP Mode + +Public HTTP mode is for servers that conform to the interface of the chia rpc server, but do not require certs to connect (such as coinset). To use Public HTTP mode, create a new client and specify `ConnectionModePublicHTTP` and provide the URL: + +```go +package main + +import ( + "net/url" + + "github.com/chia-network/go-chia-libs/pkg/rpc" +) + +func main() { + client, err := rpc.NewClient(rpc.ConnectionModePublicHTTP, rpc.WithPublicConfig(), rpc.WithBaseURL(&url.URL{ + Scheme: "https", + Host: "api.coinset.org", + })) + if err != nil { + // error happened + } + + // Get the blockchain state from the public server + state, resp, err := client.FullNodeService.GetBlockchainState() +} +``` + ### Websocket Mode To use Websocket mode, specify ConnectionModeWebsocket when creating the client: