sleeper_api 1.2.0 → 1.3.1

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: 49f189b10709caa46c846f8733372cfd50d1dff0f66548aba5cb7ce0d32508f5
4
- data.tar.gz: a0670835fa5f56541d041292b498722d0597a0599c724e95f9e3ed193151872c
3
+ metadata.gz: 3bd436fd0363716d6908e51d9137e89725230afb2c8a71479582a8894f12db57
4
+ data.tar.gz: b5933518c66d46edfc540ac261c0d473f62fc6c93f6cc03c9d7e426edebc9ed3
5
5
  SHA512:
6
- metadata.gz: dd18ff49145f5c1848857f7af5e9b15f02a8aac6f7940fe85517d333a213707f524d3f54d5d211ce94de28be9e955e6e366a3db66101ce329d39264f6eabf983
7
- data.tar.gz: 0b254fa2078732333ee53f3f1074f3700c85c28733478234cd3c303b6b2698967889ad2db33d9802b1a93d00d7dee378387bfdb1d18403f55d0104daedc934b1
6
+ metadata.gz: 7bdf8104296937896fdf11dcd71d12008e5801d9197552827293d734dd360032ac99dacaa5d7ff656e34ba2938f206bf8f864385d20b6f30230e12d819dc2039
7
+ data.tar.gz: 12eb88e92e6f27af1734b059b24313248ccf1888e6354437b391439920d0e3d2c5c00e263d7a957ad67cc791503978a92786b67b12eb6e871152a1b274e814c3
data/CHANGELOG.md CHANGED
@@ -1,3 +1,91 @@
1
+ ## [1.3.1] - 2026-09-10
2
+
3
+ ### Fixed
4
+
5
+ - **Every request raised `ArgumentError: unknown keyword: quirks_mode` under
6
+ `json` 3.0.** HTTParty 0.24.2 parses with `JSON.parse(body, quirks_mode:
7
+ true, allow_nan: true)`; json 3.0 removed that keyword. Total breakage rather
8
+ than a degradation, and it arrives through a transitive bump rather than
9
+ anything a consumer chose — this gem has no committed lockfile, so CI went
10
+ red with no commit in between. httparty 0.24.2 is the newest release and has
11
+ no fix.
12
+
13
+ `SleeperApi::JsonParser` subclasses `HTTParty::Parser` and overrides its
14
+ `json` method to drop the flag; `Client` parses with it. **Deliberately not a
15
+ `json < 3` pin in the gemspec**, which would fix the same crash by forbidding
16
+ every app that uses this gem from upgrading json at all, for a flag none of
17
+ them asked for.
18
+
19
+ `quirks_mode: true` allowed a bare scalar at the top level, which is not
20
+ academic here — Sleeper answers an unknown username with a literal `null`.
21
+ Both majors parse that correctly without it: `JSON.parse("null")` is `nil`
22
+ under 2.21.2 and under 3.0.2. The suite passes under both.
23
+
24
+ ### Documented
25
+
26
+ Three facts measured against the live API on 2026-09-10, while NFL week 1 was
27
+ half-played — one game finished, one being played, fourteen not yet started.
28
+ No behaviour changed; all three are things a caller could previously only find
29
+ out by being wrong first.
30
+
31
+ - **`#schedule`'s `status` values are named: `pre_game`, `in_game`, `complete`,
32
+ `canceled`.** `in_game` was read off a live game; the other three come from
33
+ the published 2025 and 2026 schedules. It is the only per-game signal that a
34
+ game has been played — this payload has a `date` and no kickoff time, so a
35
+ caller can know a game is under way but never how far into it. **A week is
36
+ not one event**: week 1 of 2026 held three of those states at once, so
37
+ reasoning about "has the week started" from `date` alone gets it wrong for
38
+ everyone whose game is on Sunday. Treat the four as open and match with a
39
+ fallback — a postponement would be a fifth and none has been seen.
40
+
41
+ - **`#projections` is a pre-game, whole-game projection that does not move
42
+ while the game is played.** Five players in one night's game held the same
43
+ `pts_ppr` to the decimal across six hours spanning its kickoff; a game that
44
+ had already finished still projected 19.69 for a player who scored 26.2, so
45
+ it does not settle onto the final either. This is the natural endpoint to
46
+ reach for when building anything live, and it cannot answer the question:
47
+ **there is no live projection here**, so nothing in this payload says whether
48
+ a player is on pace. A player on 8 of a projected 12 in the first quarter is
49
+ ahead of schedule, and this reports 12 all afternoon.
50
+
51
+ - **`#stats` answers a week still being played with a partial set, and says
52
+ nothing about being partial.** Mid-week-1 it returned 301 rows with 42
53
+ carrying a `pts_ppr`. That is distinct from the already-documented empty
54
+ week: a caller treating "the week's stats" as the whole week gets a
55
+ half-filled answer for as long as the week is in progress, which for a
56
+ regular-season week is most of five days. `#schedule`'s per-game `status` is
57
+ the only thing that tells a missing row from a scoreless one.
58
+
59
+ ### Changed
60
+
61
+ - `#schedule`'s spec fixture carries all four statuses rather than `pre_game`
62
+ alone, and an example pins that they pass through untranslated. The narrow
63
+ fixture was a shape live data produces only on a quiet Tuesday, and a fixture
64
+ narrower than the API is how a caller ends up written against a vocabulary of
65
+ one.
66
+
67
+ ## [1.3.0] - 2026-08-27
68
+
69
+ ### Added
70
+
71
+ - `Client#stats(season, week:, season_type:, sport:)` and `Client#projections(...)` — per-player weekly statistics and projections, both undocumented, both under `/v1`. Found by probing on 2026-08-27; the consuming app's backlog had recorded "Sleeper has no projections endpoint and no player-stats endpoint" as settled fact, and several of its cards were blocked on that.
72
+
73
+ Each returns an object keyed by player id — plus `TEAM_XXX` keys for team-level rows — holding raw counting stats (`rec`, `rush_yd`, `off_snp`, `rec_rz_tgt`, …) alongside Sleeper's canned `pts_ppr` / `pts_half_ppr` / `pts_std`. 228 distinct fields were observed across one week of 2025. Omitting `week` requests season totals, which is a shorter path and a different resource rather than a default of week 1.
74
+
75
+ Three things a caller has to know, all confirmed live:
76
+
77
+ - **Nothing 404s.** An unplayed week (`regular/2026/1`), a week out of range (`regular/2025/99`) and an unrecognised season type (`banana`) all answer 200 with `{}`. An empty body is indistinguishable from a typo, so validate arguments rather than trusting emptiness.
78
+ - **A row count is not evidence of a projection.** `projections` for a season Sleeper has not projected still returns a full set of entries — 9,386 for 2030 — every one holding only `{"adp_dd_ppr" => 1000.0}` and no `pts_ppr`. Filter on the field you want.
79
+ - **`pre` and `post` restart week numbering at 1**, exactly as `#schedule` does, so rows from different season types must never be pooled.
80
+
81
+ `adp_dd_ppr` 1000.0 and `pos_rank_*` 999.0 are "unknown" sentinels rather than values.
82
+
83
+ Path segments are escaped, unlike the older `#get_user` — an unescaped segment can walk out of the endpoint entirely, which the consuming app had to work around at its own boundary.
84
+
85
+ ### Fixed
86
+
87
+ - README.md contained 272 non-breaking spaces across 90 lines, 58 of them **inside `​```ruby` code fences** — so the documented examples raised a syntax error when copy-pasted. `lib/` and `spec/` were unaffected; only the documentation was broken. Replaced with ordinary spaces.
88
+
1
89
  ## [1.2.0] - 2026-08-23
2
90
 
3
91
  ### Added
data/CLAUDE.md CHANGED
@@ -19,7 +19,7 @@ bundle exec rubocop -A # autocorrect
19
19
  bin/console # IRB with the gem loaded
20
20
  ```
