sleeper_api 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0fdd2ef6194210448caf049cdd89a37182bcd13205e45ea9543945c172d36fd4
4
- data.tar.gz: 811a0991f5c863956c62c637971571aef98ccd379f9a3bff4b33ad1d28405290
3
+ metadata.gz: 0375caa5f944f2f083cdc8cec7672f44fc9219969e8e733bde441c377e16ede4
4
+ data.tar.gz: a1d54df90f2ff3ba533d11756993513eca6bae884f1a1fc0e57e51d9be84fa12
5
5
  SHA512:
6
- metadata.gz: 16f575a6d822157d44b7d5aef135626371e0fb03ed8359c70abf82f49bf6d9ae1373ff7a5ee49cbf4c32986593eec23e838b08e70ce5306e29d5fb3df5229be4
7
- data.tar.gz: b7b325b17bfd0c9d921ec16dbaa07fdcf6f277d242732db1f414d11f0c5846664ac300c2fc64f2401a3ef54402c112b8a9e89651f986ba3628a5fdcabc38f394
6
+ metadata.gz: ba5338528abf3a7c924dd9fdfd7dbf6440a1ac75debc3426cca4d580520a65ce7986075c2d8a6709305a5e57720c08a9b0749710e83b46381628b2991bab702b
7
+ data.tar.gz: 84ff6c0503398ca629009b1976dfd2ead6a9594d63eabdf97fb0056ed29d3b4965e8f2a4d17091c1e6d02560c1b28f469afeffeacaccc36acb7b394c3415dc2a
data/CHANGELOG.md CHANGED
@@ -1,3 +1,61 @@
1
+ ## [1.6.0] - 2026-09-28
2
+
3
+ ### Added
4
+
5
+ - **`SleeperApi::UserNotFound`, a subclass of `SleeperApi::Error`.** Sleeper
6
+ answers an unknown user with `200` and a literal `null` body — checked live
7
+ 2026-09-28 — so there was no error at all: `get_user` returned a response
8
+ whose `parsed_response` was nil. It now raises `UserNotFound`, and so does
9
+ `User.new` for a payload with no `user_id`. A caller can tell "no such user"
10
+ (permanent) from an outage without matching message strings; rescuing
11
+ `SleeperApi::Error` alone still sees both.
12
+
13
+ ### Changed
14
+
15
+ - **`League#matchups_by_week` returns rosters on a bye** instead of dropping
16
+ them. Each comes back unpaired as `{ matchup_id: nil, rosters: [roster] }`,
17
+ never grouped with another bye. A bye used to look exactly like a missing
18
+ team.
19
+ - **`get_user` raises for an unknown user** rather than returning a nil-bodied
20
+ response (see Added).
21
+
22
+ ### Fixed
23
+
24
+ - **`User#leagues`, `#rosters` and `#drafts` answer for the season asked.** The
25
+ memo was not keyed by season, so `leagues(2025)` then `leagues(2024)` on one
26
+ instance returned 2025's leagues — silently wrong rows for a multi-season
27
+ caller.
28
+ - **Every value interpolated into a request path is escaped.** `get_user("a b")`
29
+ raised `URI::InvalidURIError`, which is not a `SleeperApi::Error`, and
30
+ `get_user("../../league/123")` requested `/league/123`. Every `Client` path is
31
+ now built from escaped segments.
32
+ - **Week 18 is reachable.** `matchups_by_week` and `transactions` accepted
33
+ weeks 1–17 and Sleeper's regular season runs to 18; `#matchups` now fetches
34
+ all eighteen.
35
+ - **`Draft#summary` no longer raises on a pick with no metadata**, and a pick
36
+ with one name part is not padded with a space.
37
+ - **The README** named `get_league_playoff_bracket` / `get_league_toilet_bowl`
38
+ (the methods are `get_playoff_bracket` / `get_toilet_bowl`) and described a
39
+ disk cache that does not exist.
40
+
41
+ ## [1.5.1] - 2026-09-25
42
+
43
+ ### Fixed
44
+
45
+ - **A connection that never got an answer now raises `SleeperApi::Error`.**
46
+ Before, only non-2xx responses and timeouts did: no DNS (`SocketError`), a
47
+ refused, reset or unreachable connection (`SystemCallError`), a failed TLS
48
+ handshake (`OpenSSL::SSL::SSLError`) and a socket closed mid-response
49
+ (`EOFError`) escaped `make_request` as themselves. A caller rescuing
50
+ `SleeperApi::Error` for "Sleeper is down" missed the commonest outage of all —
51
+ a laptop waking before its network — and one consumer recorded 196 failed
52
+ imports that its retry policy never saw.
53
+
54
+ The message is `"Could not reach <path>: <original message>"`, naming the host
55
+ for a `WEB_HOST` call. **These are not retried** whatever `retries` is set to:
56
+ they fail at once, and the caller's backoff is the one that can wait long
57
+ enough to matter. `retries` still governs timeouts only.
58
+
1
59
  ## [1.5.0] - 2026-09-25
