SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/api/ApiUtils2.kt

207 lines
7.1 KiB
Kotlin

package jp.juggler.subwaytooter.api
import jp.juggler.util.coroutine.AppDispatchers
import jp.juggler.util.data.*
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.withCaption
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.internal.closeQuietly
import ru.gildor.coroutines.okhttp.await
import java.io.IOException
private val log = LogCategory("ApiUtils2")
const val JSON_SERVER_TYPE = "<>serverType"
const val SERVER_MISSKEY = "misskey"
const val SERVER_MASTODON = "mastodon"
val DEFAULT_JSON_ERROR_PARSER =
{ json: JsonObject -> json["error"]?.toString() }
private val reWhiteSpace = """\s+""".toRegex()
private val reStartJsonArray = """\A\s*\[""".toRegex()
private val reStartJsonObject = """\A\s*\{""".toRegex()
fun Request.Builder.authorizationBearer(token: String?) =
apply { token.notEmpty()?.let { header("Authorization", "Bearer $it") } }
class ApiError(
message: String,
cause: Throwable? = null,
val response: Response? = null,
) : IOException(message, cause)
private fun Response.formatError(caption: String? = null) = when {
caption.isNullOrBlank() -> "HTTP $code $message ${request.method} ${request.url}"
else -> "$caption: HTTP $code $message ${request.method} ${request.url}"
}
private fun Request.formatError(ex: Throwable, caption: String? = null) = when {
caption.isNullOrBlank() -> "${ex.withCaption()} $method $url"
else -> "$caption: ${ex.withCaption()} $method $url"
}
/**
* 応答ボディのHTMLやテキストを整形する
*/
private fun simplifyErrorHtml(body: String): String {
// // JsonObjectとして解釈できるならエラーメッセージを検出する
// try {
// val json = body.decodeJsonObject()
// jsonErrorParser(json)?.notEmpty()?.let { return it }
// } catch (_: Throwable) {
// }
// // HTMLならタグの除去を試みる
// try {
// val ct = response.body?.contentType()
// if (ct?.subtype == "html") {
// // XXX HTMLデコードを省略
// return reWhiteSpace.replace(body," ").trim()
// }
// } catch (_: Throwable) {
// }
// XXX: Amazon S3 が403を返した場合にcontent-typeが?/xmlでserverがAmazonならXMLをパースしてエラーを整形することもできるが、多分必要ない
// 通常テキストの空白や改行を整理した文字列を返す
try {
return reWhiteSpace.replace(body, " ").trim()
} catch (_: Throwable) {
}
// 全部失敗したら入力そのまま
return body
}
/**
* エラー応答のステータス部分や本文を文字列にする
*/
fun parseErrorResponse(response: Response, body: String? = null): String =
try {
val request = response.request
StringBuilder().apply {
// 応答ボディのテキストがあれば追加
if (body.isNullOrBlank()) {
append("(missing response body)")
} else {
append(simplifyErrorHtml(body))
}
if (isNotEmpty()) append(' ')
append("(HTTP ").append(response.code.toString())
response.message.notBlank()?.let { message ->
append(' ')
append(message)
}
append(") ${request.method} ${request.url}")
}.toString().replace("""[\x0d\x0a]+""".toRegex(), "\n")
} catch (ex: Throwable) {
log.e(ex, "parseErrorResponse failed.")
"(can't parse response body)"
}
suspend fun Request.await(okHttp: OkHttpClient) =
try {
okHttp.newCall(this).await()
} catch (ex: Throwable) {
throw ApiError(cause = ex, message = this.formatError(ex))
}
/**
* レスポンスボディを文字列として読む
* ボディがない場合はnullを返す
* その他はSendExceptionを返す
*/
private suspend fun Response.readString(): String? {
val response = this
return try {
// XXX: 進捗表示
withContext(AppDispatchers.IO) {
val bodyString = response.body?.string()
if (bodyString.isNullOrEmpty()) {
if (response.code in 200 until 300) {
// Misskey の /api/notes/favorites/create は 204(no content)を返す。ボディはカラになる。
return@withContext ""
} else if (!response.isSuccessful) {
throw ApiError(
response = response,
message = parseErrorResponse(response = response, body = ""),
)
}
}
bodyString
}
} catch (ex: Throwable) {
when (ex) {
is CancellationException, is ApiError -> throw ex
else -> {
log.e(ex, "readString failed.")
throw ApiError(
response = response,
message = parseErrorResponse(
response = response,
ex.withCaption("readString failed.")
)
)
}
}
} finally {
response.body?.closeQuietly()
}
}
/**
* ResponseWith<String?> をResponseWith<JsonObject?>に変換する
*/
suspend fun String?.stringToJsonObject(response: Response): JsonObject =
try {
val content = this
withContext(AppDispatchers.IO) {
when {
content == null -> throw ApiError(
response = response,
message = response.formatError("response body is null.")
)
// 204 no content は 空オブジェクトと解釈する
content == "" -> JsonObject()
reStartJsonArray.containsMatchIn(content) ->
jsonObjectOf("root" to content.decodeJsonArray())
reStartJsonObject.containsMatchIn(content) -> {
val json = content.decodeJsonObject()
DEFAULT_JSON_ERROR_PARSER(json)?.let { error ->
throw ApiError(
response = response,
message = response.formatError(error)
)
}
json
}
else -> throw ApiError(
response = response,
message = response.formatError("not a JSON object.")
)
}
}
} catch (ex: Throwable) {
when (ex) {
is CancellationException, is ApiError -> throw ex
else -> {
throw ApiError(
response = response,
message = response.formatError("readJsonObject failed."),
cause = ex,
)
}
}
}
suspend fun Response.readJsonObject() = readString().stringToJsonObject(this)