diff --git a/client.go b/client.go index dac970c..ff69047 100644 --- a/client.go +++ b/client.go @@ -1,6 +1,7 @@ package raiderio import ( + "context" "encoding/json" "errors" "fmt" @@ -30,7 +31,7 @@ func NewClient() *Client { // GetCharacter retrieves a character profile from the Raider.IO API // It returns an error if the API returns a non-200 status code, or if the // response body cannot be read or mapped to the CharacterProfile struct -func (c *Client) GetCharacter(cq *CharacterQuery) (*Character, error) { +func (c *Client) GetCharacter(ctx context.Context, cq *CharacterQuery) (*Character, error) { err := validateCharacterQuery(cq) if err != nil { return nil, err @@ -41,7 +42,7 @@ func (c *Client) GetCharacter(cq *CharacterQuery) (*Character, error) { reqUrl += "&fields=" + strings.Join(cq.fields, ",") } - body, err := c.getAPIResponse(reqUrl) + body, err := c.getAPIResponse(ctx, reqUrl) if err != nil { return nil, err } @@ -58,7 +59,7 @@ func (c *Client) GetCharacter(cq *CharacterQuery) (*Character, error) { // GetGuild retrieves a guild profile from the Raider.IO API // It returns an error if the API returns a non-200 status code, or if the // response body cannot be read or mapped to the GuildProfile struct -func (c *Client) GetGuild(gq *GuildQuery) (*Guild, error) { +func (c *Client) GetGuild(ctx context.Context, gq *GuildQuery) (*Guild, error) { err := createGuildQuery(gq) if err != nil { return nil, err @@ -69,7 +70,7 @@ func (c *Client) GetGuild(gq *GuildQuery) (*Guild, error) { reqUrl += "&fields=" + strings.Join(gq.fields, ",") } - body, err := c.getAPIResponse(reqUrl) + body, err := c.getAPIResponse(ctx, reqUrl) if err != nil { return nil, err } @@ -86,9 +87,9 @@ func (c *Client) GetGuild(gq *GuildQuery) (*Guild, error) { // It returns an error if the API returns a non-200 status code, or if the // response body cannot be read or mapped to the Raids struct // Takes an Expansion enum as a parameter -func (c *Client) GetRaids(e expansion.Expansion) (*Raids, error) { +func (c *Client) GetRaids(ctx context.Context, e expansion.Expansion) (*Raids, error) { reqUrl := c.ApiUrl + "/raiding/static-data?expansion_id=" + fmt.Sprintf("%d", e) - body, err := c.getAPIResponse(reqUrl) + body, err := c.getAPIResponse(ctx, reqUrl) if err != nil { return nil, err } @@ -106,7 +107,7 @@ func (c *Client) GetRaids(e expansion.Expansion) (*Raids, error) { // It returns an error if the API returns a non-200 status code, or if the // response body cannot be read or mapped to the RaidRankings struct // Takes a RaidQuery struct as a parameter -func (c *Client) GetRaidRankings(rq *RaidQuery) (*RaidRankings, error) { +func (c *Client) GetRaidRankings(ctx context.Context, rq *RaidQuery) (*RaidRankings, error) { err := validateRaidRankingsQuery(rq) if err != nil { return nil, err @@ -127,7 +128,7 @@ func (c *Client) GetRaidRankings(rq *RaidQuery) (*RaidRankings, error) { reqUrl += "&page=" + fmt.Sprintf("%d", rq.Page) } - body, err := c.getAPIResponse(reqUrl) + body, err := c.getAPIResponse(ctx, reqUrl) if err != nil { return nil, err } diff --git a/client_test.go b/client_test.go index ced5e46..5560a0a 100644 --- a/client_test.go +++ b/client_test.go @@ -1,25 +1,39 @@ package raiderio_test import ( + "context" + "os" "testing" + "time" "github.com/tmaffia/raiderio" "github.com/tmaffia/raiderio/expansion" "github.com/tmaffia/raiderio/region" ) -func TestNewClient(t *testing.T) { - c := raiderio.NewClient() +var c *raiderio.Client +var defaultCtx context.Context + +func setup() { + c = raiderio.NewClient() + defaultCtx = context.Background() +} + +func TestMain(m *testing.M) { + setup() + exitCode := m.Run() + os.Exit(exitCode) +} +func TestNewClient(t *testing.T) { if c.ApiUrl != "https://raider.io/api/v1" { t.Errorf("NewClient apiUrl created incorrectly") } } func TestGetCharacterProfile(t *testing.T) { - c := raiderio.NewClient() - testCases := []struct { + timeout bool region *region.Region realm string name string @@ -33,10 +47,18 @@ func TestGetCharacterProfile(t *testing.T) { {region: ®ion.Region{Slug: "badregion"}, realm: "illidan", name: "impossiblecharactername", expectedErrMsg: "invalid region"}, {region: region.US, realm: "illidan", name: "impossiblecharactername", expectedErrMsg: "character not found"}, {region: region.US, realm: "invalidrealm", name: "highervalue", expectedErrMsg: "invalid realm"}, + {timeout: true, region: region.US, realm: "illidan", name: "highervalue", expectedErrMsg: "raiderio api request timeout"}, } for _, tc := range testCases { - profile, err := c.GetCharacter(&raiderio.CharacterQuery{ + ctx := defaultCtx + var cancel context.CancelFunc + if tc.timeout { + ctx, cancel = context.WithTimeout(defaultCtx, time.Millisecond*10) + defer cancel() + } + + profile, err := c.GetCharacter(ctx, &raiderio.CharacterQuery{ Region: tc.region, Realm: tc.realm, Name: tc.name, @@ -53,9 +75,8 @@ func TestGetCharacterProfile(t *testing.T) { } func TestGetCharacterWGear(t *testing.T) { - c := raiderio.NewClient() - testCases := []struct { + timeout bool region *region.Region realm string name string @@ -63,10 +84,18 @@ func TestGetCharacterWGear(t *testing.T) { expectedName string }{ {region: region.US, realm: "illidan", name: "highervalue", expectedName: "Highervalue"}, + {timeout: true, region: region.US, realm: "illidan", name: "highervalue", expectedErrMsg: "raiderio api request timeout"}, } for _, tc := range testCases { - profile, err := c.GetCharacter(&raiderio.CharacterQuery{ + ctx := defaultCtx + var cancel context.CancelFunc + if tc.timeout { + ctx, cancel = context.WithTimeout(ctx, time.Millisecond*10) + defer cancel() + } + + profile, err := c.GetCharacter(ctx, &raiderio.CharacterQuery{ Region: tc.region, Realm: tc.realm, Name: tc.name, @@ -88,7 +117,6 @@ func TestGetCharacterWGear(t *testing.T) { } func TestGetCharacterWTalents(t *testing.T) { - c := raiderio.NewClient() cq := raiderio.CharacterQuery{ Region: region.US, Realm: "illidan", @@ -96,16 +124,15 @@ func TestGetCharacterWTalents(t *testing.T) { TalentLoadout: true, } - profile, err := c.GetCharacter(&cq) + profile, err := c.GetCharacter(defaultCtx, &cq) if err == nil && profile.TalentLoadout.LoadoutText == "" { t.Fatalf("talent loadout: %v expected to not be empty", profile.TalentLoadout.LoadoutText) } } func TestGetGuild(t *testing.T) { - c := raiderio.NewClient() - testCases := []struct { + timeout bool region *region.Region realm string name string @@ -119,10 +146,18 @@ func TestGetGuild(t *testing.T) { {region: ®ion.Region{Slug: "badregion"}, realm: "illidan", name: "warpath", expectedErrMsg: "invalid region"}, {region: region.US, realm: "illidan", name: "impossible_guild_name", expectedErrMsg: "guild not found"}, {region: region.US, realm: "invalidrealm", name: "highervalue", expectedErrMsg: "invalid realm"}, + {timeout: true, region: region.US, realm: "illidan", name: "highervalue", expectedErrMsg: "raiderio api request timeout"}, } for _, tc := range testCases { - profile, err := c.GetGuild(&raiderio.GuildQuery{ + ctx := defaultCtx + var cancel context.CancelFunc + if tc.timeout { + ctx, cancel = context.WithTimeout(ctx, time.Millisecond*10) + defer cancel() + } + + profile, err := c.GetGuild(ctx, &raiderio.GuildQuery{ Region: tc.region, Realm: tc.realm, Name: tc.name, @@ -139,7 +174,6 @@ func TestGetGuild(t *testing.T) { } func TestGetGuildWMembers(t *testing.T) { - c := raiderio.NewClient() testCases := []struct { region *region.Region realm string @@ -149,12 +183,12 @@ func TestGetGuildWMembers(t *testing.T) { } for range testCases { - profile, err := c.GetGuild((&raiderio.GuildQuery{ + profile, err := c.GetGuild(defaultCtx, &raiderio.GuildQuery{ Region: region.US, Realm: "illidan", Name: "warpath", Members: true, - })) + }) if err != nil { t.Fatalf("Error getting guild") @@ -168,7 +202,6 @@ func TestGetGuildWMembers(t *testing.T) { } func TestGetGuildWRaidProgression(t *testing.T) { - c := raiderio.NewClient() testCases := []struct { region *region.Region realm string @@ -178,7 +211,7 @@ func TestGetGuildWRaidProgression(t *testing.T) { } for range testCases { - profile, err := c.GetGuild(&raiderio.GuildQuery{ + profile, err := c.GetGuild(defaultCtx, &raiderio.GuildQuery{ Region: region.US, Realm: "illidan", Name: "warpath", @@ -196,42 +229,54 @@ func TestGetGuildWRaidProgression(t *testing.T) { } func TestGetGuildWRaidRankings(t *testing.T) { - c := raiderio.NewClient() testCases := []struct { - region *region.Region - realm string - name string - raidName string - expectedRank int + timeout bool + region *region.Region + realm string + name string + raidName string + expectedRank int + expectedErrMsg string }{ {region: region.US, realm: "illidan", name: "warpath", raidName: "aberrus-the-shadowed-crucible", expectedRank: 158}, + {timeout: true, region: region.US, realm: "illidan", name: "warpath", + raidName: "aberrus-the-shadowed-crucible", expectedErrMsg: "raiderio api request timeout"}, } for _, tc := range testCases { - profile, err := c.GetGuild(&raiderio.GuildQuery{ + ctx := defaultCtx + var cancel context.CancelFunc + if tc.timeout { + ctx, cancel = context.WithTimeout(ctx, time.Millisecond*10) + defer cancel() + } + + profile, err := c.GetGuild(ctx, &raiderio.GuildQuery{ Region: region.US, Realm: "illidan", Name: "warpath", RaidRankings: true, }) - if err != nil { - t.Errorf("Error getting guild") + if err != nil && err.Error() != tc.expectedErrMsg { + t.Fatalf("error message got: %v, expected: %v", + err.Error(), tc.expectedErrMsg) } - rank := profile.RaidRankings[tc.raidName] - - if rank.Mythic.World != tc.expectedRank { - t.Fatalf("mythic guild ranking for raid: %v, got: %d, expected: %d", - rank.RaidSlug, rank.Mythic.World, tc.expectedRank) + if err == nil { + rank := profile.RaidRankings[tc.raidName] + if rank.Mythic.World != tc.expectedRank { + t.Fatalf("mythic guild ranking for raid: %v, got: %d, expected: %d", + rank.RaidSlug, rank.Mythic.World, tc.expectedRank) + } } } } func TestGetRaids(t *testing.T) { - c := raiderio.NewClient() testCases := []struct { + timeout bool expansion expansion.Expansion raidName string raidDifficulty string @@ -239,12 +284,19 @@ func TestGetRaids(t *testing.T) { expectedErrMsg string }{ {expansion: expansion.Dragonflight, raidName: "aberrus-the-shadowed-crucible", expectedRaidName: "Aberrus, the Shadowed Crucible"}, + {timeout: true, expansion: expansion.Dragonflight, raidName: "aberrus-the-shadowed-crucible", expectedErrMsg: "raiderio api request timeout"}, {expansion: 2, expectedErrMsg: "unsupported expansion"}, } for _, tc := range testCases { - raids, err := c.GetRaids(tc.expansion) + ctx := defaultCtx + var cancel context.CancelFunc + if tc.timeout { + ctx, cancel = context.WithTimeout(ctx, time.Millisecond*10) + defer cancel() + } + raids, err := c.GetRaids(ctx, tc.expansion) if err != nil && err.Error() != tc.expectedErrMsg { t.Fatalf("expected error: %v, got %v", tc.expectedErrMsg, err.Error()) } @@ -262,6 +314,7 @@ func TestGetRaids(t *testing.T) { func TestGetRaidRankings(t *testing.T) { c := raiderio.NewClient() testCases := []struct { + timeout bool slug string difficulty raiderio.RaidDifficulty region *region.Region @@ -284,10 +337,18 @@ func TestGetRaidRankings(t *testing.T) { {slug: "aberrus-the-shadowed-crucible", difficulty: raiderio.MythicRaid, region: region.WORLD, limit: -20, expectedErrMsg: "limit must be a positive int"}, {slug: "aberrus-the-shadowed-crucible", difficulty: raiderio.MythicRaid, region: region.US, expectedRank1GuildName: "Accession", limit: 40, page: 2}, {slug: "aberrus-the-shadowed-crucible", difficulty: raiderio.MythicRaid, region: region.US, limit: 40, page: -2, expectedErrMsg: "page must be a positive int"}, + {timeout: true, slug: "aberrus-the-shadowed-crucible", difficulty: raiderio.MythicRaid, region: region.US, expectedErrMsg: "raiderio api request timeout"}, } for _, tc := range testCases { - rankings, err := c.GetRaidRankings(&raiderio.RaidQuery{ + ctx := defaultCtx + var cancel context.CancelFunc + if tc.timeout { + ctx, cancel = context.WithTimeout(ctx, time.Millisecond*10) + defer cancel() + } + + rankings, err := c.GetRaidRankings(ctx, &raiderio.RaidQuery{ Slug: tc.slug, Difficulty: raiderio.RaidDifficulty(tc.difficulty), Region: tc.region, @@ -295,7 +356,6 @@ func TestGetRaidRankings(t *testing.T) { Limit: tc.limit, Page: tc.page, }) - if err != nil && err.Error() != tc.expectedErrMsg { t.Fatalf("expected error: %v, got: %v", tc.expectedErrMsg, err.Error()) } diff --git a/errors.go b/errors.go index 7d54b7a..9e7175a 100644 --- a/errors.go +++ b/errors.go @@ -19,12 +19,13 @@ var ( ErrUnsupportedExpac = errors.New("unsupported expansion") ErrLimitOutOfBounds = errors.New("limit must be a positive int") ErrPageOutOfBounds = errors.New("page must be a positive int") + ErrApiTimeout = errors.New("raiderio api request timeout") ErrUnexpected = errors.New("unexpected error") ) // Turns api errors into standardized go errors with // consistent error messages -func wrapAPIError(responseBody *apiErrorResponse) error { +func wrapApiError(responseBody *apiErrorResponse) error { if strings.Contains(responseBody.Message, "Failed to find region") { return ErrInvalidRegion } @@ -51,3 +52,10 @@ func wrapAPIError(responseBody *apiErrorResponse) error { return ErrUnexpected } + +func wrapHttpError(err error) error { + if strings.Contains(err.Error(), "context deadline exceeded") { + return ErrApiTimeout + } + return ErrUnexpected +} diff --git a/guild_test.go b/guild_test.go index 3a05930..7270c36 100644 --- a/guild_test.go +++ b/guild_test.go @@ -8,8 +8,6 @@ import ( ) func TestGetGuildRaidRankBySlug(t *testing.T) { - c := raiderio.NewClient() - testCases := []struct { region *region.Region realm string @@ -26,7 +24,7 @@ func TestGetGuildRaidRankBySlug(t *testing.T) { } for _, tc := range testCases { - profile, _ := c.GetGuild(&raiderio.GuildQuery{ + profile, _ := c.GetGuild(defaultCtx, &raiderio.GuildQuery{ Region: tc.region, Realm: tc.realm, Name: tc.name, diff --git a/raid_test.go b/raid_test.go index 96d2d64..861471c 100644 --- a/raid_test.go +++ b/raid_test.go @@ -3,13 +3,10 @@ package raiderio_test import ( "testing" - "github.com/tmaffia/raiderio" "github.com/tmaffia/raiderio/expansion" ) func TestGetRaidBySlug(t *testing.T) { - c := raiderio.NewClient() - testCases := []struct { slug string expectedName string @@ -17,9 +14,10 @@ func TestGetRaidBySlug(t *testing.T) { }{ {slug: "aberrus-the-shadowed-crucible", expectedName: "Aberrus, the Shadowed Crucible"}, {slug: "invalid raid slug", expectedErrMsg: "invalid raid"}, + {slug: "aberrus-the-shadowed-crucibleinvalid raid slug", expectedErrMsg: "invalid raid"}, } - raids, _ := c.GetRaids(expansion.Dragonflight) + raids, _ := c.GetRaids(defaultCtx, expansion.Dragonflight) for _, tc := range testCases { raid, err := raids.GetRaidBySlug(tc.slug) diff --git a/request.go b/request.go index 705ed94..44f48ea 100644 --- a/request.go +++ b/request.go @@ -1,9 +1,11 @@ package raiderio import ( + "context" "encoding/json" "errors" "io" + "net/http" ) type apiErrorResponse struct { @@ -18,10 +20,15 @@ type apiErrorResponse struct { // Returns the error message from the api back to the client method that calls it, // so in cases where the realm or the character name cannot be found, developer is presented // with that error state. -func (c *Client) getAPIResponse(reqUrl string) ([]byte, error) { - resp, err := c.HttpClient.Get(reqUrl) +func (c *Client) getAPIResponse(ctx context.Context, reqUrl string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqUrl, nil) if err != nil { - return nil, errors.New("HTTP error on API request") + return nil, errors.New("error creating HTTP request") + } + + resp, err := c.HttpClient.Do(req) + if err != nil { + return nil, wrapHttpError(err) } var body []byte @@ -37,11 +44,11 @@ func (c *Client) getAPIResponse(reqUrl string) ([]byte, error) { // unmarshal error implies response is in an incorrect format // instead of api message, return http status if err != nil { - return nil, wrapAPIError(&responseBody) + return nil, wrapApiError(&responseBody) } // return error with message directly from the api - return nil, wrapAPIError(&responseBody) + return nil, wrapApiError(&responseBody) } return body, nil