Skip to content
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

clients/stellarcore: Add support for Core's new /getledgerentryraw #5428

Merged
merged 9 commits into from
Sep 24, 2024
158 changes: 103 additions & 55 deletions clients/stellarcore/client.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
package stellarcore

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"path"
Expand All @@ -21,7 +21,7 @@ import (
// Client represents a client that is capable of communicating with a
// stellar-core server using HTTP
type Client struct {
// HTTP is the client to use when communicating with stellar-core. If nil,
// HTTP is the client to use when communicating with stellar-core. If nil,
// http.DefaultClient will be used.
HTTP HTTP

Expand All @@ -36,7 +36,7 @@ type Client struct {
// in case an error was encountered during either the draining or closing of the
// stream, that error would be returned.
func drainReponse(hresp *http.Response, close bool, err *error) (outerror error) {
_, err2 := io.Copy(ioutil.Discard, hresp.Body)
_, err2 := io.Copy(io.Discard, hresp.Body)
if err2 != nil {
if err != nil && *err == nil {
*err = errors.Wrap(err2, "unable to read excess data from response")
Expand Down Expand Up @@ -75,54 +75,12 @@ func (c *Client) Upgrade(ctx context.Context, version int) (err error) {
}
defer drainReponse(hresp, true, &err) //nolint:errcheck

if !(hresp.StatusCode >= 200 && hresp.StatusCode < 300) {
err = errors.New("http request failed with non-200 status code")
return
if hresp.StatusCode < 200 || hresp.StatusCode >= 300 {
return errors.New("http request failed with non-200 status code")
}
return nil
}

// GetLedgerEntry submits a request to the stellar core instance to get the latest
// state of a given ledger entry.
func (c *Client) GetLedgerEntry(ctx context.Context, ledgerKey xdr.LedgerKey) (proto.GetLedgerEntryResponse, error) {
sreuland marked this conversation as resolved.
Show resolved Hide resolved
b64, err := xdr.MarshalBase64(ledgerKey)
if err != nil {
return proto.GetLedgerEntryResponse{}, errors.Wrap(err, "failed to marshal ledger key")
}
q := url.Values{}
q.Set("key", b64)

req, err := c.simpleGet(ctx, "getledgerentry", q)
if err != nil {
return proto.GetLedgerEntryResponse{}, errors.Wrap(err, "failed to create request")
}

hresp, err := c.http().Do(req)
if err != nil {
return proto.GetLedgerEntryResponse{}, errors.Wrap(err, "http request errored")
}
defer hresp.Body.Close()

if !(hresp.StatusCode >= 200 && hresp.StatusCode < 300) {
if drainReponse(hresp, false, &err) != nil {
return proto.GetLedgerEntryResponse{}, err
}
return proto.GetLedgerEntryResponse{}, errors.New("http request failed with non-200 status code")
}

responseBytes, err := io.ReadAll(hresp.Body)
if err != nil {
return proto.GetLedgerEntryResponse{}, errors.Wrap(err, "could not read response")
}

var response proto.GetLedgerEntryResponse
if err = json.Unmarshal(responseBytes, &response); err != nil {
return proto.GetLedgerEntryResponse{}, errors.Wrap(err, "json decode failed: "+string(responseBytes))
}

return response, nil
}

// Info calls the `info` command on the connected stellar core and returns the
// provided response
func (c *Client) Info(ctx context.Context) (resp *proto.InfoResponse, err error) {
Expand Down Expand Up @@ -180,7 +138,7 @@ func (c *Client) SetCursor(ctx context.Context, id string, cursor int32) (err er
}

var raw []byte
raw, err = ioutil.ReadAll(hresp.Body)
raw, err = io.ReadAll(hresp.Body)
if err != nil {
return err
}
Expand All @@ -193,9 +151,13 @@ func (c *Client) SetCursor(ctx context.Context, id string, cursor int32) (err er
return nil
}

func (c *Client) GetLedgerEntryRaw(ctx context.Context, ledgerSeq uint32, keys ...xdr.LedgerKey) (proto.GetLedgerEntryRawResponse, error) {
var resp proto.GetLedgerEntryRawResponse
return resp, c.makeLedgerKeyRequest(ctx, &resp, "getledgerentryraw", ledgerSeq, keys...)
}

// SubmitTransaction calls the `tx` command on the connected stellar core with the provided envelope
func (c *Client) SubmitTransaction(ctx context.Context, envelope string) (resp *proto.TXResponse, err error) {

q := url.Values{}
q.Set("blob", envelope)

Expand All @@ -214,13 +176,12 @@ func (c *Client) SubmitTransaction(ctx context.Context, envelope string) (resp *
}
defer drainReponse(hresp, true, &err) //nolint:errcheck

if !(hresp.StatusCode >= 200 && hresp.StatusCode < 300) {
if hresp.StatusCode < 200 || hresp.StatusCode >= 300 {
err = errors.New("http request failed with non-200 status code")
return
}

err = json.NewDecoder(hresp.Body).Decode(&resp)

if err != nil {
err = errors.Wrap(err, "json decode failed")
return
Expand All @@ -232,7 +193,6 @@ func (c *Client) SubmitTransaction(ctx context.Context, envelope string) (resp *
// WaitForNetworkSync continually polls the connected stellar-core until it
// receives a response that indicated the node has synced with the network
func (c *Client) WaitForNetworkSync(ctx context.Context) error {

for {
info, err := c.Info(ctx)

Expand All @@ -256,7 +216,6 @@ func (c *Client) WaitForNetworkSync(ctx context.Context) error {

// ManualClose closes a ledger when Core is running in `MANUAL_CLOSE` mode
func (c *Client) ManualClose(ctx context.Context) (err error) {

q := url.Values{}

var req *http.Request
Expand All @@ -274,7 +233,7 @@ func (c *Client) ManualClose(ctx context.Context) (err error) {
}
defer drainReponse(hresp, true, &err) //nolint:errcheck

if !(hresp.StatusCode >= 200 && hresp.StatusCode < 300) {
if hresp.StatusCode < 200 || hresp.StatusCode >= 300 {
err = errors.New("http request failed with non-200 status code")
return
}
Expand Down Expand Up @@ -312,7 +271,6 @@ func (c *Client) simpleGet(
newPath string,
query url.Values,
) (*http.Request, error) {

u, err := url.Parse(c.URL)
if err != nil {
return nil, errors.Wrap(err, "unparseable url")
Expand All @@ -332,3 +290,93 @@ func (c *Client) simpleGet(

return req, nil
}

// rawPost returns a new POST request to the connected stellar-core using the
// provided path and the params values encoded as the request body to construct
// the result.
func (c *Client) rawPost(
ctx context.Context,
newPath string,
body string,
) (*http.Request, error) {
u, err := url.Parse(c.URL)
if err != nil {
return nil, errors.Wrap(err, "unparseable url")
}

u.Path = path.Join(u.Path, newPath)
newURL := u.String()

var req *http.Request
req, err = http.NewRequestWithContext(
ctx,
http.MethodPost,
newURL,
bytes.NewBuffer([]byte(body)))
if err != nil {
return nil, errors.Wrap(err, "failed to create request")
}

return req, nil
}

// makeLedgerKeyRequest is a generic method to perform a request in the form
// `key=...&key=...&ledgerSeq=...` which is useful because several Stellar Core
// endpoints all use this request format. Be sure to pass `target` by reference.
func (c *Client) makeLedgerKeyRequest(
ctx context.Context,
target interface{},
endpoint string,
ledgerSeq uint32,
keys ...xdr.LedgerKey,
Shaptic marked this conversation as resolved.
Show resolved Hide resolved
) error {
if len(keys) == 0 {
return errors.New("no keys specified in request")
}

q, err := buildMultiKeyRequest(keys...)
if err != nil {
return err
}
if ledgerSeq >= 2 { // optional param
q += fmt.Sprintf("&ledgerSeq=%d", ledgerSeq)
}

var req *http.Request
req, err = c.rawPost(ctx, endpoint, q)
if err != nil {
return err
}

var hresp *http.Response
hresp, err = c.http().Do(req)
if err != nil {
return errors.Wrap(err, "http request errored")
}
defer drainReponse(hresp, true, &err) //nolint:errcheck

if hresp.StatusCode < 200 || hresp.StatusCode >= 300 {
return fmt.Errorf("http request failed with non-200 status code (%d)", hresp.StatusCode)
}

// wrap returns nil if the inner error is nil
return errors.Wrap(json.NewDecoder(hresp.Body).Decode(&target), "json decode failed")
}

// buildMultiKeyRequest is a workaround helper because, unfortunately,
// url.Values does not support multiple keys via Set(), so we have to build our
// URL parameters manually.
func buildMultiKeyRequest(keys ...xdr.LedgerKey) (string, error) {
stringKeys := make([]string, 0, len(keys))

for _, key := range keys {
keyB64, err := key.MarshalBinaryBase64()
if err != nil {
return "", errors.Wrap(err, "failed to encode LedgerKey")
}

stringKeys = append(stringKeys, "key="+url.QueryEscape(keyB64))
}

return strings.Join(stringKeys, "&"), nil
}
60 changes: 60 additions & 0 deletions clients/stellarcore/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@ package stellarcore

import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"testing"

"github.com/jarcoal/httpmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/stellar/go/keypair"
proto "github.com/stellar/go/protocols/stellarcore"
"github.com/stellar/go/support/http/httptest"
"github.com/stellar/go/xdr"
)

func TestSubmitTransaction(t *testing.T) {
Expand Down Expand Up @@ -75,3 +82,56 @@ func TestManualClose_NotAvailable(t *testing.T) {

assert.EqualError(t, err, "exception in response: Set MANUAL_CLOSE=true")
}

func TestGetLedgerEntries(t *testing.T) {
hmock := httptest.NewClient()
c := &Client{HTTP: hmock, URL: "http://localhost:11626"}

// build a fake response body
mockResp := proto.GetLedgerEntryRawResponse{
Ledger: 1215, // checkpoint align on expected request
Entries: []proto.RawLedgerEntryResponse{
{
Entry: "pretend this is XDR lol",
},
{
Entry: "pretend this is another XDR lol",
},
},
}

var key xdr.LedgerKey
acc, err := xdr.AddressToAccountId(keypair.MustRandom().Address())
require.NoError(t, err)
key.SetAccount(acc)

// happy path - fetch an entry
ce := hmock.On("POST", "http://localhost:11626/getledgerentryraw")
hmock.RegisterResponder(
"POST",
"http://localhost:11626/getledgerentryraw",
func(r *http.Request) (*http.Response, error) {
// Ensure the request has the correct POST body
requestData, ierr := io.ReadAll(r.Body)
require.NoError(t, ierr)

keyB64, ierr := key.MarshalBinaryBase64()
require.NoError(t, ierr)
expected := fmt.Sprintf("key=%s&ledgerSeq=1234", url.QueryEscape(keyB64))
require.Equal(t, expected, string(requestData))

resp, ierr := httpmock.NewJsonResponse(http.StatusOK, &mockResp)
require.NoError(t, ierr)
ce.Return(httpmock.ResponderFromResponse(resp))
return resp, nil
})

resp, err := c.GetLedgerEntryRaw(context.Background(), 1234, key)
require.NoError(t, err)
require.NotNil(t, resp)

require.EqualValues(t, 1215, resp.Ledger)
require.Len(t, resp.Entries, 2)
require.Equal(t, "pretend this is XDR lol", resp.Entries[0].Entry)
require.Equal(t, "pretend this is another XDR lol", resp.Entries[1].Entry)
}
18 changes: 0 additions & 18 deletions protocols/stellarcore/getledgerentry_response.go

This file was deleted.

11 changes: 11 additions & 0 deletions protocols/stellarcore/getledgerentryraw_response.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package stellarcore

// GetLedgerEntryRawResponse is the structure of Stellar Core's /getledgerentryraw
type GetLedgerEntryRawResponse struct {
Ledger uint32 `json:"ledger"`
Entries []RawLedgerEntryResponse `json:"entries"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

out of curiosity why is each entry a json blob containing one string field instead of simply being a string?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not my schema, heh 🤷 @SirTyson?

}

type RawLedgerEntryResponse struct {
Entry string `json:"le"` // base64-encoded xdr.LedgerEntry
}
Loading