ApiConnection.kt

/*
Copyright 2017 Hermann Krumrey <hermann@krumreyh.com>

This file is part of bundesliga-tippspiel-android.

bundesliga-tippspiel-android is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

bundesliga-tippspiel-android is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with bundesliga-tippspiel-android.  If not, see <http://www.gnu.org/licenses/>.
*/

package net.namibsun.hktipp.api
import android.util.Log
import net.namibsun.hktipp.activities.BaseActivity
import net.namibsun.hktipp.models.User
import okhttp3.Headers
import okhttp3.MediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import org.json.JSONObject
import java.io.IOException
import java.net.SocketTimeoutException

/**
 * Class that allows interaction with the hk-tippspiel API
 * @param serverUrl: The server URL to use
 * @param apiKey: The API key to use for authentication
 * @param expiration: The expiration time of the API key
 */
class ApiConnection(
    private val serverUrl: String,
    private val apiKey: String,
    val user: User,
    val expiration: Int
) {

    /**
     * Checks if the ApiConnection is authorized or not
     * @return true if the connection is authorized, false otherwise
     */
    fun isAuthorized(): Boolean {
        val resp = this.get("authorize", mapOf())
        return resp.getString("status") == "ok"
    }

    /**
     * Logs out by deleting the API key
     * @param context: If provided, deletes the API key information from the shared preferences
     */
    fun logout(context: BaseActivity? = null) {
        this.delete("key", mapOf("api_key" to this.apiKey))

        if (context != null) {
            val editor = context.sharedPreferences.edit()
            editor.remove("server_url")
            editor.remove("api_key")
            editor.remove("expiration")
            editor.apply()
        }
    }

    /**
     * Stores the API Connection info in the shared preferences
     * @param context: The context from which to load the shared preferences
     */
    fun store(context: BaseActivity) {
        val editor = context.sharedPreferences.edit()
        editor.putString("server_url", this.serverUrl)
        editor.putString("api_key", this.apiKey)
        editor.putString("user", this.user.toJson().toString())
        editor.putInt("expiration", this.expiration)
        editor.apply()
    }

    /**
     * Contains static methods of the class
     */
    companion object {

        /**
         * Loads a previously stored API connection
         * @param context: The context in from which to get the shared preferences
         * @return The loaded ApiConnection OR null if no valid connection was found
         */
        fun loadStored(context: BaseActivity): ApiConnection? {
            val prefs = context.sharedPreferences
            val serverUrl = prefs.getString("server_url", null)
            val apiKey = prefs.getString("api_key", null)
            val expiration = prefs.getInt("expiration", -1)
            val userData = prefs.getString("user", null)

            return if (
                    serverUrl == null || apiKey == null || expiration == -1 || userData == null
            ) {
                null
            } else {
                val user = User.fromJson(JSONObject(userData))
                ApiConnection(serverUrl, apiKey, user, expiration)
            }
        }

        /**
         * Allows initializing an ApiConnection object using username and password
         * @param username: The username to use
         * @param password: The password to use
         * @return The generated ApiConnection object or null if the login failed
         */
        fun login(
            username: String,
            password: String,
            serverUrl: String = "https://hk-tippspiel.com"
        ): ApiConnection? {
            val resp = this.request(
                    serverUrl,
                    HttpMethod.POST,
                    "key",
                    mapOf("username" to username, "password" to password)
            )
            return if (resp.getString("status") == "ok") {
                val data = resp.getJSONObject("data")
                ApiConnection(
                        serverUrl,
                        data.getString("api_key"),
                        User.fromJson(data.getJSONObject("user")),
                        data.getInt("expiration")
                )
            } else {
                Log.i("ApiConnection", "Failed to log in user $username.")
                null
            }
        }

        /**
         * Executes a HTTP request on an API endpoint
         * @param serverUrl: The server URL to use
         * @param method: The HTTP method to use
         * @param endpoint: The endpoint to use
         * @param params: The parameters to send
         * @param apiKey: Optional API key for authentication
         * @return The response JSON object
         */
        private fun request(
            serverUrl: String,
            method: HttpMethod,
            endpoint: String,
            params: Map<String, Any>,
            apiKey: String? = null
        ): JSONObject {

            val client = OkHttpClient()
            var builder = Request.Builder()

            val endpointUrl = this.prepareEndpointUrl(serverUrl, method, endpoint, params)
            val body = this.prepareBody(params)

            builder = builder.url(endpointUrl)
            if (apiKey != null) {
                val encoded = this.base64Encode(apiKey)
                val headers = Headers.of(mutableMapOf("Authorization" to "Basic $encoded"))
                builder = builder.headers(headers)
            }

            builder = when (method) {
                HttpMethod.POST -> builder.post(body)
                HttpMethod.GET -> builder.get()
                HttpMethod.PUT -> builder.put(body)
                HttpMethod.DELETE -> builder.delete(body)
            }

            val request = builder.build()

            try {
                val response = client.newCall(request).execute()
                val responseBody = response.body()!!.string()
                val result = JSONObject(responseBody)
                if (result.getString("status") == "error") {
                    Log.e("ApiConnection", "Error: ${result.getString("reason")}")
                }
                return result
            } catch (e: SocketTimeoutException) {
                throw IOException("timeout")
            }
        }

        /**
         * Base64-encodes a string. Must be applied to API keys
         * @param string: The string to encode
         * @return The base64-encoded string
         */
        private fun base64Encode(string: String): String {
            val b64chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"

            var glue = ""
            for (char in string) {
                val ascii = char.toByte().toInt()
                var binary = ascii.toString(2)
                while (binary.length < 8) {
                    binary = "0$binary"
                }
                glue += binary
            }

            var encoded = ""
            var block = ""
            for (char in glue) {
                block += char
                if (block.length == 6) {
                    val index = block.toInt(2)
                    encoded += b64chars[index]
                    block = ""
                }
            }
            if (block.isNotEmpty()) {
                while (block.length < 6) {
                    block += "0"
                }
                encoded += b64chars[block.toInt(2)]
            }
            return encoded
        }

        /**
         * Prepares the endpoint URL.
         * If the method is GET, appends the parameters to the URL
         * @param serverUrl: The server URL to use
         * @param method: The HTTP method to use
         * @param endpoint: The endpoint to use
         * @param params: The parameters to use
         * @return the generated endpoint URL
         */
        private fun prepareEndpointUrl(
            serverUrl: String,
            method: HttpMethod,
            endpoint: String,
            params: Map<String, Any>
        ): String {
            var endpointUrl = "$serverUrl/api/v2/$endpoint"
            if (method == HttpMethod.GET && params.isNotEmpty()) {
                endpointUrl += "?"
                for ((key, value) in params) {
                    endpointUrl += "$key=$value&"
                }
                endpointUrl = endpointUrl.substring(0, endpointUrl.length - 1)
            }
            return endpointUrl
        }

        /**
         * Prepares the request body
         * @param params: The body parameters
         * @return The Request Body
         */
        private fun prepareBody(params: Map<String, Any>): RequestBody {

            val jsonMediaType = MediaType.parse("application/json; charset=utf-8")

            val jsonData = JSONObject()
            for ((key, value) in params) {
                jsonData.put(key, value)
            }
            return RequestBody.create(jsonMediaType, jsonData.toString())
        }
    }

    /**
     * Performs an authenticated GET request to the API
     * @param endpoint: The API endpoint to connect to
     * @param params: The parameters to send
     */
    fun get(endpoint: String, params: Map<String, Any>): JSONObject {
        return request(serverUrl, HttpMethod.GET, endpoint, params, this.apiKey)
    }

    /**
     * Performs an authenticated PUT request to the API
     * @param endpoint: The API endpoint to connect to
     * @param params: The parameters to send
     */
    fun put(endpoint: String, params: Map<String, Any>): JSONObject {
        return request(serverUrl, HttpMethod.PUT, endpoint, params, this.apiKey)
    }

    /**
     * Performs an authenticated DELETE request to the API
     * @param endpoint: The API endpoint to connect to
     * @param params: The parameters to send
     */
    private fun delete(endpoint: String, params: Map<String, Any>): JSONObject {
        return request(serverUrl, HttpMethod.DELETE, endpoint, params, this.apiKey)
    }
}