Skip to content

Commit 26835cc

Browse files
authored
Merge pull request #152 from adamint/suspending_requests
Suspending requests
2 parents 4c832e7 + 2abb34c commit 26835cc

37 files changed

+373
-361
lines changed

src/commonMain/kotlin/com.adamratzman.spotify/Builder.kt

Lines changed: 66 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import com.adamratzman.spotify.http.HttpRequestMethod
66
import com.adamratzman.spotify.models.SpotifyAuthenticationException
77
import com.adamratzman.spotify.models.Token
88
import com.adamratzman.spotify.models.serialization.toObject
9-
import kotlinx.coroutines.GlobalScope
9+
import com.adamratzman.spotify.utils.runBlocking
10+
import kotlinx.coroutines.CancellationException
11+
import kotlinx.coroutines.CoroutineScope
1012
import kotlinx.coroutines.Job
1113
import kotlinx.coroutines.launch
1214

@@ -97,7 +99,7 @@ class SpotifyApiBuilder(
9799
* Create a [SpotifyApi] instance with the given [SpotifyApiBuilder] parameters and the type -
98100
* [AuthorizationType.CLIENT] for client authentication, or otherwise [AuthorizationType.APPLICATION]
99101
*/
100-
fun build(type: AuthorizationType): SpotifyApi {
102+
fun build(type: AuthorizationType): SpotifyApi<*, *> {
101103
return if (type == AuthorizationType.CLIENT) buildClient()
102104
else buildCredentialed()
103105
}
@@ -110,7 +112,7 @@ class SpotifyApiBuilder(
110112
/**
111113
* Create a new [SpotifyAppApi] that only has access to *public* endpoints and data
112114
*/
113-
fun buildCredentialed(): SpotifyApi = spotifyAppApi {
115+
fun buildCredentialed(): SpotifyAppApi = spotifyAppApi {
114116
credentials {
115117
clientId = this@SpotifyApiBuilder.clientId
116118
clientSecret = this@SpotifyApiBuilder.clientSecret
@@ -139,7 +141,7 @@ enum class AuthorizationType {
139141
APPLICATION;
140142
}
141143

142-
interface ISpotifyApiBuilder {
144+
interface ISpotifyApiBuilder<T : SpotifyApi<T, B>, B : ISpotifyApiBuilder<T, B>> {
143145
var credentials: SpotifyCredentials
144146
var authorization: SpotifyUserAuthorization
145147
var options: SpotifyApiOptions
@@ -192,27 +194,35 @@ interface ISpotifyApiBuilder {
192194
}
193195

194196
fun options(options: SpotifyApiOptions) = apply { this.options = options }
195-
}
196197

197-
interface ISpotifyClientApiBuilder : ISpotifyApiBuilder {
198198
/**
199-
* Build the client api by providing an authorization code, token string, or token object. Only one of the following
200-
* needs to be provided
199+
* Build the [T] by provided information
200+
*/
201+
suspend fun suspendBuild(): T
202+
203+
/**
204+
* Build the [T] by provided information
205+
*/
206+
fun build(): T = runBlocking { suspendBuild() }
207+
208+
/**
209+
* Build the [T] by provided information
201210
*
202-
* @param authorizationCode Spotify authorization code retrieved after authentication
203-
* @param tokenString Spotify authorization token
204-
* @param token [Token] object (useful if you already have exchanged an authorization code yourself
211+
* Provide a consumer object to be executed after the api has been successfully built
205212
*/
206-
fun build(): SpotifyClientApi
213+
fun buildAsyncAt(scope: CoroutineScope, consumer: (T) -> Unit): Job = with(scope) { buildAsync(consumer) }
207214

208215
/**
209-
* Build the client api using a provided authorization code, token string, or token object (only one of which
210-
* is necessary)
216+
* Build the [T] by provided information
211217
*
212-
* Provide a consumer object to be executed after the client has been successfully built
218+
* Provide a consumer object to be executed after the api has been successfully built
213219
*/
214-
suspend fun buildAsync(consumer: (SpotifyClientApi) -> Unit): Job
220+
fun CoroutineScope.buildAsync(consumer: (T) -> Unit): Job = launch {
221+
consumer(suspendBuild())
222+
}
223+
}
215224

225+
interface ISpotifyClientApiBuilder : ISpotifyApiBuilder<SpotifyClientApi, SpotifyClientApiBuilder> {
216226
/**
217227
* Create a Spotify authorization URL from which API access can be obtained
218228
*
@@ -232,47 +242,49 @@ class SpotifyClientApiBuilder(
232242
return getAuthUrlFull(*scopes, clientId = credentials.clientId!!, redirectUri = credentials.redirectUri!!)
233243
}
234244

235-
override fun build(): SpotifyClientApi {
245+
override suspend fun suspendBuild(): SpotifyClientApi {
236246
val clientId = credentials.clientId
237247
val clientSecret = credentials.clientSecret
238248
val redirectUri = credentials.redirectUri
239249

240250
require((clientId != null && clientSecret != null && redirectUri != null) || authorization.token != null || authorization.tokenString != null) { "You need to specify a valid clientId, clientSecret, and redirectUri in the credentials block!" }
241251
return when {
242252
authorization.authorizationCode != null -> try {
243-
require(clientId != null && clientSecret != null && redirectUri != null) { "You need to specify a valid clientId, clientSecret, and redirectUri in the credentials block!" }
244-
245-
val response = executeTokenRequest(
246-
HttpConnection(
247-
"https://accounts.spotify.com/api/token",
248-
HttpRequestMethod.POST,
249-
mapOf(
250-
"grant_type" to "authorization_code",
251-
"code" to authorization.authorizationCode,
252-
"redirect_uri" to redirectUri
253-
),
254-
null,
255-
"application/x-www-form-urlencoded",
256-
listOf(),
257-
null
258-
), clientId, clientSecret
259-
)
260-
261-
SpotifyClientApi(
262-
clientId,
263-
clientSecret,
264-
redirectUri,
265-
response.body.toObject(Token.serializer(), null),
266-
options.useCache,
267-
options.cacheLimit,
268-
options.automaticRefresh,
269-
options.retryWhenRateLimited,
270-
options.enableLogger,
271-
options.testTokenValidity
272-
)
273-
} catch (e: Exception) {
274-
throw SpotifyAuthenticationException("Invalid credentials provided in the login process", e)
275-
}
253+
require(clientId != null && clientSecret != null && redirectUri != null) { "You need to specify a valid clientId, clientSecret, and redirectUri in the credentials block!" }
254+
255+
val response = executeTokenRequest(
256+
HttpConnection(
257+
"https://accounts.spotify.com/api/token",
258+
HttpRequestMethod.POST,
259+
mapOf(
260+
"grant_type" to "authorization_code",
261+
"code" to authorization.authorizationCode,
262+
"redirect_uri" to redirectUri
263+
),
264+
null,
265+
"application/x-www-form-urlencoded",
266+
listOf(),
267+
null
268+
), clientId, clientSecret
269+
)
270+
271+
SpotifyClientApi(
272+
clientId,
273+
clientSecret,
274+
redirectUri,
275+
response.body.toObject(Token.serializer(), null),
276+
options.useCache,
277+
options.cacheLimit,
278+
options.automaticRefresh,
279+
options.retryWhenRateLimited,
280+
options.enableLogger,
281+
options.testTokenValidity
282+
)
283+
} catch (e: CancellationException) {
284+
throw e
285+
} catch (e: Exception) {
286+
throw SpotifyAuthenticationException("Invalid credentials provided in the login process", e)
287+
}
276288
authorization.token != null -> SpotifyClientApi(
277289
clientId,
278290
clientSecret,
@@ -309,49 +321,19 @@ class SpotifyClientApiBuilder(
309321
)
310322
}
311323
}
312-
313-
override suspend fun buildAsync(consumer: (SpotifyClientApi) -> Unit) = GlobalScope.launch {
314-
consumer(build())
315-
}
316324
}
317325

318-
interface ISpotifyAppApiBuilder : ISpotifyApiBuilder {
319-
/**
320-
* Build a public [SpotifyAppApi] using the provided credentials
321-
*
322-
* @param consumer Consumer to be executed after the api has been successfully built
323-
*/
324-
fun buildAsync(consumer: (SpotifyApi) -> Unit): Job
325-
326-
/**
327-
* Build a public [SpotifyAppApi] using the provided credentials
328-
*/
329-
fun build(): SpotifyApi
330-
}
326+
interface ISpotifyAppApiBuilder : ISpotifyApiBuilder<SpotifyAppApi, SpotifyAppApiBuilder>
331327

332328
class SpotifyAppApiBuilder(
333329
override var credentials: SpotifyCredentials = SpotifyCredentialsBuilder().build(),
334330
override var authorization: SpotifyUserAuthorization = SpotifyUserAuthorizationBuilder().build(),
335331
override var options: SpotifyApiOptions = SpotifyApiOptionsBuilder().build()
336332
) : ISpotifyAppApiBuilder {
337-
/**
338-
* Create a new [SpotifyAppApi] that only has access to *public* endpoints and data
339-
*/
340-
fun buildPublic() = build()
341-
342-
/**
343-
* Build a public [SpotifyAppApi] using the provided credentials
344-
*
345-
* Provide a consumer object to be executed after the api has been successfully built
346-
*/
347-
override fun buildAsync(consumer: (SpotifyApi) -> Unit) = GlobalScope.launch {
348-
consumer(build())
349-
}
350-
351333
/**
352334
* Build a public [SpotifyAppApi] using the provided credentials
353335
*/
354-
override fun build(): SpotifyApi {
336+
override suspend fun suspendBuild(): SpotifyAppApi {
355337
val clientId = credentials.clientId
356338
val clientSecret = credentials.clientSecret
357339
require((clientId != null && clientSecret != null) || authorization.token != null || authorization.tokenString != null) { "You didn't specify a client id or client secret in the credentials block!" }
@@ -399,6 +381,8 @@ class SpotifyAppApiBuilder(
399381
options.enableLogger,
400382
options.testTokenValidity
401383
)
384+
} catch (e: CancellationException) {
385+
throw e
402386
} catch (e: Exception) {
403387
throw SpotifyAuthenticationException("Invalid credentials provided in the login process", e)
404388
}

src/commonMain/kotlin/com.adamratzman.spotify/SpotifyApi.kt

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ import com.adamratzman.spotify.models.SpotifyAuthenticationException
2626
import com.adamratzman.spotify.models.Token
2727
import com.adamratzman.spotify.models.TokenValidityResponse
2828
import com.adamratzman.spotify.models.serialization.toObject
29+
import com.adamratzman.spotify.utils.runBlocking
2930
import com.adamratzman.spotify.utils.toList
31+
import kotlin.coroutines.CoroutineContext
32+
import kotlin.jvm.JvmOverloads
33+
import kotlinx.coroutines.Dispatchers
3034

3135
internal const val base = "https://api.spotify.com/v1"
3236

@@ -51,7 +55,7 @@ internal const val base = "https://api.spotify.com/v1"
5155
* @property logger The Spotify event logger
5256
*
5357
*/
54-
abstract class SpotifyApi internal constructor(
58+
sealed class SpotifyApi<T : SpotifyApi<T, B>, B : ISpotifyApiBuilder<T, B>>(
5559
val clientId: String?,
5660
val clientSecret: String?,
5761
var token: Token,
@@ -103,7 +107,9 @@ abstract class SpotifyApi internal constructor(
103107
* @return The old access token if refresh was successful
104108
* @throws BadRequestException if refresh fails
105109
*/
106-
abstract fun refreshToken(): Token
110+
fun refreshToken(): Token = runBlocking {
111+
suspendRefreshToken()
112+
}
107113

108114
/**
109115
* A list of all endpoints included in this api type
@@ -123,7 +129,7 @@ abstract class SpotifyApi internal constructor(
123129
/**
124130
* Return a new [SpotifyApiBuilderDsl] with the parameters provided to this api instance
125131
*/
126-
abstract fun getApiBuilderDsl(): ISpotifyApiBuilder
132+
abstract fun getApiBuilderDsl(): B
127133

128134
private fun clearCaches(vararg endpoints: SpotifyEndpoint) {
129135
endpoints.forEach { it.cache.clear() }
@@ -160,20 +166,35 @@ abstract class SpotifyApi internal constructor(
160166
*
161167
* @return [TokenValidityResponse] containing whether this token is valid, and if not, an Exception explaining why
162168
*/
163-
fun isTokenValid(makeTestRequest: Boolean = true): TokenValidityResponse {
169+
@JvmOverloads
170+
fun isTokenValid(makeTestRequest: Boolean = true): TokenValidityResponse = runBlocking {
171+
suspendIsTokenValid(makeTestRequest)
172+
}
173+
174+
@JvmOverloads
175+
suspend fun suspendIsTokenValid(makeTestRequest: Boolean = true, context: CoroutineContext = Dispatchers.Default): TokenValidityResponse {
164176
if (token.shouldRefresh()) return TokenValidityResponse(
165177
false,
166178
SpotifyAuthenticationException("Token needs to be refreshed (is it expired?)")
167179
)
168180
if (!makeTestRequest) return TokenValidityResponse(true, null)
169181

170182
return try {
171-
browse.getAvailableGenreSeeds().complete()
183+
browse.getAvailableGenreSeeds().suspendComplete(context)
172184
TokenValidityResponse(true, null)
173185
} catch (e: Exception) {
174186
TokenValidityResponse(false, e)
175187
}
176188
}
189+
190+
/**
191+
* If the method used to create the [token] supports token refresh and
192+
* the information in [token] is accurate, attempt to refresh the token
193+
*
194+
* @return The old access token if refresh was successful
195+
* @throws BadRequestException if refresh fails
196+
*/
197+
abstract suspend fun suspendRefreshToken(): Token
177198
}
178199

179200
/**
@@ -190,7 +211,7 @@ class SpotifyAppApi internal constructor(
190211
retryWhenRateLimited: Boolean,
191212
enableLogger: Boolean,
192213
testTokenValidity: Boolean
193-
) : SpotifyApi(
214+
) : SpotifyApi<SpotifyAppApi, SpotifyAppApiBuilder>(
194215
clientId,
195216
clientSecret,
196217
token,
@@ -239,7 +260,7 @@ class SpotifyAppApi internal constructor(
239260
*/
240261
override val following: FollowingApi = FollowingApi(this)
241262

242-
override fun refreshToken(): Token {
263+
override suspend fun suspendRefreshToken(): Token {
243264
require(clientId != null && clientSecret != null) { "Either the client id or the client secret is not set" }
244265
val currentToken = this.token
245266

@@ -291,7 +312,7 @@ class SpotifyClientApi internal constructor(
291312
retryWhenRateLimited: Boolean,
292313
enableLogger: Boolean,
293314
testTokenValidity: Boolean
294-
) : SpotifyApi(
315+
) : SpotifyApi<SpotifyClientApi, SpotifyClientApiBuilder>(
295316
clientId,
296317
clientSecret,
297318
token,
@@ -389,7 +410,7 @@ class SpotifyClientApi internal constructor(
389410
runExecutableFunctions = false
390411
}
391412

392-
override fun refreshToken(): Token {
413+
override suspend fun suspendRefreshToken(): Token {
393414
require(clientId != null && clientSecret != null) { "Either the client id or the client secret is not set" }
394415

395416
val currentToken = this.token
@@ -483,10 +504,12 @@ class SpotifyClientApi internal constructor(
483504
* Whether the current access token allows access to all of the provided scopes
484505
*/
485506
fun hasScopes(scope: SpotifyScope, vararg scopes: SpotifyScope): Boolean =
486-
!isTokenValid(false).isValid && (scopes.toList() + scope).all { token.scopes.contains(it) }
507+
!isTokenValid(false).isValid &&
508+
token.scopes.contains(scope) &&
509+
scopes.all { token.scopes.contains(it) }
487510
}
488511

489-
typealias SpotifyAPI = SpotifyApi
512+
typealias SpotifyAPI<T, B> = SpotifyApi<T, B>
490513
typealias SpotifyClientAPI = SpotifyClientApi
491514
typealias SpotifyAppAPI = SpotifyAppApi
492515

@@ -497,7 +520,7 @@ fun getAuthUrlFull(vararg scopes: SpotifyScope, clientId: String, redirectUri: S
497520
if (scopes.isEmpty()) "" else "&scope=${scopes.joinToString("%20") { it.uri }}"
498521
}
499522

500-
fun getCredentialedToken(clientId: String, clientSecret: String, api: SpotifyApi?): Token {
523+
suspend fun getCredentialedToken(clientId: String, clientSecret: String, api: SpotifyApi<*, *>?): Token {
501524
val response = executeTokenRequest(
502525
HttpConnection(
503526
"https://accounts.spotify.com/api/token",
@@ -515,7 +538,11 @@ fun getCredentialedToken(clientId: String, clientSecret: String, api: SpotifyApi
515538
throw SpotifyException.BadRequestException(response.body.toObject(AuthenticationError.serializer(), null))
516539
}
517540

518-
internal fun executeTokenRequest(httpConnection: HttpConnection, clientId: String, clientSecret: String): HttpResponse {
541+
internal suspend fun executeTokenRequest(
542+
httpConnection: HttpConnection,
543+
clientId: String,
544+
clientSecret: String
545+
): HttpResponse {
519546
return httpConnection.execute(
520547
listOf(
521548
HttpHeader(

src/commonMain/kotlin/com.adamratzman.spotify/SpotifyExceptions.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ sealed class SpotifyException(message: String, cause: Throwable? = null) : Excep
2525
"Authentication error: ${authenticationError.error}. Description: ${authenticationError.description}",
2626
401
2727
)
28+
2829
constructor(responseException: ResponseException) :
2930
this(
3031
responseException.message ?: "Bad Request",

0 commit comments

Comments
 (0)