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 +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +1 -1
- data/lib/spotted/auth.rb +174 -0
- data/lib/spotted/client.rb +0 -85
- data/lib/spotted/version.rb +1 -1
- data/lib/spotted.rb +1 -0
- data/rbi/spotted/auth.rbi +42 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 77ec607b5d1456b68fe75d3699286c08d9f73f2962027830cb54fd562f359178
|
|
4
|
+
data.tar.gz: f4e4925f3af823d98bb5b038d9ed96c508c50f5d4d2aa4439189e586a645404e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
data/lib/spotted/auth.rb
ADDED
|
@@ -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
|
data/lib/spotted/client.rb
CHANGED
|
@@ -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
|
data/lib/spotted/version.rb
CHANGED
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.
|
|
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-
|
|
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
|