Skip to content

Commit 08a0cbe

Browse files
committed
Adds atlas client
1 parent c60ba3c commit 08a0cbe

File tree

2 files changed

+314
-0
lines changed

2 files changed

+314
-0
lines changed

lib/client/atlas.go

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// atlas_client.go
2+
package client
3+
4+
import (
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"os"
9+
"strings"
10+
"time"
11+
12+
"github.com/go-resty/resty/v2"
13+
"github.com/smartcontractkit/chainlink-testing-framework/lib/logging"
14+
)
15+
16+
// AtlasClient defines the structure of the Atlas service client.
17+
type AtlasClient struct {
18+
BaseURL string
19+
RestClient *resty.Client
20+
Logger logging.Logger
21+
}
22+
23+
// TransactionHash defines a single transaction hash.
24+
type TransactionHash struct {
25+
MessageID string `json:"messageId"`
26+
}
27+
28+
// TransactionResponse defines the response format from Atlas for transaction details.
29+
type TransactionResponse struct {
30+
TransactionHash []TransactionHash `json:"transactionHash"`
31+
}
32+
33+
// NewAtlasClient creates a new Atlas client instance.
34+
func NewAtlasClient(baseURL string) *AtlasClient {
35+
logging.Init()
36+
logger := logging.GetLogger(nil, "ATLAS_CLIENT_LOG_LEVEL")
37+
logger.Info().
38+
Str("BaseURL", baseURL).
39+
Msg("Initializing Atlas Client")
40+
41+
isDebug := os.Getenv("RESTY_DEBUG") == "true"
42+
restyClient := resty.New().SetDebug(isDebug)
43+
44+
return &AtlasClient{
45+
BaseURL: baseURL,
46+
RestClient: restyClient,
47+
Logger: logger,
48+
}
49+
}
50+
51+
// GetTransactionDetails retrieves transaction details using the provided msgIdOrTxnHash.
52+
func (ac *AtlasClient) GetTransactionDetails(msgIdOrTxnHash string) (*TransactionResponse, error) {
53+
endpoint := fmt.Sprintf("%s/atlas/search?msgIdOrTxnHash=%s", ac.BaseURL, msgIdOrTxnHash)
54+
ac.Logger.Info().
55+
Str("msgIdOrTxnHash", msgIdOrTxnHash).
56+
Msg("Sending request to fetch transaction details")
57+
58+
resp, err := ac.RestClient.R().
59+
SetHeader("Content-Type", "application/json").
60+
Get(endpoint)
61+
if err != nil {
62+
ac.Logger.Error().Err(err).Msg("Failed to send request")
63+
return nil, err
64+
}
65+
66+
if resp.StatusCode() != 200 {
67+
ac.Logger.Error().Int("statusCode", resp.StatusCode()).Msg("Received non-OK status")
68+
return nil, errors.New("failed to retrieve transaction details")
69+
}
70+
71+
var transactionResponse TransactionResponse
72+
ac.Logger.Debug().Bytes("Response", resp.Body())
73+
if err := json.Unmarshal(resp.Body(), &transactionResponse); err != nil {
74+
ac.Logger.Error().Err(err).Msg("Failed to unmarshal response")
75+
return nil, err
76+
}
77+
78+
ac.Logger.Info().
79+
Str("transactionHash", msgIdOrTxnHash).
80+
Msg("Successfully retrieved transaction details")
81+
return &transactionResponse, nil
82+
}
83+
84+
// CustomTime wraps time.Time to support custom unmarshaling.
85+
type CustomTime struct {
86+
time.Time
87+
}
88+
89+
// UnmarshalJSON parses a timestamp string into a CustomTime.
90+
func (ct *CustomTime) UnmarshalJSON(b []byte) error {
91+
s := strings.Trim(string(b), `"`)
92+
if s == "null" || s == "" {
93+
ct.Time = time.Time{}
94+
return nil
95+
}
96+
t, err := time.Parse("2006-01-02T15:04:05", s)
97+
if err != nil {
98+
return err
99+
}
100+
ct.Time = t
101+
return nil
102+
}
103+
104+
// TransactionDetails represents detailed transaction data returned by Atlas.
105+
type TransactionDetails struct {
106+
MessageId *string `json:"messageId"`
107+
State *int `json:"state"`
108+
Votes *int `json:"votes"`
109+
SourceNetworkName *string `json:"sourceNetworkName"`
110+
DestNetworkName *string `json:"destNetworkName"`
111+
CommitBlockTimestamp *CustomTime `json:"commitBlockTimestamp"`
112+
Root *string `json:"root"`
113+
SendFinalized *CustomTime `json:"sendFinalized"`
114+
CommitStore *string `json:"commitStore"`
115+
Origin *string `json:"origin"`
116+
SequenceNumber *int `json:"sequenceNumber"`
117+
Sender *string `json:"sender"`
118+
Receiver *string `json:"receiver"`
119+
SourceChainId *string `json:"sourceChainId"`
120+
DestChainId *string `json:"destChainId"`
121+
RouterAddress *string `json:"routerAddress"`
122+
OnrampAddress *string `json:"onrampAddress"`
123+
OfframpAddress *string `json:"offrampAddress"`
124+
DestRouterAddress *string `json:"destRouterAddress"`
125+
SendTransactionHash *string `json:"sendTransactionHash"`
126+
SendTimestamp *CustomTime `json:"sendTimestamp"`
127+
SendBlock *int `json:"sendBlock"`
128+
SendLogIndex *int `json:"sendLogIndex"`
129+
Min *string `json:"min"`
130+
Max *string `json:"max"`
131+
CommitTransactionHash *string `json:"commitTransactionHash"`
132+
CommitBlockNumber *int `json:"commitBlockNumber"`
133+
CommitLogIndex *int `json:"commitLogIndex"`
134+
Arm *string `json:"arm"`
135+
BlessTransactionHash *string `json:"blessTransactionHash"`
136+
BlessBlockNumber *int `json:"blessBlockNumber"`
137+
BlessBlockTimestamp *CustomTime `json:"blessBlockTimestamp"`
138+
BlessLogIndex *int `json:"blessLogIndex"`
139+
ReceiptTransactionHash *string `json:"receiptTransactionHash"`
140+
ReceiptTimestamp *CustomTime `json:"receiptTimestamp"`
141+
ReceiptBlock *int `json:"receiptBlock"`
142+
ReceiptLogIndex *int `json:"receiptLogIndex"`
143+
ReceiptFinalized *CustomTime `json:"receiptFinalized"`
144+
Data *string `json:"data"`
145+
Strict *bool `json:"strict"`
146+
Nonce *int `json:"nonce"`
147+
FeeToken *string `json:"feeToken"`
148+
GasLimit *string `json:"gasLimit"`
149+
FeeTokenAmount *string `json:"feeTokenAmount"`
150+
TokenAmounts *[]string `json:"tokenAmounts"`
151+
}
152+
153+
// GetMessageDetails fetches detailed transaction info using the message endpoint.
154+
func (ac *AtlasClient) GetMessageDetails(messageID string) (*TransactionDetails, error) {
155+
endpoint := fmt.Sprintf("%s/atlas/message/%s", ac.BaseURL, messageID)
156+
ac.Logger.Info().
157+
Str("messageID", messageID).
158+
Msg("Sending request to fetch message details")
159+
160+
resp, err := ac.RestClient.R().
161+
SetHeader("Content-Type", "application/json").
162+
Get(endpoint)
163+
if err != nil {
164+
ac.Logger.Error().Err(err).Msg("Failed to send request for message details")
165+
return nil, err
166+
}
167+
168+
if resp.StatusCode() != 200 {
169+
ac.Logger.Error().Int("statusCode", resp.StatusCode()).Msg("Received non-OK status for message details")
170+
return nil, fmt.Errorf("failed to retrieve message details, status code: %d", resp.StatusCode())
171+
}
172+
173+
body := resp.Body()
174+
if string(body) == "Message not found" {
175+
ac.Logger.Warn().
176+
Str("messageID", messageID).
177+
Msg("Message not found in Atlas")
178+
return nil, errors.New("message not found")
179+
}
180+
181+
var details TransactionDetails
182+
ac.Logger.Debug().Bytes("Response", body)
183+
if err := json.Unmarshal(body, &details); err != nil {
184+
ac.Logger.Error().Err(err).Msg("Failed to unmarshal message details")
185+
return nil, err
186+
}
187+
188+
return &details, nil
189+
}

