umami-ruby 0.2.0 → 0.4.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: 8123faece9209e2ead6415c0e396a32853aa69ade5a11af2481c311a6e333e81
4
- data.tar.gz: ad58c5c87d2e27e92e90b7210a7443e589e4879ab9f9f247648b8c43f6af4a99
3
+ metadata.gz: adc8943170e57c2126ffddff58f399b17ff3cb6f0736b293a75fd32c1f1fe6c7
4
+ data.tar.gz: 8a5477ecfeaca6b2cb2857c3b715fa4d32b01ad953b2dd640385d7344c526e0f
5
5
  SHA512:
6
- metadata.gz: 5cf0256f92097a0b2374205d727f1deb7f20d3dce31ade6783001023cd7512e76b3b4f65c41dc33166a340d4ae2e1dab60fef3f2043860fec9e3a98c1d1c7d3b
7
- data.tar.gz: 6f9905fc18ac2200c003d620d5f0f2caac972436b43c9b2756fee76547b7a92ced1358662d5a25300c1d41c3a42a2e847c4bca58d5eed727d57fc90eca1de236
6
+ metadata.gz: ae319a33a3471d8688dfc36444eca87130c1c49fac89694f99377e91b5823454592164b18667cb06bc58fe43e2373040fa061772e79057ba02f76d9132bcd9e3
7
+ data.tar.gz: 87991d5621f16af9db3ae88e29a1b9b2d003c579418e1b92fca9054800aaea7f1ddacc7cc4c4922388dff0de8a0df9f0b8e6f429fbe81704604bd93351b3f5fc
data/CHANGELOG.md CHANGED
@@ -1,3 +1,24 @@
1
+ ## [0.4.0] - 2026-11-06
2
+
3
+ ### Added
4
+
5
+ - `website_pageviews_over_range`: pageviews and sessions over a range of any length, keeping the unit you asked for. Umami silently answers `unit: "day"` in months once a range spans more than 7 calendar months (and `unit: "hour"` in days past 30 days), so a year of daily points came back as twelve monthly ones. The new method splits the range into requests short enough for the unit (90 days for `day`, 30 for `hour`) and joins the results.
6
+
7
+ ### Documentation
8
+
9
+ - README: "Two traps in the Umami API". List endpoints return 20 items unless you pass `pageSize`, and long ranges come back in coarser units than requested. The basic-usage example now passes `pageSize`.
10
+
11
+ ## [0.3.0] - 2026-07-24
12
+
13
+ ### Fixed
14
+
15
+ - **Username/password authentication against self-hosted instances actually works now.** Since the connection memoization was introduced, `authenticate` built (and memoized) the Faraday connection in order to POST `/api/auth/login` — before `@access_token` existed. Faraday bakes headers in at connection build time, so every request after a successful login went out without an `Authorization` header and failed with 401. The token-based flow was unaffected, which is how this survived: docs and tests exercised logins, never "login **then** call an endpoint". `authenticate` now drops the memoized connection after storing the token so the next request rebuilds it with the Bearer header, and the new `test/integration/authentication_flow_test.rb` pins the full lifecycle (login → authorized request → connection reuse without re-login).
16
+ - `authenticate` raises `Umami::AuthenticationError` when the login response is a 200 without a `token` field, instead of leaving a client that 401s on every call with no hint why.
17
+
18
+ ### Changed
19
+
20
+ - HTTP 401 and 403 responses now raise `Umami::AuthenticationError` instead of the undifferentiated `Umami::ClientError`, giving long-lived clients a re-authentication signal (expired/revoked token, insufficient permissions). To keep existing `rescue Umami::APIError` handlers working — they used to catch these as `ClientError` — `AuthenticationError` is now a subclass of `Umami::APIError` (previously it subclassed `Umami::Error` directly). Code matching `Umami::ClientError` specifically for 401/403 must switch to `Umami::AuthenticationError`.
21
+
1
22
  ## [0.2.0] - 2026-02-02
2
23
 
3
24
  ### Added
data/README.md CHANGED
@@ -95,8 +95,8 @@ client = Umami::Client.new
95
95
  # Get current user info
96
96
  me = client.me
97
97
 
98
- # Get all websites
99
- websites = client.websites
98
+ # Get your websites (first page only: Umami pages lists at 20, see below)
99
+ websites = client.websites(pageSize: 200)
100
100
 
