Base implementation of Subsonic API in Kotlin.

This commit is contained in:
Yahor Berdnikau 2017-03-02 06:37:51 +01:00
parent 292490be7d
commit 0dd01d18ba
20 changed files with 513 additions and 12 deletions

View File

@ -9,6 +9,7 @@ buildscript {
}
dependencies {
classpath gradlePlugins.androidTools
classpath gradlePlugins.kotlin
}
}

View File

@ -7,13 +7,41 @@ ext.versions = [
androidTools : "2.2.3",
androidSupport : "22.2.1",
kotlin : "1.0.6",
retrofit : "2.1.0",
junit : "4.12",
mockitoKotlin : "1.3.0",
kluent : "1.14",
okhttp : "3.6.0",
]
ext.gradlePlugins = [
androidTools : "com.android.tools.build:gradle:$versions.androidTools"
androidTools : "com.android.tools.build:gradle:$versions.androidTools",
kotlin : "org.jetbrains.kotlin:kotlin-gradle-plugin:$versions.kotlin",
]
ext.androidSupport = [
support : "com.android.support:support-v4:$versions.androidSupport",
design : "com.android.support:design:$versions.androidSupport",
]
ext.kotlin = [
stdlib : "org.jetbrains.kotlin:kotlin-stdlib:$versions.kotlin"
]
ext.other = [
retrofit : "com.squareup.retrofit2:retrofit:$versions.retrofit",
gsonConverter : "com.squareup.retrofit2:converter-gson:$versions.retrofit",
simpleXmlConverter : "com.squareup.retrofit2:converter-simplexml:$versions.retrofit",
]
ext.testing = [
junit : "junit:junit:$versions.junit",
kotlinJunit : "org.jetbrains.kotlin:kotlin-test-junit:$versions.kotlin",
mockitoKotlin : "com.nhaarman:mockito-kotlin:$versions.mockitoKotlin",
kluent : "org.amshove.kluent:kluent:$versions.kluent",
mockWebServer : "com.squareup.okhttp3:mockwebserver:$versions.okhttp"
]

View File

@ -1,4 +1,4 @@
include ':library'
include ':library', ':subsonic-api'
include ':menudrawer'
include ':pulltorefresh'
include ':ultrasonic'

View File

@ -1,14 +1,21 @@
apply plugin: 'java'
apply plugin: 'kotlin'
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
test.java.srcDirs += 'src/integrationTest/kotlin'
test.resources.srcDirs += 'src/integrationTest/resources'
test.output.resourcesDir = test.output.classesDir
}
dependencies {
compile other.okhttp
compile kotlin.stdlib
compile other.retrofit
compile other.gsonConverter
testCompile testing.junit
testCompile testing.kotlinJunit
testCompile testing.mockitoKotlin
testCompile testing.kluent
}
sourceCompatibility = "1.7"
targetCompatibility = "1.7"
testCompile testing.mockWebServer
}

View File

