summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader/stash/StashClient.kt
blob: b613d665734fa56228d20f72149f1e500aa90072 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
package org.terst.feedreader.stash

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody

private val json = Json { ignoreUnknownKeys = true }

@Serializable
private data class CreateArtifactRequest(
    val type: String = "url",
    @SerialName("source_url") val sourceUrl: String,
    val title: String? = null
)

@Serializable
data class ArtifactResponse(
    val id: String,
    val title: String? = null,
    @SerialName("content_md") val contentMd: String? = null,
    @SerialName("processing_steps") val processingSteps: List<String> = emptyList()
)

@Serializable
private data class DuplicateErrorResponse(@SerialName("existing_id") val existingId: String)

sealed class StashSaveResult {
    data class Saved(val artifact: ArtifactResponse) : StashSaveResult()
    data class AlreadySaved(val existingId: String) : StashSaveResult()
    data class Failed(val message: String) : StashSaveResult()
}

sealed class StashVerifyResult {
    object Valid : StashVerifyResult()
    data class Invalid(val message: String) : StashVerifyResult()
}

/**
 * Client for the parts of Stash's API this app needs: POST /artifacts to
 * archive an article. Uses sync=true so content_md comes back in the same
 * response (server runs extract+summary_lite inline, up to ~30s) --
 * tags/full-summary/embeddings still fill in async server-side afterward,
 * but this app doesn't need to wait on or poll for those.
 */
class StashClient(
    private val client: OkHttpClient,
    private val baseUrl: String,
    private val apiKey: String
) {
    // A cheap authenticated GET against the same collection /artifacts POSTs to, rather than
    // a dedicated /me or /health endpoint this API doesn't document -- reads are safe/free of
    // side effects, unlike actually creating an artifact just to test the key works.
    suspend fun verifyCredentials(): StashVerifyResult = withContext(Dispatchers.IO) {
        if (baseUrl.isBlank() || apiKey.isBlank()) {
            return@withContext StashVerifyResult.Invalid("Base URL and API key are both required")
        }
        val request = Request.Builder()
            .url("$baseUrl/artifacts")
            .header("X-API-Key", apiKey)
            .get()
            .build()

        runCatching {
            client.newCall(request).execute().use { response ->
                when {
                    response.isSuccessful -> StashVerifyResult.Valid
                    response.code == 401 || response.code == 403 -> StashVerifyResult.Invalid("Rejected: API key not accepted")
                    else -> StashVerifyResult.Invalid("HTTP ${response.code}")
                }
            }
        }.getOrElse { StashVerifyResult.Invalid(it.message ?: "Couldn't reach that URL") }
    }

    suspend fun saveArticle(url: String, title: String?): StashSaveResult = withContext(Dispatchers.IO) {
        if (baseUrl.isBlank() || apiKey.isBlank()) {
            return@withContext StashSaveResult.Failed("Stash isn't configured (set base URL and API key in Settings)")
        }

        val body = json.encodeToString(
            CreateArtifactRequest.serializer(),
            CreateArtifactRequest(sourceUrl = url, title = title)
        ).toRequestBody("application/json".toMediaType())

        val request = Request.Builder()
            .url("$baseUrl/artifacts?sync=true")
            .header("X-API-Key", apiKey)
            .post(body)
            .build()

        runCatching {
            client.newCall(request).execute().use { response ->
                val responseBody = response.body?.string().orEmpty()
                when {
                    response.code == 201 || response.code == 200 ->
                        StashSaveResult.Saved(json.decodeFromString(ArtifactResponse.serializer(), responseBody))
                    response.code == 409 ->
                        StashSaveResult.AlreadySaved(
                            json.decodeFromString(DuplicateErrorResponse.serializer(), responseBody).existingId
                        )
                    else -> StashSaveResult.Failed("HTTP ${response.code}")
                }
            }
        }.getOrElse { StashSaveResult.Failed(it.message ?: "Network error") }
    }
}