From 1eab9909e867293b4f24fdc4121abfd9a44eb8c1 Mon Sep 17 00:00:00 2001 From: ruinivist Date: Sun, 8 Feb 2026 21:26:39 +0000 Subject: [PATCH] feat: add cache layer over api --- internal/geodata/cache.go | 62 ++++++++++++++++++++++++++++++++ internal/geodata/fetcher.go | 22 +++++++++++- internal/geodata/fetcher_test.go | 43 ++++++++++++++++++++++ internal/geodata/models.go | 7 ++++ 4 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 internal/geodata/cache.go create mode 100644 internal/geodata/fetcher_test.go diff --git a/internal/geodata/cache.go b/internal/geodata/cache.go new file mode 100644 index 0000000..9342b7c --- /dev/null +++ b/internal/geodata/cache.go @@ -0,0 +1,62 @@ +package geodata + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +const cacheDuration = 30 * 24 * time.Hour + +func getCachePath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".cache", "git-chronos", "geodata.json") +} + +func loadFromCache() (TimezoneMap, error) { + path := getCachePath() + + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + var cacheContainer cacheCountryData + if err := json.NewDecoder(file).Decode(&cacheContainer); err != nil { + return nil, err + } + + if time.Since(cacheContainer.FetchedAt) > cacheDuration { + return nil, fmt.Errorf("cache expired") + } + + return cacheContainer.Data, nil +} + +func saveToCache(tzMap TimezoneMap) error { + path := getCachePath() + dir := filepath.Dir(path) + + if err := os.MkdirAll(dir, 755); err != nil { // creates if not exists + return fmt.Errorf("failed to create cache dir") + } + + file, err := os.Create(path) // create if not exists + if err != nil { + return err + } + + defer file.Close() + + container := cacheCountryData{ + FetchedAt: time.Now(), + Data: tzMap, + } + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") // so it looks better + return encoder.Encode(container) +} diff --git a/internal/geodata/fetcher.go b/internal/geodata/fetcher.go index 5a426ee..7df66c5 100644 --- a/internal/geodata/fetcher.go +++ b/internal/geodata/fetcher.go @@ -8,7 +8,7 @@ import ( "time" ) -func FetchLive() (TimezoneMap, error) { +func fetchLive() (TimezoneMap, error) { client := http.Client{Timeout: 10 * time.Second} // from: https://restcountries.com @@ -32,6 +32,26 @@ func FetchLive() (TimezoneMap, error) { return buildTzMap(apidData), nil } +func Fetch() (TimezoneMap, error) { + // try to get from cache + tzMap, err := loadFromCache() + if err == nil { + return tzMap, nil + } + + // fetch live and save + tzMap, err = fetchLive() + if err != nil { + return nil, err + } + + if err = saveToCache(tzMap); err != nil { + return nil, err + } + + return tzMap, nil +} + func buildTzMap(data []countryApiReponse) TimezoneMap { tzMap := make(TimezoneMap) diff --git a/internal/geodata/fetcher_test.go b/internal/geodata/fetcher_test.go new file mode 100644 index 0000000..061483c --- /dev/null +++ b/internal/geodata/fetcher_test.go @@ -0,0 +1,43 @@ +package geodata + +import ( + "os" + "testing" +) + +func TestFetchLive(t *testing.T) { + tzMap, err := fetchLive() + + if err != nil { + t.Fatal(err) + } + + testTzMap(tzMap, t) +} + +func TestCache(t *testing.T) { + // delete cache + cachePath := getCachePath() + if err := os.Remove(cachePath); err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + + _, err := Fetch() // this populates cache + if err != nil { + t.Fatal(err) + } + + tzMap, err := loadFromCache() + if err != nil { + t.Fatal(err) + } + + testTzMap(tzMap, t) +} + +func testTzMap(tzMap TimezoneMap, t *testing.T) { + entry0100 := tzMap["+0100"] + if len(entry0100) == 0 { + t.Fatalf("no countries found to timezone") + } +} diff --git a/internal/geodata/models.go b/internal/geodata/models.go index 867b566..98873cb 100644 --- a/internal/geodata/models.go +++ b/internal/geodata/models.go @@ -1,5 +1,7 @@ package geodata +import "time" + // Country info exposed to other packages type Country struct { Name string @@ -17,3 +19,8 @@ type countryApiReponse struct { Population int64 `json:"population"` Timezones []string `json:"timezones"` // example: [ "UTC+00:00" ] } + +type cacheCountryData struct { + FetchedAt time.Time `json:"fetched_at"` + Data TimezoneMap `json:"data"` +}