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
|
sort-results: true
|
||||||
|
|
||||||
linters-settings:
|
linters-settings:
|
||||||
|
depguard:
|
||||||
|
rules:
|
||||||
|
main:
|
||||||
|
files:
|
||||||
|
- $all
|
||||||
|
allow:
|
||||||
|
- $gostd
|
||||||
|
- codeflow.dananglin.me.uk/apollo/pokedex
|
||||||
lll:
|
lll:
|
||||||
line-length: 140
|
line-length: 140
|
||||||
|
|
||||||
|
|
2
go.mod
2
go.mod
|
@ -1,3 +1,3 @@
|
||||||
module codeflow.dananglin.me.uk/apollo/pokedex
|
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