2
60
 
3
61
  ### Added
data/CLAUDE.md CHANGED
@@ -28,7 +28,9 @@ Four layers, with a deliberate split between HTTP and modeling:
28
28
  - **`SleeperApi`** (`lib/sleeper_api.rb`) — module-level config + memoized global `SleeperApi.client`. `Configuration` validates `timeout` (10–60) and `retries` (0–5), raising `SleeperApi::Error` outside those bounds.
29
29
  - **`Client`** — the only thing that talks HTTP. `include HTTParty` with `base_uri "https://api.sleeper.app"` — **the bare host; the `/v1` lives in each path**. That is deliberate and load-bearing: not every Sleeper endpoint is versioned. `/schedule/{sport}/{season_type}/{season}` is served from the host root, and while `base_uri` carried the version that endpoint was unreachable at any path a caller could pass in. A new endpoint spells out where it lives; do not move the version back into `base_uri` to shorten the paths.
30
30
 
31
- Every call funnels through the private `make_request`, which handles retry-on-timeout, logging, and converts non-2xx into `SleeperApi::Error`. **That error quotes the path**, so the path prefix is part of a public string — v1.2.0 changed it from `"Failed to fetch /user/x: 404"` to `"Failed to fetch /v1/user/x: 404"`. `Client` also owns the 24-hour in-memory player cache.
31
+ Every call funnels through the private `make_request`, which handles retry-on-timeout, logging, and converts non-2xx into `SleeperApi::Error` — and, since 1.5.1, **every connection failure too** (`CONNECTION_ERRORS`: `SocketError`, `SystemCallError`, `OpenSSL::SSL::SSLError`, `EOFError`), unretried, as `"Could not reach <path>: …"`. A consumer can rescue `SleeperApi::Error` alone and see every way a call fails. **That error quotes the path**, so the path prefix is part of a public string — v1.2.0 changed it from `"Failed to fetch /user/x: 404"` to `"Failed to fetch /v1/user/x: 404"`. `Client` also owns the 24-hour in-memory player cache.
32
+
33
+ ⚠️ **Never interpolate a value into a request path; build it with the private `path(*segments)`**, which escapes each segment. An unescaped one raised `URI::InvalidURIError` — not a `SleeperApi::Error`, so it escaped every rescue — or walked out of the endpoint: `../../league/123` as a username requested `/league/123` (G.6, v1.6.0). An unknown user is `200` with a `null` body, which `get_user` turns into `SleeperApi::UserNotFound`.
32
34
 
33
35
  ⚠️ **There are two Sleeper hosts and v1.4.0 added the second.** `WEB_HOST`
34
36
  (`https://api.sleeper.com`) is the one the web app uses, and it serves
data/README.md CHANGED
@@ -339,8 +339,8 @@ season_stats = client.stats(2025)
339
339
  real = projected.parsed_response.select { |_id, row| row.key?("pts_ppr") }
340
340
 
341
341
  # Get playoff brackets
342
- winners_bracket = client.get_league_playoff_bracket("league_id")
343
- losers_bracket = client.get_league_toilet_bowl("league_id")
342
+ winners_bracket = client.get_playoff_bracket("league_id")
343
+ losers_bracket = client.get_toilet_bowl("league_id")
344
344
 