@ -0,0 +1,92 @@
package org.moire.ultrasonic.api.subsonic
import okhttp3.mockwebserver.MockResponse
import okio.Okio
import org.amshove.kluent.`should be`
import org.amshove.kluent.`should not be`
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.moire.ultrasonic.api.subsonic.models.SubsonicResponse
import org.moire.ultrasonic.api.subsonic.rules.MockWebServerRule
import retrofit2.Response
import java.nio.charset.Charset
/**
* Integration test for [SubsonicAPI] class.
*/
class SubsonicAPITest {
companion object {
val USERNAME = "some-user"
val PASSWORD = "some-password"
val CLIENT_VERSION = SubsonicAPIVersions.V1_13_0
val CLIENT_ID = "test-client"
}
@JvmField
@Rule
val mockWebServerRule = MockWebServerRule()
private lateinit var api: SubsonicAPI
@Before
fun setUp() {
api = SubsonicAPI(mockWebServerRule.mockWebServer.url("/").toString(), USERNAME, PASSWORD,
CLIENT_VERSION, CLIENT_ID)
}
@Test
fun `Should parse ping ok response`() {
enqueueResponse("ping_ok.json")
val response = api.getApi().ping().execute()
assertResponseSuccessful(response)
with(response.body()) {
status `should be` SubsonicResponse.Status.OK
version `should be` SubsonicAPIVersions.V1_13_0
}
}
@Test
fun `Should parse error response`() {
enqueueResponse("generic_error_response.json")
val response = api.getApi().ping().execute()
assertResponseSuccessful(response)
with(response.body()) {
status `should be` SubsonicResponse.Status.ERROR
version `should be` SubsonicAPIVersions.V1_13_0
error `should be` SubsonicError.GENERIC
}
}
@Test
fun `Should parse get license response`() {
enqueueResponse("license_ok.json")
val response = api.getApi().getLicense().execute()
assertResponseSuccessful(response)
with(response.body()) {
status `should be` SubsonicResponse.Status.OK
version `should be` SubsonicAPIVersions.V1_13_0
}
}
private fun enqueueResponse(resourceName: String) {
mockWebServerRule.mockWebServer.enqueue(MockResponse()
.setBody(loadJsonResponse(resourceName)))
}
private fun loadJsonResponse(name: String): String {
val source = Okio.buffer(Okio.source(javaClass.classLoader.getResourceAsStream(name)))
return source.readString(Charset.forName("UTF-8"))
}
private fun assertResponseSuccessful(response: Response<SubsonicResponse>) {
response.isSuccessful `should be` true
response.body() `should not be` null
}
}

View File

@ -0,0 +1,27 @@
package org.moire.ultrasonic.api.subsonic.rules
import okhttp3.mockwebserver.MockWebServer
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
/**
* Starts mock web server for test and shut it down after.
*/
class MockWebServerRule: TestRule {
val mockWebServer = MockWebServer()
override fun apply(base: Statement?, description: Description?): Statement {
val ruleStatement = object: Statement() {
override fun evaluate() {
try {
mockWebServer.start()
base?.evaluate()
} finally {
mockWebServer.shutdown()
}
}
}
return ruleStatement
}
}

View File

@ -0,0 +1,10 @@
{
"subsonic-response" : {
"status" : "failed",
"version" : "1.13.0",
"error" : {
"code" : 0,
"message" : "Generic error."
}
}
}

View File

@ -0,0 +1,12 @@
{
"subsonic-response" : {
"status" : "ok",
"version" : "1.13.0",
"musicFolders" : {
"musicFolder" : [ {
"id" : 0,
"name" : "Music"
} ]
}
}
}

View File

@ -0,0 +1,10 @@
{
"subsonic-response" : {
"status" : "ok",
"version" : "1.13.0",
"license" : {
"valid" : true,
"trialExpires" : "2016-11-23T20:17:15.206Z"
}
}
}

View File

@ -0,0 +1,6 @@
{
"subsonic-response" : {
"status" : "ok",
"version" : "1.13.0"
}
}

View File

@ -1,4 +0,0 @@
package org.moire.ultrasonic.api.subsonic;
public class MyClass {
}

View File

@ -0,0 +1,59 @@
package org.moire.ultrasonic.api.subsonic
import com.google.gson.GsonBuilder
import okhttp3.OkHttpClient
import org.moire.ultrasonic.api.subsonic.models.SubsonicResponse
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
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) {
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())
}.build()
private val gson = GsonBuilder()
.registerTypeAdapter(SubsonicResponse::class.javaObjectType,
SubsonicResponse.Companion.ClassTypeAdapter())
.create()
private val retrofit = Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
private val subsonicAPI = retrofit.create(SubsonicAPIDefinition::class.java)
/**
* Get API instance.
*
* @return initialized API instance
*/
fun getApi() = 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,16 @@
package org.moire.ultrasonic.api.subsonic
import org.moire.ultrasonic.api.subsonic.models.SubsonicResponse
import retrofit2.Call
import retrofit2.http.GET
/**
* // TODO
*/
interface SubsonicAPIDefinition {
@GET("ping.view")
fun ping(): Call<SubsonicResponse>
@GET("getLicense.view")
fun getLicense(): Call<SubsonicResponse>
}

