diff --git a/commands.go b/commands.go index 2e8dc59..463c9bd 100644 --- a/commands.go +++ b/commands.go @@ -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 + 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{}, + 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) +} diff --git a/internal/pokeapi/client.go b/internal/pokeapi/client.go new file mode 100644 index 0000000..1717bca --- /dev/null +++ b/internal/pokeapi/client.go @@ -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 +} diff --git a/internal/pokeapi/client_test.go b/internal/pokeapi/client_test.go new file mode 100644 index 0000000..58eab52 --- /dev/null +++ b/internal/pokeapi/client_test.go @@ -0,0 +1 @@ +package pokeapi_test diff --git a/internal/pokecache/cache.go b/internal/pokecache/cache.go new file mode 100644 index 0000000..51e4c9a --- /dev/null +++ b/internal/pokecache/cache.go @@ -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) + }) +} diff --git a/internal/pokecache/cache_test.go b/internal/pokecache/cache_test.go new file mode 100644 index 0000000..830f917 --- /dev/null +++ b/internal/pokecache/cache_test.go @@ -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) + } + }) +}