Add using new authentication method since 1.13.0.

Signed-off-by: Yahor Berdnikau <egorr.berd@gmail.com>
This commit is contained in:
Yahor Berdnikau 2017-07-23 19:46:06 +02:00
parent 20d95ce19d
commit f1ab0a3e0c
6 changed files with 190 additions and 92 deletions

View File

@ -12,11 +12,12 @@ ext.versions = [
retrofit : "2.1.0",
jackson : "2.8.7",
okhttp : "3.6.0",
junit : "4.12",
mockitoKotlin : "1.3.0",
kluent : "1.15",
okhttp : "3.6.0",
mockitoKotlin : "1.5.0",
kluent : "1.26",
apacheCodecs : "1.10",
]
ext.gradlePlugins = [
@ -30,7 +31,7 @@ ext.androidSupport = [
]
ext.other = [
kotlinStdlib : "org.jetbrains.kotlin:kotlin-stdlib-common:$versions.kotlin",
kotlinStdlib : "org.jetbrains.kotlin:kotlin-stdlib:$versions.kotlin",
retrofit : "com.squareup.retrofit2:retrofit:$versions.retrofit",
gsonConverter : "com.squareup.retrofit2:converter-gson:$versions.retrofit",
jacksonConverter : "com.squareup.retrofit2:converter-jackson:$versions.retrofit",
@ -41,7 +42,9 @@ ext.other = [
ext.testing = [
junit : "junit:junit:$versions.junit",
kotlinJunit : "org.jetbrains.kotlin:kotlin-test-junit:$versions.kotlin",
kotlinReflect : "org.jetbrains.kotlin:kotlin-reflect:$versions.kotlin",
mockitoKotlin : "com.nhaarman:mockito-kotlin:$versions.mockitoKotlin",
kluent : "org.amshove.kluent:kluent:$versions.kluent",
mockWebServer : "com.squareup.okhttp3:mockwebserver:$versions.okhttp"
mockWebServer : "com.squareup.okhttp3:mockwebserver:$versions.okhttp",
apacheCodecs : "commons-codec:commons-codec:$versions.apacheCodecs",
]

View File

@ -16,7 +16,9 @@ dependencies {
testCompile testing.junit
testCompile testing.kotlinJunit
testCompile testing.kotlinReflect
testCompile testing.mockitoKotlin
testCompile testing.kluent
testCompile testing.mockWebServer
testCompile testing.apacheCodecs
}

View File

@ -6,21 +6,28 @@ import org.amshove.kluent.`should be`
import org.amshove.kluent.`should contain`
import org.amshove.kluent.`should equal`
import org.amshove.kluent.`should not be`
import org.amshove.kluent.`should not contain`
import org.apache.commons.codec.binary.Hex
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.moire.ultrasonic.api.subsonic.models.*
import org.moire.ultrasonic.api.subsonic.models.Artist
import org.moire.ultrasonic.api.subsonic.models.Index
import org.moire.ultrasonic.api.subsonic.models.License
import org.moire.ultrasonic.api.subsonic.models.MusicDirectoryChild
import org.moire.ultrasonic.api.subsonic.models.MusicFolder
import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse
import org.moire.ultrasonic.api.subsonic.rules.MockWebServerRule
import retrofit2.Response
import java.nio.charset.Charset
import java.security.MessageDigest
import java.text.SimpleDateFormat
import java.util.*
/**
* Integration test for [SubsonicAPI] class.
* Integration test for [SubsonicAPIClient] class.
*/
class SubsonicAPITest {
class SubsonicAPIClientTest {
companion object {
const val USERNAME = "some-user"
const val PASSWORD = "some-password"
@ -30,19 +37,58 @@ class SubsonicAPITest {
@JvmField @Rule val mockWebServerRule = MockWebServerRule()
private lateinit var api: SubsonicAPI
private lateinit var client: SubsonicAPIClient
@Before
fun setUp() {
api = SubsonicAPI(mockWebServerRule.mockWebServer.url("/").toString(), USERNAME, PASSWORD,
client = SubsonicAPIClient(mockWebServerRule.mockWebServer.url("/").toString(), USERNAME, PASSWORD,
CLIENT_VERSION, CLIENT_ID)
}
@Test
fun `Should pass password hash and salt in query params for api version 1_13_0`() {
val clientV12 = SubsonicAPIClient(mockWebServerRule.mockWebServer.url("/").toString(), USERNAME,
PASSWORD, SubsonicAPIVersions.V1_14_0, CLIENT_ID)
enqueueResponse("ping_ok.json")
clientV12.api.ping().execute()
with(mockWebServerRule.mockWebServer.takeRequest()) {
requestLine `should contain` "&s="
requestLine `should contain` "&t="
requestLine `should not contain` "&p=enc:"
val salt = requestLine.split('&').find { it.startsWith("s=") }?.substringAfter('=')
val token = requestLine.split('&').find { it.startsWith("t=") }?.substringAfter('=')
val expectedToken = String(Hex.encodeHex(MessageDigest.getInstance("MD5")
.digest("$PASSWORD$salt".toByteArray()), false))
token!! `should equal` expectedToken
}
}
@Test
fun `Should pass hex encoded password in query params for api version 1_12_0`() {
val clientV11 = SubsonicAPIClient(mockWebServerRule.mockWebServer.url("/").toString(), USERNAME,
PASSWORD, SubsonicAPIVersions.V1_12_0, CLIENT_ID)
enqueueResponse("ping_ok.json")
clientV11.api.ping().execute()
with(mockWebServerRule.mockWebServer.takeRequest()) {
requestLine `should not contain` "&s="
requestLine `should not contain` "&t="
requestLine `should contain` "&p=enc:"
val passParam = requestLine.split('&').find { it.startsWith("p=enc:") }
val encodedPassword = String(Hex.encodeHex(PASSWORD.toByteArray(), false))
passParam `should equal` "p=enc:$encodedPassword"
}
}
@Test
fun `Should parse ping ok response`() {
enqueueResponse("ping_ok.json")
val response = api.getApi().ping().execute()
val response = client.api.ping().execute()
assertResponseSuccessful(response)
with(response.body()) {
@ -52,14 +98,14 @@ class SubsonicAPITest {
@Test
fun `Should parse ping error response`() {
checkErrorCallParsed { api.getApi().ping().execute() }
checkErrorCallParsed { client.api.ping().execute() }
}
@Test
fun `Should parse get license ok response`() {
enqueueResponse("license_ok.json")
val response = api.getApi().getLicense().execute()
val response = client.api.getLicense().execute()
assertResponseSuccessful(response)
with(response.body()) {
@ -70,7 +116,7 @@ class SubsonicAPITest {
@Test
fun `Should parse get license error response`() {
val response = checkErrorCallParsed { api.getApi().getLicense().execute() }
val response = checkErrorCallParsed { client.api.getLicense().execute() }
response.license `should be` null
}
@ -79,7 +125,7 @@ class SubsonicAPITest {
fun `Should parse get music folders ok response`() {
enqueueResponse("get_music_folders_ok.json")
val response = api.getApi().getMusicFolders().execute()
val response = client.api.getMusicFolders().execute()
assertResponseSuccessful(response)
with(response.body()) {
@ -90,7 +136,7 @@ class SubsonicAPITest {
@Test
fun `Should parse get music folders error response`() {
val response = checkErrorCallParsed { api.getApi().getMusicFolders().execute() }
val response = checkErrorCallParsed { client.api.getMusicFolders().execute() }
response.musicFolders `should be` null
}
@ -100,7 +146,7 @@ class SubsonicAPITest {
// TODO: check for shortcut parsing
enqueueResponse("get_indexes_ok.json")
val response = api.getApi().getIndexes(null, null).execute()
val response = client.api.getIndexes(null, null).execute()
assertResponseSuccessful(response)
response.body().indexes `should not be` null
@ -126,7 +172,7 @@ class SubsonicAPITest {
enqueueResponse("get_indexes_ok.json")
val musicFolderId = 9L
api.getApi().getIndexes(musicFolderId, null).execute()
client.api.getIndexes(musicFolderId, null).execute()
with(mockWebServerRule.mockWebServer.takeRequest()) {
requestLine `should contain` "musicFolderId=$musicFolderId"
@ -138,7 +184,7 @@ class SubsonicAPITest {
enqueueResponse("get_indexes_ok.json")
val ifModifiedSince = System.currentTimeMillis()
api.getApi().getIndexes(null, ifModifiedSince).execute()
client.api.getIndexes(null, ifModifiedSince).execute()
with(mockWebServerRule.mockWebServer.takeRequest()) {
requestLine `should contain` "ifModifiedSince=$ifModifiedSince"
@ -151,7 +197,7 @@ class SubsonicAPITest {
val musicFolderId = 110L
val ifModifiedSince = System.currentTimeMillis()
api.getApi().getIndexes(musicFolderId, ifModifiedSince).execute()
client.api.getIndexes(musicFolderId, ifModifiedSince).execute()
with(mockWebServerRule.mockWebServer.takeRequest()) {
requestLine `should contain` "musicFolderId=$musicFolderId"
@ -161,14 +207,14 @@ class SubsonicAPITest {
@Test
fun `Should parse get indexes error response`() {
val response = checkErrorCallParsed { api.getApi().getIndexes(null, null).execute() }
val response = checkErrorCallParsed { client.api.getIndexes(null, null).execute() }
response.indexes `should be` null
}
@Test
fun `Should parse getMusicDirectory error response`() {
val response = checkErrorCallParsed { api.getApi().getMusicDirectory(1).execute() }
val response = checkErrorCallParsed { client.api.getMusicDirectory(1).execute() }
response.musicDirectory `should be` null
}
@ -178,7 +224,7 @@ class SubsonicAPITest {
enqueueResponse("get_music_directory_ok.json")
val directoryId = 124L
api.getApi().getMusicDirectory(directoryId).execute()
client.api.getMusicDirectory(directoryId).execute()
mockWebServerRule.mockWebServer.takeRequest().requestLine `should contain` "id=$directoryId"
}
@ -187,7 +233,7 @@ class SubsonicAPITest {
fun `Should parse get music directory ok response`() {
enqueueResponse("get_music_directory_ok.json")
val response = api.getApi().getMusicDirectory(1).execute()
val response = client.api.getMusicDirectory(1).execute()
assertResponseSuccessful(response)

View File

@ -1,68 +0,0 @@
package org.moire.ultrasonic.api.subsonic
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.jackson.JacksonConverterFactory
import java.math.BigInteger
/**
* Main entry for Subsonic API calls.
*
* @see SubsonicAPI http://www.subsonic.org/pages/api.jsp
*/
class SubsonicAPI(baseUrl: String,
username: String,
private val password: String,
clientProtocolVersion: SubsonicAPIVersions,
clientID: String,
debug: Boolean = false) {
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor { chain ->
// Adds default request params
val originalRequest = chain.request()
val newUrl = originalRequest.url().newBuilder()
.addQueryParameter("u", username)
.addQueryParameter("p", passwordHex())
.addQueryParameter("v", clientProtocolVersion.restApiVersion)
.addQueryParameter("c", clientID)
.addQueryParameter("f", "json")
.build()
chain.proceed(originalRequest.newBuilder().url(newUrl).build())
}
.apply {
if (debug) {
val loggingInterceptor = HttpLoggingInterceptor()
loggingInterceptor.level = HttpLoggingInterceptor.Level.BASIC
this.addInterceptor(loggingInterceptor)
}
}.build()
private val jacksonMapper = ObjectMapper()
.configure(DeserializationFeature.UNWRAP_ROOT_VALUE, true)
.registerModule(KotlinModule())
private val retrofit = Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(JacksonConverterFactory.create(jacksonMapper))
.build()
private val subsonicAPI = retrofit.create(SubsonicAPIDefinition::class.java)
/**
* Get API instance.
*
* @return initialized API instance
*/
fun getApi(): SubsonicAPIDefinition = subsonicAPI
private fun passwordHex() = "enc:${password.toHexBytes()}"
private fun String.toHexBytes(): String {
return String.format("%040x", BigInteger(1, this.toByteArray()))
}
}

View File

@ -0,0 +1,113 @@
package org.moire.ultrasonic.api.subsonic
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.jackson.JacksonConverterFactory
import java.lang.IllegalStateException
import java.math.BigInteger
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import java.security.SecureRandom
/**
* Subsonic API client that provides api access.
*
* For supported API calls see [SubsonicAPIDefinition].
*
* @author Yahor Berdnikau
*/
class SubsonicAPIClient(baseUrl: String,
username: String,
private val password: String,
clientProtocolVersion: SubsonicAPIVersions,
clientID: String,
debug: Boolean = false) {
companion object {
internal val HEX_ARRAY = "0123456789ABCDEF".toCharArray()
}
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor { chain ->
// Adds default request params
val originalRequest = chain.request()
val newUrl = originalRequest.url().newBuilder()
.addQueryParameter("u", username)
.also {
it.addPasswordQueryParam(clientProtocolVersion)
}
.addQueryParameter("v", clientProtocolVersion.restApiVersion)
.addQueryParameter("c", clientID)
.addQueryParameter("f", "json")
.build()
chain.proceed(originalRequest.newBuilder().url(newUrl).build())
}
.also {
if (debug) {
it.addLogging()
}
}.build()
private val jacksonMapper = ObjectMapper()
.configure(DeserializationFeature.UNWRAP_ROOT_VALUE, true)
.registerModule(KotlinModule())
private val retrofit = Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(JacksonConverterFactory.create(jacksonMapper))
.build()
val api: SubsonicAPIDefinition = retrofit.create(SubsonicAPIDefinition::class.java)
private val salt: String by lazy {
val secureRandom = SecureRandom()
BigInteger(130, secureRandom).toString(32)
}
private val passwordMD5Hash: String by lazy {
try {
val md5Digest = MessageDigest.getInstance("MD5")
md5Digest.digest("$password$salt".toByteArray()).toHexBytes()
} catch (e: NoSuchAlgorithmException) {
throw IllegalStateException(e)
}
}
private val passwordHex: String by lazy {
"enc:${password.toHexBytes()}"
}
private fun String.toHexBytes(): String {
return this.toByteArray().toHexBytes()
}
private fun ByteArray.toHexBytes(): String {
val hexChars = CharArray(this.size * 2)
for (j in 0..this.lastIndex) {
val v = this[j].toInt().and(0xFF)
hexChars[j * 2] = HEX_ARRAY[v.ushr(4)]
hexChars[j * 2 + 1] = HEX_ARRAY[v.and(0x0F)]
}
return String(hexChars)
}
private fun OkHttpClient.Builder.addLogging() {
val loggingInterceptor = HttpLoggingInterceptor()
loggingInterceptor.level = HttpLoggingInterceptor.Level.BASIC
this.addInterceptor(loggingInterceptor)
}
private fun HttpUrl.Builder.addPasswordQueryParam(clientProtocolVersion: SubsonicAPIVersions) {
if (clientProtocolVersion < SubsonicAPIVersions.V1_13_0) {
this.addQueryParameter("p", passwordHex)
} else {
this.addQueryParameter("t", passwordMD5Hash)
this.addQueryParameter("s", salt)
}
}
}

View File

@ -40,8 +40,10 @@ dependencies {
}
testCompile(testing.mockitoKotlin) {
exclude module: "kotlin-stdlib"
exclude module: "kotlin-reflect"
}
testCompile(testing.kluent) {
exclude module: "kotlin-stdlib"
exclude module: "kotlin-reflect"
}
}