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
This commit is contained in:
Dan Anglin 2024-09-19 10:29:13 +01:00
parent 4152a9d14f
commit 70faf8366a
Signed by: dananglin
GPG key ID: 0C1D44CFBEE68638
7 changed files with 327 additions and 60 deletions

View file

@ -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

2
go.mod
View file

@ -1,3 +1,3 @@
module codeflow.dananglin.me.uk/apollo/pokedex
go 1.23.0
go 1.23.1

View file

@ -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"`
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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"`
}