spotted 0.32.0 → 0.34.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: 5a543070c1c0ef74514cbd6cde809153c4e1df8088291e9213c99f04e6fdb63e
4
- data.tar.gz: 9f1f5aa4c514419418dd55aef926b1a164c9edefd0249acb8ed77d126de32e6f
3
+ metadata.gz: 77ec607b5d1456b68fe75d3699286c08d9f73f2962027830cb54fd562f359178
4
+ data.tar.gz: f4e4925f3af823d98bb5b038d9ed96c508c50f5d4d2aa4439189e586a645404e
5
5
  SHA512:
6
- metadata.gz: b48973410e9ee24cda90e014db519cf64ede913c62ec32f62ee8f8e283749253c9eb1a054bb3a401c85d3a39fbe0118c859595448b6d64c7430bd04c81e9d5e7
7
- data.tar.gz: '089e889095747aa0d54fd06b9b2d5e973b81b5e157b7de9ff142f967b61359c71d57e5ca962778b1bd2fa9d224d7733fdfd4b2bb3100c9b5b2e925061c23757f'
6
+ metadata.gz: 8016c1af387c5bb1f1318e2170ad24e8454d0dc593d877a314b05630c90ec643cb101c1738be367b2d9a16e4d8ddbcea9a6e10c0f8224ecf1187ecedf623e18a
7
+ data.tar.gz: 8d8419e329253cebec841027302fd74b784313cb16267a3041e0dfa9afc5997453488b1a250bc6fa37a900a5279d36c57905b7e6ab92dcfaf639705f296ccc12
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.34.0 (2026-01-16)
4
+
5
+ Full Changelog: [v0.33.0...v0.34.0](https://github.com/cjavdev/spotted/compare/v0.33.0...v0.34.0)
6
+
7
+ ### Features
8
+
9
+ * **oauth:** Adds OAuth helpers for getting new tokens ([1e4d698](https://github.com/cjavdev/spotted/commit/1e4d69811f02209449eba6bdbcbfcfaaba9530f4))
10
+
11
+
12
+ ### Chores
13
+
14
+ * lint ([43c32bd](https://github.com/cjavdev/spotted/commit/43c32bd89d548732e0c35ceefa0f13769d0781cb))
15
+ * types for auth ([5d5c7d1](https://github.com/cjavdev/spotted/commit/5d5c7d1495ab21f0f1aeb14185935a28f77e6deb))
16
+
17
+ ## 0.33.0 (2026-01-15)
18
+
19
+ Full Changelog: [v0.32.0...v0.33.0](https://github.com/cjavdev/spotted/compare/v0.32.0...v0.33.0)
20
+
21
+ ### Features
22
+
23
+ * **api:** manual updates ([4a0240e](https://github.com/cjavdev/spotted/commit/4a0240e10435cc9af7425885c72fa9e361d29613))
24
+ * **api:** manual updates ([c937e8f](https://github.com/cjavdev/spotted/commit/c937e8f8f5ac33270ae32920640f28706df98965))
25
+ * **api:** turn off oauth ([88abcd6](https://github.com/cjavdev/spotted/commit/88abcd62763cadfd6036026af2efd7ee0f2de678))
26
+
3
27
  ## 0.32.0 (2026-01-14)
4
28
 
5
29
  Full Changelog: [v0.31.1...v0.32.0](https://github.com/cjavdev/spotted/compare/v0.31.1...v0.32.0)
data/README.md CHANGED
@@ -17,7 +17,7 @@ Use the Spotted MCP Server to enable AI assistants to interact with this API, al
17
17
 
18
18
  Documentation for releases of this gem can be found [on RubyDoc](https://gemdocs.org/gems/spotted).
19
19
 
20
- The REST API documentation can be found on [spotted.stldocs.com](https://spotted.stldocs.com?docs).
20
+ The REST API documentation can be found on [spotted.cjav.dev](https://spotted.cjav.dev).
21
21
 
22
22
  ## Installation
23
23
 
@@ -26,7 +26,7 @@ To use this gem, install via Bundler by adding the following to your application
26
26
  <!-- x-release-please-start-version -->
27
27
 
28
28
  ```ruby
29
- gem "spotted", "~> 0.32.0"
29
+ gem "spotted", "~> 0.34.0"
30
30
  ```
31
31
 
32
32
  <!-- x-release-please-end -->
@@ -38,8 +38,7 @@ require "bundler/setup"
38
38
  require "spotted"
39
39
 
40
40
  spotted = Spotted::Client.new(
41
- client_id: ENV["SPOTIFY_CLIENT_ID"], # This is the default and can be omitted
42
- client_secret: ENV["SPOTIFY_CLIENT_SECRET"] # This is the default and can be omitted
41
+ access_token: ENV["SPOTIFY_ACCESS_TOKEN"] # This is the default and can be omitted
43
42
  )
44
43
 
45
44
  album = spotted.albums.retrieve("4aawyAB9vmqN3uQ7FjRGTy")
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spotted
4
+ # Handles OAuth 2.0 authorization flows for Spotify API
5
+ class Auth
6
+ AUTHORIZE_URL = "https://accounts.spotify.com/authorize"
7
+ TOKEN_URL = "https://accounts.spotify.com/api/token"
8
+
9
+ # @return [String]
10
+ attr_reader :client_id
11
+
12
+ # @return [String]
13
+ attr_reader :client_secret
14
+
15
+ # Creates a new Auth instance for handling OAuth flows
16
+ #
17
+ # @param client_id [String] The Spotify application client ID
18
+ # @param client_secret [String] The Spotify application client secret
19
+ #
20
+ # @raise [ArgumentError] if client_id or client_secret is missing
21
+ def initialize(client_id:, client_secret:)
22
+ if client_id.nil? || client_id.empty?
23
+ raise ArgumentError, "client_id is required"
24
+ end
25
+
26
+ if client_secret.nil? || client_secret.empty?
27
+ raise ArgumentError, "client_secret is required"
28
+ end
29
+
30
+ @client_id = client_id
31
+ @client_secret = client_secret
32
+ end
33
+
34
+ # Generates the Spotify authorization URL for OAuth2 authorization code flow
35
+ #
36
+ # @param redirect_uri [String] The URI to redirect to after authorization
37
+ # @param scope [String, Array<String>, nil] Space-delimited string or array of authorization scopes
38
+ # @param state [String, nil] Optional state parameter for CSRF protection
39
+ # @param show_dialog [Boolean] Whether to force the user to approve the app again
40
+ #
41
+ # @return [String] The authorization URL to redirect the user to
42
+ #
43
+ # @example Basic usage
44
+ # auth = Spotted::Auth.new(client_id: "...", client_secret: "...")
45
+ # url = auth.authorization_url(redirect_uri: "http://localhost:3000/callback")
46
+ #
47
+ # @example With scopes and state
48
+ # url = auth.authorization_url(
49
+ # redirect_uri: "http://localhost:3000/callback",
50
+ # scope: ["user-read-private", "user-read-email"],
51
+ # state: "random_state_string"
52
+ # )
53
+ def authorization_url(redirect_uri:, scope: nil, state: nil, show_dialog: false)
54
+ params = {
55
+ client_id: @client_id,
56
+ response_type: "code",
57
+ redirect_uri: redirect_uri
58
+ }
59
+
60
+ params[:scope] = scope.is_a?(Array) ? scope.join(" ") : scope if scope
61
+ params[:state] = state if state
62
+ params[:show_dialog] = "true" if show_dialog
63
+
64
+ query_string = URI.encode_www_form(params)
65
+ "#{AUTHORIZE_URL}?#{query_string}"
66
+ end
67
+
68
+ # Exchanges an authorization code for an access token
69
+ #
70
+ # @param code [String] The authorization code received from the authorization callback
71
+ # @param redirect_uri [String] The redirect URI used in the authorization request (must match exactly)
72
+ #
73
+ # @return [Hash] Token response containing:
74
+ # - access_token [String] The access token to use for API requests
75
+ # - token_type [String] The type of token (usually "Bearer")
76
+ # - expires_in [Integer] Number of seconds until the token expires
77
+ # - refresh_token [String] Token to use to refresh the access token
78
+ # - scope [String] Space-delimited list of granted scopes
79
+ #
80
+ # @raise [Spotted::APIError] if the token exchange fails
81
+ #
82
+ # @example
83
+ # auth = Spotted::Auth.new(client_id: "...", client_secret: "...")
84
+ # credentials = auth.exchange_authorization_code(
85
+ # code: params[:code],
86
+ # redirect_uri: "http://localhost:3000/callback"
87
+ # )
88
+ # access_token = credentials[:access_token]
89
+ def exchange_authorization_code(code:, redirect_uri:)
90
+ body = URI.encode_www_form(
91
+ grant_type: "authorization_code",
92
+ code: code,
93
+ redirect_uri: redirect_uri
94
+ )
95
+
96
+ make_token_request(body: body)
97
+ end
98
+
99
+ # Refreshes an access token using a refresh token
100
+ #
101
+ # @param refresh_token [String] The refresh token received from a previous authorization
102
+ #
103
+ # @return [Hash] Token response containing:
104
+ # - access_token [String] The new access token to use for API requests
105
+ # - token_type [String] The type of token (usually "Bearer")
106
+ # - expires_in [Integer] Number of seconds until the token expires
107
+ # - scope [String] Space-delimited list of granted scopes
108
+ #
109
+ # @raise [Spotted::APIError] if the token refresh fails
110
+ #
111
+ # @example
112
+ # auth = Spotted::Auth.new(client_id: "...", client_secret: "...")
113
+ # credentials = auth.refresh_access_token(refresh_token: stored_refresh_token)
114
+ # new_access_token = credentials[:access_token]
115
+ def refresh_access_token(refresh_token:)
116
+ body = URI.encode_www_form(
117
+ grant_type: "refresh_token",
118
+ refresh_token: refresh_token
119
+ )
120
+
121
+ make_token_request(body: body)
122
+ end
123
+
124
+ private
125
+
126
+ # Makes a request to the token endpoint
127
+ #
128
+ # @param body [String] The URL-encoded form body
129
+ # @return [Hash] The parsed JSON response
130
+ # @raise [Spotted::APIError] if the request fails
131
+ def make_token_request(body:)
132
+ uri = URI(TOKEN_URL)
133
+ http = Net::HTTP.new(uri.host, uri.port)
134
+ http.use_ssl = true
135
+
136
+ # Configure SSL to use system certificates
137
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
138
+ http.cert_store = OpenSSL::X509::Store.new
139
+ http.cert_store.set_default_paths
140
+
141
+ request = Net::HTTP::Post.new(uri.path)
142
+ request["Content-Type"] = "application/x-www-form-urlencoded"
143
+ request["Authorization"] = "Basic #{Base64.strict_encode64("#{@client_id}:#{@client_secret}")}"
144
+ request.body = body
145
+
146
+ response = http.request(request)
147
+
148
+ parse_token_response(response)
149
+ end
150
+
151
+ # Parses the token response and handles errors
152
+ #
153
+ # @param response [Net::HTTPResponse] The HTTP response from the token endpoint
154
+ # @return [Hash] The parsed token data with symbolized keys
155
+ # @raise [Spotted::APIError] if the response indicates an error
156
+ def parse_token_response(response)
157
+ body = JSON.parse(response.body)
158
+
159
+ case response
160
+ when Net::HTTPSuccess
161
+ # Convert string keys to symbols for easier access
162
+ body.transform_keys(&:to_sym)
163
+ else
164
+ error_message = body["error_description"] || body["error"] || "Token request failed"
165
+ raise Spotted::Errors::APIError.new(
166
+ url: uri,
167
+ status: response.code.to_i,
168
+ body: body,
169
+ message: error_message
170
+ )
171
+ end
172
+ end
173
+ end
174
+ end
@@ -15,13 +15,7 @@ module Spotted
15
15
  # Default max retry delay in seconds.
16
16
  DEFAULT_MAX_RETRY_DELAY = 8.0
17
17
 
18
- # @return [String, nil]
19
- attr_reader :client_id
20
-
21
- # @return [String, nil]
22
- attr_reader :client_secret
23
-
24
- # @return [String, nil]
18
+ # @return [String]
25
19
  attr_reader :access_token
26
20
 
27
21
  # @return [Spotted::Resources::Albums]
@@ -76,63 +70,13 @@ module Spotted
76
70
  #
77
71
  # @return [Hash{String=>String}]
78
72
  private def auth_headers
79
- unless @access_token.nil?
80
- return {**bearer_auth}
81
- end
82
-
83
- if @client_id && @client_secret
84
- return {**oauth_2_0}
85
- end
86
-
87
- {}
88
- end
89
-
90
- # @api private
91
- #
92
- # @return [Hash{String=>String}]
93
- private def bearer_auth
94
73
  return {} if @access_token.nil?
95
74
 
96
75
  {"authorization" => "Bearer #{@access_token}"}
97
76
  end
98
77
 
99
- # @api private
100
- # @return [Spotted::Internal::OAuth2ClientCredentials]
101
- attr_reader :oauth_2_0_state
102
-
103
- # @api private
104
- #
105
- # @return [Hash{String=>String}]
106
- private def oauth_2_0
107
- return @oauth_2_0_state.auth_headers if @oauth_2_0_state
108
-
109
- return {} unless @client_id && @client_secret
110
-
111
- path = Spotted::Internal::Util.interpolate_path("https://accounts.spotify.com/api/token")
112
- token_url = Spotted::Internal::Util.join_parsed_uri(
113
- @base_url_components,
114
- {
115
- path: path,
116
- query: {grant_type: "client_credentials"}
117
- }
118
- )
119
-
120
- @oauth_2_0_state = Spotted::Internal::OAuth2ClientCredentials.new(
121
- token_url: token_url.to_s,
122
- client_id: @client_id,
123
- client_secret: @client_secret,
124
- timeout: @timeout,
125
- client: self
126
- )
127
- @oauth_2_0_state.auth_headers
128
- end
129
-
130
78
  # Creates and returns a new client for interacting with the API.
131
79
  #
132
- # @param client_id [String, nil] Defaults to `ENV["SPOTIFY_CLIENT_ID"]`
133
- #
134
- # @param client_secret [String, nil] Defaults to `ENV["SPOTIFY_CLIENT_SECRET"]`
135
- #
136
80
  # @param access_token [String, nil] Defaults to `ENV["SPOTIFY_ACCESS_TOKEN"]`
137
81
  #
138
82
  # @param base_url [String, nil] Override the default base URL for the API, e.g.,
@@ -146,8 +90,6 @@ module Spotted
146
90
  #
147
91
  # @param max_retry_delay [Float]
148
92
  def initialize(
149
- client_id: ENV["SPOTIFY_CLIENT_ID"],
150
- client_secret: ENV["SPOTIFY_CLIENT_SECRET"],
151
93
  access_token: ENV["SPOTIFY_ACCESS_TOKEN"],
152
94
  base_url: ENV["SPOTTED_BASE_URL"],
153
95
  max_retries: self.class::DEFAULT_MAX_RETRIES,
@@ -157,9 +99,11 @@ module Spotted
157
99
  )
158
100
  base_url ||= "https://api.spotify.com/v1"
159
101
 
160
- @client_id = client_id&.to_s
161
- @client_secret = client_secret&.to_s
162
- @access_token = access_token&.to_s
102
+ if access_token.nil?
103
+ raise ArgumentError.new("access_token is required, and can be set via environ: \"SPOTIFY_ACCESS_TOKEN\"")
104
+ end
105
+
106
+ @access_token = access_token.to_s
163
107
 
164
108
  super(
165
109
  base_url: base_url,
@@ -186,90 +130,5 @@ module Spotted
186
130
  @recommendations = Spotted::Resources::Recommendations.new(client: self)
187
131
  @markets = Spotted::Resources::Markets.new(client: self)
188
132
  end
189
-
190
- # Generates the Spotify authorization URL for OAuth2 authorization code flow.
191
- #
192
- # @param redirect_uri [String] The URI to redirect to after authorization
193
- # @param scope [String, Array<String>, nil] Space-delimited string or array of authorization scopes
194
- # @param state [String, nil] Optional state parameter for CSRF protection
195
- # @param show_dialog [Boolean] Whether to force the user to approve the app again
196
- #
197
- # @return [String] The authorization URL to redirect the user to
198
- #
199
- # @example Basic usage
200
- # client = Spotted::Client.new(client_id: "...", client_secret: "...")
201
- # url = client.authorization_url(redirect_uri: "http://localhost:3000/callback")
202
- #
203
- # @example With scopes and state
204
- # url = client.authorization_url(
205
- # redirect_uri: "http://localhost:3000/callback",
206
- # scope: ["user-read-private", "user-read-email"],
207
- # state: "random_state_string"
208
- # )
209
- def authorization_url(redirect_uri:, scope: nil, state: nil, show_dialog: false)
210
- params = {
211
- client_id: @client_id,
212
- response_type: "code",
213
- redirect_uri: redirect_uri
214
- }
215
-
216
- params[:scope] = scope.is_a?(Array) ? scope.join(" ") : scope if scope
217
- params[:state] = state if state
218
- params[:show_dialog] = "true" if show_dialog
219
-
220
- query_string = URI.encode_www_form(params)
221
- "https://accounts.spotify.com/authorize?#{query_string}"
222
- end
223
-
224
- # Exchanges an authorization code for an access token.
225
- #
226
- # @param code [String] The authorization code to exchange
227
- # @param redirect_uri [String] The redirect URI used to obtain the authorization code
228
- #
229
- # @return [Object] The access token and refresh token
230
- def exchange_authorization_code(code:, redirect_uri:)
231
- if @client_id.nil? || @client_secret.nil?
232
- raise ArgumentError, "Both client_id and client_secret must be set to exchange an authorization code."
233
- end
234
- body = URI.encode_www_form(
235
- grant_type: "authorization_code",
236
- code: code,
237
- redirect_uri: redirect_uri
238
- )
239
- client = Spotted::Client.new(
240
- client_id: @client_id,
241
- client_secret: @client_secret,
242
- base_url: "https://accounts.spotify.com"
243
- )
244
- client.request(
245
- method: :post,
246
- headers: {
247
- "Content-Type" => "application/x-www-form-urlencoded",
248
- "Authorization" => "Basic #{Base64.strict_encode64("#{@client_id}:#{@client_secret}")}"
249
- },
250
- path: "/api/token",
251
- body: body
252
- )
253
- end
254
-
255
- def refresh_access_token(refresh_token:)
256
- if @client_id.nil? || @client_secret.nil?
257
- raise ArgumentError, "Both client_id and client_secret must be set to refresh an access token."
258
- end
259
- body = URI.encode_www_form(
260
- grant_type: "refresh_token",
261
- refresh_token: refresh_token
262
- )
263
- client = Spotted::Client.new(client_id: @client_id, client_secret: @client_secret, base_url: "https://accounts.spotify.com")
264
- client.request(
265
- method: :post,
266
- headers: {
267
- "Content-Type" => "application/x-www-form-urlencoded",
268
- "Authorization" => "Basic #{Base64.strict_encode64("#{@client_id}:#{@client_secret}")}"
269
- },
270
- path: "/api/token",
271
- body: body
272
- )
273
- end
274
133
  end
275
134
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Spotted
4
- VERSION = "0.32.0"
4
+ VERSION = "0.34.0"
5
5
  end
data/lib/spotted.rb CHANGED
@@ -16,7 +16,6 @@ require "rbconfig"
16
16
  require "securerandom"
17
17
  require "set"
18
18
  require "stringio"
19
- require "thread"
20
19
  require "time"
21
20
  require "uri"
22
21
  # rubocop:enable Lint/RedundantRequireStatement
@@ -50,10 +49,10 @@ require_relative "spotted/internal"
50
49
  require_relative "spotted/request_options"
51
50
  require_relative "spotted/file_part"
52
51
  require_relative "spotted/errors"
52
+ require_relative "spotted/auth"
53
53
  require_relative "spotted/internal/transport/base_client"
54
54
  require_relative "spotted/internal/transport/pooled_net_requester"
55
55
  require_relative "spotted/client"
56
- require_relative "spotted/internal/oauth2"
57
56
  require_relative "spotted/internal/cursor_url_page"
58
57
  require_relative "spotted/models/audiobook_base"
59
58
  require_relative "spotted/models/playlist_user_object"
@@ -0,0 +1,42 @@
1
+ # typed: strong
2
+
3
+ module Spotted
4
+ class Auth
5
+ AUTHORIZE_URL = T.let("https://accounts.spotify.com/authorize", String)
6
+ TOKEN_URL = T.let("https://accounts.spotify.com/api/token", String)
7
+
8
+ sig { returns(String) }
9
+ attr_reader :client_id
10
+
11
+ sig { returns(String) }
12
+ attr_reader :client_secret
13
+
14
+ sig { params(client_id: String, client_secret: String).void }
15
+ def initialize(client_id:, client_secret:); end
16
+
17
+ sig do
18
+ params(
19
+ redirect_uri: String,
20
+ scope: T.nilable(T.any(String, T::Array[String])),
21
+ state: T.nilable(String),
22
+ show_dialog: T::Boolean
23
+ ).returns(String)
24
+ end
25
+ def authorization_url(redirect_uri:, scope: nil, state: nil, show_dialog: false); end
26
+
27
+ sig do
28
+ params(
29
+ code: String,
30
+ redirect_uri: String
31
+ ).returns(T::Hash[Symbol, T.untyped])
32
+ end
33
+ def exchange_authorization_code(code:, redirect_uri:); end
34
+
35
+ sig do
36
+ params(
37
+ refresh_token: String
38
+ ).returns(T::Hash[Symbol, T.untyped])
39
+ end
40
+ def refresh_access_token(refresh_token:); end
41
+ end
42
+ end
@@ -10,13 +10,7 @@ module Spotted
10
10
 
11
11
  DEFAULT_MAX_RETRY_DELAY = T.let(8.0, Float)
12
12
 
13
- sig { returns(T.nilable(String)) }
14
- attr_reader :client_id
15
-
16
- sig { returns(T.nilable(String)) }
17
- attr_reader :client_secret
18
-
19
- sig { returns(T.nilable(String)) }
13
+ sig { returns(String) }
20
14
  attr_reader :access_token
21
15
 
22
16
  sig { returns(Spotted::Resources::Albums) }
@@ -72,25 +66,9 @@ module Spotted
72
66
  private def auth_headers
73
67
  end
74
68
 
75
- # @api private
76
- sig { returns(T::Hash[String, String]) }
77
- private def bearer_auth
78
- end
79
-
80
- # @api private
81
- sig { returns(Spotted::Internal::OAuth2ClientCredentials) }
82
- attr_reader :oauth_2_0_state
83
-
84
- # @api private
85
- sig { returns(T::Hash[String, String]) }
86
- private def oauth_2_0
87
- end
88
-
89
69
  # Creates and returns a new client for interacting with the API.
90
70
  sig do
91
71
  params(
92
- client_id: T.nilable(String),
93
- client_secret: T.nilable(String),
94
72
  access_token: T.nilable(String),
95
73
  base_url: T.nilable(String),
96
74
  max_retries: Integer,
@@ -100,10 +78,6 @@ module Spotted
100
78
  ).returns(T.attached_class)
101
79
  end
102
80
  def self.new(
103
- # Defaults to `ENV["SPOTIFY_CLIENT_ID"]`
104
- client_id: ENV["SPOTIFY_CLIENT_ID"],
105
- # Defaults to `ENV["SPOTIFY_CLIENT_SECRET"]`
106
- client_secret: ENV["SPOTIFY_CLIENT_SECRET"],
107
81
  # Defaults to `ENV["SPOTIFY_ACCESS_TOKEN"]`
108
82
  access_token: ENV["SPOTIFY_ACCESS_TOKEN"],
109
83
  # Override the default base URL for the API, e.g.,
@@ -8,11 +8,7 @@ module Spotted
8
8
 
9
9
  DEFAULT_MAX_RETRY_DELAY: Float
10
10
 
11
- attr_reader client_id: String?
12
-
13
- attr_reader client_secret: String?
14
-
15
- attr_reader access_token: String?
11
+ attr_reader access_token: String
16
12
 
17
13
  attr_reader albums: Spotted::Resources::Albums
18
14
 
@@ -48,16 +44,7 @@ module Spotted
48
44
 
49
45
  private def auth_headers: -> ::Hash[String, String]
50
46
 
51
- private def bearer_auth: -> ::Hash[String, String]
52
-
53
- # @api private
54
- attr_reader oauth_2_0_state: Spotted::Internal::OAuth2ClientCredentials
55
-
56
- private def oauth_2_0: -> ::Hash[String, String]
57
-
58
47
  def initialize: (
59
- ?client_id: String?,
60
- ?client_secret: String?,
61
48
  ?access_token: String?,
62
49
  ?base_url: String?,
63
50
  ?max_retries: Integer,
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spotted
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.32.0
4
+ version: 0.34.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Spotted
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-01-14 00:00:00.000000000 Z
11
+ date: 2026-01-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: cgi
@@ -50,12 +50,12 @@ files:
50
50
  - README.md
51
51
  - SECURITY.md
52
52
  - lib/spotted.rb
53
+ - lib/spotted/auth.rb
53
54
  - lib/spotted/client.rb
54
55
  - lib/spotted/errors.rb
55
56
  - lib/spotted/file_part.rb
56
57
  - lib/spotted/internal.rb
57
58
  - lib/spotted/internal/cursor_url_page.rb
58
- - lib/spotted/internal/oauth2.rb
59
59
  - lib/spotted/internal/transport/base_client.rb
60
60
  - lib/spotted/internal/transport/pooled_net_requester.rb
61
61
  - lib/spotted/internal/type/array_of.rb
@@ -276,12 +276,12 @@ files:
276
276
  - lib/spotted/resources/users/playlists.rb
277
277
  - lib/spotted/version.rb
278
278
  - manifest.yaml
279
+ - rbi/spotted/auth.rbi
279
280
  - rbi/spotted/client.rbi
280
281
  - rbi/spotted/errors.rbi
281
282
  - rbi/spotted/file_part.rbi
282
283
  - rbi/spotted/internal.rbi
283
284
  - rbi/spotted/internal/cursor_url_page.rbi
284
- - rbi/spotted/internal/oauth2.rbi
285
285
  - rbi/spotted/internal/transport/base_client.rbi
286
286
  - rbi/spotted/internal/transport/pooled_net_requester.rbi
287
287
  - rbi/spotted/internal/type/array_of.rbi
@@ -506,7 +506,6 @@ files:
506
506
  - sig/spotted/file_part.rbs
507
507
  - sig/spotted/internal.rbs
508
508
  - sig/spotted/internal/cursor_url_page.rbs
509
- - sig/spotted/internal/oauth2.rbs
510
509
  - sig/spotted/internal/transport/base_client.rbs
511
510
  - sig/spotted/internal/transport/pooled_net_requester.rbs
512
511
  - sig/spotted/internal/type/array_of.rbs
@@ -1,87 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Spotted
4
- module Internal
5
- class OAuth2ClientCredentials
6
- # @param token_url [String]
7
- # @param client_id [String]
8
- # @param client_secret [String]
9
- # @param timeout [Integer]
10
- # @param client [Object]
11
- def initialize(token_url:, client_id:, client_secret:, timeout:, client:)
12
- @token_url = token_url
13
- @client_id = client_id
14
- @client_secret = client_secret
15
- @client = client
16
- @timeout = timeout
17
- @token = nil
18
- @token_expires_at = nil
19
- @mutex = Thread::Mutex.new
20
- end
21
-
22
- # @api private
23
- #
24
- # @return [Hash{String=>String}]
25
- def auth_headers
26
- @mutex.synchronize do
27
- if @token && !token_expired?
28
- return {"Authorization" => "Bearer #{@token}"}
29
- end
30
-
31
- @token = nil
32
- @token_expires_at = nil
33
-
34
- token_response = fetch_token
35
- if token_response
36
- @token = token_response[:access_token]
37
- @token_expires_at = Time.now + token_response[:expires_in]
38
-
39
- return {"Authorization" => "Bearer #{@token}"}
40
- end
41
-
42
- {}
43
- end
44
- end
45
-
46
- # @api private
47
- #
48
- # @return [Boolean]
49
- private def token_expired?
50
- return true if @token_expires_at.nil? || @token.nil?
51
-
52
- # Consider token expired if it expires within 10 seconds
53
- Time.now > (@token_expires_at - 10)
54
- end
55
-
56
- # @api private
57
- #
58
- # @return [Object]
59
- private def fetch_token
60
- body = URI.encode_www_form(
61
- {
62
- grant_type: "client_credentials",
63
- client_id: @client_id,
64
- client_secret: @client_secret
65
- }
66
- )
67
- request = {
68
- method: :post,
69
- url: URI(@token_url),
70
- headers: {
71
- "Content-Type" => "application/x-www-form-urlencoded"
72
- },
73
- body: body,
74
- max_retries: @client.max_retries,
75
- timeout: @timeout
76
- }
77
- _status, response, stream = @client.send_request(
78
- request,
79
- redirect_count: 0,
80
- retry_count: 0,
81
- send_retry_header: true
82
- )
83
- Spotted::Internal::Util.decode_content(response, stream: stream)
84
- end
85
- end
86
- end
87
- end
@@ -1,34 +0,0 @@
1
- # typed: strong
2
-
3
- module Spotted
4
- module Internal
5
- class OAuth2ClientCredentials
6
- sig do
7
- params(
8
- token_url: String,
9
- client_id: String,
10
- client_secret: String,
11
- timeout: Integer,
12
- client: T.anything
13
- ).void
14
- end
15
- def initialize(token_url:, client_id:, client_secret:, timeout:, client:)
16
- end
17
-
18
- # @api private
19
- sig { returns(T::Hash[String, String]) }
20
- def auth_headers
21
- end
22
-
23
- # @api private
24
- sig { returns(T::Boolean) }
25
- private def token_expired?
26
- end
27
-
28
- # @api private
29
- sig { returns(T.anything) }
30
- private def fetch_token
31
- end
32
- end
33
- end
34
- end
@@ -1,19 +0,0 @@
1
- module Spotted
2
- module Internal
3
- class OAuth2ClientCredentials
4
- def initialize: (
5
- token_url: String,
6
- client_id: String,
7
- client_secret: String,
8
- timeout: Integer,
9
- client: top
10
- ) -> void
11
-
12
- def auth_headers: -> ::Hash[String, String]
13
-
14
- private def token_expired?: -> bool
15
-
16
- private def fetch_token: -> top
17
- end
18
- end
19
- end