krz/rune
an ios client for njalla
clone: git clone https://gitbay.org/krz/rune.git
main: Rune/API/Models.swift · raw
1import Foundation
2
3struct EmptyResult: Codable {}
4
5struct WalletBalance: Codable, Equatable {
6 let balance: Int
7}
8
9struct DomainListResponse: Decodable {
10 let domains: [Domain]
11}
12
13struct RecordListResponse: Codable {
14 let records: [DNSRecord]
15}
16
17struct TokenListResponse: Codable {
18 let tokens: [APIToken]
19}
20
21struct ForwardListResponse: Decodable {
22 let forwards: [EmailForward]
23
24 enum CodingKeys: String, CodingKey {
25 case forwards
26 case mailforwards
27 }
28
29 init(from decoder: Decoder) throws {
30 let container = try decoder.container(keyedBy: CodingKeys.self)
31 let forwardsValue = try? container.decodeIfPresent([EmailForward].self, forKey: .forwards)
32 let mailForwardsValue = try? container.decodeIfPresent([EmailForward].self, forKey: .mailforwards)
33 forwards = forwardsValue ?? mailForwardsValue ?? []
34 }
35}
36
37struct GlueListResponse: Decodable {
38 let glue: [GlueRecord]
39
40 enum CodingKeys: String, CodingKey {
41 case glue
42 }
43
44 init(from decoder: Decoder) throws {
45 let container = try decoder.container(keyedBy: CodingKeys.self)
46 glue = (try container.decodeIfPresent([GlueRecord].self, forKey: .glue)) ?? []
47 }
48}
49
50struct WalletTransactionListResponse: Decodable {
51 let transactions: [WalletTransaction]
52
53 enum CodingKeys: String, CodingKey {
54 case transactions
55 }
56
57 init(from decoder: Decoder) throws {
58 let container = try decoder.container(keyedBy: CodingKeys.self)
59 transactions = (try container.decodeIfPresent([WalletTransaction].self, forKey: .transactions)) ?? []
60 }
61}
62
63struct Domain: Decodable, Identifiable, Hashable {
64 var id: String { name }
65
66 let name: String
67 let status: String?
68 let expiry: String?
69 let renewPrice: Int?
70 let autorenew: Bool?
71 let mailforwarding: Bool?
72 let dnssec: Bool?
73 let lock: Bool?
74 let nameservers: [String]?
75
76 enum CodingKeys: String, CodingKey {
77 case name
78 case status
79 case expiry
80 case renewPrice = "renew_price"
81 case renewalPrice = "renewal_price"
82 case price
83 case autorenew
84 case mailforwarding
85 case dnssec
86 case dnssecEnabled = "dnssec_enabled"
87 case lock
88 case locked
89 case nameservers
90 }
91
92 init(from decoder: Decoder) throws {
93 let container = try decoder.container(keyedBy: CodingKeys.self)
94 name = try container.decode(String.self, forKey: .name)
95 status = try container.decodeIfPresent(String.self, forKey: .status)
96 expiry = try container.decodeIfPresent(String.self, forKey: .expiry)
97 renewPrice =
98 container.decodeLossyIntIfPresent(forKey: .renewPrice) ??
99 container.decodeLossyIntIfPresent(forKey: .renewalPrice) ??
100 container.decodeLossyIntIfPresent(forKey: .price)
101 autorenew = try container.decodeIfPresent(Bool.self, forKey: .autorenew)
102 mailforwarding = try container.decodeIfPresent(Bool.self, forKey: .mailforwarding)
103 dnssec = try container.decodeIfPresent(Bool.self, forKey: .dnssec) ??
104 container.decodeIfPresent(Bool.self, forKey: .dnssecEnabled)
105 lock = try container.decodeIfPresent(Bool.self, forKey: .lock) ??
106 container.decodeIfPresent(Bool.self, forKey: .locked)
107 nameservers = try container.decodeIfPresent([String].self, forKey: .nameservers)
108 }
109}
110
111private extension KeyedDecodingContainer where K == Domain.CodingKeys {
112 func decodeLossyIntIfPresent(forKey key: K) -> Int? {
113 if let intValue = try? decodeIfPresent(Int.self, forKey: key) {
114 return intValue
115 }
116
117 if let stringValue = try? decodeIfPresent(String.self, forKey: key) {
118 return Int(stringValue)
119 }
120
121 return nil
122 }
123}
124
125struct DNSRecord: Codable, Identifiable, Hashable {
126 let id: String
127 let domain: String
128 let type: String
129 let name: String
130 let content: String?
131 let ttl: Int?
132 let prio: Int?
133 let weight: Int?
134 let port: Int?
135 let target: String?
136 let sshAlgorithm: Int?
137 let sshType: Int?
138
139 enum CodingKeys: String, CodingKey {
140 case id
141 case domain
142 case type
143 case name
144 case content
145 case ttl
146 case prio
147 case weight
148 case port
149 case target
150 case sshAlgorithm = "ssh_algorithm"
151 case sshType = "ssh_type"
152 }
153
154 init(
155 id: String,
156 domain: String,
157 type: String,
158 name: String,
159 content: String?,
160 ttl: Int?,
161 prio: Int?,
162 weight: Int?,
163 port: Int?,
164 target: String?,
165 sshAlgorithm: Int?,
166 sshType: Int?
167 ) {
168 self.id = id
169 self.domain = domain
170 self.type = type
171 self.name = name
172 self.content = content
173 self.ttl = ttl
174 self.prio = prio
175 self.weight = weight
176 self.port = port
177 self.target = target
178 self.sshAlgorithm = sshAlgorithm
179 self.sshType = sshType
180 }
181
182 init(from decoder: Decoder) throws {
183 let container = try decoder.container(keyedBy: CodingKeys.self)
184
185 id = try container.decodeLossyString(forKey: .id)
186 domain = try container.decodeIfPresent(String.self, forKey: .domain) ?? ""
187 type = try container.decode(String.self, forKey: .type)
188 name = try container.decode(String.self, forKey: .name)
189 content = try container.decodeIfPresent(String.self, forKey: .content)
190 ttl = try container.decodeLossyIntIfPresent(forKey: .ttl)
191 prio = try container.decodeLossyIntIfPresent(forKey: .prio)
192 weight = try container.decodeLossyIntIfPresent(forKey: .weight)
193 port = try container.decodeLossyIntIfPresent(forKey: .port)
194 target = try container.decodeIfPresent(String.self, forKey: .target)
195 sshAlgorithm = try container.decodeLossyIntIfPresent(forKey: .sshAlgorithm)
196 sshType = try container.decodeLossyIntIfPresent(forKey: .sshType)
197 }
198
199 func withDomain(_ domain: String) -> DNSRecord {
200 DNSRecord(
201 id: id,
202 domain: domain,
203 type: type,
204 name: name,
205 content: content,
206 ttl: ttl,
207 prio: prio,
208 weight: weight,
209 port: port,
210 target: target,
211 sshAlgorithm: sshAlgorithm,
212 sshType: sshType
213 )
214 }
215}
216
217struct APIToken: Codable, Identifiable, Hashable {
218 var id: String { key }
219
220 let key: String
221 let comment: String?
222 let from: [String]?
223 let allowedDomains: [String]?
224 let allowedServers: [String]?
225 let allowedMethods: [String]?
226 let allowedPrefixes: [String]?
227 let allowedTypes: [String]?
228
229 enum CodingKeys: String, CodingKey {
230 case key
231 case comment
232 case from
233 case allowedDomains = "allowed_domains"
234 case allowedServers = "allowed_servers"
235 case allowedMethods = "allowed_methods"
236 case allowedPrefixes = "allowed_prefixes"
237 case allowedTypes = "allowed_types"
238 }
239}
240
241struct EmailForward: Codable, Hashable, Identifiable {
242 let domain: String
243 let from: String
244 let to: String
245
246 enum CodingKeys: String, CodingKey {
247 case domain
248 case from
249 case to
250 }
251
252 init(domain: String, from: String, to: String) {
253 self.domain = domain
254 self.from = from
255 self.to = to
256 }
257
258 init(from decoder: Decoder) throws {
259 let container = try decoder.container(keyedBy: CodingKeys.self)
260 let rawFrom = try container.decode(String.self, forKey: .from).trimmingCharacters(in: .whitespacesAndNewlines)
261 let to = try container.decode(String.self, forKey: .to).trimmingCharacters(in: .whitespacesAndNewlines)
262
263 let decodedDomain = try container.decodeIfPresent(String.self, forKey: .domain)?
264 .trimmingCharacters(in: .whitespacesAndNewlines)
265
266 if let atIndex = rawFrom.lastIndex(of: "@") {
267 let localPart = String(rawFrom[..<atIndex])
268 let fromDomain = String(rawFrom[rawFrom.index(after: atIndex)...])
269 self.from = localPart.isEmpty ? rawFrom : localPart
270 if let decodedDomain, !decodedDomain.isEmpty {
271 self.domain = decodedDomain
272 } else {
273 self.domain = fromDomain
274 }
275 } else {
276 self.from = rawFrom
277 self.domain = decodedDomain ?? ""
278 }
279
280 self.to = to
281 }
282
283 var id: String {
284 "\(domain)|\(from)|\(to)"
285 }
286}
287
288struct GlueRecord: Codable, Hashable, Identifiable {
289 let domain: String
290 let name: String
291 let address4: String?
292 let address6: String?
293
294 enum CodingKeys: String, CodingKey {
295 case domain
296 case name
297 case address4
298 case address6
299 }
300
301 init(domain: String, name: String, address4: String?, address6: String?) {
302 self.domain = domain
303 self.name = name
304 self.address4 = address4
305 self.address6 = address6
306 }
307
308 init(from decoder: Decoder) throws {
309 let container = try decoder.container(keyedBy: CodingKeys.self)
310 domain = (try container.decodeIfPresent(String.self, forKey: .domain)) ?? ""
311 name = try container.decode(String.self, forKey: .name)
312 address4 = try container.decodeIfPresent(String.self, forKey: .address4)
313 address6 = try container.decodeIfPresent(String.self, forKey: .address6)
314 }
315
316 var id: String {
317 "\(domain)|\(name)"
318 }
319
320 func withDomain(_ domain: String) -> GlueRecord {
321 GlueRecord(domain: domain, name: name, address4: address4, address6: address6)
322 }
323}
324
325struct WalletTransaction: Codable, Hashable, Identifiable {
326 let id: String
327 let type: String?
328 let status: String?
329 let amount: Int?
330 let date: String?
331 let details: String?
332
333 enum CodingKeys: String, CodingKey {
334 case id
335 case type
336 case status
337 case amount
338 case date
339 case details = "description"
340 }
341
342 init(from decoder: Decoder) throws {
343 let container = try decoder.container(keyedBy: CodingKeys.self)
344 id = (try? container.decodeLossyString(forKey: .id)) ?? UUID().uuidString
345 type = try container.decodeIfPresent(String.self, forKey: .type)
346 status = try container.decodeIfPresent(String.self, forKey: .status)
347 amount = try container.decodeLossyIntIfPresent(forKey: .amount)
348 date = try container.decodeIfPresent(String.self, forKey: .date)
349 details = try container.decodeIfPresent(String.self, forKey: .details)
350 }
351}
352
353struct WalletPayment: Codable, Hashable {
354 let id: String?
355 let amount: Int?
356 let status: String?
357 let address: String?
358 let url: String?
359}
360
361enum DNSRecordType: String, CaseIterable, Identifiable, Codable {
362 case a = "A"
363 case aaaa = "AAAA"
364 case aname = "ANAME"
365 case caa = "CAA"
366 case cname = "CNAME"
367 case ds = "DS"
368 case dynamic = "Dynamic"
369 case https = "HTTPS"
370 case mx = "MX"
371 case naptr = "NAPTR"
372 case ns = "NS"
373 case ptr = "PTR"
374 case srv = "SRV"
375 case sshfp = "SSHFP"
376 case svcb = "SVCB"
377 case tlsa = "TLSA"
378 case txt = "TXT"
379
380 var id: String { rawValue }
381
382 var usesContent: Bool {
383 switch self {
384 case .dynamic, .https, .svcb:
385 return false
386 default:
387 return true
388 }
389 }
390
391 var usesTTL: Bool {
392 usesContent
393 }
394
395 var usesPriority: Bool {
396 switch self {
397 case .https, .mx, .srv, .svcb:
398 return true
399 default:
400 return false
401 }
402 }
403
404 var usesWeight: Bool {
405 self == .srv
406 }
407
408 var usesPort: Bool {
409 self == .srv
410 }
411
412 var usesTarget: Bool {
413 self == .https || self == .svcb
414 }
415
416 var usesSSHFields: Bool {
417 self == .sshfp
418 }
419}
420
421enum TTLPreset: Int, CaseIterable, Identifiable {
422 case zero = 0
423 case oneMinute = 60
424 case fiveMinutes = 300
425 case fifteenMinutes = 900
426 case oneHour = 3600
427 case threeHours = 10800
428 case sixHours = 21600
429 case oneDay = 86400
430
431 var id: Int { rawValue }
432
433 var label: String {
434 switch self {
435 case .zero:
436 return "0s"
437 case .oneMinute:
438 return "1m"
439 case .fiveMinutes:
440 return "5m"
441 case .fifteenMinutes:
442 return "15m"
443 case .oneHour:
444 return "1h"
445 case .threeHours:
446 return "3h"
447 case .sixHours:
448 return "6h"
449 case .oneDay:
450 return "1d"
451 }
452 }
453
454 static func option(for seconds: Int) -> TTLOption {
455 if let preset = TTLPreset(rawValue: seconds) {
456 return TTLOption(seconds: preset.rawValue, label: preset.label)
457 }
458
459 return TTLOption(seconds: seconds, label: "\(seconds)s", isCustom: true)
460 }
461}
462
463struct TTLOption: Hashable, Identifiable {
464 let seconds: Int
465 let label: String
466 let isCustom: Bool
467
468 init(seconds: Int, label: String, isCustom: Bool = false) {
469 self.seconds = seconds
470 self.label = label
471 self.isCustom = isCustom
472 }
473
474 var id: Int { seconds }
475}
476
477struct DNSRecordDraft: Equatable {
478 var type: DNSRecordType = .a
479 var name = ""
480 var content = ""
481 var ttlSeconds = TTLPreset.oneHour.rawValue
482 var prio = ""
483 var weight = ""
484 var port = ""
485 var target = ""
486 var sshAlgorithm = ""
487 var sshType = ""
488
489 init() {}
490
491 init(record: DNSRecord) {
492 type = DNSRecordType(rawValue: record.type) ?? .a
493 name = record.name
494 content = record.content ?? ""
495 ttlSeconds = record.ttl ?? TTLPreset.oneHour.rawValue
496 prio = record.prio.map(String.init) ?? ""
497 weight = record.weight.map(String.init) ?? ""
498 port = record.port.map(String.init) ?? ""
499 target = record.target ?? ""
500 sshAlgorithm = record.sshAlgorithm.map(String.init) ?? ""
501 sshType = record.sshType.map(String.init) ?? ""
502 }
503
504 mutating func resetTypeSpecificFields() {
505 content = ""
506 ttlSeconds = TTLPreset.oneHour.rawValue
507 prio = ""
508 weight = ""
509 port = ""
510 target = ""
511 sshAlgorithm = ""
512 sshType = ""
513 }
514
515 var trimmedName: String {
516 name.trimmingCharacters(in: .whitespacesAndNewlines)
517 }
518
519 var canSubmit: Bool {
520 !trimmedName.isEmpty
521 }
522
523 var ttlOptions: [TTLOption] {
524 let presetOptions = TTLPreset.allCases.map { TTLOption(seconds: $0.rawValue, label: $0.label) }
525
526 guard type.usesTTL, TTLPreset(rawValue: ttlSeconds) == nil else {
527 return presetOptions
528 }
529
530 return presetOptions + [TTLPreset.option(for: ttlSeconds)]
531 }
532
533 func params(domain: String, id: String? = nil) -> [String: Any] {
534 var params: [String: Any] = [
535 "domain": domain,
536 "type": type.rawValue,
537 "name": trimmedName
538 ]
539
540 if let id {
541 params["id"] = id
542 }
543
544 if type.usesContent {
545 params["content"] = content.trimmingCharacters(in: .whitespacesAndNewlines)
546 }
547
548 if type.usesTTL {
549 params["ttl"] = ttlSeconds
550 }
551
552 if type.usesPriority, let value = Int(prio.trimmingCharacters(in: .whitespacesAndNewlines)) {
553 params["prio"] = value
554 }
555
556 if type.usesWeight, let value = Int(weight.trimmingCharacters(in: .whitespacesAndNewlines)) {
557 params["weight"] = value
558 }
559
560 if type.usesPort, let value = Int(port.trimmingCharacters(in: .whitespacesAndNewlines)) {
561 params["port"] = value
562 }
563
564 if type.usesTarget {
565 params["target"] = target.trimmingCharacters(in: .whitespacesAndNewlines)
566 }
567
568 if type.usesSSHFields {
569 if let value = Int(sshAlgorithm.trimmingCharacters(in: .whitespacesAndNewlines)) {
570 params["ssh_algorithm"] = value
571 }
572 if let value = Int(sshType.trimmingCharacters(in: .whitespacesAndNewlines)) {
573 params["ssh_type"] = value
574 }
575 }
576
577 return params
578 }
579}
580
581struct DomainUpdateRequest {
582 var autorenew: Bool?
583 var mailforwarding: Bool?
584 var dnssec: Bool?
585 var lock: Bool?
586 var nameservers: [String]?
587
588 var hasChanges: Bool {
589 autorenew != nil ||
590 mailforwarding != nil ||
591 dnssec != nil ||
592 lock != nil ||
593 nameservers != nil
594 }
595
596 var params: [String: Any] {
597 var params: [String: Any] = [:]
598
599 if let autorenew {
600 params["autorenew"] = autorenew
601 }
602
603 if let mailforwarding {
604 params["mailforwarding"] = mailforwarding
605 }
606
607 if let dnssec {
608 params["dnssec"] = dnssec
609 }
610
611 if let lock {
612 params["lock"] = lock
613 }
614
615 if let nameservers {
616 params["nameservers"] = nameservers
617 }
618
619 return params
620 }
621
622 func isSatisfied(by domain: Domain) -> Bool {
623 if let autorenew, domain.autorenew != autorenew {
624 return false
625 }
626
627 if let mailforwarding, domain.mailforwarding != mailforwarding {
628 return false
629 }
630
631 if let dnssec, domain.dnssec != dnssec {
632 return false
633 }
634
635 if let lock, domain.lock != lock {
636 return false
637 }
638
639 if let nameservers {
640 let normalizedResponse = domain.nameservers ?? []
641 if normalizedResponse != nameservers {
642 return false
643 }
644 }
645
646 return true
647 }
648}
649
650struct TokenCreateRequest {
651 var comment: String
652 var from: [String]
653 var allowedMethods: [String]
654 var allowedDomains: [String]
655
656 var params: [String: Any] {
657 var params: [String: Any] = [:]
658
659 let trimmedComment = comment.trimmingCharacters(in: .whitespacesAndNewlines)
660 if !trimmedComment.isEmpty {
661 params["comment"] = trimmedComment
662 }
663
664 if !from.isEmpty {
665 params["from"] = from
666 }
667
668 if !allowedMethods.isEmpty {
669 params["allowed_methods"] = allowedMethods
670 }
671
672 if !allowedDomains.isEmpty {
673 params["allowed_domains"] = allowedDomains
674 }
675
676 return params
677 }
678}
679
680extension String {
681 func formattedExpiry() -> String {
682 let parser = ISO8601DateFormatter()
683 guard let date = parser.date(from: self) else { return self }
684 let formatter = DateFormatter()
685 formatter.dateStyle = .medium
686 formatter.timeStyle = .none
687 return formatter.string(from: date)
688 }
689}
690
691private extension KeyedDecodingContainer where K == DNSRecord.CodingKeys {
692 func decodeLossyString(forKey key: K) throws -> String {
693 if let stringValue = try decodeIfPresent(String.self, forKey: key) {
694 return stringValue
695 }
696
697 if let intValue = try decodeIfPresent(Int.self, forKey: key) {
698 return String(intValue)
699 }
700
701 throw DecodingError.keyNotFound(
702 key,
703 DecodingError.Context(codingPath: codingPath, debugDescription: "Missing required value for \(key.stringValue).")
704 )
705 }
706
707 func decodeLossyIntIfPresent(forKey key: K) throws -> Int? {
708 if let intValue = try decodeIfPresent(Int.self, forKey: key) {
709 return intValue
710 }
711
712 if let stringValue = try decodeIfPresent(String.self, forKey: key) {
713 return Int(stringValue)
714 }
715
716 return nil
717 }
718}
719
720private extension KeyedDecodingContainer where K == WalletTransaction.CodingKeys {
721 func decodeLossyString(forKey key: K) throws -> String {
722 if let stringValue = try decodeIfPresent(String.self, forKey: key) {
723 return stringValue
724 }
725
726 if let intValue = try decodeIfPresent(Int.self, forKey: key) {
727 return String(intValue)
728 }
729
730 throw DecodingError.keyNotFound(
731 key,
732 DecodingError.Context(codingPath: codingPath, debugDescription: "Missing required value for \(key.stringValue).")
733 )
734 }
735
736 func decodeLossyIntIfPresent(forKey key: K) throws -> Int? {
737 if let intValue = try decodeIfPresent(Int.self, forKey: key) {
738 return intValue
739 }
740
741 if let stringValue = try decodeIfPresent(String.self, forKey: key) {
742 return Int(stringValue)
743 }
744
745 return nil
746 }
747}