krz/rune

an ios client for njalla

clone: git clone https://gitbay.org/krz/rune.git

main: Rune/API/NjallaClient.swift · raw

  1import Foundation
  2
  3struct NjallaClient: Sendable, Equatable {
  4    private let token: String
  5    private let endpoint = URL(string: "https://njal.la/api/1/")!
  6
  7    init(token: String) {
  8        self.token = token
  9    }
 10
 11    func call<T: Decodable>(_ method: String, params: [String: Any] = [:]) async throws -> T {
 12        var request = URLRequest(url: endpoint)
 13        request.httpMethod = "POST"
 14        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
 15        request.setValue("application/json", forHTTPHeaderField: "Accept")
 16        request.setValue("Njalla \(token)", forHTTPHeaderField: "Authorization")
 17
 18        let body: [String: Any] = [
 19            "jsonrpc": "2.0",
 20            "method": method,
 21            "params": params,
 22            "id": "1"
 23        ]
 24
 25        request.httpBody = try JSONSerialization.data(withJSONObject: body)
 26
 27        let (data, response) = try await URLSession.shared.data(for: request)
 28        let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
 29        debugLogRawResponse(method: method, statusCode: statusCode, data: data)
 30
 31        let decoder = JSONDecoder()
 32        let envelope: RPCResponse<T>
 33        do {
 34            envelope = try decoder.decode(RPCResponse<T>.self, from: data)
 35        } catch {
 36            debugLogDecodeFailure(method: method, error: error)
 37            throw error
 38        }
 39
 40        if let error = envelope.error {
 41            throw NjallaError.api(message: error.message)
 42        }
 43
 44        guard let result = envelope.result else {
 45            throw NjallaError.missingResult
 46        }
 47
 48        return result
 49    }
 50
 51    private func debugLogRawResponse(method: String, statusCode: Int, data: Data) {
 52        #if DEBUG
 53        let body = String(data: data, encoding: .utf8) ?? "<non-utf8 body: \(data.count) bytes>"
 54        debugPrint("[NjallaClient][\(method)] status=\(statusCode) raw=\(body)")
 55        #endif
 56    }
 57
 58    private func debugLogDecodeFailure(method: String, error: Error) {
 59        #if DEBUG
 60        debugPrint("[NjallaClient][\(method)] decode-failure=\(error.localizedDescription)")
 61        #endif
 62    }
 63
 64    func listDomains() async throws -> [Domain] {
 65        let response: DomainListResponse = try await call("list-domains")
 66        return response.domains
 67    }
 68
 69    func getDomain(named domain: String) async throws -> Domain {
 70        try await call("get-domain", params: ["domain": domain])
 71    }
 72
 73    func editDomain(named domain: String, request: DomainUpdateRequest) async throws -> Domain {
 74        var params = request.params
 75        params["domain"] = domain
 76        return try await call("edit-domain", params: params)
 77    }
 78
 79    func listForwards(for domain: String) async throws -> [EmailForward] {
 80        let response: ForwardListResponse = try await call("list-forwards", params: ["domain": domain])
 81        return response.forwards
 82    }
 83
 84    func addForward(forward: EmailForward) async throws {
 85        let _: EmptyResult = try await call(
 86            "add-forward",
 87            params: [
 88                "domain": forward.domain,
 89                "from": forward.from,
 90                "to": forward.to
 91            ]
 92        )
 93    }
 94
 95    func removeForward(_ forward: EmailForward) async throws {
 96        let _: EmptyResult = try await call(
 97            "remove-forward",
 98            params: [
 99                "domain": forward.domain,
100                "from": forward.from,
101                "to": forward.to
102            ]
103        )
104    }
105
106    func listGlue(for domain: String) async throws -> [GlueRecord] {
107        let response: GlueListResponse = try await call("list-glue", params: ["domain": domain])
108        return response.glue.map { record in
109            record.domain.isEmpty ? record.withDomain(domain) : record
110        }
111    }
112
113    func addGlue(for domain: String, name: String, address4: String?, address6: String?) async throws {
114        var params: [String: Any] = [
115            "domain": domain,
116            "name": name
117        ]
118        if let address4, !address4.isEmpty {
119            params["address4"] = address4
120        }
121        if let address6, !address6.isEmpty {
122            params["address6"] = address6
123        }
124        let _: EmptyResult = try await call("add-glue", params: params)
125    }
126
127    func editGlue(for domain: String, name: String, address4: String?, address6: String?) async throws {
128        var params: [String: Any] = [
129            "domain": domain,
130            "name": name
131        ]
132        if let address4, !address4.isEmpty {
133            params["address4"] = address4
134        }
135        if let address6, !address6.isEmpty {
136            params["address6"] = address6
137        }
138        let _: EmptyResult = try await call("edit-glue", params: params)
139    }
140
141    func removeGlue(for domain: String, name: String) async throws {
142        let _: EmptyResult = try await call(
143            "remove-glue",
144            params: [
145                "domain": domain,
146                "name": name
147            ]
148        )
149    }
150
151    func listRecords(for domain: String) async throws -> [DNSRecord] {
152        let response: RecordListResponse = try await call("list-records", params: ["domain": domain])
153        return response.records.map { record in
154            record.domain.isEmpty ? record.withDomain(domain) : record
155        }
156    }
157
158    func addRecord(for domain: String, draft: DNSRecordDraft) async throws -> DNSRecord {
159        try await call("add-record", params: draft.params(domain: domain))
160    }
161
162    func editRecord(for domain: String, id: String, draft: DNSRecordDraft) async throws -> DNSRecord {
163        try await call("edit-record", params: draft.params(domain: domain, id: id))
164    }
165
166    func removeRecord(_ record: DNSRecord) async throws -> [DNSRecord] {
167        let params: [String: Any] = [
168            "domain": record.domain,
169            "id": record.id,
170            "name": record.name,
171            "type": record.type
172        ]
173        let response: RecordListResponse = try await call("remove-record", params: params)
174        return response.records.map { item in
175            item.domain.isEmpty ? item.withDomain(record.domain) : item
176        }
177    }
178
179    func listTokens() async throws -> [APIToken] {
180        let response: TokenListResponse = try await call("list-tokens")
181        return response.tokens
182    }
183
184    func addToken(request: TokenCreateRequest) async throws {
185        let _: EmptyResult = try await call("add-token", params: request.params)
186    }
187
188    func removeToken(key: String) async throws {
189        let _: EmptyResult = try await call("remove-token", params: ["key": key])
190    }
191
192    func logout() async throws {
193        let _: EmptyResult = try await call("logout")
194    }
195
196    func getBalance() async throws -> WalletBalance {
197        try await call("get-balance")
198    }
199
200    func listTransactions() async throws -> [WalletTransaction] {
201        let response: WalletTransactionListResponse = try await call("list-transactions")
202        return response.transactions
203    }
204
205    func getPayment(id: String) async throws -> WalletPayment {
206        try await call("get-payment", params: ["id": id])
207    }
208}
209
210enum NjallaError: LocalizedError {
211    case api(message: String)
212    case missingResult
213    case networkFailure
214
215    var errorDescription: String? {
216        switch self {
217        case .api(let message):
218            return message
219        case .missingResult:
220            return "The API response did not include a result."
221        case .networkFailure:
222            return "Network request failed. Check your connection and try again."
223        }
224    }
225}
226
227extension Error {
228    var userFacingMessage: String {
229        if let njallaError = self as? NjallaError {
230            return njallaError.localizedDescription
231        }
232
233        if let urlError = self as? URLError {
234            switch urlError.code {
235            case .cancelled:
236                return urlError.localizedDescription
237            default:
238                return NjallaError.networkFailure.localizedDescription
239            }
240        }
241
242        return localizedDescription
243    }
244}
245
246private struct RPCResponse<Result: Decodable>: Decodable {
247    let result: Result?
248    let error: RPCError?
249}
250
251private struct RPCError: Decodable {
252    let code: Int
253    let message: String
254}