sleeper_api 1.5.1 → 1.7.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: c1ac943353dae30f0fd6efa95f54f3287a85e116abd8408e843e980103557e29
4
- data.tar.gz: 11569ff23125b08872ead08956c3395612a0092cf254a93450997fef069b2f19
3
+ metadata.gz: 764f1f24badfe4fbb8f017956f376a7e5604561d4d386e01a2ca77cf2174ddb8
4
+ data.tar.gz: 2616db091ddd38b8e942dce78929f8e56e2ad75de4e0bc5b5b5d52b7697bbddd
5
5
  SHA512:
6
- metadata.gz: cf5797fea22593f99b428469ea2b99089f9f463f170c86bf7536c9a50398996d6a2edf2f06707b2ae6e3077ccce00d28c1bfe3ee2799943708f65a56090a536d
7
- data.tar.gz: '08b1850e3dfb474167c1bc8df50700cab7669c7ac77c8d83ad6d757a92968ac345a82c5ecfc05b22b5238351d448782b48d297372acc33448dce0d1f4e9f1618'
6
+ metadata.gz: aea41e6b36a4cad02e526d35b0de424a32b20ce59d8d683bf8eccdd366669053c037c7ae6d2be6151031018c06d5b8d717733c8f941d3f9e7ab5bb6feb19cdb9
7
+ data.tar.gz: 2348a7481c68aaab18441ddf0096a02b5a4dc3a409ae7d7d1c90365af6139d1bdc919c62a9c9ab247c973c2bb03a7e989f17167ae3c5d23bdd95c2fc17ccf5a8
data/CHANGELOG.md CHANGED
@@ -1,3 +1,57 @@
1
+ ## [1.7.0] - 2026-10-02
2
+
3
+ ### Added
4
+
5
+ - **`Client#player(player_id, sport: "nfl")`** fetches one player from the
6
+ undocumented `/v1/players/{sport}/{id}`: the same fields as a catalog row, in
7
+ about 1.2 KB rather than the 14.6 MB `#get_players` downloads. It is live and
8
+ uncached, so it is the way to read an `injury_status` or `depth_chart_order`
9
+ fresher than a nightly catalog.
10
+ - **`SleeperApi::PlayerNotFound`, a subclass of `SleeperApi::Error`.** An
11
+ unknown player id is a `404`, checked live 2026-10-02, unlike an unknown
12
+ user's `200` and `null`. Any other failure is still a plain
13
+ `SleeperApi::Error`.
14
+
15
+ ## [1.6.0] - 2026-09-28
16
+
17
+ ### Added
18
+
19
+ - **`SleeperApi::UserNotFound`, a subclass of `SleeperApi::Error`.** Sleeper
20
+ answers an unknown user with `200` and a literal `null` body — checked live
21
+ 2026-09-28 — so there was no error at all: `get_user` returned a response
22
+ whose `parsed_response` was nil. It now raises `UserNotFound`, and so does
23
+ `User.new` for a payload with no `user_id`. A caller can tell "no such user"
24
+ (permanent) from an outage without matching message strings; rescuing
25
+ `SleeperApi::Error` alone still sees both.
26
+
27
+ ### Changed
28
+
29
+ - **`League#matchups_by_week` returns rosters on a bye** instead of dropping
30
+ them. Each comes back unpaired as `{ matchup_id: nil, rosters: [roster] }`,
31
+ never grouped with another bye. A bye used to look exactly like a missing
32
+ team.
33
+ - **`get_user` raises for an unknown user** rather than returning a nil-bodied
34
+ response (see Added).
35
+
36
+ ### Fixed
37
+
38
+ - **`User#leagues`, `#rosters` and `#drafts` answer for the season asked.** The
39
+ memo was not keyed by season, so `leagues(2025)` then `leagues(2024)` on one
40
+ instance returned 2025's leagues — silently wrong rows for a multi-season
41
+ caller.
42
+ - **Every value interpolated into a request path is escaped.** `get_user("a b")`
43
+ raised `URI::InvalidURIError`, which is not a `SleeperApi::Error`, and
44
+ `get_user("../../league/123")` requested `/league/123`. Every `Client` path is
45
+ now built from escaped segments.
46
+ - **Week 18 is reachable.** `matchups_by_week` and `transactions` accepted
47
+ weeks 1–17 and Sleeper's regular season runs to 18; `#matchups` now fetches
48
+ all eighteen.
49
+ - **`Draft#summary` no longer raises on a pick with no metadata**, and a pick
50
+ with one name part is not padded with a space.
51
+ - **The README** named `get_league_playoff_bracket` / `get_league_toilet_bowl`
52
+ (the methods are `get_playoff_bracket` / `get_toilet_bowl`) and described a
53
+ disk cache that does not exist.
54
+
1
55
  ## [1.5.1] - 2026-09-25