345
345
  # Get league drafts
346
346
  league_drafts = client.get_league_drafts("league_id")
@@ -366,7 +366,9 @@ end
366
366
 
367
367
  ### Error Types
368
368
 
369
- - SleeperApi::Error - API-related errors (404, 500, timeouts, etc.)
369
+ - SleeperApi::Error - API-related errors (404, 500, timeouts, an unreachable host)
370
+
371
+ - SleeperApi::UserNotFound - a subclass of SleeperApi::Error: no Sleeper user has that identifier. Permanent, so rescue it before SleeperApi::Error if you retry the rest
370
372
 
371
373
  - ArgumentError - Invalid parameters passed to methods
372
374
 
@@ -374,12 +376,10 @@ end
374
376
 
375
377
  ### Caching
376
378
 
377
- - Player data is cached for 24 hours to reduce API calls
379
+ - Player data is cached in memory for 24 hours, on the client instance — a new client downloads it again
378
380
 
379
381
  - League/User/Draft data is cached per instance
380
382
 
381
- - Cache files are stored in the current working directory
382
-
383
383
  ### Rate Limiting
384
384
 
385
385
  - Be mindful of Sleeper's rate limits: stay under 1000 API calls per minute
@@ -390,7 +390,7 @@ end
390
390
 
391
391
  ### Memory Usage
392
392
 
393
- - Large datasets (like all players) are cached to disk
393
+ - The all-players payload is several megabytes and held in memory; never fan it out
394
394
 
395
395
  - League rosters and matchups are fetched lazily
396
396
 
@@ -1,4 +1,5 @@
1
1
  require "httparty"
2
+ require "openssl"
2
3
  require "json"
3
4
  require "erb"
4
5
 
@@ -28,6 +29,12 @@ module SleeperApi
28
29
  # because every other endpoint here lives on `.app`.
29
30
  WEB_HOST = "https://api.sleeper.com".freeze
30
31
 
32
+ # The request never got an answer (1.5.1): no DNS, a refused or reset
33
+ # connection, a failed TLS handshake, a socket closed mid-response. Before,
34
+ # these escaped as themselves, so a caller rescuing SleeperApi::Error for
35
+ # an outage missed the commonest one. Timeouts are handled separately.
36
+ CONNECTION_ERRORS = [SocketError, SystemCallError, OpenSSL::SSL::SSLError, EOFError].freeze
37
+
31
38
  # Not HTTParty's own. Its JSON branch passes `quirks_mode`, which json 3.0
32
39
  # removed, so without this every response raises ArgumentError the moment a
33
40
  # consumer resolves json 3. See JsonParser.
@@ -69,11 +76,18 @@ module SleeperApi
69
76
 
70
77
  # Fetch user data by identifier.
71
78
  #
79
+ # Sleeper answers an unknown user with 200 and a literal `null` body, not
80
+ # a 404, so that is turned into {SleeperApi::UserNotFound} here.
81
+ #
72
82
  # @param identifier [String] Username or user ID
73
83
  # @return [Hash] Raw user data
84
+ # @raise [SleeperApi::UserNotFound] If no user has that identifier
74
85
  # @see https://docs.sleeper.com/#user
75
86
  def get_user(identifier)
76
- make_request("/v1/user/#{identifier}")
87
+ response = make_request(path("v1", "user", identifier))
88
+ raise UserNotFound, "No Sleeper user #{identifier.to_s.inspect}" if response.parsed_response.nil?
89
+
90
+ response
77
91
  end
78
92
 
79
93
  # Get leagues for a user in a specific season.
@@ -84,7 +98,7 @@ module SleeperApi
84
98
  # @return [Array<Hash>] League data
85
99
  # @see https://docs.sleeper.com/#get-all-leagues-for-user
86
100
  def get_user_leagues(user_id, sport: "nfl", season: Time.now.year)
87
- make_request("/v1/user/#{user_id}/leagues/#{sport}/#{season}")
101
+ make_request(path("v1", "user", user_id, "leagues", sport, season))
88
102
  end
89
103
 
90
104
  # Get drafts for a user in a specific season.