21
21
 
22
- CI (`.github/workflows/ci.yml`) runs `bundle exec rake ci` on Ruby 3.2 only. The gemspec claims `required_ruby_version >= 2.6.0` and RuboCop targets 2.6, but nothing tests below 3.2 — treat 2.6 compatibility as unverified.
22
+ CI (`.github/workflows/ci.yml`) runs `bundle exec rake ci` on Ruby 3.2 and 3.4. The gemspec claims `required_ruby_version >= 2.6.0` and RuboCop targets 2.6, but nothing tests below 3.2 — treat 2.6 compatibility as unverified.
23
23
 
24
24
  ## Architecture
25
25
 
@@ -44,6 +44,22 @@ Four layers, with a deliberate split between HTTP and modeling:
44
44
 
45
45
  **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.
46
46
 
47
+ ### Undocumented endpoints carry undocumented vocabularies
48
+
49
+ Sleeper publishes no field reference, so every enum this gem passes through was
50
+ found by looking. Four are known — `#schedule`'s game `status`, `Player`'s
51
+ `status` and `injury_status`, and a roster's `lineup_position` — and **none of
52
+ them is closed**. Match with a fallback rather than a whitelist, and never
53
+ translate one into a vocabulary of this gem's own: the caller is the only one
54
+ who knows what to do with a value nobody has seen.
55
+
56
+ **When a value is measured, name it in the docstring and put the date and the
57
+ circumstance next to it.** `in_game` shipped as an unnamed fallback in the
58
+ consuming app for a day before anyone saw a live game; the fallback was right,
59
+ and the thing that made it a fact rather than a guess was writing down that it
60
+ was read at 21:13 on 2026-09-10 during a specific game. A vocabulary with no
61
+ provenance is indistinguishable from one somebody assumed.
62
+
47
63
  ## Testing