2
56
 
3
57
  ### Fixed
data/CLAUDE.md CHANGED
@@ -30,6 +30,8 @@ Four layers, with a deliberate split between HTTP and modeling:
30
30
 
31
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
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`.
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
35
37
  **richer rows from same-looking paths**: `#stats_with_context` returns the
@@ -68,7 +70,7 @@ Four layers, with a deliberate split between HTTP and modeling:
68
70
 
69
71
  **Guard nil defensively.** Sleeper omits fields freely — users without `metadata`, rosters without `players`, matchups without `points`. Real leagues hit these; the spec fixtures often don't. Use `&.dig` rather than `[]` on anything nested off an API response.
70
72
 
71
- **Never fan out `get_players`.** It's a multi-megabyte payload of every NFL player. It's cached for 24h in an ivar on the client instance — which means the cache dies with the client. Consumers holding a short-lived client re-download it every time.
73
+ **Never fan out `get_players`.** It's a multi-megabyte payload of every NFL player. It's cached for 24h in an ivar on the client instance — which means the cache dies with the client. Consumers holding a short-lived client re-download it every time. For one player, `#player(id)` fetches `/v1/players/nfl/{id}` live (~1.2 KB, undocumented, uncached); an unknown id is a **404** there, not the `200`/`null` an unknown user gets, and becomes `PlayerNotFound`.
72
74
 
73
75
  ### Undocumented endpoints carry undocumented vocabularies
74
76
 
data/README.md CHANGED
@@ -243,10 +243,15 @@ Access player data with automatic caching:
243
243
  # Get all players (cached for 24 hours)
244
244
  players = client.get_players
245
245
 
246
- # Find specific player
246
+ # Find specific player (reads the cached catalog above)
247
247
  player = client.get_player_by_id("1234")
248
248
  puts "#{player['first_name']} #{player['last_name']} - #{player['position']}"
249
249
 
250
+ # One player, live and uncached: ~1.2 KB instead of the whole catalog.
251
+ # Use it for a fresher injury_status or depth_chart_order than the catalog has.
252
+ fresh = client.player("1234")
253
+ puts "#{fresh['injury_status']} (#{fresh['injury_body_part']})"
254
+
250
255
  # Get trending players
251
256
  trending_adds = client.trending_players(type: "add", limit: 10)
252
257
  trending_drops = client.trending_players(type: "drop", limit: 10)
@@ -339,8 +344,8 @@ season_stats = client.stats(2025)
339
344
  real = projected.parsed_response.select { |_id, row| row.key?("pts_ppr") }
340
345
 
341
346
  # Get playoff brackets
342
- winners_bracket = client.get_league_playoff_bracket("league_id")
343
- losers_bracket = client.get_league_toilet_bowl("league_id")
347
+ winners_bracket = client.get_playoff_bracket("league_id")
348
+ losers_bracket = client.get_toilet_bowl("league_id")
344
349
 
345
350
  # Get league drafts
346
351
  league_drafts = client.get_league_drafts("league_id")
@@ -366,7 +371,11 @@ end
366
371
 
367
372
  ### Error Types
368
373
 
369
- - SleeperApi::Error - API-related errors (404, 500, timeouts, etc.)
374
+ - SleeperApi::Error - API-related errors (404, 500, timeouts, an unreachable host)
375
+
376
+ - 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
377
+
378
+ - SleeperApi::PlayerNotFound - a subclass of SleeperApi::Error: `client.player` was given an id Sleeper does not have. Permanent, like UserNotFound
370
379
 
371
380
  - ArgumentError - Invalid parameters passed to methods
372
381
 
@@ -374,12 +383,10 @@ end
374
383
 
375
384
  ### Caching
376
385
 
377
- - Player data is cached for 24 hours to reduce API calls
386
+ - Player data is cached in memory for 24 hours, on the client instance — a new client downloads it again
378
387
 
379
388
  - League/User/Draft data is cached per instance
380
389
 
381
- - Cache files are stored in the current working directory
382
-
383
390
  ### Rate Limiting
384
391
 
385
392
  - Be mindful of Sleeper's rate limits: stay under 1000 API calls per minute