@@ -95,7 +109,7 @@ module SleeperApi
95
109
  # @return [Array<Hash>] Draft data
96
110
  # @see https://docs.sleeper.com/#get-all-drafts-for-user
97
111
  def get_user_drafts(user_id, sport: "nfl", season: Time.now.year)
98
- make_request("/v1/user/#{user_id}/drafts/#{sport}/#{season}")
112
+ make_request(path("v1", "user", user_id, "drafts", sport, season))
99
113
  end
100
114
 
101
115
  # Fetch league details.
@@ -104,7 +118,7 @@ module SleeperApi
104
118
  # @return [Hash] League metadata
105
119
  # @see https://docs.sleeper.com/#get-a-specific-league
106
120
  def get_league(league_id)
107
- make_request("/v1/league/#{league_id}")
121
+ make_request(path("v1", "league", league_id))
108
122
  end
109
123
 
110
124
  # Get all rosters in a league.
@@ -113,7 +127,7 @@ module SleeperApi
113
127
  # @return [Array<Hash>] Roster data
114
128
  # @see https://docs.sleeper.com/#getting-rosters-in-a-league
115
129
  def get_league_rosters(league_id)
116
- make_request("/v1/league/#{league_id}/rosters")
130
+ make_request(path("v1", "league", league_id, "rosters"))
117
131
  end
118
132
 
119
133
  # Get all users in a league.
@@ -122,7 +136,7 @@ module SleeperApi
122
136
  # @return [Array<Hash>] User data
123
137
  # @see https://docs.sleeper.com/#getting-users-in-a-league
124
138
  def get_league_users(league_id)
125
- make_request("/v1/league/#{league_id}/users")
139
+ make_request(path("v1", "league", league_id, "users"))
126
140
  end
127
141
 
128
142
  # Get matchups for a specific week.
@@ -132,7 +146,7 @@ module SleeperApi
132
146
  # @return [Array<Hash>] Matchup data
133
147
  # @see https://docs.sleeper.com/#getting-matchups-in-a-league
134
148
  def get_league_matchups(league_id, week)
135
- make_request("/v1/league/#{league_id}/matchups/#{week}")
149
+ make_request(path("v1", "league", league_id, "matchups", week))
136
150
  end
137
151
 
138
152
  # Get playoff winners bracket.
@@ -141,7 +155,7 @@ module SleeperApi
141
155
  # @return [Array<Hash>] Bracket matchups
142
156
  # @see https://docs.sleeper.com/#getting-the-playoff-bracket
143
157
  def get_playoff_bracket(league_id)
144
- make_request("/v1/league/#{league_id}/winners_bracket")
158
+ make_request(path("v1", "league", league_id, "winners_bracket"))
145
159
  end
146
160
 
147
161
  # Get toilet bowl (losers bracket).
@@ -150,7 +164,7 @@ module SleeperApi
150
164
  # @return [Array<Hash>] Bracket matchups
151
165
  # @see https://docs.sleeper.com/#getting-the-playoff-bracket
152
166
  def get_toilet_bowl(league_id)
153
- make_request("/v1/league/#{league_id}/losers_bracket")
167
+ make_request(path("v1", "league", league_id, "losers_bracket"))
154
168
  end
155
169
 
156
170
  # Get transactions for a specific week.
@@ -160,7 +174,7 @@ module SleeperApi
160
174
  # @return [Array<Hash>] Transaction data
161
175
  # @see https://docs.sleeper.com/#get-transactions
162
176
  def get_transactions(league_id, week)
163
- make_request("/v1/league/#{league_id}/transactions/#{week}")
177
+ make_request(path("v1", "league", league_id, "transactions", week))
164
178
  end
165
179
 
166
180
  # Get league drafts.
@@ -169,7 +183,7 @@ module SleeperApi
169
183
  # @return [Array<Hash>] Draft data
170
184
  # @see https://docs.sleeper.com/#get-all-drafts-for-a-league
171
185
  def get_league_drafts(league_id)
172
- make_request("/v1/league/#{league_id}/drafts")
186
+ make_request(path("v1", "league", league_id, "drafts"))
173
187
  end
174
188
 
175
189
  # Get traded draft picks for a league.
