feat: implemented poke api client with cache
This commit is contained in:
+65
@@ -4,10 +4,17 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/StevanFreeborn/pokedexcli/internal/pokeapi"
|
||||
"github.com/StevanFreeborn/pokedexcli/internal/pokecache"
|
||||
)
|
||||
|
||||
type context struct {
|
||||
commands map[string]cliCommand
|
||||
client *pokeapi.Client
|
||||
prevOffset int
|
||||
nextOffset int
|
||||
}
|
||||
|
||||
func (r *context) register(c cliCommand) {
|
||||
@@ -29,12 +36,29 @@ func NewContext() *context {
|
||||
callback: commandHelp,
|
||||
}
|
||||
|
||||
var mapCommand = cliCommand{
|
||||
name: "map",
|
||||
description: "Displays the names of the next 20 location areas",
|
||||
callback: commandMap,
|
||||
}
|
||||
|
||||
var mapBackCommand = cliCommand{
|
||||
name: "mapb",
|
||||
description: "Displays the names of the previous 20 location areas",
|
||||
callback: commandMapBack,
|
||||
}
|
||||
|
||||
c := &context{
|
||||
commands: map[string]cliCommand{},
|
||||
client: pokeapi.NewClient(pokecache.NewCache(5 * time.Second)),
|
||||
prevOffset: 0,
|
||||
nextOffset: 0,
|
||||
}
|
||||
|
||||
c.register(exitCommand)
|
||||
c.register(helpCommand)
|
||||
c.register(mapCommand)
|
||||
c.register(mapBackCommand)
|
||||
|
||||
return c
|
||||
}
|
||||
@@ -73,3 +97,44 @@ func commandHelp(c *context) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getMaps(c *context, offset int) error {
|
||||
page, err := c.client.Locations.Get(offset)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Unable to get locations")
|
||||
return err
|
||||
}
|
||||
|
||||
c.prevOffset = page.GetPreviousOffset()
|
||||
c.nextOffset = page.GetNextOffset()
|
||||
|
||||
if len(page.Results) == 0 {
|
||||
fmt.Println("No locations found")
|
||||
} else {
|
||||
locationsPerPage := 20
|
||||
currentPage := (offset / locationsPerPage) + 1
|
||||
totalPages := page.Count / locationsPerPage
|
||||
|
||||
fmt.Printf("Displaying page %d of %d page(s)\n", currentPage, totalPages)
|
||||
}
|
||||
|
||||
for _, location := range page.Results {
|
||||
fmt.Printf("%s\n", location.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func commandMap(c *context) error {
|
||||
return getMaps(c, c.nextOffset)
|
||||
}
|
||||
|
||||
func commandMapBack(c *context) error {
|
||||
if c.prevOffset == 0 && (c.nextOffset == 0 || c.nextOffset == 20) {
|
||||
fmt.Println("You're on the first page")
|
||||
return nil
|
||||
}
|
||||
|
||||
return getMaps(c, c.prevOffset)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package pokeapi_test
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user