diff --git a/banzuke.go b/banzuke.go new file mode 100644 index 0000000..5ca7831 --- /dev/null +++ b/banzuke.go @@ -0,0 +1,32 @@ +package sumoapi + +// Banzuke represents the ranking list of rikishi in a basho division. +type Banzuke struct { + BashoID BashoID `json:"bashoId" jsonschema:"The unique identifier for the basho (sumo tournament)."` + Division string `json:"division" jsonschema:"The division of the basho (sumo tournament). One of Makuuchi, Juryo, Makushita, Sandanme, Jonidan, Jonokuchi."` + East []RikishiBanzuke `json:"east" jsonschema:"The banzuke (ranking list) for the east side of the division."` + West []RikishiBanzuke `json:"west" jsonschema:"The banzuke (ranking list) for the west side of the division."` +} + +// RikishiBanzuke represents a rikishi's ranking information in a basho division. +type RikishiBanzuke struct { + Side string `json:"side" jsonschema:"The side of the rikishi in the banzuke (ranking list). Either East or West."` + RikishiID int `json:"rikishiID" jsonschema:"The unique identifier for the rikishi (sumo wrestler)."` + ShikonaEnglish string `json:"shikonaEn" jsonschema:"The shikona (ring name) in English of the rikishi."` + ShikonaJapanese string `json:"shikonaJp" jsonschema:"The shikona (ring name) in Japanese of the rikishi."` + HumanReadableRankName string `json:"rank" jsonschema:"The human-readable name of the rank (e.g., Maegashira 1 East, Ozeki 2 West) of the rikishi (sumo wrestler) in the specific basho (sumo tournament)."` + NumericRankName int `json:"rankValue" jsonschema:"The numeric name of the rank of the rikishi (sumo wrestler) in the specific basho (sumo tournament)."` + Wins int `json:"wins" jsonschema:"The number of wins the rikishi (sumo wrestler) achieved in the specific basho (sumo tournament)."` + Losses int `json:"losses" jsonschema:"The number of losses the rikishi (sumo wrestler) had in the specific basho (sumo tournament)."` + Absences int `json:"absences" jsonschema:"The number of absences the rikishi (sumo wrestler) had in the specific basho (sumo tournament)."` + Matches []RikishiBanzukeMatch `json:"record" jsonschema:"The list of matches the rikishi (sumo wrestler) had or will have in the specific basho (sumo tournament)."` +} + +// RikishiBanzukeMatch represents a match against an opponent in the banzuke. +type RikishiBanzukeMatch struct { + OpponentShikonaEnglish string `json:"opponentShikonaEn" jsonschema:"The shikona (ring name) in English of the opponent rikishi (sumo wrestler)."` + OpponentShikonaJapanese string `json:"opponentShikonaJp" jsonschema:"The shikona (ring name) in Japanese of the opponent rikishi (sumo wrestler)."` + OpponentID int `json:"opponentID" jsonschema:"The unique identifier for the opponent rikishi (sumo wrestler)."` + Result string `json:"result,omitempty" jsonschema:"The result of the match for the rikishi (sumo wrestler). One of win, loss, absent, fusen win (forfeit win), fusen loss (forfeit loss). This field may be omitted if the match has not yet occurred."` + Kimarite string `json:"kimarite,omitempty" jsonschema:"The kimarite (technique) used in the match, if the match has already occurred."` +} diff --git a/client.go b/client.go index 8139430..bd5cd49 100644 --- a/client.go +++ b/client.go @@ -18,6 +18,7 @@ type Client interface { ListRikishiMatchesAPI ListRikishiMatchesAgainstOpponentAPI GetBashoAPI + GetBanzukeAPI GetBashoWithTorikumiAPI ListKimariteAPI ListKimariteMatchesAPI @@ -70,6 +71,9 @@ func (c *client) doRequest(ctx context.Context, method, path string, query url.V if err != nil { return nil, fmt.Errorf("error creating http request: %w", err) } + if obj != nil { + req.Header.Set("Content-Type", "application/json") + } resp, err := c.httpClient.Do(req) if err != nil { diff --git a/get_banzuke.go b/get_banzuke.go new file mode 100644 index 0000000..e75756f --- /dev/null +++ b/get_banzuke.go @@ -0,0 +1,23 @@ +package sumoapi + +import ( + "context" + "fmt" +) + +// GetBanzukeAPI defines the methods available for retrieving a banzuke. +type GetBanzukeAPI interface { + // GetBanzuke calls the GET /api/basho/{bashoID}/banzuke/{division} endpoint. + GetBanzuke(ctx context.Context, req GetBanzukeRequest) (*Banzuke, error) +} + +// GetBanzukeRequest represents the request parameters for the GetBanzuke method. +type GetBanzukeRequest struct { + BashoID BashoID `json:"bashoId" jsonschema:"The unique identifier of the basho (sumo tournament) to retrieve the banzuke (ranking list) for. Format: YYYYMM, e.g., 202401 for the January 2024 basho."` + Division string `json:"division" jsonschema:"The division of the basho (sumo tournament) to retrieve the banzuke (ranking list) for. One of Makuuchi, Juryo, Makushita, Sandanme, Jonidan, Jonokuchi."` +} + +func (c *client) GetBanzuke(ctx context.Context, req GetBanzukeRequest) (*Banzuke, error) { + path := fmt.Sprintf("/basho/%s/banzuke/%s", req.BashoID.String(), req.Division) + return getObject[Banzuke](ctx, c, path, nil) +} diff --git a/get_banzuke_test.go b/get_banzuke_test.go new file mode 100644 index 0000000..772ca91 --- /dev/null +++ b/get_banzuke_test.go @@ -0,0 +1,272 @@ +package sumoapi_test + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + . "github.com/onsi/gomega" + + "github.com/sumo-mcp/sumoapi-go" +) + +func TestClient_GetBanzuke(t *testing.T) { + t.Run("get banzuke for division", func(t *testing.T) { + g := NewWithT(t) + + mockResp := `{ + "bashoId": "202511", + "division": "Makuuchi", + "east": [ + { + "side": "East", + "rikishiID": 8850, + "shikonaEn": "Onosato", + "shikonaJp": "大の里", + "rankValue": 101, + "rank": "Yokozuna 1 East", + "record": [ + { + "result": "win", + "opponentShikonaEn": "Takayasu", + "opponentShikonaJp": "高安", + "opponentID": 44, + "kimarite": "yorikiri" + } + ], + "wins": 11, + "losses": 4, + "absences": 0 + } + ], + "west": [ + { + "side": "West", + "rikishiID": 19, + "shikonaEn": "Hoshoryu", + "shikonaJp": "豊昇龍", + "rankValue": 201, + "rank": "Ozeki 1 West", + "record": [], + "wins": 12, + "losses": 3, + "absences": 0 + } + ] + }` + + transport := &mockTransport{ + validateRequest: func(req *http.Request) error { + g.Expect(req.Method).To(Equal(http.MethodGet)) + g.Expect(req.URL.Scheme).To(Equal("https")) + g.Expect(req.URL.Host).To(Equal("sumo-api.com")) + g.Expect(req.URL.Path).To(Equal("/api/basho/202511/banzuke/Makuuchi")) + return nil + }, + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(mockResp)), + }, + } + + client := sumoapi.New(sumoapi.WithHTTPClient(&http.Client{Transport: transport})) + bashoID := sumoapi.BashoID{Year: 2025, Month: 11} + resp, err := client.GetBanzuke(context.Background(), sumoapi.GetBanzukeRequest{ + BashoID: bashoID, + Division: "Makuuchi", + }) + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(resp).ToNot(BeNil()) + g.Expect(resp.BashoID).To(Equal(bashoID)) + g.Expect(resp.Division).To(Equal("Makuuchi")) + + // Check east side + g.Expect(resp.East).To(HaveLen(1)) + g.Expect(resp.East[0].Side).To(Equal("East")) + g.Expect(resp.East[0].RikishiID).To(Equal(8850)) + g.Expect(resp.East[0].ShikonaEnglish).To(Equal("Onosato")) + g.Expect(resp.East[0].ShikonaJapanese).To(Equal("大の里")) + g.Expect(resp.East[0].HumanReadableRankName).To(Equal("Yokozuna 1 East")) + g.Expect(resp.East[0].NumericRankName).To(Equal(101)) + g.Expect(resp.East[0].Wins).To(Equal(11)) + g.Expect(resp.East[0].Losses).To(Equal(4)) + g.Expect(resp.East[0].Absences).To(Equal(0)) + + // Check match record + g.Expect(resp.East[0].Matches).To(HaveLen(1)) + g.Expect(resp.East[0].Matches[0].Result).To(Equal("win")) + g.Expect(resp.East[0].Matches[0].OpponentShikonaEnglish).To(Equal("Takayasu")) + g.Expect(resp.East[0].Matches[0].OpponentShikonaJapanese).To(Equal("高安")) + g.Expect(resp.East[0].Matches[0].OpponentID).To(Equal(44)) + g.Expect(resp.East[0].Matches[0].Kimarite).To(Equal("yorikiri")) + + // Check west side + g.Expect(resp.West).To(HaveLen(1)) + g.Expect(resp.West[0].Side).To(Equal("West")) + g.Expect(resp.West[0].RikishiID).To(Equal(19)) + g.Expect(resp.West[0].ShikonaEnglish).To(Equal("Hoshoryu")) + }) + + t.Run("get banzuke for different division", func(t *testing.T) { + g := NewWithT(t) + + mockResp := `{ + "bashoId": "202501", + "division": "Juryo", + "east": [], + "west": [] + }` + + transport := &mockTransport{ + validateRequest: func(req *http.Request) error { + g.Expect(req.URL.Path).To(Equal("/api/basho/202501/banzuke/Juryo")) + return nil + }, + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(mockResp)), + }, + } + + client := sumoapi.New(sumoapi.WithHTTPClient(&http.Client{Transport: transport})) + bashoID := sumoapi.BashoID{Year: 2025, Month: 1} + resp, err := client.GetBanzuke(context.Background(), sumoapi.GetBanzukeRequest{ + BashoID: bashoID, + Division: "Juryo", + }) + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(resp).ToNot(BeNil()) + g.Expect(resp.BashoID).To(Equal(bashoID)) + g.Expect(resp.Division).To(Equal("Juryo")) + }) + + t.Run("banzuke with all result types", func(t *testing.T) { + g := NewWithT(t) + + mockResp := `{ + "bashoId": "202511", + "division": "Makuuchi", + "east": [ + { + "side": "East", + "rikishiID": 1, + "shikonaEn": "TestRikishi", + "shikonaJp": "テスト力士", + "rankValue": 101, + "rank": "Yokozuna 1 East", + "record": [ + {"result": "win", "opponentShikonaEn": "A", "opponentShikonaJp": "あ", "opponentID": 2, "kimarite": "yorikiri"}, + {"result": "loss", "opponentShikonaEn": "B", "opponentShikonaJp": "い", "opponentID": 3, "kimarite": "oshidashi"}, + {"result": "absent", "opponentShikonaEn": "C", "opponentShikonaJp": "う", "opponentID": 4}, + {"result": "fusen win", "opponentShikonaEn": "D", "opponentShikonaJp": "え", "opponentID": 5, "kimarite": "fusen"}, + {"result": "fusen loss", "opponentShikonaEn": "E", "opponentShikonaJp": "お", "opponentID": 6, "kimarite": "fusen"} + ], + "wins": 2, + "losses": 2, + "absences": 1 + } + ], + "west": [] + }` + + transport := &mockTransport{ + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(mockResp)), + }, + } + + client := sumoapi.New(sumoapi.WithHTTPClient(&http.Client{Transport: transport})) + resp, err := client.GetBanzuke(context.Background(), sumoapi.GetBanzukeRequest{ + BashoID: sumoapi.BashoID{Year: 2025, Month: 11}, + Division: "Makuuchi", + }) + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(resp).ToNot(BeNil()) + g.Expect(resp.East[0].Matches).To(HaveLen(5)) + g.Expect(resp.East[0].Matches[0].Result).To(Equal("win")) + g.Expect(resp.East[0].Matches[1].Result).To(Equal("loss")) + g.Expect(resp.East[0].Matches[2].Result).To(Equal("absent")) + g.Expect(resp.East[0].Matches[3].Result).To(Equal("fusen win")) + g.Expect(resp.East[0].Matches[4].Result).To(Equal("fusen loss")) + }) + + t.Run("context is propagated", func(t *testing.T) { + g := NewWithT(t) + + mockResp := `{ + "bashoId": "202511", + "division": "Makuuchi", + "east": [], + "west": [] + }` + + type testKey struct{} + ctx := context.WithValue(context.Background(), testKey{}, "test-value") + + transport := &mockTransport{ + validateRequest: func(req *http.Request) error { + g.Expect(req.Context().Value(testKey{})).To(Equal("test-value")) + return nil + }, + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(mockResp)), + }, + } + + client := sumoapi.New(sumoapi.WithHTTPClient(&http.Client{Transport: transport})) + _, err := client.GetBanzuke(ctx, sumoapi.GetBanzukeRequest{ + BashoID: sumoapi.BashoID{Year: 2025, Month: 11}, + Division: "Makuuchi", + }) + + g.Expect(err).ToNot(HaveOccurred()) + }) + + t.Run("http request error", func(t *testing.T) { + g := NewWithT(t) + + transport := &mockTransport{ + validateRequest: func(req *http.Request) error { + return http.ErrAbortHandler + }, + } + + client := sumoapi.New(sumoapi.WithHTTPClient(&http.Client{Transport: transport})) + resp, err := client.GetBanzuke(context.Background(), sumoapi.GetBanzukeRequest{ + BashoID: sumoapi.BashoID{Year: 2025, Month: 11}, + Division: "Makuuchi", + }) + + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("error making http request")) + g.Expect(resp).To(BeNil()) + }) + + t.Run("invalid JSON response", func(t *testing.T) { + g := NewWithT(t) + + transport := &mockTransport{ + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("not valid json")), + }, + } + + client := sumoapi.New(sumoapi.WithHTTPClient(&http.Client{Transport: transport})) + resp, err := client.GetBanzuke(context.Background(), sumoapi.GetBanzukeRequest{ + BashoID: sumoapi.BashoID{Year: 2025, Month: 11}, + Division: "Makuuchi", + }) + + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("error unmarshaling response body")) + g.Expect(resp).To(BeNil()) + }) +} diff --git a/rank.go b/rank.go index 9d878b6..ffa9987 100644 --- a/rank.go +++ b/rank.go @@ -5,6 +5,6 @@ type Rank struct { ID RikishiChangeID `json:"id" jsonschema:"The unique identifier for the rank in the format {bashoID}-{rikishiID} where {bashoID} is in the format YYYYMM and {rikishiID} is the unique identifier for the rikishi in the API."` BashoID BashoID `json:"bashoId" jsonschema:"The ID of the basho (sumo tournament) in the format YYYYMM."` RikishiID int `json:"rikishiId" jsonschema:"The unique identifier for the rikishi (sumo wrestler) in the API."` - HumanReadableName string `json:"rank,omitempty" jsonschema:"The human-readable name of the rank (e.g., Maegashira 1 East, Ozeki 2 West) of the rikishi in the specific basho (sumo tournament)."` - NumericName int `json:"rankValue,omitempty" jsonschema:"The numeric name of the rank of the rikishi in the specific basho (sumo tournament)."` + HumanReadableName string `json:"rank,omitempty" jsonschema:"The human-readable name of the rank (e.g., Maegashira 1 East, Ozeki 2 West) of the rikishi (sumo wrestler) in the specific basho (sumo tournament)."` + NumericName int `json:"rankValue,omitempty" jsonschema:"The numeric name of the rank of the rikishi (sumo wrestler) in the specific basho (sumo tournament)."` } diff --git a/tests/integration/get_banzuke_test.go b/tests/integration/get_banzuke_test.go new file mode 100644 index 0000000..2b7302a --- /dev/null +++ b/tests/integration/get_banzuke_test.go @@ -0,0 +1,93 @@ +package integration_test + +import ( + "context" + "testing" + + . "github.com/onsi/gomega" + + "github.com/sumo-mcp/sumoapi-go" +) + +func TestIntegration_GetBanzuke(t *testing.T) { + g := NewWithT(t) + + client := sumoapi.New() + + bashoID := sumoapi.BashoID{Year: 2025, Month: 11} + resp, err := client.GetBanzuke(context.Background(), sumoapi.GetBanzukeRequest{ + BashoID: bashoID, + Division: "Makuuchi", + }) + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(resp).ToNot(BeNil()) + g.Expect(resp.BashoID).To(Equal(bashoID)) + g.Expect(resp.Division).To(Equal("Makuuchi")) + g.Expect(resp.East).To(HaveLen(22)) + g.Expect(resp.West).To(HaveLen(20)) + + g.Expect(resp.East[0].Side).To(Equal("East")) + g.Expect(resp.East[0].RikishiID).To(Equal(8850)) + g.Expect(resp.East[0].ShikonaEnglish).To(Equal("Onosato")) + g.Expect(resp.East[0].ShikonaJapanese).To(Equal("大の里 泰輝")) + g.Expect(resp.East[0].HumanReadableRankName).To(Equal("Yokozuna 1 East")) + g.Expect(resp.East[0].NumericRankName).To(Equal(101)) + g.Expect(resp.East[0].Wins).To(Equal(11)) + g.Expect(resp.East[0].Losses).To(Equal(4)) + g.Expect(resp.East[0].Absences).To(Equal(0)) + g.Expect(resp.East[0].Matches).To(HaveLen(15)) + + g.Expect(resp.East[0].Matches[0].Result).To(Equal("win")) + g.Expect(resp.East[0].Matches[0].OpponentShikonaEnglish).To(Equal("Takayasu")) + g.Expect(resp.East[0].Matches[0].OpponentShikonaJapanese).To(Equal("高安")) + g.Expect(resp.East[0].Matches[0].OpponentID).To(Equal(44)) + g.Expect(resp.East[0].Matches[0].Kimarite).To(Equal("yorikiri")) + + g.Expect(resp.East[0].Matches[14].Result).To(Equal("fusen loss")) + g.Expect(resp.East[0].Matches[14].OpponentShikonaEnglish).To(Equal("Hoshoryu")) + g.Expect(resp.East[0].Matches[14].Kimarite).To(Equal("fusen")) + + g.Expect(resp.West[0].Side).To(Equal("West")) + g.Expect(resp.West[0].RikishiID).To(Equal(19)) + g.Expect(resp.West[0].ShikonaEnglish).To(Equal("Hoshoryu")) + g.Expect(resp.West[0].ShikonaJapanese).To(Equal("豊昇龍 智勝")) + g.Expect(resp.West[0].HumanReadableRankName).To(Equal("Yokozuna 1 West")) + g.Expect(resp.West[0].NumericRankName).To(Equal(101)) + g.Expect(resp.West[0].Wins).To(Equal(12)) + g.Expect(resp.West[0].Losses).To(Equal(3)) + g.Expect(resp.West[0].Absences).To(Equal(0)) + + g.Expect(resp.West[0].Matches[14].Result).To(Equal("fusen win")) + g.Expect(resp.West[0].Matches[14].OpponentShikonaEnglish).To(Equal("Onosato")) + g.Expect(resp.West[0].Matches[14].Kimarite).To(Equal("fusen")) + + var meisei *sumoapi.RikishiBanzuke + for i := range resp.East { + if resp.East[i].RikishiID == 38 { + meisei = &resp.East[i] + break + } + } + g.Expect(meisei).ToNot(BeNil()) + g.Expect(meisei.ShikonaEnglish).To(Equal("Meisei")) + g.Expect(meisei.Absences).To(Equal(9)) + g.Expect(meisei.Matches[0].Result).To(Equal("absent")) + + resultTypes := make(map[string]bool) + for _, wrestler := range resp.East { + for _, match := range wrestler.Matches { + resultTypes[match.Result] = true + } + } + for _, wrestler := range resp.West { + for _, match := range wrestler.Matches { + resultTypes[match.Result] = true + } + } + g.Expect(resultTypes).To(HaveKey("win")) + g.Expect(resultTypes).To(HaveKey("loss")) + g.Expect(resultTypes).To(HaveKey("absent")) + g.Expect(resultTypes).To(HaveKey("fusen win")) + g.Expect(resultTypes).To(HaveKey("fusen loss")) +}