@@ -178,7 +192,7 @@ module SleeperApi
178
192
  # @return [Array<Hash>] Traded picks
179
193
  # @see https://docs.sleeper.com/#get-traded-picks-in-a-draft
180
194
  def get_league_traded_picks(league_id)
181
- make_request("/v1/league/#{league_id}/traded_picks")
195
+ make_request(path("v1", "league", league_id, "traded_picks"))
182
196
  end
183
197
 
184
198
  # Fetch draft details.
@@ -187,7 +201,7 @@ module SleeperApi
187
201
  # @return [Hash] Draft metadata
188
202
  # @see https://docs.sleeper.com/#get-a-specific-draft
189
203
  def get_draft(draft_id)
190
- make_request("/v1/draft/#{draft_id}")
204
+ make_request(path("v1", "draft", draft_id))
191
205
  end
192
206
 
193
207
  # Get draft picks.
@@ -196,7 +210,7 @@ module SleeperApi
196
210
  # @return [Array<Hash>] Pick data
197
211
  # @see https://docs.sleeper.com/#get-all-picks-in-a-draft
198
212
  def get_draft_picks(draft_id)
199
- make_request("/v1/draft/#{draft_id}/picks")
213
+ make_request(path("v1", "draft", draft_id, "picks"))
200
214
  end
201
215
 
202
216
  # Get traded draft picks for a draft.
@@ -205,7 +219,7 @@ module SleeperApi
205
219
  # @return [Array<Hash>] Traded picks
206
220
  # @see https://docs.sleeper.com/#get-traded-picks-in-a-draft
207
221
  def get_draft_traded_picks(draft_id)
208
- make_request("/v1/draft/#{draft_id}/traded_picks")
222
+ make_request(path("v1", "draft", draft_id, "traded_picks"))
209
223
  end
210
224
 
211
225
  # Get NFL state (week, season status).
@@ -214,7 +228,7 @@ module SleeperApi
214
228
  # @return [Hash] State data
215
229
  # @see https://docs.sleeper.com/#get-nfl-state
216
230
  def get_nfl_state(sport = "nfl")
217
- nfl_state = make_request("/v1/state/#{sport}")
231
+ nfl_state = make_request(path("v1", "state", sport))
218
232
  nfl_state.each_with_object({}) do |(k, v), result|
219
233
  key = k.is_a?(String) ? k.to_sym : k
220
234
  result[key] = v
@@ -417,7 +431,7 @@ module SleeperApi
417
431
  # @param sport [String] Sport code (default: "nfl")
418
432
  # @return [HTTParty::Response] Array of games
419
433
  def schedule(season, season_type: "regular", sport: "nfl")
420
- make_request("/schedule/#{sport}/#{season_type}/#{season}")
434
+ make_request(path("schedule", sport, season_type, season))
421
435
  end
422
436
 
423
437
  # Get trending players.
@@ -429,7 +443,8 @@ module SleeperApi
429
443
  # @return [Array<Hash>] Trending players
430
444
  # @see https://docs.sleeper.com/#trending-players
431
445
  def trending_players(sport = "nfl", type: "add", lookback_hours: 24, limit: 25)
432
- make_request("/v1/players/#{sport}/trending/#{type}?lookback_hours=#{lookback_hours}&limit=#{limit}")
446
+ query = URI.encode_www_form(lookback_hours: lookback_hours, limit: limit)
447
+ make_request("#{path("v1", "players", sport, "trending", type)}?#{query}")
433
448
  end
434
449
 
435
450
  # Get all player data (cached for 24 hours).
@@ -440,7 +455,7 @@ module SleeperApi
440
455
  def get_players(sport = "nfl")
441
456
  return @players_cache if @players_cache && @cache_timestamp && (Time.now - @cache_timestamp) < (3600 * 24)
442
457
 
443
- response = make_request("/v1/players/#{sport}")
458
+ response = make_request(path("v1", "players", sport))
444
459
  @players_cache = response.parsed_response
445
460
  @cache_timestamp = Time.now
446
461
 
@@ -459,18 +474,18 @@ module SleeperApi
459
474
 
460
475
  private
