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 +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +3 -4
- data/lib/spotted/auth.rb +174 -0
- data/lib/spotted/client.rb +6 -147
- data/lib/spotted/version.rb +1 -1
- data/lib/spotted.rb +1 -2
- data/rbi/spotted/auth.rbi +42 -0
- data/rbi/spotted/client.rbi +1 -27
- data/sig/spotted/client.rbs +1 -14
- metadata +4 -5
- data/lib/spotted/internal/oauth2.rb +0 -87
- data/rbi/spotted/internal/oauth2.rbi +0 -34
- data/sig/spotted/internal/oauth2.rbs +0 -19
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,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.
|
|
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.
|
|
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
|
-
|
|
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")
|
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
|
@@ -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
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
data/lib/spotted/version.rb
CHANGED
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
|
data/rbi/spotted/client.rbi
CHANGED
|
@@ -10,13 +10,7 @@ module Spotted
|
|
|
10
10
|
|
|
11
11
|
DEFAULT_MAX_RETRY_DELAY = T.let(8.0, Float)
|
|
12
12
|
|
|
13
|
-
sig { returns(
|
|
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.,
|
data/sig/spotted/client.rbs
CHANGED
|
@@ -8,11 +8,7 @@ module Spotted
|
|
|
8
8
|
|
|
9
9
|
DEFAULT_MAX_RETRY_DELAY: Float
|
|
10
10
|
|
|
11
|
-
attr_reader
|
|
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.
|
|
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,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
|