lib/client/atlas_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// atlas_test.go
2+
package client
3+
4+
import (
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"os"
9+
"testing"
10+
11+
"github.com/smartcontractkit/chainlink-testing-framework/lib/utils/ptr"
12+
"github.com/stretchr/testify/assert"
13+
)
14+
15+
// TestAtlasClient_GetTransactionDetails_Success verifies that GetTransactionDetails successfully parses a valid response.
16+
func TestAtlasClient_GetTransactionDetails_Success(t *testing.T) {
17+
// Create a mock Atlas server
18+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19+
// Check that the endpoint contains "/atlas/search"
20+
assert.Contains(t, r.URL.String(), "/atlas/search")
21+
w.WriteHeader(http.StatusOK)
22+
// Return a valid JSON response
23+
response := `{"transactionHash": [{"messageId": "abc123"}]}`
24+
_, err := w.Write([]byte(response))
25+
assert.NoError(t, err)
26+
}))
27+
defer mockServer.Close()
28+
29+
// Create the Atlas client using the mock server URL
30+
client := NewAtlasClient(mockServer.URL)
31+
32+
// Call GetTransactionDetails
33+
resp, err := client.GetTransactionDetails("abc123")
34+
assert.NoError(t, err)
35+
assert.NotNil(t, resp)
36+
assert.Len(t, resp.TransactionHash, 1)
37+
assert.Equal(t, "abc123", resp.TransactionHash[0].MessageID)
38+
}
39+
40+
// TestAtlasClient_GetTransactionDetails_NonOK verifies that a non-200 response results in an error.
41+
func TestAtlasClient_GetTransactionDetails_NonOK(t *testing.T) {
42+
// Create a mock Atlas server that returns a non-OK status code
43+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
44+
assert.Contains(t, r.URL.String(), "/atlas/search")
45+
w.WriteHeader(http.StatusBadRequest)
46+
}))
47+
defer mockServer.Close()
48+
49+
client := NewAtlasClient(mockServer.URL)
50+
resp, err := client.GetTransactionDetails("abc123")
51+
assert.Nil(t, resp)
52+
assert.Error(t, err)
53+
}
54+
55+
// TestAtlasClient_GetMessageDetails_Success verifies that GetMessageDetails correctly unmarshals a valid response.
56+
func TestAtlasClient_GetMessageDetails_Success(t *testing.T) {
57+
// Prepare a minimal valid response for TransactionDetails.
58+
detailsResponse := TransactionDetails{
59+
MessageId: ptr.Ptr("msg123"),
60+
State: ptr.Ptr(1),
61+
Votes: ptr.Ptr(5),
62+
SourceNetworkName: ptr.Ptr("source"),
63+
DestNetworkName: ptr.Ptr("dest"),
64+
}
65+
responseBytes, err := json.Marshal(detailsResponse)
66+
assert.NoError(t, err)
67+
68+
// Create a mock server returning the above JSON.
69+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
70+
assert.Equal(t, "/atlas/message/msg123", r.URL.Path)
71+
w.WriteHeader(http.StatusOK)
72+
_, err := w.Write(responseBytes)
73+
assert.NoError(t, err)
74+
}))
75+
defer mockServer.Close()
76+
77+
client := NewAtlasClient(mockServer.URL)
78+
details, err := client.GetMessageDetails("msg123")
79+
assert.NoError(t, err)
80+
assert.NotNil(t, details)
81+
assert.Equal(t, "msg123", *details.MessageId)
82+
assert.Equal(t, "source", *details.SourceNetworkName)
83+
assert.Equal(t, "dest", *details.DestNetworkName)
84+
}
85+
86+
// TestAtlasClient_GetMessageDetails_MessageNotFound checks that a "Message not found" response is handled correctly.
87+
func TestAtlasClient_GetMessageDetails_MessageNotFound(t *testing.T) {
88+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
89+
assert.Equal(t, "/atlas/message/msg123", r.URL.Path)
90+
w.WriteHeader(http.StatusOK)
91+
_, err := w.Write([]byte("Message not found"))
92+
assert.NoError(t, err)
93+
}))
94+
defer mockServer.Close()
95+
96+
client := NewAtlasClient(mockServer.URL)
97+
details, err := client.GetMessageDetails("msg123")
98+
assert.Nil(t, details)
99+
assert.Error(t, err)
100+
assert.Equal(t, "message not found", err.Error())
101+
}
102+
103+
// TestAtlasClient_GetMessageDetails_NonOK verifies that a non-200 status code leads to an error.
104+
func TestAtlasClient_GetMessageDetails_NonOK(t *testing.T) {
105+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
106+
assert.Equal(t, "/atlas/message/msg123", r.URL.Path)
107+
w.WriteHeader(http.StatusNotFound)
108+
}))
109+
defer mockServer.Close()
110+
111+
client := NewAtlasClient(mockServer.URL)
112+
details, err := client.GetMessageDetails("msg123")
113+
assert.Nil(t, details)
114+
assert.Error(t, err)
115+
assert.Contains(t, err.Error(), "failed to retrieve message details")
116+
}
117+
118+
// TestAtlasClient_DebugMode verifies that the RESTY_DEBUG environment variable enables debug mode in the Resty client.
119+
func TestAtlasClient_DebugMode(t *testing.T) {
120+
os.Setenv("RESTY_DEBUG", "true")
121+
defer os.Unsetenv("RESTY_DEBUG")
122+
123+
client := NewAtlasClient("http://example.com")
124+
assert.True(t, client.RestClient.Debug)
125+
}

0 commit comments

Comments
 (0)