461
476
 
462
- # Path for #stats and #projections. Segments are escaped because they are
463
- # interpolated into a URI path: an unescaped one can walk out of the
464
- # endpoint entirely, which is the bug the consuming app had to work around
465
- # for #get_user.
466
- #
467
- # A nil week drops the segment rather than defaulting, because the shorter
468
- # path is season totals.
469
- def weekly_path(resource, sport, season_type, season, week)
470
- segments = [resource, sport, season_type, season, week].compact
471
- escaped = segments.map { |segment| ERB::Util.url_encode(segment.to_s) }
477
+ # Every request path is built here, from escaped segments. An unescaped
478
+ # one raised URI::InvalidURIError, which is not a SleeperApi::Error, or
479
+ # walked out of the endpoint entirely: `../../league/123` as a username
480
+ # requested /league/123.
481
+ def path(*segments)
482
+ "/#{segments.map { |segment| ERB::Util.url_encode(segment.to_s) }.join("/")}"
483
+ end
472
484
 
473
- "/v1/#{escaped.join("/")}"
485
+ # Path for #stats and #projections. A nil week drops the segment rather
486
+ # than defaulting, because the shorter path is season totals.
487
+ def weekly_path(resource, sport, season_type, season, week)
488
+ path("v1", *[resource, sport, season_type, season, week].compact)
474
489
  end
475
490
 
476
491
  # Make an HTTP request with retry logic and logging.
@@ -516,6 +531,11 @@ module SleeperApi
516
531
  @config.logger&.error("Request timed out for #{named} after #{retries} retries")
517
532
  raise SleeperApi::Error, "Request timed out after #{retries} retries"
518
533
  end
534
+ rescue *CONNECTION_ERRORS => e
535
+ # No retry: these fail at once, and the caller's backoff is the one
536
+ # that can wait long enough to matter.
537
+ @config.logger&.error("Could not reach #{named}: #{e.message}")
538
+ raise SleeperApi::Error, "Could not reach #{named}: #{e.message}"
519
539
  end
520
540
  end
521
541
  end
@@ -109,8 +109,8 @@ module SleeperApi
109
109
  top_picks: picks.select { |pick| pick[:round] == 1 }.map do |pick|
110
110
  {
111
111
  player_id: pick[:player_id],
112
- player_name: "#{pick[:metadata][:first_name]} #{pick[:metadata][:last_name]}",
113
- position: pick[:metadata][:position],
112
+ player_name: pick_player_name(pick),
113
+ position: pick[:metadata]&.dig(:position),
114
114
  picked_by: pick[:picked_by]
115
115
  }
116
116
  end
@@ -135,6 +135,13 @@ module SleeperApi
135
135
 
136
136
  private
137
137
 
138
+ # A pick can arrive with null metadata, or a team defense with only a
139
+ # last name, so the name is built from whatever parts exist.
140
+ def pick_player_name(pick)
141
+ parts = [pick[:metadata]&.dig(:first_name), pick[:metadata]&.dig(:last_name)].compact
142
+ parts.empty? ? nil : parts.join(" ")
143
+ end
144
+
138
145
  def fetch_draft_data
139
146
  @draft_data ||= @client.get_draft(@draft_id)
140
147
  end
@@ -22,7 +22,7 @@ module SleeperApi
22
22
 
23
23
  @league_id = league_id
24
24
  @client = client
25
- @weeks = 1..17
25
+ @weeks = 1..18
26
26
  @league_data = nil
27
27
  @league_rosters = nil
28
28
  @league_users = nil
@@ -105,7 +105,7 @@ module SleeperApi
105
105
 
106
106
  # Get formatted matchups for a specific week.
107
107
  #
108
- # @param week [Integer] Week number (1-17)
108
+ # @param week [Integer] Week number (1-18)
109
109
  # @return [Array<Hash>] Matchup data with scoring breakdown
110
110
  # @raise [ArgumentError] If week is invalid
111
111
  #
@@ -117,7 +117,7 @@ module SleeperApi
117
117
  # end
118
118
  # end
119
119
  def matchups_by_week(week: nil)
