diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 155c5c7..8988a2b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -30,7 +30,7 @@ on:
- main
env:
- VERSION_NUMBER: 'v1.8.0'
+ VERSION_NUMBER: 'v1.8.1'
DOCKERHUB_REGISTRY_NAME: 'digitalghostdev/poke-cli'
AWS_REGION: 'us-west-2'
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 16995b2..88a2427 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -14,7 +14,7 @@ builds:
- windows
- darwin
ldflags:
- - -s -w -X main.version=v1.8.0
+ - -s -w -X main.version=v1.8.1
archives:
- formats: [ 'zip' ]
diff --git a/Dockerfile b/Dockerfile
index 7605a0c..327d079 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,7 +8,7 @@ RUN go mod download
COPY . .
-RUN go build -ldflags "-X main.version=v1.8.0" -o poke-cli .
+RUN go build -ldflags "-X main.version=v1.8.1" -o poke-cli .
# build 2
FROM --platform=$BUILDPLATFORM alpine:3.22
diff --git a/README.md b/README.md
index fb4e1e7..5eef998 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
Pokémon CLI
-
+
@@ -95,11 +95,11 @@ Cloudsmith is a fully cloud-based service that lets you easily create, store, an
3. Choose how to interact with the container:
* Run a single command and exit:
```bash
- docker run --rm -it digitalghostdev/poke-cli:v1.8.0 [subcommand] flag]
+ docker run --rm -it digitalghostdev/poke-cli:v1.8.1 [subcommand] [flag]
```
* Enter the container and use its shell:
```bash
- docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.8.0 -c "cd /app && exec sh"
+ docker run --rm -it --name poke-cli --entrypoint /bin/sh digitalghostdev/poke-cli:v1.8.1 -c "cd /app && exec sh"
# placed into the /app directory, run the program with './poke-cli'
# example: ./poke-cli ability swift-swim
```
diff --git a/card_data/README.md b/card_data/README.md
index fe89ea1..9017a98 100644
--- a/card_data/README.md
+++ b/card_data/README.md
@@ -3,4 +3,23 @@
This directory stores all the code for all backend data processing related to Pokémon TCG data.
Instead of calling directly to the PokéAPI for data from the video game, I took this a step further
-and decided to process all the data myself, load it into Supabase, and read from that API.
\ No newline at end of file
+and decided to process all the data myself, load it into Supabase, and read from that API.
+
+## Data Architecture
+Runs at 2:00PM PST daily.
+
+
+
+1. TCGPlayer pricing data and TCGDex card data are called and processed through a data pipeline orchestrated by Dagster and hosted on AWS.
+
+2. When the pipeline starts, Pydantic validates the incoming API data against a pre-defined schema, ensuring the data types match the expected structure.
+
+3. Polars is used to create DataFrames.
+
+4. The data is loaded into a Supabase staging schema.
+
+5. Soda data quality checks are performed.
+
+6. `dbt` runs and builds the final tables in a Supabase production schema.
+
+7. Users are then able to query the `pokeapi.co` or supabase APIs for either video game or trading card data, respectively.
diff --git a/card_data/pipelines/poke_cli_dbt/dbt_project.yml b/card_data/pipelines/poke_cli_dbt/dbt_project.yml
index 06c038e..333cfe3 100644
--- a/card_data/pipelines/poke_cli_dbt/dbt_project.yml
+++ b/card_data/pipelines/poke_cli_dbt/dbt_project.yml
@@ -1,5 +1,5 @@
name: 'poke_cli_dbt'
-version: '1.8.0'
+version: '1.8.1'
profile: 'poke_cli_dbt'
diff --git a/cmd/ability/ability.go b/cmd/ability/ability.go
index 4a5cc30..bbb3921 100644
--- a/cmd/ability/ability.go
+++ b/cmd/ability/ability.go
@@ -3,14 +3,15 @@ package ability
import (
"flag"
"fmt"
+ "os"
+ "strings"
+
"github.com/digitalghost-dev/poke-cli/cmd/utils"
"github.com/digitalghost-dev/poke-cli/connections"
"github.com/digitalghost-dev/poke-cli/flags"
"github.com/digitalghost-dev/poke-cli/styling"
"golang.org/x/text/cases"
"golang.org/x/text/language"
- "os"
- "strings"
)
func AbilityCommand() (string, error) {
@@ -52,19 +53,14 @@ func AbilityCommand() (string, error) {
if err := abilityFlags.Parse(args[3:]); err != nil {
output.WriteString(fmt.Sprintf("error parsing flags: %v\n", err))
abilityFlags.Usage()
- if os.Getenv("GO_TESTING") != "1" {
- os.Exit(1)
- }
+
return output.String(), err
}
abilitiesStruct, abilityName, err := connections.AbilityApiCall(endpoint, abilityName, connections.APIURL)
if err != nil {
- if os.Getenv("GO_TESTING") != "1" {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- return err.Error(), nil
+ output.WriteString(err.Error())
+ return output.String(), err
}
// Extract English short_effect
@@ -88,7 +84,6 @@ func AbilityCommand() (string, error) {
capitalizedAbility := cases.Title(language.English).String(strings.ReplaceAll(abilityName, "-", " "))
output.WriteString(styling.StyleBold.Render(capitalizedAbility) + "\n")
- // Print the generation where the ability was first introduced.
generationParts := strings.Split(abilitiesStruct.Generation.Name, "-")
if len(generationParts) > 1 {
generationUpper := strings.ToUpper(generationParts[1])
@@ -100,15 +95,15 @@ func AbilityCommand() (string, error) {
// API is missing some data for the short_effect for abilities from Generation 9.
// If short_effect is empty, fallback to the move's flavor_text_entry.
if englishShortEffect == "" {
- output.WriteString(fmt.Sprintf("%s Effect: "+englishFlavorEntry, styling.ColoredBullet))
+ output.WriteString(fmt.Sprintf("%s Effect: %s", styling.ColoredBullet, englishFlavorEntry))
} else {
- output.WriteString(fmt.Sprintf("%s Effect: "+englishShortEffect, styling.ColoredBullet))
+ output.WriteString(fmt.Sprintf("%s Effect: %s", styling.ColoredBullet, englishShortEffect))
}
if *pokemonFlag || *shortPokemonFlag {
if err := flags.PokemonAbilitiesFlag(&output, endpoint, abilityName); err != nil {
output.WriteString(fmt.Sprintf("error parsing flags: %v\n", err))
- return "", fmt.Errorf("error parsing flags: %w", err)
+ return output.String(), fmt.Errorf("error parsing flags: %w", err)
}
}
diff --git a/cmd/ability/ability_test.go b/cmd/ability/ability_test.go
index 5da9108..020d6c0 100644
--- a/cmd/ability/ability_test.go
+++ b/cmd/ability/ability_test.go
@@ -1,26 +1,15 @@
package ability
import (
+ "os"
+ "testing"
+
"github.com/digitalghost-dev/poke-cli/cmd/utils"
"github.com/digitalghost-dev/poke-cli/styling"
"github.com/stretchr/testify/assert"
- "os"
- "testing"
)
func TestAbilityCommand(t *testing.T) {
- err := os.Setenv("GO_TESTING", "1")
- if err != nil {
- t.Fatalf("Failed to set GO_TESTING env var: %v", err)
- }
-
- defer func() {
- err := os.Unsetenv("GO_TESTING")
- if err != nil {
- t.Logf("Warning: failed to unset GO_TESTING: %v", err)
- }
- }()
-
tests := []struct {
name string
args []string
@@ -53,6 +42,11 @@ func TestAbilityCommand(t *testing.T) {
args: []string{"ability", "anger-point", "--pokemon"},
expectedOutput: utils.LoadGolden(t, "ability_flag_pokemon.golden"),
},
+ {
+ name: "Ability command: special character in API call",
+ args: []string{"ability", "poison-point"},
+ expectedOutput: utils.LoadGolden(t, "ability_poison_point.golden"),
+ },
}
for _, tt := range tests {
diff --git a/cmd/card/imageviewer.go b/cmd/card/imageviewer.go
index e8f0084..3cc85a2 100644
--- a/cmd/card/imageviewer.go
+++ b/cmd/card/imageviewer.go
@@ -1,21 +1,60 @@
package card
import (
+ "github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/digitalghost-dev/poke-cli/styling"
)
type ImageModel struct {
- CardName string
- ImageURL string
- Error error
+ CardName string
+ ImageURL string
+ Error error
+ Loading bool
+ Spinner spinner.Model
+ ImageData string
+}
+
+type imageReadyMsg struct {
+ sixelData string
+ err error
+}
+
+// fetchImageCmd downloads and renders the image asynchronously
+func fetchImageCmd(imageURL string) tea.Cmd {
+ return func() tea.Msg {
+ sixelData, err := CardImage(imageURL)
+ if err != nil {
+ return imageReadyMsg{err: err}
+ }
+ return imageReadyMsg{sixelData: sixelData}
+ }
}
func (m ImageModel) Init() tea.Cmd {
- return nil
+ return tea.Batch(
+ m.Spinner.Tick,
+ fetchImageCmd(m.ImageURL),
+ )
}
func (m ImageModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
+ case imageReadyMsg:
+ m.Loading = false
+ if msg.err != nil {
+ m.Error = msg.err
+ m.ImageData = ""
+ } else {
+ m.Error = nil
+ m.ImageData = msg.sixelData
+ }
+ return m, nil
+ case spinner.TickMsg:
+ var cmd tea.Cmd
+ m.Spinner, cmd = m.Spinner.Update(msg)
+ return m, cmd
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc":
@@ -26,15 +65,26 @@ func (m ImageModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m ImageModel) View() string {
- return m.ImageURL
+ if m.Loading {
+ return lipgloss.NewStyle().Padding(2).Render(
+ m.Spinner.View() + "Loading image for \n" + m.CardName,
+ )
+ }
+ if m.Error != nil {
+ return styling.Red.Render(m.Error.Error())
+ }
+ return m.ImageData
}
func ImageRenderer(cardName string, imageURL string) ImageModel {
- imageData, err := CardImage(imageURL)
+ s := spinner.New()
+ s.Spinner = spinner.Dot
+ s.Style = styling.Yellow
return ImageModel{
CardName: cardName,
- ImageURL: imageData,
- Error: err,
+ ImageURL: imageURL,
+ Loading: true,
+ Spinner: s,
}
}
diff --git a/cmd/card/imageviewer_test.go b/cmd/card/imageviewer_test.go
index 7b2efa5..4d86e98 100644
--- a/cmd/card/imageviewer_test.go
+++ b/cmd/card/imageviewer_test.go
@@ -1,25 +1,19 @@
package card
import (
- "image"
- "image/color"
- "image/png"
- "net/http"
- "net/http/httptest"
+ "strings"
"testing"
+ spinnerpkg "github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
)
func TestImageModel_Init(t *testing.T) {
- model := ImageModel{
- CardName: "001/198 - Pineco",
- ImageURL: "test-sixel-data",
- }
+ model := ImageRenderer("001/198 - Pineco", "http://example.com/image.png")
cmd := model.Init()
- if cmd != nil {
- t.Error("Init() should return nil")
+ if cmd == nil {
+ t.Error("Init() should return a command (batch of spinner tick + fetch)")
}
}
@@ -53,7 +47,6 @@ func TestImageModel_Update_CtrlC(t *testing.T) {
msg := tea.KeyMsg{Type: tea.KeyCtrlC}
_, cmd := model.Update(msg)
- // Should return quit command
if cmd == nil {
t.Error("Update with Ctrl+C should return tea.Quit command")
}
@@ -73,107 +66,110 @@ func TestImageModel_Update_DifferentKey(t *testing.T) {
}
}
-func TestImageModel_View(t *testing.T) {
- expectedURL := "test-sixel-data-123"
+func TestImageModel_View_Loading(t *testing.T) {
+ model := ImageRenderer("001/198 - Pineco", "http://example.com/image.png")
+
+ result := model.View()
+
+ // When loading, should show spinner and card name
+ if result == "" {
+ t.Error("View() should not be empty when loading")
+ }
+ // Can't check exact spinner output as it's dynamic, but should contain card name
+ if !strings.Contains(result, "001/198 - Pineco") {
+ t.Error("View() should contain card name when loading")
+ }
+}
+
+func TestImageModel_View_Loaded(t *testing.T) {
+ expectedData := "test-sixel-data-123"
model := ImageModel{
- CardName: "001/198 - Pineco",
- ImageURL: expectedURL,
+ CardName: "001/198 - Pineco",
+ ImageURL: "http://example.com/image.png",
+ Loading: false,
+ ImageData: expectedData,
}
result := model.View()
- if result != expectedURL {
- t.Errorf("View() = %v, want %v", result, expectedURL)
+ if result != expectedData {
+ t.Errorf("View() = %v, want %v", result, expectedData)
}
}
func TestImageModel_View_Empty(t *testing.T) {
model := ImageModel{
- CardName: "001/198 - Pineco",
- ImageURL: "",
+ CardName: "001/198 - Pineco",
+ ImageURL: "",
+ Loading: false,
+ ImageData: "",
}
result := model.View()
if result != "" {
- t.Errorf("View() with empty ImageURL should return empty string, got %v", result)
+ t.Errorf("View() with empty ImageData should return empty string, got %v", result)
}
}
-func TestImageRenderer_Success(t *testing.T) {
- // Create a test HTTP server that serves a valid PNG image
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- img := image.NewRGBA(image.Rect(0, 0, 10, 10))
- blue := color.RGBA{R: 0, G: 0, B: 255, A: 255}
- for y := 0; y < 10; y++ {
- for x := 0; x < 10; x++ {
- img.Set(x, y, blue)
- }
- }
-
- w.Header().Set("Content-Type", "image/png")
- w.WriteHeader(http.StatusOK)
- _ = png.Encode(w, img)
- }))
- defer server.Close()
-
- model := ImageRenderer("Pikachu", server.URL)
+func TestImageRenderer_InitializesCorrectly(t *testing.T) {
+ testURL := "http://example.com/pikachu.png"
+ model := ImageRenderer("Pikachu", testURL)
if model.CardName != "Pikachu" {
t.Errorf("ImageRenderer() CardName = %v, want %v", model.CardName, "Pikachu")
}
- if model.Error != nil {
- t.Errorf("ImageRenderer() Error should be nil on success, got %v", model.Error)
+ if model.ImageURL != testURL {
+ t.Errorf("ImageRenderer() ImageURL = %v, want %v", model.ImageURL, testURL)
+ }
+
+ if !model.Loading {
+ t.Error("ImageRenderer() should initialize with Loading = true")
}
- if model.ImageURL == "" {
- t.Error("ImageRenderer() ImageURL should not be empty on success")
+ if model.ImageData != "" {
+ t.Error("ImageRenderer() should initialize with empty ImageData")
}
}
-func TestImageRenderer_Error(t *testing.T) {
- // Create a test HTTP server that returns an error
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusNotFound)
- }))
- defer server.Close()
+func TestImageModel_Update_ImageReady(t *testing.T) {
+ model := ImageRenderer("Charizard", "http://example.com/charizard.png")
- model := ImageRenderer("Charizard", server.URL)
+ msg := imageReadyMsg{sixelData: "test-sixel-data-456"}
+ newModel, cmd := model.Update(msg)
- if model.CardName != "Charizard" {
- t.Errorf("ImageRenderer() CardName = %v, want %v", model.CardName, "Charizard")
+ if cmd != nil {
+ t.Error("Update with imageReadyMsg should return nil command")
}
- if model.Error == nil {
- t.Error("ImageRenderer() Error should not be nil when image fetch fails")
+ updatedModel := newModel.(ImageModel)
+ if updatedModel.Loading {
+ t.Error(`Update with imageReadyMsg should set Loading to false`)
}
- if model.ImageURL != "" {
- t.Errorf("ImageRenderer() ImageURL should be empty on error, got %v", model.ImageURL)
+ if updatedModel.ImageData != "test-sixel-data-456" {
+ t.Errorf("Update with imageReadyMsg should set ImageData, got %v", updatedModel.ImageData)
}
}
-func TestImageRenderer_InvalidImage(t *testing.T) {
- // Create a test HTTP server that returns invalid image data
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "image/png")
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte("not a valid image"))
- }))
- defer server.Close()
+func TestImageModel_Update_SpinnerTick(t *testing.T) {
+ model := ImageRenderer("Mewtwo", "http://example.com/mewtwo.png")
- model := ImageRenderer("Mewtwo", server.URL)
+ msg := model.Spinner.Tick()
- if model.CardName != "Mewtwo" {
- t.Errorf("ImageRenderer() CardName = %v, want %v", model.CardName, "Mewtwo")
+ if _, ok := msg.(spinnerpkg.TickMsg); !ok {
+ t.Fatalf("expected spinner.TickMsg, got %T", msg)
}
- if model.Error == nil {
- t.Error("ImageRenderer() Error should not be nil when image decoding fails")
+ newModel, returnedCmd := model.Update(msg)
+
+ if returnedCmd == nil {
+ t.Error("Update with spinner.TickMsg should return a command")
}
- if model.ImageURL != "" {
- t.Errorf("ImageRenderer() ImageURL should be empty on error, got %v", model.ImageURL)
+ // Model should still be ImageModel
+ if _, ok := newModel.(ImageModel); !ok {
+ t.Error("Update should return ImageModel")
}
}
diff --git a/cmd/item/item.go b/cmd/item/item.go
index 56c0978..4c5b162 100644
--- a/cmd/item/item.go
+++ b/cmd/item/item.go
@@ -50,11 +50,8 @@ func ItemCommand() (string, error) {
itemStruct, itemName, err := connections.ItemApiCall(endpoint, itemName, connections.APIURL)
if err != nil {
- if os.Getenv("GO_TESTING") != "1" {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }
- return err.Error(), nil
+ output.WriteString(err.Error())
+ return output.String(), err
}
itemInfoContainer(&output, itemStruct, itemName)
diff --git a/cmd/pokemon/pokemon.go b/cmd/pokemon/pokemon.go
index e67c112..8bf8f91 100644
--- a/cmd/pokemon/pokemon.go
+++ b/cmd/pokemon/pokemon.go
@@ -77,7 +77,7 @@ func PokemonCommand() (string, error) {
return output.String(), err
}
- pokemonSpeciesStruct, err := connections.PokemonSpeciesApiCall("pokemon-species", pokemonStruct.Species.Name, connections.APIURL)
+ pokemonSpeciesStruct, _, err := connections.PokemonSpeciesApiCall("pokemon-species", pokemonStruct.Species.Name, connections.APIURL)
if err != nil {
output.WriteString(err.Error())
return output.String(), err
diff --git a/cmd/speed/speed.go b/cmd/speed/speed.go
index 0617d51..48928f9 100644
--- a/cmd/speed/speed.go
+++ b/cmd/speed/speed.go
@@ -4,6 +4,12 @@ import (
"errors"
"flag"
"fmt"
+ "log"
+ "math"
+ "os"
+ "strconv"
+ "strings"
+
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
xstrings "github.com/charmbracelet/x/exp/strings"
@@ -12,11 +18,6 @@ import (
"github.com/digitalghost-dev/poke-cli/styling"
"golang.org/x/text/cases"
"golang.org/x/text/language"
- "log"
- "math"
- "os"
- "strconv"
- "strings"
)
// DefaultSpeedStat is the default implementation of SpeedStatFunc
@@ -304,18 +305,18 @@ func formula() (string, error) {
finalSpeedStr := fmt.Sprintf("%.0f", finalSpeedFloor)
header := fmt.Sprintf("%s at level %s with selected options has a current speed of %s.",
- styling.YellowAdaptive(chosenPokemon),
- styling.YellowAdaptive(pokemon.Level),
- styling.YellowAdaptive(finalSpeedStr),
+ styling.Yellow.Render(chosenPokemon),
+ styling.Yellow.Render(pokemon.Level),
+ styling.Yellow.Render(finalSpeedStr),
)
body := fmt.Sprintf("EVs: %s\nIVs: %s\nModifiers: %s\nNature: %s\nAbility: %s\nSpeed Stage: %s\nBase Speed: %s",
- styling.YellowAdaptive(pokemon.SpeedEV),
- styling.YellowAdaptive(pokemon.SpeedIV),
- styling.YellowAdaptive(xstrings.EnglishJoin(pokemon.Modifier, true)),
- styling.YellowAdaptive(pokemon.Nature),
- styling.YellowAdaptive(pokemon.Ability),
- styling.YellowAdaptive(pokemon.SpeedStage),
- styling.YellowAdaptive(speedStr),
+ styling.Yellow.Render(pokemon.SpeedEV),
+ styling.Yellow.Render(pokemon.SpeedIV),
+ styling.Yellow.Render(xstrings.EnglishJoin(pokemon.Modifier, true)),
+ styling.Yellow.Render(pokemon.Nature),
+ styling.Yellow.Render(pokemon.Ability),
+ styling.Yellow.Render(pokemon.SpeedStage),
+ styling.Yellow.Render(speedStr),
)
docStyle := lipgloss.NewStyle().
diff --git a/connections/connection.go b/connections/connection.go
index fa509f9..cbe9c28 100644
--- a/connections/connection.go
+++ b/connections/connection.go
@@ -16,6 +16,30 @@ import (
const APIURL = "https://pokeapi.co/api/v2/"
+var httpClient = &http.Client{Timeout: 30 * time.Second}
+
+type EndpointResource interface {
+ GetResourceName() string
+}
+
+func FetchEndpoint[T EndpointResource](endpoint, resourceName, baseURL, resourceType string) (T, string, error) {
+ var zero T
+ fullURL := baseURL + endpoint + "/" + resourceName
+
+ var result T
+ err := ApiCallSetup(fullURL, &result, false)
+
+ if err != nil {
+ errMessage := styling.ErrorBorder.Render(
+ styling.ErrorColor.Render("✖ Error!"),
+ fmt.Sprintf("\n%s not found.\n• Perhaps a typo?\n• Missing a hyphen instead of a space?", resourceType),
+ )
+ return zero, "", fmt.Errorf("%s", errMessage)
+ }
+
+ return result, result.GetResourceName(), nil
+}
+
// ApiCallSetup Helper function to handle API calls and JSON unmarshalling
func ApiCallSetup(rawURL string, target interface{}, skipHTTPSCheck bool) error {
parsedURL, err := url.Parse(rawURL)
@@ -32,16 +56,17 @@ func ApiCallSetup(rawURL string, target interface{}, skipHTTPSCheck bool) error
return errors.New("only HTTPS URLs are allowed for security reasons")
}
- client := http.Client{
- Timeout: time.Second * 30,
- }
-
- res, err := client.Get(parsedURL.String())
+ resp, err := httpClient.Get(parsedURL.String())
if err != nil {
return fmt.Errorf("error making GET request: %w", err)
}
- defer res.Body.Close()
- body, err := io.ReadAll(res.Body)
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("non-200 response: %d", resp.StatusCode)
+ }
+
+ body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("error reading response body: %w", err)
}
@@ -54,107 +79,26 @@ func ApiCallSetup(rawURL string, target interface{}, skipHTTPSCheck bool) error
return nil
}
-// AbilityApiCall function for calling the ability endpoint of the pokeAPI
-func AbilityApiCall(endpoint string, abilityName string, baseURL string) (structs.AbilityJSONStruct, string, error) {
- fullURL := baseURL + endpoint + "/" + abilityName
-
- var abilityStruct structs.AbilityJSONStruct
- err := ApiCallSetup(fullURL, &abilityStruct, false)
-
- if err != nil {
- errMessage := styling.ErrorBorder.Render(
- styling.ErrorColor.Render("✖ Error!"),
- "\nAbility not found.\n\u2022 Perhaps a typo?\n\u2022 Missing a hyphen instead of a space?",
- )
- return structs.AbilityJSONStruct{}, "", fmt.Errorf("%s", errMessage)
- }
-
- return abilityStruct, abilityStruct.Name, nil
+func AbilityApiCall(endpoint, abilityName, baseURL string) (structs.AbilityJSONStruct, string, error) {
+ return FetchEndpoint[structs.AbilityJSONStruct](endpoint, abilityName, baseURL, "Ability")
}
-// ItemApiCall function for calling the item endpoint of the pokeAPI
func ItemApiCall(endpoint string, itemName string, baseURL string) (structs.ItemJSONStruct, string, error) {
- fullURL := baseURL + endpoint + "/" + itemName
-
- var itemStruct structs.ItemJSONStruct
- err := ApiCallSetup(fullURL, &itemStruct, false)
-
- if err != nil {
- errMessage := styling.ErrorBorder.Render(
- styling.ErrorColor.Render("✖ Error!"),
- "\nItem not found.\n\u2022 Perhaps a typo?\n\u2022 Missing a hyphen instead of a space?",
- )
- return structs.ItemJSONStruct{}, "", fmt.Errorf("%s", errMessage)
- }
-
- return itemStruct, itemStruct.Name, nil
+ return FetchEndpoint[structs.ItemJSONStruct](endpoint, itemName, baseURL, "Item")
}
-// MoveApiCall function for calling the move endpoint of the pokeAPI
func MoveApiCall(endpoint string, moveName string, baseURL string) (structs.MoveJSONStruct, string, error) {
- fullURL := baseURL + endpoint + "/" + moveName
-
- var moveStruct structs.MoveJSONStruct
- err := ApiCallSetup(fullURL, &moveStruct, false)
-
- if err != nil {
- errMessage := styling.ErrorBorder.Render(
- styling.ErrorColor.Render("✖ Error!"),
- "\nMove not found.\n\u2022 Perhaps a typo?\n\u2022 Missing a hyphen instead of a space?",
- )
- return structs.MoveJSONStruct{}, "", fmt.Errorf("%s", errMessage)
- }
-
- return moveStruct, moveStruct.Name, nil
+ return FetchEndpoint[structs.MoveJSONStruct](endpoint, moveName, baseURL, "Move")
}
-// PokemonApiCall function for calling the pokemon endpoint of the pokeAPI
func PokemonApiCall(endpoint string, pokemonName string, baseURL string) (structs.PokemonJSONStruct, string, error) {
- fullURL := baseURL + endpoint + "/" + pokemonName
-
- var pokemonStruct structs.PokemonJSONStruct
- err := ApiCallSetup(fullURL, &pokemonStruct, false)
-
- if err != nil {
- errMessage := styling.ErrorBorder.Render(
- styling.ErrorColor.Render("✖ Error!"),
- "\nPokémon not found.\n\u2022 Perhaps a typo?\n\u2022 Missing a hyphen instead of a space?",
- )
- return structs.PokemonJSONStruct{}, "", fmt.Errorf("%s", errMessage)
- }
-
- return pokemonStruct, pokemonStruct.Name, nil
+ return FetchEndpoint[structs.PokemonJSONStruct](endpoint, pokemonName, baseURL, "Pokémon")
}
-// PokemonSpeciesApiCall function for calling the pokemon endpoint of the pokeAPI
-func PokemonSpeciesApiCall(endpoint string, pokemonSpeciesName string, baseURL string) (structs.PokemonSpeciesJSONStruct, error) {
- fullURL := baseURL + endpoint + "/" + pokemonSpeciesName
-
- var pokemonSpeciesStruct structs.PokemonSpeciesJSONStruct
- err := ApiCallSetup(fullURL, &pokemonSpeciesStruct, false)
-
- if err != nil {
- errMessage := styling.ErrorBorder.Render(
- styling.ErrorColor.Render("✖ Error!"),
- "\nPokémon not found.\n\u2022 Perhaps a typo?\n\u2022 Missing a hyphen instead of a space?",
- )
- return structs.PokemonSpeciesJSONStruct{}, fmt.Errorf("%s", errMessage)
- }
-
- return pokemonSpeciesStruct, nil
+func PokemonSpeciesApiCall(endpoint string, pokemonSpeciesName string, baseURL string) (structs.PokemonSpeciesJSONStruct, string, error) {
+ return FetchEndpoint[structs.PokemonSpeciesJSONStruct](endpoint, pokemonSpeciesName, baseURL, "PokémonSpecies")
}
-// TypesApiCall function for calling the type endpoint of the pokeAPI
-func TypesApiCall(endpoint string, typesName string, baseURL string) (structs.TypesJSONStruct, string, int) {
- fullURL := baseURL + endpoint + "/" + typesName
- var typesStruct structs.TypesJSONStruct
-
- err := ApiCallSetup(fullURL, &typesStruct, false)
-
- if err != nil {
- fmt.Println(err)
- return structs.TypesJSONStruct{}, "", 0
- }
-
- return typesStruct, typesStruct.Name, typesStruct.ID
+func TypesApiCall(endpoint string, typesName string, baseURL string) (structs.TypesJSONStruct, string, error) {
+ return FetchEndpoint[structs.TypesJSONStruct](endpoint, typesName, baseURL, "Type")
}
diff --git a/connections/connection_test.go b/connections/connection_test.go
index 29f1e70..f2fdb82 100644
--- a/connections/connection_test.go
+++ b/connections/connection_test.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "strconv"
"testing"
"github.com/digitalghost-dev/poke-cli/structs"
@@ -57,6 +58,33 @@ func TestApiCallSetup(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "error unmarshalling JSON")
})
+
+ t.Run("non-200 status code returns error", func(t *testing.T) {
+ testCases := []struct {
+ name string
+ statusCode int
+ }{
+ {"404 Not Found", http.StatusNotFound},
+ {"500 Internal Server Error", http.StatusInternalServerError},
+ {"403 Forbidden", http.StatusForbidden},
+ {"503 Service Unavailable", http.StatusServiceUnavailable},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(tc.statusCode)
+ }))
+ defer ts.Close()
+
+ var target map[string]string
+ err := ApiCallSetup(ts.URL, &target, true)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "non-200 response")
+ assert.Contains(t, err.Error(), strconv.Itoa(tc.statusCode))
+ })
+ }
+ })
}
func TestAbilityApiCall(t *testing.T) {
@@ -86,10 +114,11 @@ func TestAbilityApiCall(t *testing.T) {
}))
defer ts.Close()
- ability, _, err := AbilityApiCall("/ability", "non-existent-ability", ts.URL)
+ ability, name, err := AbilityApiCall("/ability", "non-existent-ability", ts.URL)
require.Error(t, err, "Expected an error for invalid ability")
assert.Equal(t, structs.AbilityJSONStruct{}, ability, "Expected empty ability struct on error")
+ assert.Empty(t, name, "Expected empty name string on error")
assert.Contains(t, err.Error(), "Ability not found", "Expected 'Ability not found' in error message")
assert.Contains(t, err.Error(), "Perhaps a typo?", "Expected helpful suggestion in error message")
@@ -123,10 +152,11 @@ func TestItemApiCall(t *testing.T) {
}))
defer ts.Close()
- item, _, err := ItemApiCall("/item", "non-existent-item", ts.URL)
+ item, name, err := ItemApiCall("/item", "non-existent-item", ts.URL)
require.Error(t, err, "Expected an error for invalid item")
assert.Equal(t, structs.ItemJSONStruct{}, item, "Expected empty item struct on error")
+ assert.Empty(t, name, "Expected empty name string on error")
assert.Contains(t, err.Error(), "Item not found", "Expected 'Item not found' in error message")
assert.Contains(t, err.Error(), "Perhaps a typo?", "Expected helpful suggestion in error message")
@@ -160,10 +190,11 @@ func TestMoveApiCall(t *testing.T) {
}))
defer ts.Close()
- move, _, err := MoveApiCall("/move", "non-existent-move", ts.URL)
+ move, name, err := MoveApiCall("/move", "non-existent-move", ts.URL)
require.Error(t, err, "Expected an error for invalid move")
assert.Equal(t, structs.MoveJSONStruct{}, move, "Expected empty move struct on error")
+ assert.Empty(t, name, "Expected empty name string on error")
assert.Contains(t, err.Error(), "Move not found", "Expected 'Move not found' in error message")
assert.Contains(t, err.Error(), "Perhaps a typo?", "Expected helpful suggestion in error message")
@@ -197,10 +228,11 @@ func TestPokemonApiCall(t *testing.T) {
}))
defer ts.Close()
- pokemon, _, err := PokemonApiCall("/pokemon", "non-existent-pokemon", ts.URL)
+ pokemon, name, err := PokemonApiCall("/pokemon", "non-existent-pokemon", ts.URL)
require.Error(t, err, "Expected an error for invalid pokemon")
assert.Equal(t, structs.PokemonJSONStruct{}, pokemon, "Expected empty pokemon struct on error")
+ assert.Empty(t, name, "Expected empty name string on error")
assert.Contains(t, err.Error(), "Pokémon not found", "Expected 'Pokémon not found' in error message")
assert.Contains(t, err.Error(), "Perhaps a typo?", "Expected helpful suggestion in error message")
@@ -209,40 +241,58 @@ func TestPokemonApiCall(t *testing.T) {
// TestTypesApiCall - Test for the TypesApiCall function
func TestTypesApiCall(t *testing.T) {
- expectedTypes := structs.TypesJSONStruct{
- Name: "electric",
- ID: 13,
- Pokemon: []struct {
- Pokemon struct {
- Name string `json:"name"`
- URL string `json:"url"`
- } `json:"pokemon"`
- Slot int `json:"slot"`
- }{
- {Pokemon: struct {
- Name string `json:"name"`
- URL string `json:"url"`
- }{Name: "pikachu", URL: "https://pokeapi.co/api/v2/pokemon/25/"},
- Slot: 1},
- },
- }
+ t.Run("Successful API call returns expected type", func(t *testing.T) {
+ expectedTypes := structs.TypesJSONStruct{
+ Name: "electric",
+ ID: 13,
+ Pokemon: []struct {
+ Pokemon struct {
+ Name string `json:"name"`
+ URL string `json:"url"`
+ } `json:"pokemon"`
+ Slot int `json:"slot"`
+ }{
+ {Pokemon: struct {
+ Name string `json:"name"`
+ URL string `json:"url"`
+ }{Name: "pikachu", URL: "https://pokeapi.co/api/v2/pokemon/25/"},
+ Slot: 1},
+ },
+ }
- ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusOK)
- err := json.NewEncoder(w).Encode(expectedTypes)
- assert.NoError(t, err, "Expected no error for skipHTTPSCheck")
- }))
- defer ts.Close()
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ err := json.NewEncoder(w).Encode(expectedTypes)
+ assert.NoError(t, err, "Expected no error for encoding response")
+ }))
+ defer ts.Close()
+
+ typesStruct, name, err := TypesApiCall("/type", "electric", ts.URL)
+
+ require.NoError(t, err, "Expected no error on successful API call")
+ assert.Equal(t, expectedTypes, typesStruct, "Expected types struct does not match")
+ assert.Equal(t, "electric", name, "Expected type name does not match")
+ })
+
+ t.Run("Failed API call returns styled error", func(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Simulate API failure (e.g., 404 Not Found)
+ http.Error(w, "Not Found", http.StatusNotFound)
+ }))
+ defer ts.Close()
- typesStruct, name, id := TypesApiCall("/type", "electric", ts.URL)
+ typesStruct, name, err := TypesApiCall("/type", "non-existent-type", ts.URL)
- assert.Equal(t, expectedTypes, typesStruct)
- assert.Equal(t, "electric", name)
- assert.Equal(t, 13, id)
+ require.Error(t, err, "Expected an error for invalid type")
+ assert.Equal(t, structs.TypesJSONStruct{}, typesStruct, "Expected empty types struct on error")
+ assert.Empty(t, name, "Expected empty name string on error")
+
+ assert.Contains(t, err.Error(), "Type not found", "Expected 'Type not found' in error message")
+ assert.Contains(t, err.Error(), "Perhaps a typo?", "Expected helpful suggestion in error message")
+ })
}
func TestPokemonSpeciesApiCall(t *testing.T) {
- // Successful API call returns expected species data
t.Run("Successful API call returns expected species", func(t *testing.T) {
expectedSpecies := structs.PokemonSpeciesJSONStruct{
Name: "flareon",
@@ -255,13 +305,13 @@ func TestPokemonSpeciesApiCall(t *testing.T) {
}))
defer ts.Close()
- species, err := PokemonSpeciesApiCall("/pokemon-species", "flareon", ts.URL)
+ species, name, err := PokemonSpeciesApiCall("/pokemon-species", "flareon", ts.URL)
require.NoError(t, err, "Expected no error on successful API call")
assert.Equal(t, expectedSpecies, species, "Expected species struct does not match")
+ assert.Equal(t, "flareon", name, "Expected species name does not match")
})
- // Failed API call returns styled error
t.Run("Failed API call returns styled error", func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Simulate API failure (e.g., 404 Not Found)
@@ -269,12 +319,13 @@ func TestPokemonSpeciesApiCall(t *testing.T) {
}))
defer ts.Close()
- species, err := PokemonSpeciesApiCall("/pokemon-species", "non-existent-species", ts.URL)
+ species, name, err := PokemonSpeciesApiCall("/pokemon-species", "non-existent-species", ts.URL)
require.Error(t, err, "Expected an error for invalid species")
assert.Equal(t, structs.PokemonSpeciesJSONStruct{}, species, "Expected empty species struct on error")
+ assert.Empty(t, name, "Expected empty name string on error")
- assert.Contains(t, err.Error(), "Pokémon not found", "Expected 'Pokémon not found' in error message")
+ assert.Contains(t, err.Error(), "PokémonSpecies not found", "Expected 'PokémonSpecies not found' in error message")
assert.Contains(t, err.Error(), "Perhaps a typo?", "Expected helpful suggestion in error message")
})
}
diff --git a/nfpm.yaml b/nfpm.yaml
index dca2fa6..dfea760 100644
--- a/nfpm.yaml
+++ b/nfpm.yaml
@@ -1,7 +1,7 @@
name: "poke-cli"
arch: "arm64"
platform: "linux"
-version: "v1.8.0"
+version: "v1.8.1"
section: "default"
version_schema: semver
maintainer: "Christian S"
diff --git a/structs/structs.go b/structs/structs.go
index 90a4e2c..4381edf 100644
--- a/structs/structs.go
+++ b/structs/structs.go
@@ -1,5 +1,29 @@
package structs
+func (a AbilityJSONStruct) GetResourceName() string {
+ return a.Name
+}
+
+func (i ItemJSONStruct) GetResourceName() string {
+ return i.Name
+}
+
+func (m MoveJSONStruct) GetResourceName() string {
+ return m.Name
+}
+
+func (p PokemonJSONStruct) GetResourceName() string {
+ return p.Name
+}
+
+func (s PokemonSpeciesJSONStruct) GetResourceName() string {
+ return s.Name
+}
+
+func (t TypesJSONStruct) GetResourceName() string {
+ return t.Name
+}
+
// AbilityJSONStruct ability endpoint from API
type AbilityJSONStruct struct {
Name string `json:"name"`
diff --git a/styling/styling.go b/styling/styling.go
index 8c43ac3..3e4ac93 100644
--- a/styling/styling.go
+++ b/styling/styling.go
@@ -2,19 +2,18 @@ package styling
import (
"fmt"
- "github.com/charmbracelet/huh"
- "github.com/charmbracelet/lipgloss"
"image/color"
"regexp"
+
+ "github.com/charmbracelet/huh"
+ "github.com/charmbracelet/lipgloss"
)
var (
- Green = lipgloss.NewStyle().Foreground(lipgloss.Color("#38B000"))
- Red = lipgloss.NewStyle().Foreground(lipgloss.Color("#D00000"))
- Gray = lipgloss.Color("#777777")
- YellowAdaptive = func(s string) string {
- return lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#E1AD01", Dark: "#FFDE00"}).Render(s)
- }
+ Green = lipgloss.NewStyle().Foreground(lipgloss.Color("#38B000"))
+ Red = lipgloss.NewStyle().Foreground(lipgloss.Color("#D00000"))
+ Gray = lipgloss.Color("#777777")
+ Yellow = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#E1AD01", Dark: "#FFDE00"})
ColoredBullet = lipgloss.NewStyle().
SetString("•").
Foreground(lipgloss.Color("#FFCC00"))
diff --git a/testdata/ability_poison_point.golden b/testdata/ability_poison_point.golden
new file mode 100644
index 0000000..58444f9
--- /dev/null
+++ b/testdata/ability_poison_point.golden
@@ -0,0 +1,3 @@
+Poison Point
+• First introduced in generation III
+• Effect: Has a 30% chance of poisoning attacking Pokémon on contact.
\ No newline at end of file
diff --git a/testdata/main_latest_flag.golden b/testdata/main_latest_flag.golden
index 4166dd4..ebd0a2a 100644
--- a/testdata/main_latest_flag.golden
+++ b/testdata/main_latest_flag.golden
@@ -2,6 +2,6 @@
┃ ┃
┃ Latest available release ┃
┃ on GitHub: ┃
-┃ • v1.7.4 ┃
+┃ • v1.8.0 ┃
┃ ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