generated from templates/go-generic
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:
parent
4152a9d14f
commit
70faf8366a
7 changed files with 327 additions and 60 deletions
|
@ -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
2
go.mod
|
@ -1,3 +1,3 @@
|
|||
module codeflow.dananglin.me.uk/apollo/pokedex
|
||||
|
||||
go 1.23.0
|
||||
go 1.23.1
|
||||
|
|
59
internal/api/pokeapi/types.go
Normal file
59
internal/api/pokeapi/types.go
Normal 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"`
|
||||
}
|
85
internal/pokecache/pokecache.go
Normal file
85
internal/pokecache/pokecache.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
81
internal/pokecache/pokecache_test.go
Normal file
81
internal/pokecache/pokecache_test.go
Normal 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)
|
||||
}
|
||||
}
|
93
internal/pokeclient/pokeclient.go
Normal file
93
internal/pokeclient/pokeclient.go
Normal 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
|
||||
}
|
59
types.go
59
types.go
|
@ -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"`
|
||||
}
|
Loading…
Reference in a new issue