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
128
129
//
// KCPClient.swift
// AT-IRC
//
// Thin async client over the embedded kcp apiserver. AtircgoKCPRequest is
// synchronous and runs in-process over a net.Pipe; requests are moved off the
// main actor. kcp answers with a JSON envelope
// {status, headers, bodyJSON|bodyBase64}.
//
import Foundation
import AtircGo
struct KCPResponse {
let status: Int
/// Decoded body, when it is JSON.
let json: Any?
let data: Data
}
enum KCPError: LocalizedError {
case notReady
case transport(String)
case malformedResponse
case http(status: Int, message: String)
var errorDescription: String? {
switch self {
case .notReady:
return "kcp is not running"
case .transport(let message):
return "kcp request failed: \(message)"
case .malformedResponse:
return "kcp returned an unexpected response"
case .http(let status, let message):
return "kcp HTTP \(status): \(message)"
}
}
var isNotFound: Bool {
if case .http(404, _) = self { return true }
return false
}
}
struct KCPClient: Sendable {
func get(_ path: String) async throws -> KCPResponse {
try await request("GET", path)
}
/// GET with a bounded retry on 404, for resources that appear a moment
/// after the embedded requester starts (APIBinding, CRDs).
func getRetryingNotFound(
_ path: String,
attempts: Int,
delay: Duration
) async throws -> KCPResponse {
var lastError: Error = KCPError.malformedResponse
for _ in 0..<max(1, attempts) {
do {
return try await get(path)
} catch let error as KCPError where error.isNotFound {
lastError = error
try? await Task.sleep(for: delay)
}
}
throw lastError
}
/// - Parameter direct: when true (default) the request bypasses the
/// requester's in-process exposure and reaches the apiserver. The OAuth
/// callback must instead be handled by the requester's own route, so it
/// passes false.
func request(
_ method: String,
_ path: String,
body: Data? = nil,
contentType: String = "application/json",
direct: Bool = true
) async throws -> KCPResponse {
guard AtircgoKCPReady() else { throw KCPError.notReady }
// The app talks to the apiserver, not through the requester's
// in-process exposure (X-Atirc-Direct); once the requester runs its
// handlers otherwise answer /apis/socialweb... from their own view.
var headerFields: [String: String] = [:]
if let body { headerFields["Content-Type"] = contentType }
if direct { headerFields["X-Atirc-Direct"] = "1" }
let headersData = try JSONSerialization.data(withJSONObject: headerFields)
let headers = String(decoding: headersData, as: UTF8.self)
let bodyText = body.map { String(decoding: $0, as: UTF8.self) } ?? ""
let raw: (response: String, error: String?) = try await Task.detached(priority: .userInitiated) {
var requestError: NSError?
let response = AtircgoKCPRequest(method, path, headers, bodyText, &requestError)
return (response, requestError?.localizedDescription)
}.value
if let error = raw.error {
throw KCPError.transport(error)
}
guard let rawData = raw.response.data(using: .utf8),
let envelope = try? JSONSerialization.jsonObject(with: rawData) as? [String: Any],
let status = envelope["status"] as? Int else {
throw KCPError.malformedResponse
}
let data: Data
if let base64 = envelope["bodyBase64"] as? String {
data = Data(base64Encoded: base64) ?? Data()
} else if let bodyJSON = envelope["bodyJSON"] {
data = (try? JSONSerialization.data(withJSONObject: bodyJSON)) ?? Data()
} else {
data = Data()
}
var json: Any?
if !data.isEmpty {
json = try? JSONSerialization.jsonObject(with: data)
}
if !(200..<300).contains(status) {
let object = json as? [String: Any]
let message = object?["message"] as? String
?? object?["reason"] as? String
?? "HTTP \(status)"
throw KCPError.http(status: status, message: message)
}
return KCPResponse(status: status, json: json, data: data)
}
}