@@ -390,7 +397,7 @@ end
390
397
 
391
398
  ### Memory Usage
392
399
 
393
- - Large datasets (like all players) are cached to disk
400
+ - The all-players payload is several megabytes and held in memory; never fan it out
394
401
 
395
402
  - League rosters and matchups are fetched lazily
396
403
 
@@ -76,11 +76,18 @@ module SleeperApi
76
76
 
77
77
  # Fetch user data by identifier.
78
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
+ #
79
82
  # @param identifier [String] Username or user ID
80
83
  # @return [Hash] Raw user data
84
+ # @raise [SleeperApi::UserNotFound] If no user has that identifier
81
85
  # @see https://docs.sleeper.com/#user
82
86
  def get_user(identifier)
83
- 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
84
91
  end
85
92
 
86
93
  # Get leagues for a user in a specific season.
@@ -91,7 +98,7 @@ module SleeperApi
91
98
  # @return [Array<Hash>] League data
92
99
  # @see https://docs.sleeper.com/#get-all-leagues-for-user
93
100
  def get_user_leagues(user_id, sport: "nfl", season: Time.now.year)
94
- make_request("/v1/user/#{user_id}/leagues/#{sport}/#{season}")
101
+ make_request(path("v1", "user", user_id, "leagues", sport, season))
95
102
  end
96
103
 
97
104
  # Get drafts for a user in a specific season.
@@ -102,7 +109,7 @@ module SleeperApi
102
109
  # @return [Array<Hash>] Draft data
103
110
  # @see https://docs.sleeper.com/#get-all-drafts-for-user
104
111
  def get_user_drafts(user_id, sport: "nfl", season: Time.now.year)
105
- make_request("/v1/user/#{user_id}/drafts/#{sport}/#{season}")
112
+ make_request(path("v1", "user", user_id, "drafts", sport, season))
106
113
  end
107
114
 
108
115
  # Fetch league details.
@@ -111,7 +118,7 @@ module SleeperApi
111
118
  # @return [Hash] League metadata
112
119
  # @see https://docs.sleeper.com/#get-a-specific-league
113
120
  def get_league(league_id)
114
- make_request("/v1/league/#{league_id}")
121
+ make_request(path("v1", "league", league_id))
115
122
  end
116
123
 
117
124
  # Get all rosters in a league.
@@ -120,7 +127,7 @@ module SleeperApi
120
127
  # @return [Array<Hash>] Roster data
121
128
  # @see https://docs.sleeper.com/#getting-rosters-in-a-league
122
129
  def get_league_rosters(league_id)
123
- make_request("/v1/league/#{league_id}/rosters")
130
+ make_request(path("v1", "league", league_id, "rosters"))
124
131
  end
125
132
 
126
133
  # Get all users in a league.
@@ -129,7 +136,7 @@ module SleeperApi
129
136
  # @return [Array<Hash>] User data
130
137
  # @see https://docs.sleeper.com/#getting-users-in-a-league
131
138
  def get_league_users(league_id)
132
- make_request("/v1/league/#{league_id}/users")
139
+ make_request(path("v1", "league", league_id, "users"))
133
140
  end
134
141
 
135
142
  # Get matchups for a specific week.
@@ -139,7 +146,7 @@ module SleeperApi
139
146
  # @return [Array<Hash>] Matchup data
140
147
  # @see https://docs.sleeper.com/#getting-matchups-in-a-league
141
148
  def get_league_matchups(league_id, week)
142
- make_request("/v1/league/#{league_id}/matchups/#{week}")
149
+ make_request(path("v1", "league", league_id, "matchups", week))
143
150
  end
144
151
 
145
152
  # Get playoff winners bracket.
@@ -148,7 +155,7 @@ module SleeperApi
148
155
  # @return [Array<Hash>] Bracket matchups
149
156
  # @see https://docs.sleeper.com/#getting-the-playoff-bracket
150
157
  def get_playoff_bracket(league_id)
151
- make_request("/v1/league/#{league_id}/winners_bracket")
158
+ make_request(path("v1", "league", league_id, "winners_bracket"))
152
159
  end
153
160
 
154
161
  # Get toilet bowl (losers bracket).
@@ -157,7 +164,7 @@ module SleeperApi
157
164
  # @return [Array<Hash>] Bracket matchups
158
165
  # @see https://docs.sleeper.com/#getting-the-playoff-bracket
159
166
  def get_toilet_bowl(league_id)