View File

@ -0,0 +1,45 @@
package org.moire.ultrasonic.api.subsonic
/**
* Subsonic REST API versions.
*/
enum class SubsonicAPIVersions(val subsonicVersions: String, val restApiVersion: String) {
V1_1_0("3.8", "1.1.0"),
V1_1_1("3.9", "1.1.1"),
V1_2_0("4.0", "1.2.0"),
V1_3_0("4.1", "1.3.0"),
V1_4_0("4.2", "1.4.0"),
V1_5_0("4.4", "1.5.0"),
V1_6_0("4.5", "1.6.0"),
V1_7_0("4.6", "1.7.0"),
V1_8_0("4.7", "1.8.0"),
V1_9_0("4.8", "1.9.0"),
V1_10_2("4.9", "1.10.2"),
V1_11_0("5.1", "1.11.0"),
V1_12_0("5.2", "1.12.0"),
V1_13_0("5.3", "1.13.0"),
V1_14_0("6.0", "1.14.0");
companion object {
fun fromApiVersion(apiVersion: String): SubsonicAPIVersions {
when (apiVersion) {
"1.1.0" -> return V1_1_0
"1.1.1" -> return V1_1_1
"1.2.0" -> return V1_2_0
"1.3.0" -> return V1_3_0
"1.4.0" -> return V1_4_0
"1.5.0" -> return V1_5_0
"1.6.0" -> return V1_6_0
"1.7.0" -> return V1_7_0
"1.8.0" -> return V1_8_0
"1.9.0" -> return V1_9_0
"1.10.2" -> return V1_10_2
"1.11.0" -> return V1_11_0
"1.12.0" -> return V1_12_0
"1.13.0" -> return V1_13_0
"1.14.0" -> return V1_14_0
}
throw IllegalArgumentException("Unknown api version $apiVersion")
}
}
}

View File

@ -0,0 +1,22 @@
package org.moire.ultrasonic.api.subsonic
/**
* Common API errors.
*/
enum class SubsonicError(val code: Int) {
GENERIC(0),
REQUIRED_PARAM_MISSING(10),
INCOMPATIBLE_CLIENT_PROTOCOL_VERSION(20),
INCOMPATIBLE_SERVER_PROTOCOL_VERSION(30),
WRONG_USERNAME_OR_PASSWORD(40),
TOKEN_AUTH_NOT_SUPPORTED_FOR_LDAP(41),
USER_NOT_AUTHORIZED_FOR_OPERATION(50),
TRIAL_PERIOD_IS_OVER(60),
REQUESTED_DATA_WAS_NOT_FOUND(70);
companion object {
fun parseErrorFromJson(jsonErrorCode: Int) = SubsonicError.values()
.filter { it.code == jsonErrorCode }.firstOrNull()
?: throw IllegalArgumentException("Unknown code $jsonErrorCode")
}
}

View File

@ -0,0 +1,73 @@
package org.moire.ultrasonic.api.subsonic.models
import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonWriter
import org.moire.ultrasonic.api.subsonic.SubsonicAPIVersions
import org.moire.ultrasonic.api.subsonic.SubsonicError
/**
* Base Subsonic API response.
*/
data class SubsonicResponse(val status: Status,
val version: SubsonicAPIVersions,
val error: SubsonicError?) {
enum class Status(val jsonValue: String) {
OK("ok"), ERROR("failed");
companion object {
fun getStatusFromJson(jsonValue: String) = Status.values()
.filter { it.jsonValue == jsonValue }.firstOrNull()
?: throw IllegalArgumentException("Unknown status value: $jsonValue")
}
}
companion object {
class ClassTypeAdapter: TypeAdapter<SubsonicResponse>() {
override fun read(`in`: JsonReader?): SubsonicResponse {
if (`in` == null) {
throw NullPointerException("No json for parsing")
}
var status: Status = Status.ERROR
var version: SubsonicAPIVersions = SubsonicAPIVersions.V1_1_0
var error: SubsonicError? = null
`in`.beginObject()
if ("subsonic-response" == `in`.nextName()) {
`in`.beginObject()
while (`in`.hasNext()) {
when (`in`.nextName()) {
"status" -> status = Status.getStatusFromJson(`in`.nextString())
"version" -> version = SubsonicAPIVersions.fromApiVersion(`in`.nextString())
"error" -> error = parseError(`in`)
else -> `in`.skipValue()
}
}
`in`.endObject()
} else{
throw IllegalArgumentException("Not a subsonic-response json!")
}
`in`.endObject()
return SubsonicResponse(status, version, error)
}
override fun write(out: JsonWriter?, value: SubsonicResponse?) {
throw UnsupportedOperationException("not implemented")
}
private fun parseError(reader: JsonReader): SubsonicError? {
var error: SubsonicError? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
"code" -> error = SubsonicError.parseErrorFromJson(reader.nextInt())
else -> reader.skipValue()
}
}
reader.endObject()
return error
}
}
}
}

