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}