package clash import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" ) const defaultBaseURL = "https://api.clashofclans.com/v1" // Client wraps the Clash of Clans REST API. type Client struct { token string httpClient *http.Client baseURL string } // MemberDetail combines clan and player information for reporting. type MemberDetail struct { Tag string Name string TownHallLevel int League string Trophies int Donations int DonationsReceived int LastSeen time.Time } // NewClient constructs a Clash API client with the provided token. func NewClient(token string, opts ...Option) *Client { c := &Client{ token: token, httpClient: &http.Client{ Timeout: 10 * time.Second, }, baseURL: defaultBaseURL, } for _, opt := range opts { opt(c) } return c } // Option customises the API client. type Option func(*Client) // WithHTTPClient injects a custom HTTP client. func WithHTTPClient(httpClient *http.Client) Option { return func(c *Client) { if httpClient != nil { c.httpClient = httpClient } } } // WithBaseURL overrides the default API base URL. Useful for testing. func WithBaseURL(baseURL string) Option { return func(c *Client) { if baseURL != "" { c.baseURL = strings.TrimRight(baseURL, "/") } } } // ClanMembers returns detailed information for current clan members. func (c *Client) ClanMembers(ctx context.Context, clanTag string) ([]MemberDetail, error) { clan, err := c.fetchClan(ctx, clanTag) if err != nil { return nil, err } if len(clan.MemberList) == 0 { return nil, nil } members := make([]MemberDetail, 0, len(clan.MemberList)) for _, member := range clan.MemberList { player, err := c.fetchPlayer(ctx, member.Tag) if err != nil { return nil, fmt.Errorf("fetch player %s: %w", member.Tag, err) } lastSeen, err := parseLastSeen(player.LastSeen) if err != nil { lastSeen = time.Time{} } members = append(members, MemberDetail{ Tag: member.Tag, Name: member.Name, TownHallLevel: player.TownHallLevel, League: member.League.Name, Trophies: member.Trophies, Donations: member.Donations, DonationsReceived: member.DonationsReceived, LastSeen: lastSeen, }) } return members, nil } func (c *Client) fetchClan(ctx context.Context, clanTag string) (*clanResponse, error) { escapedTag := url.PathEscape(strings.TrimSpace(clanTag)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/clans/%s", c.baseURL, escapedTag), nil) if err != nil { return nil, fmt.Errorf("build clan request: %w", err) } c.applyHeaders(req) res, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("execute clan request: %w", err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(res.Body, 2048)) return nil, fmt.Errorf("clan request failed: status=%d body=%s", res.StatusCode, string(body)) } var payload clanResponse if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { return nil, fmt.Errorf("decode clan response: %w", err) } return &payload, nil } func (c *Client) fetchPlayer(ctx context.Context, playerTag string) (*playerResponse, error) { escapedTag := url.PathEscape(strings.TrimSpace(playerTag)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/players/%s", c.baseURL, escapedTag), nil) if err != nil { return nil, fmt.Errorf("build player request: %w", err) } c.applyHeaders(req) res, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("execute player request: %w", err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(res.Body, 2048)) return nil, fmt.Errorf("player request failed: status=%d body=%s", res.StatusCode, string(body)) } var payload playerResponse if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { return nil, fmt.Errorf("decode player response: %w", err) } return &payload, nil } func (c *Client) applyHeaders(req *http.Request) { req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Bearer "+c.token) } func parseLastSeen(value string) (time.Time, error) { if value == "" { return time.Time{}, fmt.Errorf("empty lastSeen") } layouts := []string{ "20060102T150405.000Z", time.RFC3339, } for _, layout := range layouts { if t, err := time.Parse(layout, value); err == nil { return t, nil } } return time.Time{}, fmt.Errorf("unsupported lastSeen format: %s", value) } type clanResponse struct { MemberList []clanMember `json:"memberList"` } type clanMember struct { Tag string `json:"tag"` Name string `json:"name"` Donations int `json:"donations"` DonationsReceived int `json:"donationsReceived"` Trophies int `json:"trophies"` League league `json:"league"` Role string `json:"role"` ExpLevel int `json:"expLevel"` TownHallLevel int `json:"townHallLevel"` } type league struct { Name string `json:"name"` } type playerResponse struct { Tag string `json:"tag"` Name string `json:"name"` LastSeen string `json:"lastSeen"` TownHallLevel int `json:"townHallLevel"` }