feat: implemented poke api client with cache
This commit is contained in:
+67
-2
@@ -4,10 +4,17 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/StevanFreeborn/pokedexcli/internal/pokeapi"
|
||||||
|
"github.com/StevanFreeborn/pokedexcli/internal/pokecache"
|
||||||
)
|
)
|
||||||
|
|
||||||
type context struct {
|
type context struct {
|
||||||
commands map[string]cliCommand
|
commands map[string]cliCommand
|
||||||
|
client *pokeapi.Client
|
||||||
|
prevOffset int
|
||||||
|
nextOffset int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *context) register(c cliCommand) {
|
func (r *context) register(c cliCommand) {
|
||||||
@@ -29,12 +36,29 @@ func NewContext() *context {
|
|||||||
callback: commandHelp,
|
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{
|
c := &context{
|
||||||
commands: map[string]cliCommand{},
|
commands: map[string]cliCommand{},
|
||||||
|
client: pokeapi.NewClient(pokecache.NewCache(5 * time.Second)),
|
||||||
|
prevOffset: 0,
|
||||||
|
nextOffset: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
c.register(exitCommand)
|
c.register(exitCommand)
|
||||||
c.register(helpCommand)
|
c.register(helpCommand)
|
||||||
|
c.register(mapCommand)
|
||||||
|
c.register(mapBackCommand)
|
||||||
|
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
@@ -73,3 +97,44 @@ func commandHelp(c *context) error {
|
|||||||
|
|
||||||
return nil
|
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