feat: implemented poke api client with cache

This commit is contained in:
Stevan Freeborn
2026-07-28 07:34:17 -05:00
parent c78ff443a6
commit 68b34ec28f
5 changed files with 399 additions and 2 deletions
+191
View File
@@ -0,0 +1,191 @@
package pokeapi
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/StevanFreeborn/pokedexcli/internal/pokecache"
)
type Client struct {
http *http.Client
baseUrl string
cache *pokecache.Cache
Locations *locationsEndpoint
}
func NewClient(cache *pokecache.Cache) *Client {
c := &Client{
http: &http.Client{},
baseUrl: "https://pokeapi.co/api/v2",
cache: cache,
}
c.Locations = &locationsEndpoint{client: c, path: "/location-area"}
return c
}
func (c *Client) NewRequest(method string, path string, queryParams map[string]string, body any) (*http.Request, error) {
fullUrl := fmt.Sprintf("%s/%s", strings.TrimRight(c.baseUrl, "/"), strings.TrimLeft(path, "/"))
validUrl, err := url.Parse(fullUrl)
if err != nil {
return nil, fmt.Errorf("failed to parse request url: %w", err)
}
query := validUrl.Query()
for key, value := range queryParams {
query.Add(key, value)
}
validUrl.RawQuery = query.Encode()
var bodyReader io.Reader
if body != nil {
jsonData, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
bodyReader = bytes.NewReader(jsonData)
}
req, err := http.NewRequest(method, validUrl.String(), bodyReader)
if err != nil {
return nil, fmt.Errorf("failed to create new request: %w", err)
}
return req, nil
}
type PokeAPIError struct {
StatusCode int
BodyText string
}
func (e PokeAPIError) Error() string {
return ""
}
func (c *Client) handleAPIError(resp *http.Response) error {
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
var bodyText string
if err != nil {
bodyText = string(bodyBytes)
}
return &PokeAPIError{
StatusCode: resp.StatusCode,
BodyText: bodyText,
}
}
func (c *Client) doWithJsonResponse(req *http.Request, v any) error {
requestUrl := req.URL.String()
cachedData, found := c.cache.Get(requestUrl)
if found {
return json.Unmarshal(cachedData, v)
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return c.handleAPIError(resp)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
c.cache.Add(requestUrl, body)
return json.Unmarshal(body, v)
}
type Page[T any] struct {
Count int `json:"count"`
Next string `json:"next"`
Previous string `json:"previous"`
Results []T `json:"results"`
}
func getOffset(str string) int {
valid, err := url.Parse(str)
if err != nil {
return 0
}
stringOffset := valid.Query().Get("offset")
offset, err := strconv.Atoi(stringOffset)
if err != nil {
return 0
}
return offset
}
func (p *Page[T]) GetNextOffset() int {
return getOffset(p.Next)
}
func (p *Page[T]) GetPreviousOffset() int {
return getOffset(p.Previous)
}
type Location struct {
Name string `json:"name"`
Url string `json:"url"`
}
type locationsEndpoint struct {
path string
client *Client
}
func (le *locationsEndpoint) Get(offset int) (Page[Location], error) {
const limit int = 20
var page Page[Location]
queryParams := map[string]string{
"offset": strconv.Itoa(offset),
"limit": strconv.Itoa(limit),
}
req, err := le.client.NewRequest(http.MethodGet, le.path, queryParams, nil)
if err != nil {
return page, err
}
err = le.client.doWithJsonResponse(req, &page)
if err != nil {
return page, err
}
return page, nil
}
+1
View File
@@ -0,0 +1 @@
package pokeapi_test
+76
View File
@@ -0,0 +1,76 @@
package pokecache
import (
"sync"
"time"
)
type cacheEntry struct {
val []byte
createdAt time.Time
}
type Cache struct {
entries map[string]cacheEntry
lock sync.Mutex
done chan struct{}
once sync.Once
}
func NewCache(interval time.Duration) *Cache {
c := Cache{
entries: map[string]cacheEntry{},
done: make(chan struct{}),
}
go c.runReapLoop(interval)
return &c
}
func (c *Cache) runReapLoop(interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.lock.Lock()
for k, v := range c.entries {
if time.Since(v.createdAt) > interval {
delete(c.entries, k)
}
}
c.lock.Unlock()
case <-c.done:
return
}
}
}
func (c *Cache) Add(key string, value []byte) {
c.lock.Lock()
defer c.lock.Unlock()
c.entries[key] = cacheEntry{
val: value,
createdAt: time.Now(),
}
}
func (c *Cache) Get(key string) ([]byte, bool) {
c.lock.Lock()
defer c.lock.Unlock()
v, ok := c.entries[key]
return v.val, ok
}
func (c *Cache) Stop() {
c.once.Do(func() {
close(c.done)
})
}
+64
View File
@@ -0,0 +1,64 @@
package pokecache_test
import (
"testing"
"time"
"github.com/StevanFreeborn/pokedexcli/internal/pokecache"
)
func TestNewCache(t *testing.T) {
t.Run("it should return new initialized cache", func(t *testing.T) {
cache := pokecache.NewCache(5 * time.Second)
defer cache.Stop()
if cache == nil {
t.Errorf("expected result to be non nil value")
}
})
t.Run("it should start reaping loop", func(t *testing.T) {
interval := 1 * time.Second
wait := interval + 1
key := "key"
bytes := []byte("Hello World")
cache := pokecache.NewCache(1 * time.Second)
defer cache.Stop()
cache.Add(key, bytes)
time.Sleep(wait)
_, found := cache.Get(key)
if found {
t.Errorf("expected entry for key %s to have been reaped", key)
}
})
}
func TestAdd(t *testing.T) {
t.Run("it should add entry to cache and allow retrieving entry", func(t *testing.T) {
key := "key"
bytes := []byte("Hello World")
cache := pokecache.NewCache(5 * time.Second)
defer cache.Stop()
cache.Add(key, bytes)
result, found := cache.Get(key)
if !found {
t.Errorf("expected to find entry for key %s", key)
}
expected := string(bytes)
strResult := string(result)
if strResult != expected {
t.Errorf("expected to find value %s for key %s but received %s", expected, key, strResult)
}
})
}