From 70faf8366affbce86bb8eca3ac2cc3ec83c3520d Mon Sep 17 00:00:00 2001 From: Dan Anglin Date: Thu, 19 Sep 2024 10:29:13 +0100 Subject: [PATCH] checkpoint: pokedex cli progress - Added in-memory cache - Created new package for defining the types of the pokeapi API - Created an internal package for the client --- .golangci.yaml | 8 +++ go.mod | 2 +- internal/api/pokeapi/types.go | 59 ++++++++++++++++++ internal/pokecache/pokecache.go | 85 +++++++++++++++++++++++++ internal/pokecache/pokecache_test.go | 81 ++++++++++++++++++++++++ internal/pokeclient/pokeclient.go | 93 ++++++++++++++++++++++++++++ types.go | 59 ------------------ 7 files changed, 327 insertions(+), 60 deletions(-) create mode 100644 internal/api/pokeapi/types.go create mode 100644 internal/pokecache/pokecache.go create mode 100644 internal/pokecache/pokecache_test.go create mode 100644 internal/pokeclient/pokeclient.go delete mode 100644 types.go diff --git a/.golangci.yaml b/.golangci.yaml index 059f5bc..23cb346 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -13,6 +13,14 @@ output: sort-results: true linters-settings: + depguard: + rules: + main: + files: + - $all + allow: + - $gostd + - codeflow.dananglin.me.uk/apollo/pokedex lll: line-length: 140 diff --git a/go.mod b/go.mod index 7039358..e0329de 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module codeflow.dananglin.me.uk/apollo/pokedex -go 1.23.0 +go 1.23.1 diff --git a/internal/api/pokeapi/types.go b/internal/api/pokeapi/types.go new file mode 100644 index 0000000..85a23c0 --- /dev/null +++ b/internal/api/pokeapi/types.go @@ -0,0 +1,59 @@ +package pokeapi + +// LocationArea is a section of areas such as floors in a building or a cave. +//type LocationArea struct { +// ID int `json:"id"` +// Name string `json:"name"` +// GameIndex int `json:"game_index"` +// EncounterMethodRates []EncounterMethodRate `json:"encounter_method_rates"` +// Location NamedAPIResource `json:"location"` +// Names []Name `json:"names"` +// PokemonEncounters []PokemonEncounter `json:"pokemon_encounters"` +//} +// +//type EncounterMethodRate struct { +// EncounterMethod NamedAPIResource `json:"encounter_method"` +// VersionDetails EncounterVersionDetails `json:"version_details"` +//} +// +//type EncounterVersionDetails struct { +// Rate int `json:"rate"` +// Version NamedAPIResource `json:"version"` +//} +// +//type Name struct { +// Name string `json:"name"` +// Language NamedAPIResource `json:"language"` +//} +// +//// PokemonEncounter is details of a possible Pokemon encounter. +//type PokemonEncounter struct { +// Pokemon NamedAPIResource `json:"pokemon"` +// VersionDetails []VersionEncounterDetails `json:"version_details"` +//} +// +//type VersionEncounterDetails struct { +// Version NamedAPIResource `json:"version"` +// MaxChance int `json:"max_chance"` +// EncounterDetails []Encounter `json:"encounter_details"` +//} +// +//type Encounter struct { +// MinLevel int `json:"min_level"` +// MaxLevel int `json:"max_level"` +// ConditionValues []NamedAPIResource `json:"condition_values"` +// Chance int `json:"chance"` +// Method NamedAPIResource `json:"method"` +//} + +type NamedAPIResourceList struct { + Count int `json:"count"` + Next *string `json:"next"` + Previous *string `json:"previous"` + Results []NamedAPIResource `json:"results"` +} + +type NamedAPIResource struct { + Name string `json:"name"` + URL string `json:"url"` +} diff --git a/internal/pokecache/pokecache.go b/internal/pokecache/pokecache.go new file mode 100644 index 0000000..2428c5a --- /dev/null +++ b/internal/pokecache/pokecache.go @@ -0,0 +1,85 @@ +package pokecache + +import ( + "maps" + "sync" + "time" +) + +type Cache struct { + stopChan chan struct{} + mu *sync.Mutex + entries map[string]cacheEntry + interval time.Duration +} + +type cacheEntry struct { + createdAt time.Time + val []byte +} + +func NewCache(interval time.Duration) *Cache { + cache := Cache{ + stopChan: make(chan struct{}), + mu: &sync.Mutex{}, + entries: make(map[string]cacheEntry), + interval: interval, + } + + go cache.readLoop() + + return &cache +} + +func (c *Cache) Add(key string, val []byte) { + c.mu.Lock() + defer c.mu.Unlock() + + entry := cacheEntry{ + createdAt: time.Now(), + val: val, + } + + c.entries[key] = entry +} + +func (c *Cache) Get(key string) ([]byte, bool) { + c.mu.Lock() + defer c.mu.Unlock() + + value, exist := c.entries[key] + + return value.val, exist +} + +// func (c *Cache) Stop() { +// c.stopChan <- struct{}{} +// } + +func (c *Cache) readLoop() { + ticker := time.NewTicker(c.interval) + defer ticker.Stop() + +readloop: + for { + select { + case <-c.stopChan: + break readloop + case <-ticker.C: + c.cleanupEntries() + } + } +} + +func (c *Cache) cleanupEntries() { + c.mu.Lock() + defer c.mu.Unlock() + + expiredTime := time.Now().Add(-c.interval) + + for key := range maps.All(c.entries) { + if c.entries[key].createdAt.Before(expiredTime) { + delete(c.entries, key) + } + } +} diff --git a/internal/pokecache/pokecache_test.go b/internal/pokecache/pokecache_test.go new file mode 100644 index 0000000..43a15e2 --- /dev/null +++ b/internal/pokecache/pokecache_test.go @@ -0,0 +1,81 @@ +package pokecache_test + +import ( + "fmt" + "slices" + "testing" + "time" + + "codeflow.dananglin.me.uk/apollo/pokedex/internal/pokecache" +) + +const ( + keyNotFoundFormat = "The key %q was not found after adding it to the cache" + keyFoundAfterCleanupFormat = "The key %q was found after cache cleanup" +) + +func TestCacheAddGet(t *testing.T) { + cases := []struct { + key string + value []byte + }{ + { + key: "https://example.org/path", + value: []byte("testdata"), + }, + { + key: "https://example.org/api/v1/path", + value: []byte(`{"version": "v1.0.0", "key": "value"}`), + }, + } + + interval := 1 * time.Minute + + cache := pokecache.NewCache(interval) + + testFunc := func(key string, value []byte) func(*testing.T) { + return func(t *testing.T) { + cache.Add(key, value) + gotBytes, exists := cache.Get(key) + + if !exists { + t.Fatalf(keyNotFoundFormat, key) + } + + want := string(value) + got := string(gotBytes) + + if got != want { + t.Errorf("Unexpected value retrieved from the cache: want %s, got %s", want, got) + } + } + } + + for ind, testcase := range slices.All(cases) { + t.Run(fmt.Sprintf("Test case: %d", ind+1), testFunc(testcase.key, testcase.value)) + } +} + +func TestReadLoop(t *testing.T) { + const ( + baseTime = 5 * time.Millisecond + waitTime = 10 * baseTime + ) + + key := "https://example.org/api/v1/path" + value := []byte(`{"version": "v1.0.0", "key": "value"}`) + + cache := pokecache.NewCache(baseTime) + + cache.Add(key, value) + + if _, exists := cache.Get(key); !exists { + t.Fatalf(keyNotFoundFormat, key) + } + + time.Sleep(waitTime) + + if _, exists := cache.Get(key); exists { + t.Errorf(keyFoundAfterCleanupFormat, key) + } +} diff --git a/internal/pokeclient/pokeclient.go b/internal/pokeclient/pokeclient.go new file mode 100644 index 0000000..83d1d9d --- /dev/null +++ b/internal/pokeclient/pokeclient.go @@ -0,0 +1,93 @@ +package pokeclient + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "codeflow.dananglin.me.uk/apollo/pokedex/internal/api/pokeapi" + "codeflow.dananglin.me.uk/apollo/pokedex/internal/pokecache" +) + +type Client struct { + httpClient http.Client + cache *pokecache.Cache + timeout time.Duration +} + +func NewClient(cacheCleanupInterval, timeout time.Duration) *Client { + cache := pokecache.NewCache(cacheCleanupInterval) + + client := Client{ + httpClient: http.Client{}, + cache: cache, + timeout: timeout, + } + + return &client +} + +func (c *Client) GetNamedAPIResourceList(url string) (pokeapi.NamedAPIResourceList, error) { + var list pokeapi.NamedAPIResourceList + + dataFromCache, exists := c.cache.Get(url) + if exists { + fmt.Println("Using data from cache.") + + if err := decodeJSON(dataFromCache, &list); err != nil { + return pokeapi.NamedAPIResourceList{}, fmt.Errorf("unable to decode the data from cache: %w", err) + } + + return list, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), c.timeout) + defer cancel() + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return pokeapi.NamedAPIResourceList{}, fmt.Errorf("error creating the HTTP request: %w", err) + } + + resp, err := c.httpClient.Do(request) + if err != nil { + return pokeapi.NamedAPIResourceList{}, fmt.Errorf("error getting the response from the server: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return pokeapi.NamedAPIResourceList{}, fmt.Errorf( + "received a bad status from %s: (%d) %s", + url, + resp.StatusCode, + resp.Status, + ) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return pokeapi.NamedAPIResourceList{}, fmt.Errorf( + "unable to read the response from the server: %w", + err, + ) + } + + if err := decodeJSON(body, &list); err != nil { + return pokeapi.NamedAPIResourceList{}, fmt.Errorf("unable to decode the data from the server: %w", err) + } + + c.cache.Add(url, body) + + return list, nil +} + +func decodeJSON(data []byte, value any) error { + if err := json.Unmarshal(data, value); err != nil { + return fmt.Errorf("unable to decode the JSON data: %w", err) + } + + return nil +} diff --git a/types.go b/types.go deleted file mode 100644 index 3f8301e..0000000 --- a/types.go +++ /dev/null @@ -1,59 +0,0 @@ -package main - -// LocationArea is a section of areas such as floors in a building or a cave. -type LocationArea struct { - ID int `json:"id"` - Name string `json:"name"` - GameIndex int `json:"game_index"` - EncounterMethodRates []EncounterMethodRate `json:"encounter_method_rates"` - Location NamedAPIResource `json:"location"` - Names []Name `json:"names"` - PokemonEncounters []PokemonEncounter `json:"pokemon_encounters"` -} - -type EncounterMethodRate struct { - EncounterMethod NamedAPIResource `json:"encounter_method"` - VersionDetails EncounterVersionDetails `json:"version_details"` -} - -type EncounterVersionDetails struct { - Rate int `json:"rate"` - Version NamedAPIResource `json:"version"` -} - -type Name struct { - Name string `json:"name"` - Language NamedAPIResource `json:"language"` -} - -// PokemonEncounter is details of a possible Pokemon encounter. -type PokemonEncounter struct { - Pokemon NamedAPIResource `json:"pokemon"` - VersionDetails []VersionEncounterDetails `json:"version_details"` -} - -type VersionEncounterDetails struct { - Version NamedAPIResource `json:"version"` - MaxChance int `json:"max_chance"` - EncounterDetails []Encounter `json:"encounter_details"` -} - -type Encounter struct { - MinLevel int `json:"min_level"` - MaxLevel int `json:"max_level"` - ConditionValues []NamedAPIResource `json:"condition_values"` - Chance int `json:"chance"` - Method NamedAPIResource `json:"method"` -} - -type NamedAPIResourceList struct { - Count int `json:"count"` - Next *string `json:"next"` - Previous *string `json:"previous"` - Results []NamedAPIResource `json:"results"` -} - -type NamedAPIResource struct { - Name string `json:"name"` - URL string `json:"url"` -}