Skip to content

Commit

Permalink
clients/stellarcore: Add support for Core's new /getledgerentryraw (#…
Browse files Browse the repository at this point in the history
…5428)

* Remove GetLedgerEntry to make breaking changes more visible
* Add unit tests for POST, request body, response JSON
  • Loading branch information
Shaptic authored Sep 24, 2024
1 parent 69667b2 commit 6904ece
Show file tree
Hide file tree
Showing 4 changed files with 174 additions and 73 deletions.
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) {
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,
) 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"`
}

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

0 comments on commit 6904ece

Please sign in to comment.