diff --git a/go.mod b/go.mod index d20cfc9..6f63bd0 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/gorilla/websocket v1.5.1 github.com/ogen-go/ogen v0.77.0 github.com/r3labs/sse/v2 v2.10.0 + github.com/stretchr/testify v1.8.4 github.com/tonkeeper/tongo v1.7.0 go.opentelemetry.io/otel v1.19.0 go.opentelemetry.io/otel/metric v1.19.0 @@ -16,6 +17,7 @@ require ( ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.10.0 // indirect github.com/fatih/color v1.15.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect @@ -26,6 +28,7 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/oasisprotocol/curve25519-voi v0.0.0-20220328075252-7dd334e3daae // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/segmentio/asm v1.2.0 // indirect github.com/snksoft/crc v1.1.0 // indirect go.uber.org/multierr v1.11.0 // indirect @@ -39,4 +42,5 @@ require ( golang.org/x/tools v0.14.0 // indirect gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/tonapi.go b/tonapi.go index 85f7767..3590a5a 100644 --- a/tonapi.go +++ b/tonapi.go @@ -1,13 +1,24 @@ package tonapi import ( + "bytes" "context" "encoding/base64" + "encoding/json" + "io" + "net/url" + "time" + "github.com/go-faster/errors" + ht "github.com/ogen-go/ogen/http" "github.com/tonkeeper/tongo" "github.com/tonkeeper/tongo/tlb" ) +type Custom interface { + Request(ctx context.Context, method, url string, params map[string]string, data []byte) (json.RawMessage, error) +} + func (c *Client) GetSeqno(ctx context.Context, account tongo.AccountID) (uint32, error) { res, err := c.GetAccountSeqno(ctx, GetAccountSeqnoParams{AccountID: account.ToRaw()}) if err != nil { @@ -53,3 +64,80 @@ func (c *Client) GetAccountState(ctx context.Context, accountID tongo.AccountID) } return shardAccount, nil } + +// Request sends an HTTP request with the given method, URL, parameters, and data, +// and returns the response as a json.RawMessage. +func (c *Client) Request(ctx context.Context, method, endpoint string, query map[string]string, data []byte) (json.RawMessage, error) { + const contentType = "application/json" + + // Start measuring the request duration + startTime := time.Now() + defer func() { + // Use floating point division here for higher precision (instead of Millisecond method). + elapsedDuration := time.Since(startTime) + c.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond)) + }() + + // Parse the full URL by resolving the endpoint relative to the server URL + u := c.serverURL.ResolveReference(&url.URL{Path: endpoint}) + + // Add query parameters to the URL if any + if query != nil { + q := u.Query() + for key, value := range query { + q.Set(key, value) + } + u.RawQuery = q.Encode() + } + + // Create the request + req, err := ht.NewRequest(ctx, method, u) + if err != nil { + // Increment the error counter + c.errors.Add(ctx, 1) + return nil, err + } + if data != nil { + ht.SetBody(req, bytes.NewReader(data), contentType) + } + + // Set the content type header + req.Header.Set("Content-Type", contentType) + + // Send the request using the baseClient's HTTP client + resp, err := c.cfg.Client.Do(req) // Use the appropriate client or config + if err != nil { + // Increment the error counter + c.errors.Add(ctx, 1) + return nil, err + } + defer resp.Body.Close() + + c.requests.Add(ctx, 1) + + // Check if the response status code indicates an error + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + // Increment the error counter + c.errors.Add(ctx, 1) + return nil, errors.New(resp.Status) + } + + // Read the response body + body, err := io.ReadAll(resp.Body) + if err != nil { + // Increment the error counter + c.errors.Add(ctx, 1) + return nil, err + } + + // Unmarshal the response body into json.RawMessage + var jsonResponse json.RawMessage + err = json.Unmarshal(body, &jsonResponse) + if err != nil { + // Increment the error counter + c.errors.Add(ctx, 1) + return nil, err + } + + return jsonResponse, nil +} diff --git a/tonapi_test.go b/tonapi_test.go new file mode 100644 index 0000000..a1cd283 --- /dev/null +++ b/tonapi_test.go @@ -0,0 +1,77 @@ +package tonapi + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/go-faster/errors" + "github.com/stretchr/testify/require" + "github.com/tonkeeper/tongo/ton" +) + +var systemAccountID = ton.MustParseAccountID("Ef8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAU") + +func TestCustomRequest(t *testing.T) { + client, err := New() + if err != nil { + t.Fatalf("failed to init tonapi client: %v", err) + } + + tests := []struct { + name string + method string + path string + query map[string]string + err error + }{ + { + name: "fail to get account info - method not allowed", + method: http.MethodPost, + path: fmt.Sprintf("v2/accounts/%v", systemAccountID), + err: errors.New("405 Method Not Allowed"), + }, + { + name: "ok to get account info", + method: http.MethodGet, + path: fmt.Sprintf("v2/accounts/%v", systemAccountID), + err: nil, + }, + { + name: "fail with invalid account ID", + method: http.MethodGet, + path: "v2/accounts/invalidAccountID", + err: errors.New("400 Bad Request"), + }, + { + name: "fail with non-existent path", + method: http.MethodGet, + path: "v2/nonexistentpath", + err: errors.New("404 Not Found"), + }, + { + name: "ok to get collections", + method: http.MethodGet, + path: "v2/nfts/collections", + query: map[string]string{"limit": "10"}, + err: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := client.Request(context.Background(), tt.method, tt.path, tt.query, nil) + if tt.err != nil { + require.Error(t, err) + require.Equal(t, tt.err.Error(), err.Error()) + require.Nil(t, resp) + } else { + require.NoError(t, err) + require.NotNil(t, resp) + } + time.Sleep(time.Millisecond * 100) // rps limit + }) + } +}