Skip to content

Commit 4a2fc83

Browse files
committed
Moves utils into trueblocks-core and updates go.mods
1 parent 64fa789 commit 4a2fc83

9 files changed

+402
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package utils
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"os"
9+
"time"
10+
11+
"github.com/TrueBlocks/trueblocks-core/src/apps/chifra/pkg/file"
12+
)
13+
14+
// DownloadAndStoreJSON is a generic function that:
15+
// - Downloads from the given URL if the local file is stale.
16+
// - Stores it in the given file path.
17+
// - Unmarshals the JSON bytes into a type T and returns a *T.
18+
//
19+
// T must be a Go type compatible with the JSON structure (e.g. a struct or slice).
20+
func DownloadAndStoreJSON[T any](url, filename string, cacheTTL time.Duration) (*T, error) {
21+
// Use your existing caching logic from "downloadAndStore"
22+
bytes, err := downloadAndStore(url, filename, cacheTTL)
23+
if err != nil {
24+
var zero T
25+
return &zero, err
26+
}
27+
28+
var result T
29+
if err := json.Unmarshal(bytes, &result); err != nil {
30+
return &result, err
31+
}
32+
return &result, nil
33+
}
34+
35+
// downloadAndStore retrieves data from the specified URL and caches it in the provided
36+
// filename for up to `dur`. If the file already exists and is newer than `dur`, it returns
37+
// the file's contents without making a network request. Otherwise, it fetches from the URL.
38+
//
39+
// If the server returns 404, the function writes an empty file to disk and returns a zero-length
40+
// byte slice. For other non-200 status codes, it returns an error.
41+
//
42+
// If the response is valid JSON, it is pretty-formatted before being saved; otherwise it is
43+
// saved as-is. The function returns the written file content as a byte slice.
44+
func downloadAndStore(url, filename string, dur time.Duration) ([]byte, error) {
45+
if file.FileExists(filename) {
46+
lastModDate, err := file.GetModTime(filename)
47+
if err != nil {
48+
return nil, err
49+
}
50+
if time.Since(lastModDate) < dur {
51+
data, err := os.ReadFile(filename)
52+
if err != nil {
53+
return nil, err
54+
}
55+
return data, nil
56+
}
57+
}
58+
59+
resp, err := http.Get(url)
60+
if err != nil {
61+
return nil, err
62+
}
63+
defer resp.Body.Close()
64+
65+
if resp.StatusCode == http.StatusNotFound {
66+
// If the file doesn't exist remotely, store an empty file
67+
if err := os.WriteFile(filename, []byte{}, 0644); err != nil {
68+
return nil, err
69+
}
70+
// Optionally update its mod time
71+
_ = file.Touch(filename)
72+
return []byte{}, nil
73+
} else if resp.StatusCode != http.StatusOK {
74+
return nil, fmt.Errorf("received status %d %s for URL %s",
75+
resp.StatusCode, resp.Status, url)
76+
}
77+
78+
rawData, err := io.ReadAll(resp.Body)
79+
if err != nil {
80+
return nil, err
81+
}
82+
83+
var prettyData []byte
84+
if json.Valid(rawData) {
85+
var jsonData interface{}
86+
if err := json.Unmarshal(rawData, &jsonData); err != nil {
87+
return nil, err
88+
}
89+
prettyData, err = json.MarshalIndent(jsonData, "", " ")
90+
if err != nil {
91+
return nil, err
92+
}
93+
} else {
94+
prettyData = rawData
95+
}
96+
97+
if err := os.WriteFile(filename, prettyData, 0644); err != nil {
98+
return nil, err
99+
}
100+
101+
_ = file.Touch(filename)
102+
103+
return prettyData, nil
104+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package utils
2+
3+
import (
4+
"path/filepath"
5+
"time"
6+
7+
"github.com/TrueBlocks/trueblocks-core/src/apps/chifra/pkg/file"
8+
)
9+
10+
type ChainList struct {
11+
Chains []ChainListItem `json:"chains"`
12+
ChainsMap map[int]*ChainListItem
13+
}
14+
15+
type ChainListItem struct {
16+
Name string `json:"name"`
17+
Chain string `json:"chain"`
18+
Icon string `json:"icon"`
19+
Rpc []string `json:"rpc"`
20+
Faucets []string `json:"faucets"`
21+
NativeCurrency NativeCurrency `json:"nativeCurrency"`
22+
InfoURL string `json:"infoURL"`
23+
ShortName string `json:"shortName"`
24+
ChainID int `json:"chainId"`
25+
NetworkID int `json:"networkId"`
26+
Explorers []Explorer `json:"explorers"`
27+
}
28+
29+
type NativeCurrency struct {
30+
Name string `json:"name"`
31+
Symbol string `json:"symbol"`
32+
Decimals int `json:"decimals"`
33+
}
34+
35+
type Explorer struct {
36+
Name string `json:"name"`
37+
URL string `json:"url"`
38+
Standard string `json:"standard"`
39+
}
40+
41+
func UpdateChainList(configPath string) (*ChainList, error) {
42+
_ = file.EstablishFolder(configPath)
43+
44+
chainURL := "https://chainid.network/chains.json"
45+
chainsFile := filepath.Join(configPath, "chains.json")
46+
47+
chainData, err := DownloadAndStoreJSON[[]ChainListItem](chainURL, chainsFile, 24*time.Hour)
48+
if err != nil {
49+
return nil, err
50+
}
51+
52+
var chainList ChainList
53+
chainList.Chains = *chainData
54+
chainList.ChainsMap = make(map[int]*ChainListItem)
55+
56+
for _, chain := range chainList.Chains {
57+
chainCopy := chain
58+
chainList.ChainsMap[chain.ChainID] = &chainCopy
59+
}
60+
61+
return &chainList, nil
62+
}
63+
64+
func GetChainListItem(configPath string, chainId int) *ChainListItem {
65+
if chainList, err := UpdateChainList(ResolvePath(configPath)); err != nil {
66+
return nil
67+
} else {
68+
if ch, ok := chainList.ChainsMap[chainId]; !ok {
69+
return nil
70+
} else {
71+
return ch
72+
}
73+
}
74+
}
+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package utils
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"time"
7+
)
8+
9+
// PingServer sends a GET request to the provided URL and returns true if
10+
// the server responds with a 200 status code.
11+
func PingServer(serverUrl string) bool {
12+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
13+
defer cancel()
14+
15+
req, err := http.NewRequestWithContext(ctx, "GET", serverUrl, nil)
16+
if err != nil {
17+
return false
18+
}
19+
20+
clientHTTP := &http.Client{}
21+
resp, err := clientHTTP.Do(req)
22+
if err != nil {
23+
return false
24+
}
25+
defer resp.Body.Close()
26+
27+
return resp.StatusCode == http.StatusOK
28+
}
+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package utils
2+
3+
import "strings"
4+
5+
// RemoveAny returns a new string with all characters from string A that are present in
6+
// string B removed. The function uses a map for efficient lookups and preserves the
7+
// order of characters in A.
8+
func RemoveAny(A, B string) string {
9+
result := strings.Builder{}
10+
toRemove := make(map[rune]struct{})
11+
for _, char := range B {
12+
toRemove[char] = struct{}{}
13+
}
14+
for _, char := range A {
15+
if _, exists := toRemove[char]; !exists {
16+
result.WriteRune(char)
17+
}
18+
}
19+
return result.String()
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package utils
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
// Testing status: reviewed
10+
11+
func TestRemoveAny(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
A string
15+
B string
16+
expected string
17+
}{
18+
{"Remove characters", "hello world", "lo", "he wrd"},
19+
{"Empty B", "hello", "", "hello"},
20+
{"Empty A", "", "xyz", ""},
21+
{"Remove all", "abc", "abc", ""},
22+
{"No matching characters", "hello", "xyz", "hello"},
23+
{"Unicode characters", "你好世界", "界", "你好世"},
24+
{"Duplicate characters in B", "banana", "na", "b"},
25+
{"Case sensitivity", "Hello", "h", "Hello"},
26+
}
27+
28+
for _, tt := range tests {
29+
t.Run(tt.name, func(t *testing.T) {
30+
result := RemoveAny(tt.A, tt.B)
31+
assert.Equal(t, tt.expected, result)
32+
})
33+
}
34+
}
+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package utils
2+
3+
import (
4+
"fmt"
5+
"log"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
)
10+
11+
// ResolveValidPath returns an absolute path expanded for ~, $HOME or other env variables
12+
func ResolveValidPath(path string) (string, error) {
13+
resolved := ResolvePath(path)
14+
if resolved != ToValidPath(path) {
15+
return resolved, fmt.Errorf("invalid folder: %s", path)
16+
}
17+
return resolved, nil
18+
}
19+
20+
// ResolvePath returns an absolute path expanded for ~, $HOME or other env variables
21+
func ResolvePath(path string) string {
22+
if path == "" {
23+
return ""
24+
}
25+
26+
if strings.HasPrefix(path, "~") {
27+
if path == "~" || strings.HasPrefix(path, "~/") {
28+
home, err := os.UserHomeDir()
29+
if err != nil {
30+
log.Fatalf("failed to resolve home directory: %v", err)
31+
}
32+
path = filepath.Join(home, strings.TrimPrefix(path, "~"))
33+
} else {
34+
log.Fatalf("unsupported path format: %s", path)
35+
}
36+
}
37+
38+
for _, part := range strings.Split(path, "/") {
39+
if strings.HasPrefix(part, "$") {
40+
envVar := strings.TrimPrefix(part, "$")
41+
if os.Getenv(envVar) == "" {
42+
log.Fatalf("path contains unset environment variable: %s", part)
43+
}
44+
}
45+
}
46+
47+
path = os.ExpandEnv(path)
48+
49+
absolutePath, err := filepath.Abs(path)
50+
if err != nil {
51+
log.Fatalf("failed to resolve absolute path: %v", err)
52+
}
53+
54+
return absolutePath
55+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package utils
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
// Testing status: reviewed
13+
14+
func TestResolvePath_Fatals(t *testing.T) {
15+
if os.Getenv("TEST_FATAL") == "1" {
16+
input := os.Getenv("TEST_INPUT")
17+
_ = ResolvePath(input)
18+
return
19+
}
20+
21+
tests := []struct {
22+
input string
23+
}{
24+
{"~username"},
25+
{"$UNSET_ENV_VAR/test"},
26+
}
27+
28+
for _, tt := range tests {
29+
t.Run(tt.input, func(t *testing.T) {
30+
cmd := exec.Command(os.Args[0], "-test.run=TestResolvePath_Fatals")
31+
cmd.Env = append(os.Environ(), "TEST_FATAL=1", "TEST_INPUT="+tt.input, "UNSET_ENV_VAR=")
32+
err := cmd.Run()
33+
if err == nil || err.Error() != "exit status 1" {
34+
t.Fatalf("expected Fatal to exit with status 1, got %v for input %s", err, tt.input)
35+
}
36+
})
37+
}
38+
}
39+
40+
func TestResolvePath_NonFatals(t *testing.T) {
41+
homeDir, _ := os.UserHomeDir()
42+
currentDir, _ := os.Getwd()
43+
44+
tests := []struct {
45+
input string
46+
expected string
47+
}{
48+
{"~", homeDir},
49+
{"~/test", filepath.Join(homeDir, "test")},
50+
{"$HOME/test", filepath.Join(homeDir, "test")},
51+
{"./test", filepath.Join(currentDir, "test")},
52+
{"/usr/local/test", "/usr/local/test"},
53+
{"", ""},
54+
}
55+
56+
for _, tt := range tests {
57+
t.Run(tt.input, func(t *testing.T) {
58+
result := ResolvePath(tt.input)
59+
assert.Equal(t, tt.expected, result)
60+
})
61+
}
62+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package utils
2+
3+
import "regexp"
4+
5+
func StripColors(input string) string {
6+
re := regexp.MustCompile(`\033\[[0-9;]*[mK]`)
7+
return re.ReplaceAllString(input, "")
8+
}

0 commit comments

Comments
 (0)