48
64
 
49
65
  RSpec + WebMock, with `WebMock.disable_net_connect!` — **specs must never hit the network**. Stub with `stub_request` (see `client_spec.rb`) or `instance_double(SleeperApi::Client)` for resource-object specs (see `league_spec.rb`).
@@ -54,6 +70,24 @@ RSpec + WebMock, with `WebMock.disable_net_connect!` — **specs must never hit
54
70
 
55
71
  `lib/` is at 100% line coverage. Every bug found in this gem so far has been in a `League#format_*` method handling a field Sleeper omitted, exercised only by real data. When adding a formatter, write the nil-field case first.
56
72
 
73
+ ### There is no committed Gemfile.lock, so CI resolves fresh every run
74
+
75
+ That is right for a library — a lockfile would hide exactly the incompatibility
76
+ a consumer is going to hit — but it has a consequence worth naming: **a green
77
+ run does not stay green.** A transitive release can turn CI red with no commit
78
+ in between, and the first PR opened afterwards looks like the culprit.
79
+
80
+ `json` 3.0 did this on 2026-09-11. It removed `quirks_mode`, which
81
+ `httparty` 0.24.2 passes on every JSON parse, so 11 examples began raising
82
+ `ArgumentError: unknown keyword: quirks_mode` — on a documentation-only PR that
83
+ touched no code. Check whether `main` is red before reading a failure as the
84
+ branch's fault; `main`'s last run can be weeks old.
85
+
86
+ `SleeperApi::JsonParser` is the fix, and it is worth reading before reaching for
87
+ a version pin: the gem overrides the one broken method rather than constraining
88
+ `json` in the gemspec, because a constraint there would forbid every consuming
89
+ app from upgrading `json` for a flag none of them asked for.
90
+
57
91
  ## Release
58
92
 
59
93
  Version lives in `lib/sleeper_api/version.rb`. Update `CHANGELOG.md`, then `bundle exec rake release` from a clean `main`, which builds to `pkg/`, tags, pushes the tag, and uploads to rubygems.
