summaryrefslogtreecommitdiff
path: root/Rune/API
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-13 22:37:22 -0500
committerChristian Cleberg <[email protected]>2026-04-13 22:37:22 -0500
commit5dcfe0f147c7871eaf5f3a88bd9e9fded38d4633 (patch)
tree6bcbda4f02a131d3e3b870a3904ccd42f4af2f26 /Rune/API
parentfcf864a15b70e4ecb5bd789b1db3116221c34394 (diff)
downloadrune-5dcfe0f147c7871eaf5f3a88bd9e9fded38d4633.tar.gz
rune-5dcfe0f147c7871eaf5f3a88bd9e9fded38d4633.tar.bz2
rune-5dcfe0f147c7871eaf5f3a88bd9e9fded38d4633.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.swift201
-rw-r--r--Rune/API/NjallaClient.swift82
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 {