160
- make_request("/v1/league/#{league_id}/losers_bracket")
167
+ make_request(path("v1", "league", league_id, "losers_bracket"))
161
168
  end
162
169
 
163
170
  # Get transactions for a specific week.
@@ -167,7 +174,7 @@ module SleeperApi
167
174
  # @return [Array<Hash>] Transaction data
168
175
  # @see https://docs.sleeper.com/#get-transactions
169
176
  def get_transactions(league_id, week)
170
- make_request("/v1/league/#{league_id}/transactions/#{week}")
177
+ make_request(path("v1", "league", league_id, "transactions", week))
171
178
  end
172
179
 
173
180
  # Get league drafts.
@@ -176,7 +183,7 @@ module SleeperApi
176
183
  # @return [Array<Hash>] Draft data
177
184
  # @see https://docs.sleeper.com/#get-all-drafts-for-a-league
178
185
  def get_league_drafts(league_id)
179
- make_request("/v1/league/#{league_id}/drafts")
186
+ make_request(path("v1", "league", league_id, "drafts"))
180
187
  end
181
188
 
182
189
  # Get traded draft picks for a league.
@@ -185,7 +192,7 @@ module SleeperApi
185
192
  # @return [Array<Hash>] Traded picks
186
193
  # @see https://docs.sleeper.com/#get-traded-picks-in-a-draft
187
194
  def get_league_traded_picks(league_id)
188
- make_request("/v1/league/#{league_id}/traded_picks")
195
+ make_request(path("v1", "league", league_id, "traded_picks"))
189
196
  end
190
197
 
191
198
  # Fetch draft details.
@@ -194,7 +201,7 @@ module SleeperApi
194
201
  # @return [Hash] Draft metadata
195
202
  # @see https://docs.sleeper.com/#get-a-specific-draft
196
203
  def get_draft(draft_id)
197
- make_request("/v1/draft/#{draft_id}")
204
+ make_request(path("v1", "draft", draft_id))
198
205
  end
199
206
 
200
207
  # Get draft picks.
@@ -203,7 +210,7 @@ module SleeperApi
203
210
  # @return [Array<Hash>] Pick data
204
211
  # @see https://docs.sleeper.com/#get-all-picks-in-a-draft
205
212
  def get_draft_picks(draft_id)
206
- make_request("/v1/draft/#{draft_id}/picks")
213
+ make_request(path("v1", "draft", draft_id, "picks"))
207
214
  end
208
215
 
209
216
  # Get traded draft picks for a draft.
@@ -212,7 +219,7 @@ module SleeperApi
212
219
  # @return [Array<Hash>] Traded picks
213
220
  # @see https://docs.sleeper.com/#get-traded-picks-in-a-draft
214
221
  def get_draft_traded_picks(draft_id)
215
- make_request("/v1/draft/#{draft_id}/traded_picks")
222
+ make_request(path("v1", "draft", draft_id, "traded_picks"))
216
223
  end
217
224
 
218
225
  # Get NFL state (week, season status).
@@ -221,7 +228,7 @@ module SleeperApi
221
228
  # @return [Hash] State data
222
229
  # @see https://docs.sleeper.com/#get-nfl-state
223
230
  def get_nfl_state(sport = "nfl")
224
- nfl_state = make_request("/v1/state/#{sport}")
231
+ nfl_state = make_request(path("v1", "state", sport))
225
232
  nfl_state.each_with_object({}) do |(k, v), result|
226
233
  key = k.is_a?(String) ? k.to_sym : k
227
234
  result[key] = v
@@ -424,7 +431,7 @@ module SleeperApi
424
431
  # @param sport [String] Sport code (default: "nfl")
425
432
  # @return [HTTParty::Response] Array of games
426
433
  def schedule(season, season_type: "regular", sport: "nfl")
427
- make_request("/schedule/#{sport}/#{season_type}/#{season}")
434
+ make_request(path("schedule", sport, season_type, season))
428
435
  end
429
436
 
430
437
  # Get trending players.
@@ -436,7 +443,8 @@ module SleeperApi
436
443
  # @return [Array<Hash>] Trending players
437
444
  # @see https://docs.sleeper.com/#trending-players
438
445
  def trending_players(sport = "nfl", type: "add", lookback_hours: 24, limit: 25)
439
- 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}")
440
448
  end
441
449
 
442
450
  # Get all player data (cached for 24 hours).
@@ -447,7 +455,7 @@ module SleeperApi
447
455
  def get_players(sport = "nfl")