120
- raise ArgumentError, "Week must be between 1 and 17" unless @weeks.include?(week)
120
+ raise ArgumentError, "Week must be between #{@weeks.min} and #{@weeks.max}" unless @weeks.include?(week)
121
121
 
122
122
  fetch_matchups([week]) unless @matchups&.key?(week)
123
123
  format_matchups(week)
@@ -140,7 +140,7 @@ module SleeperApi
140
140
 
141
141
  # Get formatted transactions for a specific week.
142
142
  #
143
- # @param week [Integer] Week number (1-17)
143
+ # @param week [Integer] Week number (1-18)
144
144
  # @return [Array<Hash>] Transaction data with adds/drops and draft picks
145
145
  # @raise [ArgumentError] If week is invalid
146
146
  #
@@ -150,7 +150,7 @@ module SleeperApi
150
150
  # puts "#{tx[:type]}: #{tx[:adds]&.length || 0} adds, #{tx[:drops]&.length || 0} drops"
151
151
  # end
152
152
  def transactions(week: nil)
153
- raise ArgumentError, "Week must be between 1 and 17" unless @weeks.include?(week)
153
+ raise ArgumentError, "Week must be between #{@weeks.min} and #{@weeks.max}" unless @weeks.include?(week)
154
154
 
155
155
  fetch_transactions([week]) unless @transactions&.key?(week)
156
156
  format_transactions(week)
@@ -320,32 +320,32 @@ module SleeperApi
320
320
  week_matchups = @matchups[week] || []
321
321
  return [] if week_matchups.empty?
322
322
 
323
- week_matchups.group_by { |match| match["matchup_id"] }.map do |matchup_id, matchup_entries|
324
- next unless matchup_id
323
+ # A roster on a bye has a null matchup_id. Each one is its own unpaired
324
+ # entry: grouping the nils together would pair two teams that never met.
325
+ paired, byes = week_matchups.partition { |match| match["matchup_id"] }
325
326
 
326
- {
327
- matchup_id: matchup_id,
328
- rosters: matchup_entries.map do |roster|
329
- starters = roster["starters"] || []
330
-
331
- bench = (roster["players"] || []) - starters
332
- {
333
- roster_id: roster["roster_id"],
334
- points: roster["points"],
335
- custom_points: roster["custom_points"],
336
- total_points: (roster["points"] || 0) + (roster["custom_points"] || 0),
337
- starters: starters,
338
- bench: bench,
339
- starter_points: (starters || []).map do |starter_id|
340
- { starter_id => roster["players_points"]&.dig(starter_id) || 0 }
341
- end,
342
- bench_points: bench.map do |bench_player_id|
343
- { bench_player_id => roster["players_points"]&.dig(bench_player_id) || 0 }
344
- end
345
- }
346
- end
347
- }
348
- end.compact
327
+ paired.group_by { |match| match["matchup_id"] }.map do |matchup_id, entries|
328
+ { matchup_id: matchup_id, rosters: entries.map { |roster| format_matchup_roster(roster) } }
329
+ end + byes.map { |roster| { matchup_id: nil, rosters: [format_matchup_roster(roster)] } }
330
+ end
331
+
332
+ def format_matchup_roster(roster)
333
+ starters = roster["starters"] || []
334
+ bench = (roster["players"] || []) - starters
335
+ {
336
+ roster_id: roster["roster_id"],
337
+ points: roster["points"],
338
+ custom_points: roster["custom_points"],
339
+ total_points: (roster["points"] || 0) + (roster["custom_points"] || 0),
340
+ starters: starters,
341
+ bench: bench,
342
+ starter_points: player_points(roster, starters),
343
+ bench_points: player_points(roster, bench)
344
+ }
345
+ end
346
+
347
+ def player_points(roster, player_ids)
348
+ player_ids.map { |player_id| { player_id => roster["players_points"]&.dig(player_id) || 0 } }
349
349
  end
350
350
 
351
351
  def format_users
@@ -14,18 +14,20 @@ module SleeperApi
14
14
  # @param identifier [String, Integer] Username or user ID
15
15
  # @param client [SleeperApi::Client] HTTP client instance
16
16
  # @raise [ArgumentError] If identifier is empty
