feat: add card detail API contracts and compatibility parsing
This commit is contained in:
@@ -144,7 +144,7 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel
|
|||||||
- The modal dialog has "Add comment" as a title.
|
- The modal dialog has "Add comment" as a title.
|
||||||
- The modal dialog has an editable markdown-enabled text field for the comment.
|
- The modal dialog has an editable markdown-enabled text field for the comment.
|
||||||
- The modal dialog has two buttons at the bottom on the right side for "Cancel" and "Add" respectively.
|
- The modal dialog has two buttons at the bottom on the right side for "Cancel" and "Add" respectively.
|
||||||
- Current status: full card detail is still pending. Tapping a board-detail card currently navigates to `CardDetailPlaceholderActivity` with card id/title extras.
|
- Current status: full card detail UI is still pending. Card-detail domain models and API contracts are now present (`CardDetail`, `CardActivity`, tags), with compatibility behavior implemented in `HttpKanbnApiClient` for card detail fetch, card update fallback variants with auth short-circuit, activity endpoint fallback parsing, and add-comment fallback/logical-failure handling.
|
||||||
|
|
||||||
**Settings view**
|
**Settings view**
|
||||||
- The view shows a list of settings that can be changed by the user. The following settings are available:
|
- The view shows a list of settings that can be changed by the user. The following settings are available:
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import java.net.HttpURLConnection
|
|||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -18,6 +20,9 @@ import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
|
|||||||
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
|
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
|
||||||
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
||||||
import space.hackenslacker.kanbn4droid.app.boards.WorkspaceSummary
|
import space.hackenslacker.kanbn4droid.app.boards.WorkspaceSummary
|
||||||
|
import space.hackenslacker.kanbn4droid.app.carddetail.CardActivity
|
||||||
|
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetail
|
||||||
|
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailTag
|
||||||
|
|
||||||
interface KanbnApiClient {
|
interface KanbnApiClient {
|
||||||
suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult
|
suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult
|
||||||
@@ -97,6 +102,29 @@ interface KanbnApiClient {
|
|||||||
suspend fun getLabelByPublicId(baseUrl: String, apiKey: String, labelId: String): BoardsApiResult<LabelDetail> {
|
suspend fun getLabelByPublicId(baseUrl: String, apiKey: String, labelId: String): BoardsApiResult<LabelDetail> {
|
||||||
return BoardsApiResult.Failure("Label detail is not implemented.")
|
return BoardsApiResult.Failure("Label detail is not implemented.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getCardDetail(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult<CardDetail> {
|
||||||
|
return BoardsApiResult.Failure("Card detail is not implemented.")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateCard(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
cardId: String,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
dueDate: LocalDate?,
|
||||||
|
): BoardsApiResult<Unit> {
|
||||||
|
return BoardsApiResult.Failure("Card update is not implemented.")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun listCardActivities(baseUrl: String, apiKey: String, cardId: String): BoardsApiResult<List<CardActivity>> {
|
||||||
|
return BoardsApiResult.Failure("Card activity listing is not implemented.")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addCardComment(baseUrl: String, apiKey: String, cardId: String, comment: String): BoardsApiResult<Unit> {
|
||||||
|
return BoardsApiResult.Failure("Card comment creation is not implemented.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class LabelDetail(
|
data class LabelDetail(
|
||||||
@@ -547,6 +575,198 @@ class HttpKanbnApiClient : KanbnApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getCardDetail(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
cardId: String,
|
||||||
|
): BoardsApiResult<CardDetail> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
request(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
path = "/api/v1/cards/$cardId",
|
||||||
|
method = "GET",
|
||||||
|
apiKey = apiKey,
|
||||||
|
) { code, body ->
|
||||||
|
if (code in 200..299) {
|
||||||
|
parseCardDetail(body, fallbackId = cardId)
|
||||||
|
?.let { BoardsApiResult.Success(it) }
|
||||||
|
?: BoardsApiResult.Failure("Malformed card detail response.")
|
||||||
|
} else {
|
||||||
|
BoardsApiResult.Failure(serverMessage(body, code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateCard(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
cardId: String,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
dueDate: LocalDate?,
|
||||||
|
): BoardsApiResult<Unit> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
val canonicalPayload = buildCardMutationPayload(title, description, dueDate, aliasKeys = false)
|
||||||
|
val aliasPayload = buildCardMutationPayload(title, description, dueDate, aliasKeys = true)
|
||||||
|
|
||||||
|
val attempts = listOf(
|
||||||
|
"PATCH" to canonicalPayload,
|
||||||
|
"PATCH" to aliasPayload,
|
||||||
|
"PUT" to canonicalPayload,
|
||||||
|
"PUT" to aliasPayload,
|
||||||
|
)
|
||||||
|
|
||||||
|
var lastFailureMessage = "Card update failed."
|
||||||
|
for ((method, payload) in attempts) {
|
||||||
|
val response = requestRaw(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
path = "/api/v1/cards/$cardId",
|
||||||
|
method = method,
|
||||||
|
apiKey = apiKey,
|
||||||
|
body = payload,
|
||||||
|
)
|
||||||
|
if (response == null) {
|
||||||
|
return@withContext BoardsApiResult.Failure("Card update failed.")
|
||||||
|
}
|
||||||
|
if (response.code in 200..299) {
|
||||||
|
return@withContext BoardsApiResult.Success(Unit)
|
||||||
|
}
|
||||||
|
if (response.code == 401 || response.code == 403) {
|
||||||
|
return@withContext BoardsApiResult.Failure(serverMessage(response.body, response.code))
|
||||||
|
}
|
||||||
|
lastFailureMessage = serverMessage(response.body, response.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
val getResponse = requestRaw(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
path = "/api/v1/cards/$cardId",
|
||||||
|
method = "GET",
|
||||||
|
apiKey = apiKey,
|
||||||
|
)
|
||||||
|
if (getResponse == null) {
|
||||||
|
return@withContext BoardsApiResult.Failure(lastFailureMessage)
|
||||||
|
}
|
||||||
|
if (getResponse.code == 401 || getResponse.code == 403) {
|
||||||
|
return@withContext BoardsApiResult.Failure(serverMessage(getResponse.body, getResponse.code))
|
||||||
|
}
|
||||||
|
if (getResponse.code !in 200..299) {
|
||||||
|
return@withContext BoardsApiResult.Failure(serverMessage(getResponse.body, getResponse.code))
|
||||||
|
}
|
||||||
|
|
||||||
|
val fullPutPayload = buildCardFullPutPayload(getResponse.body, title, description, dueDate)
|
||||||
|
if (fullPutPayload == null) {
|
||||||
|
return@withContext BoardsApiResult.Failure(lastFailureMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
val finalResponse = requestRaw(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
path = "/api/v1/cards/$cardId",
|
||||||
|
method = "PUT",
|
||||||
|
apiKey = apiKey,
|
||||||
|
body = fullPutPayload,
|
||||||
|
)
|
||||||
|
if (finalResponse == null) {
|
||||||
|
return@withContext BoardsApiResult.Failure(lastFailureMessage)
|
||||||
|
}
|
||||||
|
if (finalResponse.code in 200..299) {
|
||||||
|
BoardsApiResult.Success(Unit)
|
||||||
|
} else {
|
||||||
|
BoardsApiResult.Failure(serverMessage(finalResponse.body, finalResponse.code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun listCardActivities(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
cardId: String,
|
||||||
|
): BoardsApiResult<List<CardActivity>> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
val paths = listOf(
|
||||||
|
"/api/v1/cards/$cardId/activities?limit=50",
|
||||||
|
"/api/v1/cards/$cardId/actions?limit=50",
|
||||||
|
"/api/v1/cards/$cardId/card-activities?limit=50",
|
||||||
|
)
|
||||||
|
|
||||||
|
var lastFailure = "Card activities are not available."
|
||||||
|
for (path in paths) {
|
||||||
|
val response = requestRaw(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
path = path,
|
||||||
|
method = "GET",
|
||||||
|
apiKey = apiKey,
|
||||||
|
)
|
||||||
|
if (response == null) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (response.code in 200..299) {
|
||||||
|
val parsed = parseCardActivities(response.body)
|
||||||
|
if (parsed != null) {
|
||||||
|
val normalized = parsed
|
||||||
|
.sortedByDescending { it.createdAtEpochMillis }
|
||||||
|
.take(10)
|
||||||
|
return@withContext BoardsApiResult.Success(normalized)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (response.code == 401 || response.code == 403) {
|
||||||
|
return@withContext BoardsApiResult.Failure(serverMessage(response.body, response.code))
|
||||||
|
}
|
||||||
|
lastFailure = serverMessage(response.body, response.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
BoardsApiResult.Failure(lastFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun addCardComment(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
cardId: String,
|
||||||
|
comment: String,
|
||||||
|
): BoardsApiResult<Unit> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
val attempts = listOf(
|
||||||
|
Pair("/api/v1/cards/$cardId/comment-actions", "{\"text\":\"${jsonEscape(comment)}\"}"),
|
||||||
|
Pair("/api/v1/cards/$cardId/comment-actions", "{\"comment\":\"${jsonEscape(comment)}\"}"),
|
||||||
|
Pair("/api/v1/cards/$cardId/actions/comments", "{\"text\":\"${jsonEscape(comment)}\"}"),
|
||||||
|
)
|
||||||
|
|
||||||
|
var lastFailure = "Comment could not be added."
|
||||||
|
for ((path, payload) in attempts) {
|
||||||
|
val response = requestRaw(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
path = path,
|
||||||
|
method = "POST",
|
||||||
|
apiKey = apiKey,
|
||||||
|
body = payload,
|
||||||
|
)
|
||||||
|
if (response == null) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.code !in 200..299) {
|
||||||
|
if (response.code == 401 || response.code == 403) {
|
||||||
|
return@withContext BoardsApiResult.Failure(serverMessage(response.body, response.code))
|
||||||
|
}
|
||||||
|
lastFailure = serverMessage(response.body, response.code)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
when (evaluateCommentResponse(response.body)) {
|
||||||
|
CommentResponseClassification.Success -> return@withContext BoardsApiResult.Success(Unit)
|
||||||
|
CommentResponseClassification.LogicalFailure -> {
|
||||||
|
return@withContext BoardsApiResult.Failure(extractLogicalFailureMessage(response.body))
|
||||||
|
}
|
||||||
|
CommentResponseClassification.ParseIncompatible -> continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BoardsApiResult.Failure(lastFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun <T> request(
|
private fun <T> request(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
path: String,
|
path: String,
|
||||||
@@ -591,6 +811,48 @@ class HttpKanbnApiClient : KanbnApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class RawResponse(val code: Int, val body: String)
|
||||||
|
|
||||||
|
private fun requestRaw(
|
||||||
|
baseUrl: String,
|
||||||
|
path: String,
|
||||||
|
method: String,
|
||||||
|
apiKey: String,
|
||||||
|
body: String? = null,
|
||||||
|
): RawResponse? {
|
||||||
|
val endpoint = "${baseUrl.trimEnd('/')}$path"
|
||||||
|
val connection = (URL(endpoint).openConnection() as HttpURLConnection).apply {
|
||||||
|
configureRequestMethod(this, method)
|
||||||
|
connectTimeout = 10_000
|
||||||
|
readTimeout = 10_000
|
||||||
|
setRequestProperty("x-api-key", apiKey)
|
||||||
|
if (body != null) {
|
||||||
|
doOutput = true
|
||||||
|
setRequestProperty("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
if (body != null) {
|
||||||
|
connection.outputStream.bufferedWriter().use { writer -> writer.write(body) }
|
||||||
|
}
|
||||||
|
val code = connection.responseCode
|
||||||
|
RawResponse(code = code, body = readResponseBody(connection, code))
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
connection.inputStream?.close()
|
||||||
|
} catch (_: IOException) {
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
connection.errorStream?.close()
|
||||||
|
} catch (_: IOException) {
|
||||||
|
}
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun configureRequestMethod(connection: HttpURLConnection, method: String) {
|
private fun configureRequestMethod(connection: HttpURLConnection, method: String) {
|
||||||
try {
|
try {
|
||||||
connection.requestMethod = method
|
connection.requestMethod = method
|
||||||
@@ -789,6 +1051,239 @@ class HttpKanbnApiClient : KanbnApiClient {
|
|||||||
return CreatedEntityRef(publicId = publicId.ifBlank { null })
|
return CreatedEntityRef(publicId = publicId.ifBlank { null })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parseCardDetail(body: String, fallbackId: String): CardDetail? {
|
||||||
|
if (body.isBlank()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val root = parseJsonObject(body) ?: return null
|
||||||
|
val data = root["data"] as? Map<*, *>
|
||||||
|
val card = (data?.get("card") as? Map<*, *>)
|
||||||
|
?: (root["card"] as? Map<*, *>)
|
||||||
|
?: data
|
||||||
|
?: root
|
||||||
|
|
||||||
|
val id = extractId(card).ifBlank { fallbackId }
|
||||||
|
val title = extractString(card, "title", "name").ifBlank { "Card" }
|
||||||
|
val description = firstPresent(card, "description", "body")?.toString().orEmpty()
|
||||||
|
val dueDate = parseCardDueDate(firstPresent(card, "dueDate", "dueAt", "due_at", "due"))
|
||||||
|
val listPublicId = extractString(card, "listPublicId", "listId", "list_id").ifBlank {
|
||||||
|
(card["list"] as? Map<*, *>)?.let { extractString(it, "publicId", "public_id", "id") }.orEmpty()
|
||||||
|
}.ifBlank { null }
|
||||||
|
val index = parseCardIndex(firstPresent(card, "index", "position"))
|
||||||
|
val tags = extractObjectArray(card, "labels", "tags", "data").mapNotNull { rawTag ->
|
||||||
|
val tagId = extractId(rawTag)
|
||||||
|
if (tagId.isBlank()) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
CardDetailTag(
|
||||||
|
id = tagId,
|
||||||
|
name = extractTitle(rawTag, "Tag"),
|
||||||
|
colorHex = extractString(rawTag, "colourCode", "colorCode", "colorHex", "color", "hex"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return CardDetail(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
description = description,
|
||||||
|
dueDate = dueDate,
|
||||||
|
listPublicId = listPublicId,
|
||||||
|
index = index,
|
||||||
|
tags = tags,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseCardDueDate(raw: Any?): LocalDate? {
|
||||||
|
val value = raw?.toString()?.trim().orEmpty()
|
||||||
|
if (value.isBlank() || value.equals("null", ignoreCase = true)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
runCatching { return LocalDate.parse(value) }
|
||||||
|
runCatching { return OffsetDateTime.parse(value).withOffsetSameInstant(ZoneOffset.UTC).toLocalDate() }
|
||||||
|
runCatching { return Instant.parse(value).atOffset(ZoneOffset.UTC).toLocalDate() }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseCardIndex(raw: Any?): Int? {
|
||||||
|
return when (raw) {
|
||||||
|
is Number -> raw.toInt()
|
||||||
|
is String -> raw.toIntOrNull() ?: raw.toDoubleOrNull()?.toInt()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildCardMutationPayload(
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
dueDate: LocalDate?,
|
||||||
|
aliasKeys: Boolean,
|
||||||
|
): String {
|
||||||
|
val dueValue = dueDate?.let { "\"${it}T00:00:00Z\"" } ?: "null"
|
||||||
|
return if (aliasKeys) {
|
||||||
|
"{" +
|
||||||
|
"\"name\":\"${jsonEscape(title)}\"," +
|
||||||
|
"\"body\":\"${jsonEscape(description)}\"," +
|
||||||
|
"\"dueAt\":$dueValue" +
|
||||||
|
"}"
|
||||||
|
} else {
|
||||||
|
"{" +
|
||||||
|
"\"title\":\"${jsonEscape(title)}\"," +
|
||||||
|
"\"description\":\"${jsonEscape(description)}\"," +
|
||||||
|
"\"dueDate\":$dueValue" +
|
||||||
|
"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildCardFullPutPayload(
|
||||||
|
cardBody: String,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
dueDate: LocalDate?,
|
||||||
|
): String? {
|
||||||
|
if (cardBody.isBlank()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val root = parseJsonObject(cardBody) ?: return null
|
||||||
|
val data = root["data"] as? Map<*, *>
|
||||||
|
val card = (data?.get("card") as? Map<*, *>)
|
||||||
|
?: (root["card"] as? Map<*, *>)
|
||||||
|
?: data
|
||||||
|
?: root
|
||||||
|
|
||||||
|
val listPublicId = extractString(card, "listPublicId", "listId", "list_id").ifBlank {
|
||||||
|
(card["list"] as? Map<*, *>)?.let { extractString(it, "publicId", "public_id", "id") }.orEmpty()
|
||||||
|
}
|
||||||
|
if (listPublicId.isBlank()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val indexField = parseCardIndex(firstPresent(card, "index", "position")) ?: 0
|
||||||
|
val dueField = dueDate?.let { "\"${it}T00:00:00Z\"" } ?: "null"
|
||||||
|
return "{" +
|
||||||
|
"\"title\":\"${jsonEscape(title)}\"," +
|
||||||
|
"\"description\":\"${jsonEscape(description)}\"," +
|
||||||
|
"\"dueDate\":$dueField," +
|
||||||
|
"\"index\":$indexField," +
|
||||||
|
"\"listPublicId\":\"${jsonEscape(listPublicId)}\"" +
|
||||||
|
"}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseCardActivities(body: String): List<CardActivity>? {
|
||||||
|
if (body.isBlank()) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val parsed = parseJsonValue(body) ?: return null
|
||||||
|
val items = when (parsed) {
|
||||||
|
is List<*> -> parsed.mapNotNull { it as? Map<*, *> }
|
||||||
|
is Map<*, *> -> extractActivityArray(parsed)
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
val trimmed = body.trim()
|
||||||
|
if (trimmed == "[]") {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
val asMap = parsed as? Map<*, *> ?: return emptyList()
|
||||||
|
val hadContainer = sequenceOf(
|
||||||
|
"activities",
|
||||||
|
"actions",
|
||||||
|
"cardActivities",
|
||||||
|
"card_activities",
|
||||||
|
"items",
|
||||||
|
"data",
|
||||||
|
).any { key -> asMap.containsKey(key) || ((asMap["data"] as? Map<*, *>)?.containsKey(key) == true) }
|
||||||
|
if (hadContainer) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.mapNotNull { raw ->
|
||||||
|
val id = extractString(raw, "id", "publicId", "public_id")
|
||||||
|
if (id.isBlank()) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
val createdAtRaw = firstPresent(raw, "createdAt", "created_at", "date", "timestamp")?.toString().orEmpty()
|
||||||
|
val createdAt = parseEpochMillis(createdAtRaw)
|
||||||
|
?: return@mapNotNull null
|
||||||
|
CardActivity(
|
||||||
|
id = id,
|
||||||
|
type = extractString(raw, "type", "actionType", "kind").ifBlank { "activity" },
|
||||||
|
text = extractString(raw, "text", "comment", "message", "description", "body", "content"),
|
||||||
|
createdAtEpochMillis = createdAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractActivityArray(source: Map<*, *>): List<Map<*, *>> {
|
||||||
|
val topLevel = extractObjectArray(source, "activities", "actions", "cardActivities", "card_activities", "items", "data")
|
||||||
|
if (topLevel.isNotEmpty()) {
|
||||||
|
return topLevel
|
||||||
|
}
|
||||||
|
val data = source["data"] as? Map<*, *> ?: return emptyList()
|
||||||
|
return extractObjectArray(data, "activities", "actions", "cardActivities", "card_activities", "items", "data")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseEpochMillis(raw: String): Long? {
|
||||||
|
val trimmed = raw.trim()
|
||||||
|
if (trimmed.isBlank()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return trimmed.toLongOrNull()
|
||||||
|
?: runCatching { Instant.parse(trimmed).toEpochMilli() }.getOrNull()
|
||||||
|
?: runCatching { OffsetDateTime.parse(trimmed).toInstant().toEpochMilli() }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class CommentResponseClassification {
|
||||||
|
Success,
|
||||||
|
LogicalFailure,
|
||||||
|
ParseIncompatible,
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun evaluateCommentResponse(body: String): CommentResponseClassification {
|
||||||
|
if (body.isBlank()) {
|
||||||
|
return CommentResponseClassification.Success
|
||||||
|
}
|
||||||
|
val root = parseJsonValue(body) as? Map<*, *> ?: return CommentResponseClassification.ParseIncompatible
|
||||||
|
|
||||||
|
val explicitError = firstPresent(root, "error", "errors")?.toString().orEmpty()
|
||||||
|
if (explicitError.isNotBlank() && !explicitError.equals("null", ignoreCase = true)) {
|
||||||
|
return CommentResponseClassification.LogicalFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
val successValue = firstPresent(root, "success")
|
||||||
|
if (successValue is Boolean && !successValue) {
|
||||||
|
return CommentResponseClassification.LogicalFailure
|
||||||
|
}
|
||||||
|
val okValue = firstPresent(root, "ok")
|
||||||
|
if (okValue is Boolean && !okValue) {
|
||||||
|
return CommentResponseClassification.LogicalFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
val status = firstPresent(root, "status")?.toString()?.trim()?.lowercase().orEmpty()
|
||||||
|
if (status == "error" || status == "failed" || status == "failure") {
|
||||||
|
return CommentResponseClassification.LogicalFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
val successKeyPresent = sequenceOf("commentAction", "comment", "action", "data").any { key ->
|
||||||
|
root.containsKey(key) && root[key] != null
|
||||||
|
}
|
||||||
|
if (successKeyPresent) {
|
||||||
|
return CommentResponseClassification.Success
|
||||||
|
}
|
||||||
|
|
||||||
|
return CommentResponseClassification.ParseIncompatible
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractLogicalFailureMessage(body: String): String {
|
||||||
|
val root = parseJsonValue(body) as? Map<*, *> ?: return "Comment could not be added."
|
||||||
|
val message = extractString(root, "message", "error", "detail", "cause")
|
||||||
|
return message.ifBlank { "Comment could not be added." }
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseLists(board: Map<*, *>): List<BoardListDetail> {
|
private fun parseLists(board: Map<*, *>): List<BoardListDetail> {
|
||||||
return extractObjectArray(board, "lists", "items", "data").mapNotNull { rawList ->
|
return extractObjectArray(board, "lists", "items", "data").mapNotNull { rawList ->
|
||||||
val id = extractId(rawList)
|
val id = extractId(rawList)
|
||||||
@@ -871,13 +1366,17 @@ class HttpKanbnApiClient : KanbnApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun parseJsonObject(body: String): Map<String, Any?>? {
|
private fun parseJsonObject(body: String): Map<String, Any?>? {
|
||||||
|
val parsed = parseJsonValue(body)
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
return parsed as? Map<String, Any?>
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseJsonValue(body: String): Any? {
|
||||||
val trimmed = body.trim()
|
val trimmed = body.trim()
|
||||||
if (trimmed.isBlank()) {
|
if (trimmed.isBlank()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val parsed = runCatching { MiniJsonParser(trimmed).parseValue() }.getOrNull()
|
return runCatching { MiniJsonParser(trimmed).parseValue() }.getOrNull()
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
return parsed as? Map<String, Any?>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun jsonEscape(value: String): String {
|
private fun jsonEscape(value: String): String {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package space.hackenslacker.kanbn4droid.app.carddetail
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
data class CardDetail(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val description: String,
|
||||||
|
val dueDate: LocalDate?,
|
||||||
|
val listPublicId: String?,
|
||||||
|
val index: Int?,
|
||||||
|
val tags: List<CardDetailTag>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CardDetailTag(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val colorHex: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CardActivity(
|
||||||
|
val id: String,
|
||||||
|
val type: String,
|
||||||
|
val text: String,
|
||||||
|
val createdAtEpochMillis: Long,
|
||||||
|
)
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
package space.hackenslacker.kanbn4droid.app.auth
|
||||||
|
|
||||||
|
import java.io.BufferedInputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.ServerSocket
|
||||||
|
import java.net.Socket
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
||||||
|
import space.hackenslacker.kanbn4droid.app.carddetail.CardActivity
|
||||||
|
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetail
|
||||||
|
|
||||||
|
class CardDetailApiCompatibilityTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getCardDetail_parsesNestedContainers_andNormalizesDueDateVariants() = runTest {
|
||||||
|
TestServer().use { server ->
|
||||||
|
server.registerSequence(
|
||||||
|
path = "/api/v1/cards/card-1",
|
||||||
|
method = "GET",
|
||||||
|
responses = listOf(
|
||||||
|
200 to """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"card": {
|
||||||
|
"public_id": "card-1",
|
||||||
|
"name": "Direct date",
|
||||||
|
"body": "Desc",
|
||||||
|
"dueDate": "2026-03-16",
|
||||||
|
"listPublicId": "list-a",
|
||||||
|
"index": 4,
|
||||||
|
"labels": [
|
||||||
|
{"publicId": "tag-1", "name": "Urgent", "color": "#FF0000"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
200 to """
|
||||||
|
{
|
||||||
|
"card": {
|
||||||
|
"publicId": "card-1",
|
||||||
|
"title": "Instant date",
|
||||||
|
"description": "Desc",
|
||||||
|
"dueAt": "2026-03-16T23:30:00-02:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val client = HttpKanbnApiClient()
|
||||||
|
val first = client.getCardDetail(server.baseUrl, "api", "card-1")
|
||||||
|
val second = client.getCardDetail(server.baseUrl, "api", "card-1")
|
||||||
|
|
||||||
|
val firstDetail = (first as BoardsApiResult.Success<CardDetail>).value
|
||||||
|
val secondDetail = (second as BoardsApiResult.Success<CardDetail>).value
|
||||||
|
assertEquals(LocalDate.of(2026, 3, 16), firstDetail.dueDate)
|
||||||
|
assertEquals(LocalDate.of(2026, 3, 17), secondDetail.dueDate)
|
||||||
|
assertEquals("card-1", firstDetail.id)
|
||||||
|
assertEquals("Direct date", firstDetail.title)
|
||||||
|
assertEquals("Desc", firstDetail.description)
|
||||||
|
assertEquals("list-a", firstDetail.listPublicId)
|
||||||
|
assertEquals(4, firstDetail.index)
|
||||||
|
assertEquals("tag-1", firstDetail.tags.first().id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun updateCard_usesFallbackOrder_andFinalFullPut() = runTest {
|
||||||
|
TestServer().use { server ->
|
||||||
|
server.registerSequence(
|
||||||
|
path = "/api/v1/cards/card-2",
|
||||||
|
method = "PATCH",
|
||||||
|
responses = listOf(
|
||||||
|
400 to "{}",
|
||||||
|
409 to "{}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
server.registerSequence(
|
||||||
|
path = "/api/v1/cards/card-2",
|
||||||
|
method = "PUT",
|
||||||
|
responses = listOf(
|
||||||
|
400 to "{}",
|
||||||
|
500 to "{}",
|
||||||
|
200 to "{}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
server.register(
|
||||||
|
path = "/api/v1/cards/card-2",
|
||||||
|
method = "GET",
|
||||||
|
status = 200,
|
||||||
|
responseBody =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"card": {
|
||||||
|
"publicId": "card-2",
|
||||||
|
"title": "Current title",
|
||||||
|
"description": "Current desc",
|
||||||
|
"index": 11,
|
||||||
|
"listPublicId": "list-old",
|
||||||
|
"dueDate": "2026-01-20T00:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = HttpKanbnApiClient().updateCard(
|
||||||
|
baseUrl = server.baseUrl,
|
||||||
|
apiKey = "api",
|
||||||
|
cardId = "card-2",
|
||||||
|
title = "New title",
|
||||||
|
description = "New desc",
|
||||||
|
dueDate = LocalDate.of(2026, 3, 18),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(result is BoardsApiResult.Success<*>)
|
||||||
|
val requests = server.findRequests("/api/v1/cards/card-2")
|
||||||
|
assertEquals("PATCH", requests[0].method)
|
||||||
|
assertEquals("PATCH", requests[1].method)
|
||||||
|
assertEquals("PUT", requests[2].method)
|
||||||
|
assertEquals("PUT", requests[3].method)
|
||||||
|
assertEquals("GET", requests[4].method)
|
||||||
|
assertEquals("PUT", requests[5].method)
|
||||||
|
assertTrue(requests[0].body.contains("\"title\":\"New title\""))
|
||||||
|
assertTrue(requests[0].body.contains("\"description\":\"New desc\""))
|
||||||
|
assertTrue(requests[0].body.contains("\"dueDate\":\"2026-03-18T00:00:00Z\""))
|
||||||
|
assertTrue(requests[1].body.contains("\"name\":\"New title\""))
|
||||||
|
assertTrue(requests[1].body.contains("\"body\":\"New desc\""))
|
||||||
|
assertTrue(requests[1].body.contains("\"dueAt\":\"2026-03-18T00:00:00Z\""))
|
||||||
|
assertTrue(requests[2].body.contains("\"title\":\"New title\""))
|
||||||
|
assertTrue(requests[3].body.contains("\"name\":\"New title\""))
|
||||||
|
assertTrue(requests[5].body.contains("\"listPublicId\":\"list-old\""))
|
||||||
|
assertTrue(requests[5].body.contains("\"index\":11"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun updateCard_stopsFallbackOnAuthFailure() = runTest {
|
||||||
|
TestServer().use { server ->
|
||||||
|
server.register(path = "/api/v1/cards/card-auth", method = "PATCH", status = 401, responseBody = "{}")
|
||||||
|
|
||||||
|
val result = HttpKanbnApiClient().updateCard(
|
||||||
|
baseUrl = server.baseUrl,
|
||||||
|
apiKey = "api",
|
||||||
|
cardId = "card-auth",
|
||||||
|
title = "Title",
|
||||||
|
description = "Desc",
|
||||||
|
dueDate = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(result is BoardsApiResult.Failure)
|
||||||
|
val requests = server.findRequests("/api/v1/cards/card-auth")
|
||||||
|
assertEquals(1, requests.size)
|
||||||
|
assertEquals("PATCH", requests.first().method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listCardActivities_triesEndpointVariants_thenReturnsNewestFirstTopTen() = runTest {
|
||||||
|
TestServer().use { server ->
|
||||||
|
server.register(
|
||||||
|
path = "/api/v1/cards/card-3/activities?limit=50",
|
||||||
|
method = "GET",
|
||||||
|
status = 200,
|
||||||
|
responseBody = "{\"data\":\"not-an-array\"}",
|
||||||
|
)
|
||||||
|
val secondPayloadItems = (1..12).joinToString(",") { index ->
|
||||||
|
val day = index.toString().padStart(2, '0')
|
||||||
|
"""{"id":"a-$index","type":"comment","text":"Item $index","createdAt":"2026-01-${day}T00:00:00Z"}"""
|
||||||
|
}
|
||||||
|
server.register(
|
||||||
|
path = "/api/v1/cards/card-3/actions?limit=50",
|
||||||
|
method = "GET",
|
||||||
|
status = 200,
|
||||||
|
responseBody = "{\"data\":[${secondPayloadItems}]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = HttpKanbnApiClient().listCardActivities(server.baseUrl, "api", "card-3")
|
||||||
|
|
||||||
|
val activities = (result as BoardsApiResult.Success<List<CardActivity>>).value
|
||||||
|
assertEquals(10, activities.size)
|
||||||
|
assertEquals("a-12", activities.first().id)
|
||||||
|
assertEquals("a-3", activities.last().id)
|
||||||
|
val requests = server.findRequests("/api/v1/cards/card-3/activities?limit=50") +
|
||||||
|
server.findRequests("/api/v1/cards/card-3/actions?limit=50") +
|
||||||
|
server.findRequests("/api/v1/cards/card-3/card-activities?limit=50")
|
||||||
|
assertEquals(2, requests.size)
|
||||||
|
assertEquals(
|
||||||
|
LocalDate.of(2026, 1, 12).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(),
|
||||||
|
activities.first().createdAtEpochMillis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun addCardComment_fallbacksOnAmbiguousSuccess_andStopsOnLogicalFailure() = runTest {
|
||||||
|
TestServer().use { server ->
|
||||||
|
server.registerSequence(
|
||||||
|
path = "/api/v1/cards/card-4/comment-actions",
|
||||||
|
method = "POST",
|
||||||
|
responses = listOf(
|
||||||
|
200 to "{\"maybe\":\"unknown\"}",
|
||||||
|
200 to "{\"success\":false,\"message\":\"blocked\"}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = HttpKanbnApiClient().addCardComment(server.baseUrl, "api", "card-4", "A comment")
|
||||||
|
|
||||||
|
assertTrue(result is BoardsApiResult.Failure)
|
||||||
|
val requests = server.findRequests("/api/v1/cards/card-4/comment-actions")
|
||||||
|
assertEquals(2, requests.size)
|
||||||
|
assertEquals("{\"text\":\"A comment\"}", requests[0].body)
|
||||||
|
assertEquals("{\"comment\":\"A comment\"}", requests[1].body)
|
||||||
|
assertTrue(server.findRequests("/api/v1/cards/card-4/actions/comments").isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun addCardComment_fallsBackToThirdEndpoint_andSucceedsOnCommentActionPayload() = runTest {
|
||||||
|
TestServer().use { server ->
|
||||||
|
server.register(path = "/api/v1/cards/card-5/comment-actions", method = "POST", status = 500, responseBody = "{}")
|
||||||
|
server.register(path = "/api/v1/cards/card-5/comment-actions", method = "POST", status = 400, responseBody = "{}")
|
||||||
|
server.register(
|
||||||
|
path = "/api/v1/cards/card-5/actions/comments",
|
||||||
|
method = "POST",
|
||||||
|
status = 200,
|
||||||
|
responseBody = "{\"commentAction\":{\"id\":\"x\"}}",
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = HttpKanbnApiClient().addCardComment(server.baseUrl, "api", "card-5", "Looks good")
|
||||||
|
|
||||||
|
assertTrue(result is BoardsApiResult.Success<*>)
|
||||||
|
val endpoint1Requests = server.findRequests("/api/v1/cards/card-5/comment-actions")
|
||||||
|
assertEquals(2, endpoint1Requests.size)
|
||||||
|
val endpoint3Requests = server.findRequests("/api/v1/cards/card-5/actions/comments")
|
||||||
|
assertEquals(1, endpoint3Requests.size)
|
||||||
|
assertEquals("{\"text\":\"Looks good\"}", endpoint3Requests.first().body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class CapturedRequest(
|
||||||
|
val method: String,
|
||||||
|
val path: String,
|
||||||
|
val body: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private class TestServer : AutoCloseable {
|
||||||
|
private val requests = CopyOnWriteArrayList<CapturedRequest>()
|
||||||
|
private val responses = mutableMapOf<String, Pair<Int, String>>()
|
||||||
|
private val responseSequences = mutableMapOf<String, ArrayDeque<Pair<Int, String>>>()
|
||||||
|
private val running = AtomicBoolean(true)
|
||||||
|
private val serverSocket = ServerSocket().apply {
|
||||||
|
bind(InetSocketAddress("127.0.0.1", 0))
|
||||||
|
}
|
||||||
|
private val executor = Executors.newSingleThreadExecutor()
|
||||||
|
|
||||||
|
val baseUrl: String = "http://127.0.0.1:${serverSocket.localPort}"
|
||||||
|
|
||||||
|
init {
|
||||||
|
executor.execute {
|
||||||
|
while (running.get()) {
|
||||||
|
val socket = try {
|
||||||
|
serverSocket.accept()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
if (!running.get()) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
handle(socket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun register(path: String, method: String, status: Int, responseBody: String) {
|
||||||
|
responses["${method.uppercase()} $path"] = status to responseBody
|
||||||
|
}
|
||||||
|
|
||||||
|
fun registerSequence(path: String, method: String, responses: List<Pair<Int, String>>) {
|
||||||
|
responseSequences["${method.uppercase()} $path"] = ArrayDeque(responses)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findRequests(path: String): List<CapturedRequest> {
|
||||||
|
return requests.filter { it.path == path }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handle(socket: Socket) {
|
||||||
|
socket.use { s ->
|
||||||
|
s.soTimeout = 3_000
|
||||||
|
val input = BufferedInputStream(s.getInputStream())
|
||||||
|
val output = s.getOutputStream()
|
||||||
|
|
||||||
|
val requestLine = readHttpLine(input).orEmpty()
|
||||||
|
if (requestLine.isBlank()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val parts = requestLine.split(" ")
|
||||||
|
val method = parts.getOrNull(0).orEmpty()
|
||||||
|
val path = parts.getOrNull(1).orEmpty()
|
||||||
|
|
||||||
|
var contentLength = 0
|
||||||
|
var methodOverride: String? = null
|
||||||
|
while (true) {
|
||||||
|
val line = readHttpLine(input).orEmpty()
|
||||||
|
if (line.isBlank()) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
val separatorIndex = line.indexOf(':')
|
||||||
|
if (separatorIndex <= 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val headerName = line.substring(0, separatorIndex).trim().lowercase()
|
||||||
|
val headerValue = line.substring(separatorIndex + 1).trim()
|
||||||
|
if (headerName == "x-http-method-override") {
|
||||||
|
methodOverride = headerValue
|
||||||
|
} else if (headerName == "content-length") {
|
||||||
|
contentLength = headerValue.toIntOrNull() ?: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val bodyBytes = if (contentLength > 0) ByteArray(contentLength) else ByteArray(0)
|
||||||
|
if (contentLength > 0) {
|
||||||
|
var total = 0
|
||||||
|
while (total < contentLength) {
|
||||||
|
val read = input.read(bodyBytes, total, contentLength - total)
|
||||||
|
if (read <= 0) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
total += read
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val body = String(bodyBytes)
|
||||||
|
val effectiveMethod = methodOverride ?: method
|
||||||
|
requests += CapturedRequest(method = effectiveMethod, path = path, body = body)
|
||||||
|
|
||||||
|
val sequenceKey = "$effectiveMethod $path"
|
||||||
|
val sequence = responseSequences[sequenceKey]
|
||||||
|
val sequencedResponse = if (sequence != null && sequence.isNotEmpty()) sequence.removeFirst() else null
|
||||||
|
val response = sequencedResponse ?: responses[sequenceKey] ?: (404 to "")
|
||||||
|
writeResponse(output, response.first, response.second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeResponse(output: OutputStream, status: Int, body: String) {
|
||||||
|
val bytes = body.toByteArray()
|
||||||
|
val reason = when (status) {
|
||||||
|
200 -> "OK"
|
||||||
|
400 -> "Bad Request"
|
||||||
|
401 -> "Unauthorized"
|
||||||
|
404 -> "Not Found"
|
||||||
|
409 -> "Conflict"
|
||||||
|
500 -> "Internal Server Error"
|
||||||
|
else -> "Error"
|
||||||
|
}
|
||||||
|
val responseHeaders =
|
||||||
|
"HTTP/1.1 $status $reason\r\n" +
|
||||||
|
"Content-Type: application/json\r\n" +
|
||||||
|
"Content-Length: ${bytes.size}\r\n" +
|
||||||
|
"Connection: close\r\n\r\n"
|
||||||
|
output.write(responseHeaders.toByteArray())
|
||||||
|
output.write(bytes)
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readHttpLine(input: BufferedInputStream): String? {
|
||||||
|
val builder = StringBuilder()
|
||||||
|
while (true) {
|
||||||
|
val next = input.read()
|
||||||
|
if (next == -1) {
|
||||||
|
return if (builder.isEmpty()) null else builder.toString()
|
||||||
|
}
|
||||||
|
if (next == '\n'.code) {
|
||||||
|
if (builder.isNotEmpty() && builder.last() == '\r') {
|
||||||
|
builder.deleteCharAt(builder.length - 1)
|
||||||
|
}
|
||||||
|
return builder.toString()
|
||||||
|
}
|
||||||
|
builder.append(next.toChar())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
running.set(false)
|
||||||
|
serverSocket.close()
|
||||||
|
executor.shutdownNow()
|
||||||
|
executor.awaitTermination(3, TimeUnit.SECONDS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user