~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atirc-ios-client

0
1
main
129 lines · 4.5 KB Raw History
//
//  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)
    }
}