Response.kt (6075B)
1 /* 2 * This file is part of GNU Taler 3 * (C) 2020 Taler Systems S.A. 4 * 5 * GNU Taler is free software; you can redistribute it and/or modify it under the 6 * terms of the GNU General Public License as published by the Free Software 7 * Foundation; either version 3, or (at your option) any later version. 8 * 9 * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY 10 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 11 * A PARTICULAR PURPOSE. See the GNU General Public License for more details. 12 * 13 * You should have received a copy of the GNU General Public License along with 14 * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 15 */ 16 17 package net.taler.merchantlib 18 19 import io.ktor.client.plugins.ResponseException 20 import io.ktor.client.statement.bodyAsText 21 import java.io.IOException 22 import java.net.ConnectException 23 import java.net.SocketTimeoutException 24 import java.net.UnknownHostException 25 import java.nio.channels.UnresolvedAddressException 26 import kotlinx.serialization.Serializable 27 import kotlinx.serialization.json.Json 28 29 class Response<out T> private constructor( 30 private val value: Any? 31 ) { 32 companion object { 33 private const val NETWORK_ERROR_MESSAGE = 34 "Network error: check your internet connection and merchant URL." 35 36 suspend fun <T> response(request: suspend () -> T): Response<T> { 37 return try { 38 success(request()) 39 } catch (e: Throwable) { 40 println(e) 41 failure(e) 42 } 43 } 44 45 fun <T> success(value: T): Response<T> = 46 Response(value) 47 48 fun <T> failure(e: Throwable): Response<T> = 49 Response(Failure(e)) 50 } 51 52 val isFailure: Boolean get() = value is Failure 53 54 suspend fun handle(onFailure: ((String) -> Unit)? = null, onSuccess: ((T) -> Unit)? = null) { 55 if (value is Failure) onFailure?.let { it(getFailureString(value)) } 56 else onSuccess?.let { 57 @Suppress("UNCHECKED_CAST") 58 it(value as T) 59 } 60 } 61 62 suspend fun handleSuspend( 63 onFailure: ((String) -> Any)? = null, 64 onSuccess: (suspend (T) -> Any)? = null 65 ) { 66 if (value is Failure) onFailure?.let { it(getFailureString(value)) } 67 else onSuccess?.let { 68 @Suppress("UNCHECKED_CAST") 69 it(value as T) 70 } 71 } 72 73 private suspend fun getFailureString(failure: Failure): String = when (val exception = failure.exception) { 74 is ResponseException -> getExceptionString(exception) 75 is UnknownHostException, 76 is UnresolvedAddressException, 77 is ConnectException, 78 is SocketTimeoutException -> NETWORK_ERROR_MESSAGE 79 is IOException -> exception.message?.takeIf(String::isNotBlank) ?: NETWORK_ERROR_MESSAGE 80 else -> exception.message?.takeIf(String::isNotBlank) ?: exception.toString() 81 } 82 83 private suspend fun getExceptionString(e: ResponseException): String { 84 val response = e.response 85 val responseText = response.bodyAsText() 86 parseInventoryAvailabilityError(response.status.value, responseText)?.let { return it } 87 return try { 88 val error = Json.decodeFromString<Error>(responseText) 89 buildString { 90 append("Error") 91 error.code?.let { 92 append(' ') 93 append(it) 94 } 95 append(" (") 96 append(response.status.value) 97 append(")") 98 error.hint?.takeIf(String::isNotBlank)?.let { 99 append(": ") 100 append(it) 101 } 102 error.detail?.takeIf(String::isNotBlank)?.let { 103 append(" - ") 104 append(it) 105 } 106 } 107 } catch (ex: Exception) { 108 fallbackStatusMessage(response.status.value) 109 } 110 } 111 112 private fun parseInventoryAvailabilityError(statusCode: Int, body: String): String? { 113 if (statusCode != 410) return null 114 val error = runCatching { 115 Json { ignoreUnknownKeys = true } 116 .decodeFromString<InventoryAvailabilityError>(body) 117 }.getOrNull() ?: return null 118 val productId = error.productId?.takeIf(String::isNotBlank) ?: return null 119 val requested = error.unitRequestedQuantity 120 ?.takeIf(String::isNotBlank) 121 ?: error.requestedQuantity?.toString() 122 ?: return null 123 val available = error.unitAvailableQuantity 124 ?.takeIf(String::isNotBlank) 125 ?: error.availableQuantity?.toString() 126 ?: return null 127 return "Inventory stock unavailable for product $productId: " + 128 "$requested requested, $available available." 129 } 130 131 private fun fallbackStatusMessage(statusCode: Int): String = when (statusCode) { 132 400 -> "Bad request (400)" 133 401 -> "Unauthorized (401)" 134 403 -> "Forbidden (403)" 135 404 -> "Not found (404): check the merchant URL and instance path." 136 408 -> "Request timed out (408)" 137 in 500..599 -> "Server error ($statusCode)" 138 else -> "HTTP error ($statusCode)" 139 } 140 141 private class Failure(val exception: Throwable) 142 143 @Serializable 144 private class Error( 145 val code: Int?, 146 val hint: String?, 147 val detail: String? = null, 148 ) 149 150 @Serializable 151 private class InventoryAvailabilityError( 152 @kotlinx.serialization.SerialName("product_id") 153 val productId: String? = null, 154 @kotlinx.serialization.SerialName("requested_quantity") 155 val requestedQuantity: Int? = null, 156 @kotlinx.serialization.SerialName("unit_requested_quantity") 157 val unitRequestedQuantity: String? = null, 158 @kotlinx.serialization.SerialName("available_quantity") 159 val availableQuantity: Int? = null, 160 @kotlinx.serialization.SerialName("unit_available_quantity") 161 val unitAvailableQuantity: String? = null, 162 ) 163 }