17
- # @raise [SleeperApi::Error] If user data is invalid
17
+ # @raise [SleeperApi::UserNotFound] If no user has that identifier
18
18
  def initialize(identifier, client)
19
19
  raise ArgumentError, "identifier must be a non-empty string" if identifier.to_s.empty?
20
20
 
21
21
  @identifier = identifier
22
22
  @client = client
23
23
  @user_data = nil
24
- @leagues = nil
25
- @drafts = nil
24
+ # Keyed by season: one instance is asked about several seasons, and a
25
+ # single memo answered every later season with the first one's data.
26
+ @leagues = {}
27
+ @drafts = {}
26
28
 
27
29
  fetch_user_data
28
- @user_id = @user_data["user_id"] || raise(SleeperApi::Error, "Invalid user data: user_id not found")
30
+ @user_id = @user_data["user_id"] || raise(SleeperApi::UserNotFound, "Invalid user data: user_id not found")
29
31
  end
30
32
 
31
33
  # Dynamically define attribute readers for user data.
@@ -51,7 +53,6 @@ module SleeperApi
51
53
  raise ArgumentError, "season must be a valid year" unless season.is_a?(Integer)
52
54
 
53
55
  fetch_leagues(season)
54
- @leagues
55
56
  end
56
57
 
57
58
  # Get all rosters for this user across all leagues in a season.
@@ -62,8 +63,7 @@ module SleeperApi
62
63
  def rosters(season = Time.now.year)
63
64
  raise ArgumentError, "season must be a valid year" unless season.is_a?(Integer)
64
65
 
65
- fetch_leagues(season) unless @leagues
66
- (@leagues || []).flat_map do |league|
66
+ fetch_leagues(season).flat_map do |league|
67
67
  league_instance = League.new(league[:league_id], @client, no_data: true)
68
68
  league_instance.rosters(user_id: @user_id)
69
69
  end
@@ -77,8 +77,7 @@ module SleeperApi
77
77
  def drafts(season = Time.now.year)
78
78
  raise ArgumentError, "season must be a valid year" unless season.is_a?(Integer)
79
79
 
80
- fetch_drafts(season) unless @drafts
81
- format_drafts
80
+ format_drafts(fetch_drafts(season))
82
81
  end
83
82
 
84
83
  # Get a summary of user's league participation.
@@ -115,17 +114,19 @@ module SleeperApi
115
114
  end
116
115
 
117
116
  def fetch_leagues(season)
118
- @leagues ||= (@client.get_user_leagues(@user_id, season: season) || []).map do |league|
117
+ @leagues[season] ||= (@client.get_user_leagues(@user_id, season: season) || []).map do |league|
119
118
  deep_symbolize_keys(league)
120
119
  end
121
120
  end
122
121
 
123
122
  def fetch_drafts(season)
124
- @drafts ||= (@client.get_user_drafts(@user_id, season: season) || []).map { |draft| deep_symbolize_keys(draft) }
123
+ @drafts[season] ||= (@client.get_user_drafts(@user_id, season: season) || []).map do |draft|
124
+ deep_symbolize_keys(draft)
125
+ end
125
126
  end
126
127
 
127
- def format_drafts
128
- (@drafts || []).map do |draft|
128
+ def format_drafts(drafts)
129
+ drafts.map do |draft|
129
130
  draft.merge(
130
131
  league_name: draft.dig(:metadata, :name),
131
132
  scoring_type: draft.dig(:metadata, :scoring_type),
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SleeperApi
4
- VERSION = "1.5.0"
4
+ VERSION = "1.6.0"
5
5
  end
data/lib/sleeper_api.rb CHANGED
@@ -9,6 +9,10 @@ require_relative "sleeper_api/draft"
9
9
  module SleeperApi
10
10
  class Error < StandardError; end
11
11
 
12
+ # The identifier names no Sleeper user. Permanent, unlike the rest of
13
+ # Error — retrying will not help — so a caller can rescue it on its own.
14
+ class UserNotFound < Error; end
15
+
12
16
  class << self
13
17
  attr_writer :configuration
14
18
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sleeper_api
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eruity1