diff options
| author | Christian Cleberg <[email protected]> | 2026-04-13 22:37:22 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-13 22:37:22 -0500 |
| commit | 5dcfe0f147c7871eaf5f3a88bd9e9fded38d4633 (patch) | |
| tree | 6bcbda4f02a131d3e3b870a3904ccd42f4af2f26 /Rune/API | |
| parent | fcf864a15b70e4ecb5bd789b1db3116221c34394 (diff) | |
| download | rune-1.2.0.tar.gz rune-1.2.0.tar.bz2 rune-1.2.0.zip | |
v1.2.0 domain management and wallet expansionv1.2.0
Complete v1.1-aligned domain flows with forwards and glue CRUD, improve API payload resilience, and add wallet transaction/payment views.
Harden error handling and UX states across domain screens, remove out-of-scope DNSSEC/renewal record flows for now, and keep auth/session behavior aligned with token-based usage.
Diffstat (limited to 'Rune/API')
| -rw-r--r-- | Rune/API/Models.swift | 201 | ||||
| -rw-r--r-- | Rune/API/NjallaClient.swift | 82 |
2 files changed, 280 insertions, 3 deletions
diff --git a/Rune/API/Models.swift b/Rune/API/Models.swift index 161e8df..31f928d 100644 --- a/Rune/API/Models.swift +++ b/Rune/API/Models.swift @@ -18,8 +18,46 @@ struct TokenListResponse: Codable { let tokens: [APIToken] } -struct ForwardListResponse: Codable { +struct ForwardListResponse: Decodable { let forwards: [EmailForward] + + enum CodingKeys: String, CodingKey { + case forwards + case mailforwards + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let forwardsValue = try? container.decodeIfPresent([EmailForward].self, forKey: .forwards) + let mailForwardsValue = try? container.decodeIfPresent([EmailForward].self, forKey: .mailforwards) + forwards = forwardsValue ?? mailForwardsValue ?? [] + } +} + +struct GlueListResponse: Decodable { + let glue: [GlueRecord] + + enum CodingKeys: String, CodingKey { + case glue + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + glue = (try container.decodeIfPresent([GlueRecord].self, forKey: .glue)) ?? [] + } +} + +struct WalletTransactionListResponse: Decodable { + let transactions: [WalletTransaction] + + enum CodingKeys: String, CodingKey { + case transactions + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + transactions = (try container.decodeIfPresent([WalletTransaction].self, forKey: .transactions)) ?? [] + } } struct Domain: Decodable, Identifiable, Hashable { @@ -28,6 +66,7 @@ struct Domain: Decodable, Identifiable, Hashable { let name: String let status: String? let expiry: String? + let renewPrice: Int? let autorenew: Bool? let mailforwarding: Bool? let dnssec: Bool? @@ -38,6 +77,9 @@ struct Domain: Decodable, Identifiable, Hashable { case name case status case expiry + case renewPrice = "renew_price" + case renewalPrice = "renewal_price" + case price case autorenew case mailforwarding case dnssec @@ -52,6 +94,10 @@ struct Domain: Decodable, Identifiable, Hashable { name = try container.decode(String.self, forKey: .name) status = try container.decodeIfPresent(String.self, forKey: .status) expiry = try container.decodeIfPresent(String.self, forKey: .expiry) + renewPrice = + container.decodeLossyIntIfPresent(forKey: .renewPrice) ?? + container.decodeLossyIntIfPresent(forKey: .renewalPrice) ?? + container.decodeLossyIntIfPresent(forKey: .price) autorenew = try container.decodeIfPresent(Bool.self, forKey: .autorenew) mailforwarding = try container.decodeIfPresent(Bool.self, forKey: .mailforwarding) dnssec = try container.decodeIfPresent(Bool.self, forKey: .dnssec) ?? @@ -62,6 +108,20 @@ struct Domain: Decodable, Identifiable, Hashable { } } +private extension KeyedDecodingContainer where K == Domain.CodingKeys { + func decodeLossyIntIfPresent(forKey key: K) -> Int? { + if let intValue = try? decodeIfPresent(Int.self, forKey: key) { + return intValue + } + + if let stringValue = try? decodeIfPresent(String.self, forKey: key) { + return Int(stringValue) + } + + return nil + } +} + struct DNSRecord: Codable, Identifiable, Hashable { let id: String let domain: String @@ -183,11 +243,121 @@ struct EmailForward: Codable, Hashable, Identifiable { let from: String let to: String + enum CodingKeys: String, CodingKey { + case domain + case from + case to + } + + init(domain: String, from: String, to: String) { + self.domain = domain + self.from = from + self.to = to + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let rawFrom = try container.decode(String.self, forKey: .from).trimmingCharacters(in: .whitespacesAndNewlines) + let to = try container.decode(String.self, forKey: .to).trimmingCharacters(in: .whitespacesAndNewlines) + + let decodedDomain = try container.decodeIfPresent(String.self, forKey: .domain)? + .trimmingCharacters(in: .whitespacesAndNewlines) + + if let atIndex = rawFrom.lastIndex(of: "@") { + let localPart = String(rawFrom[..<atIndex]) + let fromDomain = String(rawFrom[rawFrom.index(after: atIndex)...]) + self.from = localPart.isEmpty ? rawFrom : localPart + if let decodedDomain, !decodedDomain.isEmpty { + self.domain = decodedDomain + } else { + self.domain = fromDomain + } + } else { + self.from = rawFrom + self.domain = decodedDomain ?? "" + } + + self.to = to + } + var id: String { "\(domain)|\(from)|\(to)" } } +struct GlueRecord: Codable, Hashable, Identifiable { + let domain: String + let name: String + let address4: String? + let address6: String? + + enum CodingKeys: String, CodingKey { + case domain + case name + case address4 + case address6 + } + + init(domain: String, name: String, address4: String?, address6: String?) { + self.domain = domain + self.name = name + self.address4 = address4 + self.address6 = address6 + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + domain = (try container.decodeIfPresent(String.self, forKey: .domain)) ?? "" + name = try container.decode(String.self, forKey: .name) + address4 = try container.decodeIfPresent(String.self, forKey: .address4) + address6 = try container.decodeIfPresent(String.self, forKey: .address6) + } + + var id: String { + "\(domain)|\(name)" + } + + func withDomain(_ domain: String) -> GlueRecord { + GlueRecord(domain: domain, name: name, address4: address4, address6: address6) + } +} + +struct WalletTransaction: Codable, Hashable, Identifiable { + let id: String + let type: String? + let status: String? + let amount: Int? + let date: String? + let details: String? + + enum CodingKeys: String, CodingKey { + case id + case type + case status + case amount + case date + case details = "description" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = (try? container.decodeLossyString(forKey: .id)) ?? UUID().uuidString + type = try container.decodeIfPresent(String.self, forKey: .type) + status = try container.decodeIfPresent(String.self, forKey: .status) + amount = try container.decodeLossyIntIfPresent(forKey: .amount) + date = try container.decodeIfPresent(String.self, forKey: .date) + details = try container.decodeIfPresent(String.self, forKey: .details) + } +} + +struct WalletPayment: Codable, Hashable { + let id: String? + let amount: Int? + let status: String? + let address: String? + let url: String? +} + enum DNSRecordType: String, CaseIterable, Identifiable, Codable { case a = "A" case aaaa = "AAAA" @@ -546,3 +716,32 @@ private extension KeyedDecodingContainer where K == DNSRecord.CodingKeys { return nil } } + +private extension KeyedDecodingContainer where K == WalletTransaction.CodingKeys { + func decodeLossyString(forKey key: K) throws -> String { + if let stringValue = try decodeIfPresent(String.self, forKey: key) { + return stringValue + } + + if let intValue = try decodeIfPresent(Int.self, forKey: key) { + return String(intValue) + } + + throw DecodingError.keyNotFound( + key, + DecodingError.Context(codingPath: codingPath, debugDescription: "Missing required value for \(key.stringValue).") + ) + } + + func decodeLossyIntIfPresent(forKey key: K) throws -> Int? { + if let intValue = try decodeIfPresent(Int.self, forKey: key) { + return intValue + } + + if let stringValue = try decodeIfPresent(String.self, forKey: key) { + return Int(stringValue) + } + + return nil + } +} diff --git a/Rune/API/NjallaClient.swift b/Rune/API/NjallaClient.swift index 8da52cb..f91c924 100644 --- a/Rune/API/NjallaClient.swift +++ b/Rune/API/NjallaClient.swift @@ -25,10 +25,17 @@ struct NjallaClient: Sendable, Equatable { request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response) = try await URLSession.shared.data(for: request) - _ = (response as? HTTPURLResponse)?.statusCode ?? -1 + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + debugLogRawResponse(method: method, statusCode: statusCode, data: data) let decoder = JSONDecoder() - let envelope = try decoder.decode(RPCResponse<T>.self, from: data) + let envelope: RPCResponse<T> + do { + envelope = try decoder.decode(RPCResponse<T>.self, from: data) + } catch { + debugLogDecodeFailure(method: method, error: error) + throw error + } if let error = envelope.error { throw NjallaError.api(message: error.message) @@ -41,6 +48,19 @@ struct NjallaClient: Sendable, Equatable { return result } + private func debugLogRawResponse(method: String, statusCode: Int, data: Data) { + #if DEBUG + let body = String(data: data, encoding: .utf8) ?? "<non-utf8 body: \(data.count) bytes>" + debugPrint("[NjallaClient][\(method)] status=\(statusCode) raw=\(body)") + #endif + } + + private func debugLogDecodeFailure(method: String, error: Error) { + #if DEBUG + debugPrint("[NjallaClient][\(method)] decode-failure=\(error.localizedDescription)") + #endif + } + func listDomains() async throws -> [Domain] { let response: DomainListResponse = try await call("list-domains") return response.domains @@ -83,6 +103,51 @@ struct NjallaClient: Sendable, Equatable { ) } + func listGlue(for domain: String) async throws -> [GlueRecord] { + let response: GlueListResponse = try await call("list-glue", params: ["domain": domain]) + return response.glue.map { record in + record.domain.isEmpty ? record.withDomain(domain) : record + } + } + + func addGlue(for domain: String, name: String, address4: String?, address6: String?) async throws { + var params: [String: Any] = [ + "domain": domain, + "name": name + ] + if let address4, !address4.isEmpty { + params["address4"] = address4 + } + if let address6, !address6.isEmpty { + params["address6"] = address6 + } + let _: EmptyResult = try await call("add-glue", params: params) + } + + func editGlue(for domain: String, name: String, address4: String?, address6: String?) async throws { + var params: [String: Any] = [ + "domain": domain, + "name": name + ] + if let address4, !address4.isEmpty { + params["address4"] = address4 + } + if let address6, !address6.isEmpty { + params["address6"] = address6 + } + let _: EmptyResult = try await call("edit-glue", params: params) + } + + func removeGlue(for domain: String, name: String) async throws { + let _: EmptyResult = try await call( + "remove-glue", + params: [ + "domain": domain, + "name": name + ] + ) + } + func listRecords(for domain: String) async throws -> [DNSRecord] { let response: RecordListResponse = try await call("list-records", params: ["domain": domain]) return response.records.map { record in @@ -124,9 +189,22 @@ struct NjallaClient: Sendable, Equatable { let _: EmptyResult = try await call("remove-token", params: ["key": key]) } + func logout() async throws { + let _: EmptyResult = try await call("logout") + } + func getBalance() async throws -> WalletBalance { try await call("get-balance") } + + func listTransactions() async throws -> [WalletTransaction] { + let response: WalletTransactionListResponse = try await call("list-transactions") + return response.transactions + } + + func getPayment(id: String) async throws -> WalletPayment { + try await call("get-payment", params: ["id": id]) + } } enum NjallaError: LocalizedError { |