448
456
  return @players_cache if @players_cache && @cache_timestamp && (Time.now - @cache_timestamp) < (3600 * 24)
449
457
 
450
- response = make_request("/v1/players/#{sport}")
458
+ response = make_request(path("v1", "players", sport))
451
459
  @players_cache = response.parsed_response
452
460
  @cache_timestamp = Time.now
453
461
 
@@ -464,20 +472,35 @@ module SleeperApi
464
472
  get_players(sport)[player_id]
465
473
  end
466
474
 
475
+ # Fetch one player, live: about 1.2 KB where #get_players is 14.6 MB, and
476
+ # not cached. Undocumented; same fields as a catalog row.
477
+ #
478
+ # Sleeper answers an unknown id with 404, unlike an unknown user's 200 and
479
+ # null, and that 404 becomes {SleeperApi::PlayerNotFound}.
480
+ #
481
+ # @param player_id [String] Player ID
482
+ # @param sport [String] Sport code (default: "nfl")
483
+ # @return [HTTParty::Response] The player's raw data
484
+ # @raise [SleeperApi::PlayerNotFound] If no player has that ID
485
+ def player(player_id, sport: "nfl")
486
+ make_request(path("v1", "players", sport, player_id),
487
+ not_found: -> { PlayerNotFound.new("No Sleeper player #{player_id.to_s.inspect}") })
488
+ end
489
+
467
490
  private
468
491
 
469
- # Path for #stats and #projections. Segments are escaped because they are
470
- # interpolated into a URI path: an unescaped one can walk out of the
471
- # endpoint entirely, which is the bug the consuming app had to work around
472
- # for #get_user.
473
- #
474
- # A nil week drops the segment rather than defaulting, because the shorter
475
- # path is season totals.
476
- def weekly_path(resource, sport, season_type, season, week)
477
- segments = [resource, sport, season_type, season, week].compact
478
- escaped = segments.map { |segment| ERB::Util.url_encode(segment.to_s) }
492
+ # Every request path is built here, from escaped segments. An unescaped
493
+ # one raised URI::InvalidURIError, which is not a SleeperApi::Error, or
494
+ # walked out of the endpoint entirely: `../../league/123` as a username
495
+ # requested /league/123.
496
+ def path(*segments)
497
+ "/#{segments.map { |segment| ERB::Util.url_encode(segment.to_s) }.join("/")}"
498
+ end
479
499
 
480
- "/v1/#{escaped.join("/")}"
500
+ # Path for #stats and #projections. A nil week drops the segment rather
501
+ # than defaulting, because the shorter path is season totals.
502
+ def weekly_path(resource, sport, season_type, season, week)
503
+ path("v1", *[resource, sport, season_type, season, week].compact)
481
504
  end
482
505
 
483
506
  # Make an HTTP request with retry logic and logging.
@@ -495,9 +518,11 @@ module SleeperApi
495
518
  #
496
519
  # @param path [String] API endpoint path
497
520
  # @param host [String, nil] a different host, e.g. WEB_HOST
521
+ # @param not_found [Proc, nil] builds the error a 404 raises, for an
522
+ # endpoint where 404 means "no such thing" rather than a failure
498
523
  # @return [HTTParty::Response]
499
524
  # @raise [SleeperApi::Error] On HTTP errors or timeouts
500
- def make_request(path, host: nil)
525
+ def make_request(path, host: nil, not_found: nil)
501
526
  named = host ? "#{host}#{path}" : path
502
527
  options = { timeout: @config.timeout }
503
528
  options[:base_uri] = host if host
@@ -511,6 +536,8 @@ module SleeperApi
511
536
  response
512
537
  else
513
538
  @config.logger&.error("Failed to fetch #{named}: #{response.code}")
539
+ raise not_found.call if not_found && response.code == 404
540
+
514
541
  raise SleeperApi::Error, "Failed to fetch #{named}: #{response.code}"
515
542
  end
516
543
  rescue Net::OpenTimeout, Net::ReadTimeout => e
@@ -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.1"
4
+ VERSION = "1.7.0"
5
5
  end
data/lib/sleeper_api.rb CHANGED
@@ -9,6 +9,13 @@ 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
+
16
+ # The id names no Sleeper player. Permanent, like UserNotFound.
17
+ class PlayerNotFound < Error; end
18
+
12
19
  class << self
13
20
  attr_writer :configuration
14
21
  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.1
4
+ version: 1.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eruity1