summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader/stash/StashClient.kt
blob: 346ae49869b8f02171258a332aae36366a6ea8ee (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
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
package org.terst.feedreader.stash

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.terst.feedreader.data.await
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
import java.util.concurrent.TimeUnit

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).await().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 {
            val call = client.newCall(request)
            // sync=true makes the server run extract+summary_lite inline before responding
            // (documented above as taking up to ~30s), but `client` is the shared Net.client
            // used for feed fetching too, whose default read timeout (OkHttp's stock 10s) is
            // sized for a normal feed request, not this. Overriding per-call, not on the
            // shared client, so feed fetches don't inherit a 35s timeout they don't need.
            call.timeout().timeout(35, TimeUnit.SECONDS)
            call.await().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 ->
                        // The status code alone already tells us definitively this was
                        // rejected as a duplicate -- that's true regardless of whether the
                        // body happens to match DuplicateErrorResponse's exact shape. Losing
                        // the existing-artifact id to a schema mismatch shouldn't downgrade
                        // a real "already saved" into a misleading "save failed".
                        StashSaveResult.AlreadySaved(
                            runCatching {
                                json.decodeFromString(DuplicateErrorResponse.serializer(), responseBody).existingId
                            }.getOrDefault("")
                        )
                    else -> StashSaveResult.Failed("HTTP ${response.code}")
                }
            }
        }.getOrElse { StashSaveResult.Failed(it.message ?: "Network error") }
    }
}