spotted 0.33.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: 6b3243cac7dcf0803cbd0bff07b62b0394ac29dfd62c143bc7c1334157f7f9a9
4
- data.tar.gz: b59e7369c5fe8604894b0995a0dbdd2ff26d7392d04ca8373ab6b4c1d73e6a4c
3
+ metadata.gz: 77ec607b5d1456b68fe75d3699286c08d9f73f2962027830cb54fd562f359178
4
+ data.tar.gz: f4e4925f3af823d98bb5b038d9ed96c508c50f5d4d2aa4439189e586a645404e
5
5
  SHA512:
6
- metadata.gz: bf009a7418ffbe4bbcbc04eb49c2fc60916b0d0d45013e134673a146852b79d77aad84e79e9a500c8383e3d6ccae1cd5f0ff35a650d39573871854067c07ff18
7
- data.tar.gz: 38c5fe4312c8b96e2125aa365064f18580fd144874c0c19b7114d8cea47b1cde3ed501046c963042f89bdd627a6c423646b9b2a4fc879857cfa380fc8160107f
6
+ metadata.gz: 8016c1af387c5bb1f1318e2170ad24e8454d0dc593d877a314b05630c90ec643cb101c1738be367b2d9a16e4d8ddbcea9a6e10c0f8224ecf1187ecedf623e18a
7
+ data.tar.gz: 8d8419e329253cebec841027302fd74b784313cb16267a3041e0dfa9afc5997453488b1a250bc6fa37a900a5279d36c57905b7e6ab92dcfaf639705f296ccc12
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
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
+
3
17
  ## 0.33.0 (2026-01-15)
4
18
 
5
19
  Full Changelog: [v0.32.0...v0.33.0](https://github.com/cjavdev/spotted/compare/v0.32.0...v0.33.0)
data/README.md CHANGED
@@ -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.33.0"
29
+ gem "spotted", "~> 0.34.0"
30
30
  ```
31
31
 
32
32
  <!-- x-release-please-end -->
@@ -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
@@ -130,90 +130,5 @@ module Spotted
130
130
  @recommendations = Spotted::Resources::Recommendations.new(client: self)
131
131
  @markets = Spotted::Resources::Markets.new(client: self)
132
132
  end
133
-
134
- # Generates the Spotify authorization URL for OAuth2 authorization code flow.
135
- #
136
- # @param redirect_uri [String] The URI to redirect to after authorization
137
- # @param scope [String, Array<String>, nil] Space-delimited string or array of authorization scopes
138
- # @param state [String, nil] Optional state parameter for CSRF protection
139
- # @param show_dialog [Boolean] Whether to force the user to approve the app again
140
- #
141
- # @return [String] The authorization URL to redirect the user to
142
- #
143
- # @example Basic usage
144
- # client = Spotted::Client.new(client_id: "...", client_secret: "...")
145
- # url = client.authorization_url(redirect_uri: "http://localhost:3000/callback")
146
- #
147
- # @example With scopes and state
148
- # url = client.authorization_url(
149
- # redirect_uri: "http://localhost:3000/callback",
150
- # scope: ["user-read-private", "user-read-email"],
151
- # state: "random_state_string"
152
- # )
153
- def authorization_url(redirect_uri:, scope: nil, state: nil, show_dialog: false)
154
- params = {
155
- client_id: @client_id,
156
- response_type: "code",
157
- redirect_uri: redirect_uri
158
- }
159
-
160
- params[:scope] = scope.is_a?(Array) ? scope.join(" ") : scope if scope
161
- params[:state] = state if state
162
- params[:show_dialog] = "true" if show_dialog
163
-
164
- query_string = URI.encode_www_form(params)
165
- "https://accounts.spotify.com/authorize?#{query_string}"
166
- end
167
-
168
- # Exchanges an authorization code for an access token.
169
- #
170
- # @param code [String] The authorization code to exchange
171
- # @param redirect_uri [String] The redirect URI used to obtain the authorization code
172
- #
173
- # @return [Object] The access token and refresh token
174
- def exchange_authorization_code(code:, redirect_uri:)
175
- if @client_id.nil? || @client_secret.nil?
176
- raise ArgumentError, "Both client_id and client_secret must be set to exchange an authorization code."
177
- end
178
- body = URI.encode_www_form(
179
- grant_type: "authorization_code",
180
- code: code,
181
- redirect_uri: redirect_uri
182
- )
183
- client = Spotted::Client.new(
184
- client_id: @client_id,
185
- client_secret: @client_secret,
186
- base_url: "https://accounts.spotify.com"
187
- )
188
- client.request(
189
- method: :post,
190
- headers: {
191
- "Content-Type" => "application/x-www-form-urlencoded",
192
- "Authorization" => "Basic #{Base64.strict_encode64("#{@client_id}:#{@client_secret}")}"
193
- },
194
- path: "/api/token",
195
- body: body
196
- )
197
- end
198
-
199
- def refresh_access_token(refresh_token:)
200
- if @client_id.nil? || @client_secret.nil?
201
- raise ArgumentError, "Both client_id and client_secret must be set to refresh an access token."
202
- end
203
- body = URI.encode_www_form(
204
- grant_type: "refresh_token",
205
- refresh_token: refresh_token
206
- )
207
- client = Spotted::Client.new(client_id: @client_id, client_secret: @client_secret, base_url: "https://accounts.spotify.com")
208
- client.request(
209
- method: :post,
210
- headers: {
211
- "Content-Type" => "application/x-www-form-urlencoded",
212
- "Authorization" => "Basic #{Base64.strict_encode64("#{@client_id}:#{@client_secret}")}"
213
- },
214
- path: "/api/token",
215
- body: body
216
- )
217
- end
218
133
  end
219
134
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Spotted
4
- VERSION = "0.33.0"
4
+ VERSION = "0.34.0"
5
5
  end
data/lib/spotted.rb CHANGED
@@ -49,6 +49,7 @@ require_relative "spotted/internal"
49
49
  require_relative "spotted/request_options"
50
50
  require_relative "spotted/file_part"
51
51
  require_relative "spotted/errors"
52
+ require_relative "spotted/auth"
52
53
  require_relative "spotted/internal/transport/base_client"
53
54
  require_relative "spotted/internal/transport/pooled_net_requester"
54
55
  require_relative "spotted/client"
@@ -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
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.33.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-15 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,6 +50,7 @@ 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
@@ -275,6 +276,7 @@ files:
275
276
  - lib/spotted/resources/users/playlists.rb
276
277
  - lib/spotted/version.rb
277
278
  - manifest.yaml
279
+ - rbi/spotted/auth.rbi
278
280
  - rbi/spotted/client.rbi
279
281
  - rbi/spotted/errors.rbi
280
282
  - rbi/spotted/file_part.rbi