View File

@ -0,0 +1,28 @@
package org.moire.ultrasonic.api.subsonic
import org.amshove.kluent.`should equal`
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
/**
* Unit test for [SubsonicAPIVersions] class.
*/
@RunWith(Parameterized::class)
class SubsonicAPIVersionsTest(private val apiVersion: SubsonicAPIVersions) {
companion object {
@JvmStatic
@Parameterized.Parameters
fun data(): List<SubsonicAPIVersions> = SubsonicAPIVersions.values().asList()
}
@Test
fun `Should proper convert api version to enum`() {
SubsonicAPIVersions.fromApiVersion(apiVersion.restApiVersion) `should equal` apiVersion
}
@Test(expected = IllegalArgumentException::class)
fun `Should throw IllegalArgumentException for unknown api version`() {
SubsonicAPIVersions.fromApiVersion(apiVersion.restApiVersion.substring(0, 2))
}
}

View File

@ -0,0 +1,28 @@
package org.moire.ultrasonic.api.subsonic
import org.amshove.kluent.`should equal`
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
/**
* Unit test for [SubsonicError].
*/
@RunWith(Parameterized::class)
class SubsonicErrorTest(private val error: SubsonicError) {
companion object {
@JvmStatic
@Parameterized.Parameters
fun data(): List<SubsonicError> = SubsonicError.values().toList()
}
@Test
fun `Should proper convert error code to error`() {
SubsonicError.parseErrorFromJson(error.code) `should equal` error
}
@Test(expected = IllegalArgumentException::class)
fun `Should throw IllegalArgumentException from unknown error code`() {
SubsonicError.parseErrorFromJson(error.code + 10000)
}
}

View File

@ -0,0 +1,28 @@
package org.moire.ultrasonic.api.subsonic.models
import org.amshove.kluent.`should equal`
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
/**
* Unit test for [SubsonicResponse.Status] class
*/
@RunWith(Parameterized::class)
class StatusTest(private val status: SubsonicResponse.Status) {
companion object {
@JvmStatic
@Parameterized.Parameters
fun data(): List<SubsonicResponse.Status> = SubsonicResponse.Status.values().toList()
}
@Test
fun `Should proper parse response status`() {
SubsonicResponse.Status.getStatusFromJson(status.jsonValue) `should equal` status
}
@Test(expected = IllegalArgumentException::class)
fun `Should throw IllegalArgumentException on unknown status`() {
SubsonicResponse.Status.getStatusFromJson(status.jsonValue.plus("-some"))
}
}

View File

@ -1,4 +1,5 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
android {
compileSdkVersion versions.compileSdk
@ -16,13 +17,25 @@ android {
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
}
}
sourceSets {
test.java.srcDirs += 'src/test/kotlin'
}
}
dependencies {
compile project(':menudrawer')
compile project(':pulltorefresh')
compile project(':library')
compile project(':subsonic-api')
compile androidSupport.support
compile androidSupport.design
compile kotlin.stdlib
testCompile testing.junit
testCompile testing.kotlinJunit
testCompile testing.mockitoKotlin
testCompile testing.kluent
}