101
101
  # Get a specific website
102
102
  website = client.website("website_id")
@@ -125,6 +125,14 @@ pageviews = client.website_pageviews("website_id", {
125
125
  timezone: "America/Los_Angeles"
126
126
  })
127
127
 
128
+ # Pageviews over a range of any length, keeping daily points (see below)
129
+ year = client.website_pageviews_over_range("website_id", {
130
+ startAt: Time.utc(2025, 11, 1).to_i * 1000,
131
+ endAt: Time.utc(2026, 11, 1).to_i * 1000 - 1,
132
+ unit: "day",
133
+ timezone: "UTC"
134
+ })
135
+
128
136
  # Get metrics by type
129
137
  metrics = client.website_metrics("website_id", {
130
138
  startAt: 1656679719687,
@@ -325,6 +333,34 @@ client.reset_website("website_id")
325
333
  client.delete_website("website_id")
326
334
  ```
327
335
 
336
+ ### Two traps in the Umami API
337
+
338
+ Both fail silently: you get a normal-looking answer with data missing.
339
+
340
+ **Lists stop at 20 items.** Every list endpoint (`websites`, `my_websites`,
341
+ `team_websites`, `admin_websites`, users, teams, reports, ...) returns one page
342
+ of 20 unless you ask for more. With 32 sites, `client.websites` returns 20, and
343
+ the other 12 look like they don't exist. Pass `pageSize`, and compare the
344
+ response's `count` with the items you got:
345
+
346
+ ```ruby
347
+ response = client.websites(pageSize: 200)
348
+ response["data"].size == response["count"] # otherwise, fetch the next page
349
+ ```
350
+
351
+ **Long ranges come back in coarser units than you asked for.** If a range is
352
+ too long for the requested `unit`, the server switches to a coarser one and
353
+ says nothing. On Umami 3.x, `unit: "day"` turns into monthly points once the
354
+ range spans more than 7 calendar months, and `unit: "hour"` turns into daily
355
+ points past 30 days (`getMinimumUnit` in Umami's `src/lib/date.ts`). A year of
356
+ `unit: "day"` from `website_pageviews` is twelve monthly points, and code that
357
+ reads them as days puts a month's total on one day.
358
+
359
+ `website_pageviews_over_range` takes the same params as `website_pageviews`,
360
+ splits a long range into requests short enough for the unit (90 days for
361
+ `day`, 30 for `hour`), and joins the results. Start `startAt` at midnight in
362
+ the `timezone` you pass, so no day is split across two requests.
363
+
328
364
  ## Available Methods
329
365
 
330
366
  ### Authentication
@@ -374,6 +410,7 @@ client.delete_website("website_id")
374
410
  - `website_stats(website_id, params)` - Get statistics
375
411
  - `website_active_visitors(website_id)` - Get active visitors
376
412
  - `website_pageviews(website_id, params)` - Get pageviews
413
+ - `website_pageviews_over_range(website_id, params)` - Get pageviews over a range of any length, keeping the requested unit
377
414
  - `website_events(website_id, params)` - Get events (time-series)
378
415
  - `website_events_series(website_id, params)` - Get events series
379
416
  - `website_metrics(website_id, params)` - Get metrics
@@ -440,12 +477,12 @@ begin
440
477
  client.website("non-existent-id")
441
478
  rescue Umami::NotFoundError => e
442
479
  puts "Website not found: #{e.message}"
480
+ rescue Umami::AuthenticationError => e
481
+ puts "Authentication failed: #{e.message}" # rejected login, expired/revoked token (401), forbidden (403)
443
482
  rescue Umami::ClientError => e
444
483
  puts "Client error: #{e.message}"
445
484
  rescue Umami::ServerError => e
446
485
  puts "Server error: #{e.message}"
447
- rescue Umami::AuthenticationError => e
448
- puts "Authentication failed: #{e.message}"
449
486
  rescue Umami::ConfigurationError => e
450
487
  puts "Configuration error: #{e.message}"
451
488
  rescue Umami::APIError => e
@@ -456,10 +493,10 @@ end
456
493
  **Error class hierarchy:**
457
494
  - `Umami::Error` - Base error class
458
495
  - `Umami::ConfigurationError` - Configuration issues
459
- - `Umami::AuthenticationError` - Authentication failures
460
496
  - `Umami::APIError` - API request failures
497
+ - `Umami::AuthenticationError` - Authentication failures: rejected logins, and HTTP 401/403 API responses (since 0.3.0 — an expired token on a long-lived client surfaces here so you can re-authenticate)
461
498
  - `Umami::NotFoundError` - Resource not found (404)
462
- - `Umami::ClientError` - Client errors (4xx)
499
+ - `Umami::ClientError` - Other client errors (4xx)
463
500
  - `Umami::ServerError` - Server errors (5xx)
464
501
 
465
502
  ## Logging
data/context7.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "url": "https://context7.com/rameerez/umami-ruby",
3
+ "public_key": "pk_HibNJE5rTFvy1txHHXUot"
4
+ }
data/lib/umami/client.rb CHANGED
@@ -72,6 +72,25 @@ module Umami
72
72
 
73
73
  data = JSON.parse(response.body)
74
74
  @access_token = data["token"]
75
+
76
+ # Umami answers a successful login with { "token": ..., "user": ... }
77
+ # (https://umami.is/docs/api/authentication#post-/api/auth/login).
78
+ # A 200 without a token would otherwise produce a client that fails
79
+ # every request with an opaque 401 — fail loudly at the source.
80
+ if @access_token.nil? || @access_token.empty?
81
+ raise Umami::AuthenticationError, "Authentication succeeded but the response carried no token"
82
+ end
83
+
84
+ # ⚠️ Load-bearing reset. The login POST above just memoized
85
+ # `@connection` — necessarily WITHOUT an Authorization header, since
86
+ # `@access_token` was still nil when the connection was built, and
87
+ # Faraday bakes headers into the connection at build time. Dropping
88
+ # the memo forces the next request to rebuild the connection, this
89
+ # time with the Bearer token (see #connection). Without this reset,
90
+ # every request after a username/password login fails with 401 —
91
+ # the exact bug shipped in <= 0.2.0 (why token-based auth was the
92
+ # only flow that worked against self-hosted instances).
93
+ @connection = nil
75
94
  rescue Faraday::Error, JSON::ParserError => e
76
95
  raise Umami::AuthenticationError, "Authentication failed: #{e.message}"
77
96
  end
@@ -494,6 +513,59 @@ module Umami
494
513
  get("/api/websites/#{website_id}/pageviews", params)
495
514
  end
496
515
 
516
+ # Longest range, in days, that one pageviews request can cover and still
517
+ # get the unit it asked for. Umami silently picks a coarser unit when the
518
+ # range is too long for the one requested: 3.x answers `unit: "day"` in
519
+ # months once the range spans more than 7 calendar months, and
520
+ # `unit: "hour"` in days past 30 days (src/lib/date.ts, getMinimumUnit).
521
+ # These limits leave room for older servers too.
522
+ PAGEVIEWS_DAYS_PER_REQUEST = { "hour" => 30, "day" => 90 }.freeze
523
+
524
+ # Get pageviews and sessions over a range of any length, keeping the unit
525
+ # you asked for
526
+ #
527
+ # Takes the same params as {#website_pageviews}. A range longer than one
528
+ # request can hold for that unit (see PAGEVIEWS_DAYS_PER_REQUEST) is split
529
+ # into consecutive requests, and their series are joined. Without this,
530
+ # a year of `unit: "day"` comes back as twelve monthly points, with no
531
+ # error.
532
+ #
533
+ # Start `startAt` at midnight (or on the hour) in the `timezone` you pass,
534
+ # so no bucket is split across two requests. If one is, its counts are
535
+ # added together, which can count a visitor twice in "sessions".
536
+ #
537
+ # @param website_id [String] The website's ID
538
+ # @param params [Hash] Same as {#website_pageviews}; :startAt, :endAt and :unit are required
539
+ # @return [Hash] "pageviews" and "sessions" arrays of { "x" => bucket, "y" => count }, in order
540
+ # @example A year of daily visitors
541
+ # client.website_pageviews_over_range(website_id,
542
+ # startAt: Time.utc(2025, 11, 1).to_i * 1000,
543
+ # endAt: Time.utc(2026, 11, 1).to_i * 1000 - 1,
544
+ # unit: "day", timezone: "UTC")
545
+ def website_pageviews_over_range(website_id, params = {})
546
+ params = params.transform_keys(&:to_sym)
547
+ days = PAGEVIEWS_DAYS_PER_REQUEST[params[:unit].to_s]
548
+ return website_pageviews(website_id, params) unless days
549
+
550
+ start_at, end_at = Integer(params.fetch(:startAt)), Integer(params.fetch(:endAt))
551
+ step = days * 24 * 60 * 60 * 1000
552
+ series = Hash.new { |hash, key| hash[key] = {} }
553
+
554
+ start_at.step(end_at, step) do |from|
555
+ response = website_pageviews(website_id, params.merge(startAt: from, endAt: [from + step - 1, end_at].min))
556
+
557
+ %w[pageviews sessions].each do |key|
558
+ Array(response[key]).each do |point|
559
+ series[key][point["x"]] = series[key].fetch(point["x"], 0) + point["y"].to_i
560
+ end
561
+ end
562
+ end
563
+
564
+ %w[pageviews sessions].to_h do |key|
565
+ [key, series[key].map { |x, y| { "x" => x, "y" => y } }]
566
+ end
567
+ end
568
+
497
569
  # Get website events within a time range
498
570
  #
499
571
  # This method returns event data with optional time-series grouping.
@@ -1298,6 +1370,12 @@ module Umami
1298
1370
  handle_error(e)
1299
1371
  end
1300
1372
 
1373
+ # Memoized so keep-alive and middleware setup are paid once, BUT the
1374
+ # Authorization header is baked in at build time — Faraday connections
1375
+ # don't re-read `@access_token` afterwards. Anything that changes the
1376
+ # token MUST set `@connection = nil` so the next request rebuilds with
1377
+ # the fresh header (see #authenticate, where forgetting exactly that
1378
+ # was the <= 0.2.0 username/password bug).
1301
1379
  def connection
1302
1380
  @connection ||= Faraday.new(url: uri_base) do |faraday|
1303
1381
  faraday.request :json
@@ -1308,8 +1386,17 @@ module Umami
1308
1386
  end
1309
1387
  end
1310
1388
 
1389
+ # Order matters: Faraday::UnauthorizedError (401) and ForbiddenError
1390
+ # (403) are SUBCLASSES of Faraday::ClientError
1391
+ # (https://lostisland.github.io/faraday/#/middleware/included/raising-errors),
1392
+ # so they must be matched before the generic 4xx case or they'd
1393
+ # surface as an undifferentiated ClientError. Raising
1394
+ # AuthenticationError instead gives callers a re-authentication signal
1395
+ # (e.g. an expired or revoked token on a long-lived client).
1311
1396
  def handle_error(error)
1312
1397
  case error
1398
+ when Faraday::UnauthorizedError, Faraday::ForbiddenError
1399
+ raise Umami::AuthenticationError, "Authentication failed: #{error.message}"
1313
1400
  when Faraday::ResourceNotFound
1314
1401
  raise Umami::NotFoundError, "Resource not found: #{error.message}"
1315
1402
  when Faraday::ClientError
data/lib/umami/errors.rb CHANGED
@@ -5,12 +5,19 @@ module Umami
5
5
  # Error raised when there's a configuration issue
6
6
  class ConfigurationError < Error; end
7
7
 
8
- # Error raised when authentication fails
9
- class AuthenticationError < Error; end
10
-
11
8
  # Base error class for API-related errors
12
9
  class APIError < Error; end
13
10
 
11
+ # Error raised when authentication fails — a rejected login, a login
12
+ # response without a token, or an API request answered 401/403 (an
13
+ # expired or revoked token on a long-lived client, insufficient
14
+ # permissions). Deliberately a subclass of APIError since 0.2.1:
15
+ # before that, HTTP 401/403 surfaced as ClientError, so callers who
16
+ # `rescue Umami::APIError` were already catching them — subclassing
17
+ # APIError keeps those rescues working while giving auth failures
18
+ # their own class to match on.
19
+ class AuthenticationError < APIError; end
20
+
14
21
  # Error raised when a resource is not found
15
22
  class NotFoundError < APIError; end
16
23
 
data/lib/umami/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Umami
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: umami-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-02-03 00:00:00.000000000 Z
10
+ date: 2026-11-06 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: faraday
@@ -39,6 +39,7 @@ files:
39
39
  - LICENSE.txt
40
40
  - README.md
41
41
  - Rakefile
42
+ - context7.json
42
43
  - docs/Umami.html
43
44
  - docs/Umami/APIError.html
44
45
  - docs/Umami/AuthenticationError.html