data/README.md CHANGED
@@ -2,71 +2,71 @@
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/sleeper_api.svg)](https://badge.fury.io/rb/sleeper_api)
4
4
 
5
- A comprehensive Ruby gem for interacting with [Sleeper's fantasy football API](https://docs.sleeper.com/). Built with performance, reliability, and developer experience in mind.
5
+ A comprehensive Ruby gem for interacting with [Sleeper's fantasy football API](https://docs.sleeper.com/). Built with performance, reliability, and developer experience in mind.
6
6
 
7
7
  ## Features
8
8
 
9
- - Complete API Coverage - Users, leagues, drafts, players, matchups, transactions
9
+ - Complete API Coverage - Users, leagues, drafts, players, matchups, transactions
10
10
 
11
- - Performance Optimized - Smart caching, connection pooling, rate limiting
11
+ - Performance Optimized - Smart caching, connection pooling, rate limiting
12
12
 
13
- - Robust Error Handling - Automatic retries, timeout management, detailed error messages
13
+ - Robust Error Handling - Automatic retries, timeout management, detailed error messages
14
14
 
15
- - Well Tested - 90%+ test coverage with RSpec
15
+ - Well Tested - 90%+ test coverage with RSpec
16
16
 
17
- - Highly Configurable - Custom timeouts, retries, logging
17
+ - Highly Configurable - Custom timeouts, retries, logging
18
18
 
19
- - Production Ready - Type signatures, CI/CD, code quality tools
19
+ - Production Ready - Type signatures, CI/CD, code quality tools
20
20
 
21
21
  ## Installation
22
22
 
23
23
  Add this line to your application's Gemfile:
24
24
 
25
25
  ```ruby
26
- gem 'sleeper_api'
26
+ gem 'sleeper_api'
27
27
  ```
28
28
 
29
29
  ## Quick Start
30
30
 
31
31
  ```ruby
32
- require 'sleeper_api'
32
+ require 'sleeper_api'
33
33
 
34
- # Basic usage with default configuration
35
- client = SleeperApi.client
36
- league = client.league("123456789012345678")
34
+ # Basic usage with default configuration
35
+ client = SleeperApi.client
36
+ league = client.league("123456789012345678")
37
37
 
38
- # Access league information
39
- puts league.name          # "My Fantasy League"
40
- puts league.total_rosters # 12
41
- puts league.status        # "in_season"
38
+ # Access league information
39
+ puts league.name # "My Fantasy League"
40
+ puts league.total_rosters # 12
41
+ puts league.status # "in_season"
42
42
 
43
- # Get rosters
44
- rosters = league.rosters
45
- rosters.each do |roster|
46
-   puts "#{roster[:owner_display_name]}: #{roster[:wins]}-#{roster[:losses]}"
43
+ # Get rosters
44
+ rosters = league.rosters
45
+ rosters.each do |roster|
46
+ puts "#{roster[:owner_display_name]}: #{roster[:wins]}-#{roster[:losses]}"
47
47
  end
48
48
  ```
49
49
 
50
50
  ## Configuration
51
51
 
52
- Customize the gem's behavior:
52
+ Customize the gem's behavior:
53
53
 
54
54
  ```ruby
55
- SleeperApi.configure do |config|
56
-   config.timeout = 45    # Request timeout in seconds (10-60)
57
-   config.retries = 5     # Number of retries on failure (0-5)
58
-   config.logger = Logger.new(STDOUT)  # Custom logger
55
+ SleeperApi.configure do |config|
56
+ config.timeout = 45 # Request timeout in seconds (10-60)
57
+ config.retries = 5 # Number of retries on failure (0-5)
58
+ config.logger = Logger.new(STDOUT) # Custom logger
59
59
  end
60
60
 
61
- # Configuration is applied to all subsequent client instances
62
- client = SleeperApi.client
61
+ # Configuration is applied to all subsequent client instances
62
+ client = SleeperApi.client
63
63
  ```
64
64
 
65
65
  ## API Coverage
66
66
 
67
67
  ### Users
68
68
 
69
- Get user information and their leagues/drafts:
69
+ Get user information and their leagues/drafts:
70
70
 
71
71
  ```ruby
72
72
  # Find a user by username
@@ -118,7 +118,7 @@ puts "Overall: #{summary[:total_wins]}-#{summary[:total_losses]}"
118
118
 
119
119
  ### Leagues
120
120
 
121
- Access league data, rosters, matchups, and transactions:
121
+ Access league data, rosters, matchups, and transactions:
122
122
 
123
123
  ```ruby
124
124
  league = SleeperApi.client.league("123456")
@@ -180,7 +180,7 @@ matchup[:winner_owner] # => "Team Beta"
180
180
 
181
181
  ### Drafts
182
182
 
183
- Access draft information, picks, and traded picks:
183
+ Access draft information, picks, and traded picks:
184
184
 
185
185
  ```ruby
186
186
  # Get a specific draft
@@ -237,19 +237,19 @@ team_3_rounds = draft.team_picks[3] # Team 3's picks by round
237
237
 
238
238
  ### Players
239
239
 
240
- Access player data with automatic caching:
240
+ Access player data with automatic caching:
241
241
 
242
242
  ```ruby
243
- # Get all players (cached for 24 hours)
244
- players = client.get_players
243
+ # Get all players (cached for 24 hours)
244
+ players = client.get_players
245
245
 
246
- # Find specific player
247
- player = client.get_player_by_id("1234")
248
- puts "#{player['first_name']} #{player['last_name']} - #{player['position']}"
246
+ # Find specific player
247
+ player = client.get_player_by_id("1234")
248
+ puts "#{player['first_name']} #{player['last_name']} - #{player['position']}"
249
249
 
250
- # Get trending players
251
- trending_adds = client.trending_players(type: "add", limit: 10)
252
- trending_drops = client.trending_players(type: "drop", limit: 10)
250
+ # Get trending players
251
+ trending_adds = client.trending_players(type: "add", limit: 10)
252
+ trending_drops = client.trending_players(type: "drop", limit: 10)
253
253
  ```
254
254
 
255
255
  ### Player Helper
@@ -313,103 +313,121 @@ rosters.each do |roster|
313
313
  end
314
314
  ```
315
315
 
316
- ### Additional Endpoints
316
+ ### Additional Endpoints
317
317
 
318
318
  ```ruby
319
- # Get NFL state
320
- state = client.get_nfl_state
321
- puts "Current week: #{state['week']}"
319
+ # Get NFL state
320
+ state = client.get_nfl_state
321
+ puts "Current week: #{state['week']}"
322
+
323
+ # Per-player weekly stats and projections (undocumented endpoints).
324
+ # Keyed by player id, plus TEAM_XXX keys for team-level rows.
325
+ week_stats = client.stats(2025, week: 1)
326
+ puts week_stats["4046"]["pts_ppr"] # => 21.4
327
+ puts week_stats["4046"]["off_snp"] # raw counting stats too
328
+
329
+ projected = client.projections(2026, week: 1)
330
+
331
+ # Omit the week for season totals — a different resource, not a default.
332
+ season_stats = client.stats(2025)
333
+
334
+ # Nothing here 404s: an unplayed week, a week out of range and an unknown
335
+ # season type all answer 200 with {}. And a projections call for a season
336
+ # Sleeper has not projected still returns thousands of entries carrying only
337
+ # an `adp_dd_ppr` sentinel — a row count is not evidence of a projection, so
338
+ # filter on the field you actually want.
339
+ real = projected.parsed_response.select { |_id, row| row.key?("pts_ppr") }
322
340
 
323
- # Get playoff brackets
324
- winners_bracket = client.get_league_playoff_bracket("league_id")
325
- losers_bracket = client.get_league_toilet_bowl("league_id")
341
+ # Get playoff brackets
342
+ winners_bracket = client.get_league_playoff_bracket("league_id")
343
+ losers_bracket = client.get_league_toilet_bowl("league_id")
326
344
 
327
- # Get league drafts
328
- league_drafts = client.get_league_drafts("league_id")
345
+ # Get league drafts
346
+ league_drafts = client.get_league_drafts("league_id")
329
347
 
330
- # Get traded picks
331
- traded_picks = client.get_league_traded_picks("league_id")
348
+ # Get traded picks
349
+ traded_picks = client.get_league_traded_picks("league_id")
332
350
  ```
333
351
 
334
- ## Error Handling
352
+ ## Error Handling
335
353
 
336
354
  The gem provides comprehensive error handling:
337
355
 
338
356
  ```ruby
339
357
  begin
340
-   league = client.league("invalid_id")
341
-   # Process league data
342
- rescue SleeperApi::Error => e
343
-   puts "API Error: #{e.message}"
344
- rescue ArgumentError => e
345
-   puts "Invalid parameter: #{e.message}"
358
+ league = client.league("invalid_id")
359
+ # Process league data
360
+ rescue SleeperApi::Error => e
361
+ puts "API Error: #{e.message}"
362
+ rescue ArgumentError => e
363
+ puts "Invalid parameter: #{e.message}"
346
364
  end
347
365
  ```
348
366
 
349
367
  ### Error Types
350
368
 
351
- - SleeperApi::Error - API-related errors (404, 500, timeouts, etc.)
369
+ - SleeperApi::Error - API-related errors (404, 500, timeouts, etc.)
352
370
 
353
- - ArgumentError - Invalid parameters passed to methods
371
+ - ArgumentError - Invalid parameters passed to methods
354
372
 
355
373
  ## Performance Considerations
356
374
 
357
375
  ### Caching
358
376
 
359
- - Player data is cached for 24 hours to reduce API calls
377
+ - Player data is cached for 24 hours to reduce API calls
360
378
 
361
- - League/User/Draft data is cached per instance
379
+ - League/User/Draft data is cached per instance
362
380
 
363
- - Cache files are stored in the current working directory
381
+ - Cache files are stored in the current working directory
364
382
 
365
- ### Rate Limiting
383
+ ### Rate Limiting
366
384
 
367
- - Be mindful of Sleeper's rate limits: stay under 1000 API calls per minute
385
+ - Be mindful of Sleeper's rate limits: stay under 1000 API calls per minute
368
386
 
369
- - The gem automatically handles timeouts and retries
387
+ - The gem automatically handles timeouts and retries
370
388
 
371
389
  - Consider caching frequently accessed data in your application
372
390
 
373
391
  ### Memory Usage
374
392
 
375
- - Large datasets (like all players) are cached to disk
393
+ - Large datasets (like all players) are cached to disk
376
394
 
377
- - League rosters and matchups are fetched lazily
395
+ - League rosters and matchups are fetched lazily
378
396
 
379
- - Use no_data: true when initializing leagues if you don't need immediate data
397
+ - Use no_data: true when initializing leagues if you don't need immediate data
380
398
 
381
399
  ## Testing
382
400
 
383
401
  The gem includes comprehensive tests:
384
402
 
385
403
  ```shellscript
386
- # Run all tests
387
- bundle exec rspec
388
- # Run with coverage
389
- bundle exec rspec --coverage
390
- # Run specific test file
391
- bundle exec rspec spec/sleeper_api/client_spec.rb
404
+ # Run all tests
405
+ bundle exec rspec
406
+ # Run with coverage
407
+ bundle exec rspec --coverage
408
+ # Run specific test file
409
+ bundle exec rspec spec/sleeper_api/client_spec.rb
392
410
  ```
393
411
 
394
412
  ### Setup
395
413
 
396
414
  ```shellscript
397
- git clone https://github.com/eruity1/sleeper_api.git
398
- cd sleeper_api
399
- bundle install
415
+ git clone https://github.com/eruity1/sleeper_api.git
416
+ cd sleeper_api
417
+ bundle install
400
418
  ```
401
419
 
402
420
  ### Code Quality
403
421
 
404
422
  ```shellscript
405
- # Run all checks (tests + linting)
406
- bundle exec rake ci
423
+ # Run all checks (tests + linting)
424
+ bundle exec rake ci
407
425
 
408
- # Run RuboCop
409
- bundle exec rubocop
426
+ # Run RuboCop
427
+ bundle exec rubocop
410
428
 
411
- # Auto-fix RuboCop issues
412
- bundle exec rubocop -a
429
+ # Auto-fix RuboCop issues
430
+ bundle exec rubocop -a
413
431
  ```
414
432
 
415
433
  ### Contributing
@@ -426,24 +444,24 @@ bundle exec rubocop -a
426
444
 
427
445
  ### Development Dependencies
428
446
 
429
- - rspec - Testing framework
447
+ - rspec - Testing framework
430
448
 
431
- - rubocop - Code style and quality
449
+ - rubocop - Code style and quality
432
450
 
433
- - simplecov - Test coverage
451
+ - simplecov - Test coverage
434
452
 
435
- - webmock - HTTP request mocking
453
+ - webmock - HTTP request mocking
436
454
 
437
455
  ## Requirements
438
456
 
439
457
  - Ruby 2.6.0 or higher
440
458
 
441
- - No external dependencies (HTTParty is bundled)
459
+ - No external dependencies (HTTParty is bundled)
442
460
 
443
461
  ## License
444
462
 
445
- The gem is available as open source under the terms of the MIT License.
463
+ The gem is available as open source under the terms of the MIT License.
446
464
 
447
465
  ## Changelog
448
466
 
449
- See CHANGELOG.md for version history and updates.
467
+ See CHANGELOG.md for version history and updates.
@@ -1,5 +1,6 @@
1
1
  require "httparty"
2
2
  require "json"
3
+ require "erb"
3
4
 
4
5
  module SleeperApi
5
6
  # HTTP client for Sleeper API requests.
@@ -21,6 +22,11 @@ module SleeperApi
21
22
  # pass to make_request.
22
23
  base_uri "https://api.sleeper.app"
23
24
 
25
+ # Not HTTParty's own. Its JSON branch passes `quirks_mode`, which json 3.0
26
+ # removed, so without this every response raises ArgumentError the moment a
27
+ # consumer resolves json 3. See JsonParser.
28
+ parser SleeperApi::JsonParser
29
+
24
30
  # @param config [SleeperApi::Configuration] Client configuration
25
31
  def initialize(config)
26
32
  @config = config
@@ -209,6 +215,76 @@ module SleeperApi
209
215
  end
210
216
  end
211
217
 
218
+ # Get per-player statistics for one week, or for a whole season.
219
+ #
220
+ # Undocumented. Returns an object keyed by player id — plus `TEAM_XXX` keys
221
+ # for team-level rows — each holding raw counting stats (`rec`, `rush_yd`,
222
+ # `off_snp`, `rec_rz_tgt`…) alongside Sleeper's three canned point totals
223
+ # `pts_ppr` / `pts_half_ppr` / `pts_std`. 228 distinct fields were observed
224
+ # across one week of 2025.
225
+ #
226
+ # **Nothing here 404s.** An unplayed week, a week out of range, and an
227
+ # unrecognised season type all answer 200 with `{}`, so an empty result is
228
+ # a legitimate answer and is indistinguishable from a typo. Validate the
229
+ # arguments before you trust an empty body.
230
+ #
231
+ # **A week still being played answers with a partial set, and says nothing
232
+ # about being partial.** Mid-week-1 of 2026 this returned 301 rows with
233
+ # only 42 carrying a `pts_ppr` — the two teams whose games had finished or
234
+ # started. A caller that treats "the week's stats" as the whole week gets a
235
+ # half-filled answer for as long as the week is in progress, which for a
236
+ # regular-season week is most of five days. #schedule's per-game `status`
237
+ # is the only thing that can tell a missing row from a scoreless one.
238
+ #
239
+ # Omitting `week` requests season totals, which is a different resource at
240
+ # a shorter path rather than a default of week 1.
241
+ #
242
+ # `pre` and `post` restart week numbering at 1, exactly as #schedule does,
243
+ # so rows from different season types must never be pooled.
244
+ #
245
+ # @param season [Integer, String] Season year, e.g. 2025
246
+ # @param week [Integer, String, nil] Week number, or nil for season totals
247
+ # @param season_type [String] "regular" (default), "pre", or "post"
248
+ # @param sport [String] Sport code (default: "nfl")
249
+ # @return [HTTParty::Response] Player id to stats mapping
250
+ def stats(season, week: nil, season_type: "regular", sport: "nfl")
251
+ make_request(weekly_path("stats", sport, season_type, season, week))
252
+ end
253
+
254
+ # Get per-player projections for one week, or for a whole season.
255
+ #
256
+ # Same shape and same caveats as #stats, with one of its own: for a season
257
+ # Sleeper has not projected, this still returns a full set of entries —
258
+ # 9,386 of them for 2030 — every one holding only `{"adp_dd_ppr" => 1000.0}`
259
+ # and no `pts_ppr` at all. **A row count is not evidence of a projection.**
260
+ # Filter on the field you actually want.
261
+ #
262
+ # `adp_dd_ppr` 1000.0 and `pos_rank_*` 999.0 are "unknown" sentinels rather
263
+ # than values.
264
+ #
265
+ # ⚠️ **This is a pre-game, whole-game projection and it does not move while
266
+ # the game is played.** Measured on 2026-09-10 across six hours spanning a
267
+ # kickoff: five players in that night's game held the same `pts_ppr` before
268
+ # it started and while it was in progress, to the decimal. Checked again
269
+ # against a game that had already finished — the projection still read
270
+ # 19.69 for a player who had scored 26.2, so it does not settle onto the
271
+ # final either.
272
+ #
273
+ # That matters because it is the natural thing to reach for and the wrong
274
+ # one: **there is no live projection here**, so nothing in this payload can
275
+ # say whether a player is on pace. A player on 8 points of a projected 12
276
+ # in the first quarter is ahead of schedule, and this endpoint will report
277
+ # 12 all afternoon.
278
+ #
279
+ # @param season [Integer, String] Season year, e.g. 2026
280
+ # @param week [Integer, String, nil] Week number, or nil for season totals
281
+ # @param season_type [String] "regular" (default), "pre", or "post"
282
+ # @param sport [String] Sport code (default: "nfl")
283
+ # @return [HTTParty::Response] Player id to projections mapping
284
+ def projections(season, week: nil, season_type: "regular", sport: "nfl")
285
+ make_request(weekly_path("projections", sport, season_type, season, week))
286
+ end
287
+
212
288
  # Get a season's game schedule.
213
289
  #
214
290
  # Undocumented, and served from the host root rather than /v1 — hence the
@@ -220,6 +296,26 @@ module SleeperApi
220
296
  # `post` (weeks 1-4) restart week numbering, so games from different season
221
297
  # types must never be pooled.
222
298
  #
299
+ # **`status` is `pre_game`, `in_game`, `complete` or `canceled`**, observed
300
+ # across the 2025 and 2026 regular seasons. `in_game` was read off a live
301
+ # game on 2026-09-10; the other three come from the published schedule. It
302
+ # is the only per-game signal of whether a game has been played — there is
303
+ # no kickoff time anywhere in this payload, only `date`, so a caller can
304
+ # know that a game is under way but never how far into it.
305
+ #
306
+ # **A week is not one event, and a caller reasoning from `date` alone will
307
+ # get that wrong.** Week 1 of 2026 held all three live states at the same
308
+ # moment: one `complete`, one `in_game`, fourteen `pre_game`.
309
+ #
310
+ # Treat the four as an open vocabulary. A postponement or a suspension
311
+ # would be a fifth value and none has been seen, so match with a fallback
312
+ # rather than a whitelist.
313
+ #
314
+ # **A team can carry two games in one week.** 2026 lists a canceled DAL/SEA
315
+ # in week 6 that was superseded rather than called off, and both teams play
316
+ # someone else that week. Order by status before picking one, or you will
317
+ # hand a team a bye it does not have.
318
+ #
223
319
  # A season Sleeper has not scheduled yet answers 200 with an empty array
224
320
  # rather than 404, so an empty result is a legitimate answer and not an
225
321
  # error.
@@ -271,6 +367,20 @@ module SleeperApi
271
367
 
272
368
  private
273
369
 
370
+ # Path for #stats and #projections. Segments are escaped because they are
371
+ # interpolated into a URI path: an unescaped one can walk out of the
372
+ # endpoint entirely, which is the bug the consuming app had to work around
373
+ # for #get_user.
374
+ #
375
+ # A nil week drops the segment rather than defaulting, because the shorter
376
+ # path is season totals.
377
+ def weekly_path(resource, sport, season_type, season, week)
378
+ segments = [resource, sport, season_type, season, week].compact
379
+ escaped = segments.map { |segment| ERB::Util.url_encode(segment.to_s) }
380
+
381
+ "/v1/#{escaped.join("/")}"
382
+ end
383
+
274
384
  # Make an HTTP request with retry logic and logging.
275
385
  #
276
386
  # @param path [String] API endpoint path
@@ -0,0 +1,40 @@
1
+ require "httparty"
2
+ require "json"
3
+
4
+ module SleeperApi
5
+ # HTTParty's JSON parser, minus the one argument that stops it working.
6
+ #
7
+ # HTTParty 0.24.2 parses with `JSON.parse(body, quirks_mode: true,
8
+ # allow_nan: true)`. **json 3.0 removed `quirks_mode`**, so every response
9
+ # this gem parses raises `ArgumentError: unknown keyword: quirks_mode` the
10
+ # moment a consumer resolves json 3 — which is total breakage, not a
11
+ # degradation, and it arrives through a transitive bump nobody asked for.
12
+ # httparty 0.24.2 is the newest release and has no fix.
13
+ #
14
+ # **Dropping the option rather than pinning json is what keeps this the
15
+ # gem's problem instead of its consumers'.** A `json < 3` constraint in the
16
+ # gemspec would fix the same crash by forbidding every app that uses this
17
+ # gem from upgrading json at all, for a flag none of them asked for.
18
+ #
19
+ # `quirks_mode: true` allowed a bare scalar at the top level. That is not
20
+ # academic here — Sleeper answers an unknown username with a literal `null`.
21
+ # Both majors parse that correctly without the flag, checked on 2026-09-11:
22
+ # `JSON.parse("null")` is `nil` under json 2.21.2 and under 3.0.2. The flag
23
+ # has been doing nothing for this gem for some time.
24
+ #
25
+ # **@api private.** It is public only because `parser` has to name something
26
+ # a consumer's Ruby can resolve. Nothing here is a promise: it exists to be
27
+ # deleted the day httparty parses without the flag, and it is why 1.3.1 is a
28
+ # patch rather than a minor — a new constant that is not API does not earn
29
+ # one, and a fix for total breakage should reach a `~> 1.3.0` pin.
30
+ #
31
+ # A subclass rather than a `parser` lambda, because everything else in
32
+ # `HTTParty::Parser#parse` still applies — the blank-body guard, the format
33
+ # detection, the supported-format table. One method is wrong; one method is
34
+ # overridden.
35
+ class JsonParser < HTTParty::Parser
36
+ def json
37
+ JSON.parse(body, allow_nan: true)
38
+ end
39
+ end
40
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SleeperApi
4
- VERSION = "1.2.0"
4
+ VERSION = "1.3.1"
5
5
  end
data/lib/sleeper_api.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require_relative "sleeper_api/version"
2
2
  require_relative "sleeper_api/helpers"
3
+ require_relative "sleeper_api/json_parser"
3
4
  require_relative "sleeper_api/client"
4
5
  require_relative "sleeper_api/league"
5
6
  require_relative "sleeper_api/user"
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.2.0
4
+ version: 1.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eruity1
@@ -148,6 +148,7 @@ files:
148
148
  - lib/sleeper_api/client.rb
149
149
  - lib/sleeper_api/draft.rb
150
150
  - lib/sleeper_api/helpers.rb
151
+ - lib/sleeper_api/json_parser.rb
151
152
  - lib/sleeper_api/league.rb
152
153
  - lib/sleeper_api/user.rb
153
154
  - lib/sleeper_api/version.rb