krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v4.5.0: DomainDig/ExternalDataService.swift · raw

  1import Foundation
  2
  3actor ExternalDataService {
  4    static let shared = ExternalDataService()
  5
  6    private enum RequestKey: Hashable {
  7        case ownershipHistory(String)
  8        case dnsHistory(String)
  9        case extendedSubdomains(String)
 10        case pricing(String)
 11    }
 12
 13    private enum RateLimitBucket: Hashable {
 14        case history
 15        case subdomains
 16        case pricing
 17
 18        var minimumSpacing: TimeInterval {
 19            switch self {
 20            case .history:
 21                return 1.0
 22            case .subdomains:
 23                return 1.5
 24            case .pricing:
 25                return 1.0
 26            }
 27        }
 28    }
 29
 30    private enum CachedPayload {
 31        case ownershipHistory(ServiceResult<[DomainOwnershipHistoryEvent]>)
 32        case dnsHistory(ServiceResult<[DNSHistoryEvent]>)
 33        case extendedSubdomains(ServiceResult<[DiscoveredSubdomain]>)
 34        case pricing(ServiceResult<DomainPricingInsight>)
 35    }
 36
 37    private struct CacheEntry {
 38        let payload: CachedPayload
 39        let expiresAt: Date
 40    }
 41
 42    private struct Configuration {
 43        let ownershipHistoryURL: String?
 44        let dnsHistoryURL: String?
 45        let extendedSubdomainsURL: String?
 46        let pricingURL: String?
 47    }
 48
 49    private let ttl: TimeInterval = 900
 50    private let session: URLSession = .shared
 51    private var cache: [RequestKey: CacheEntry] = [:]
 52    private var inFlight: [RequestKey: Task<CachedPayload, Never>] = [:]
 53    private var nextAllowedAt: [RateLimitBucket: Date] = [:]
 54
 55    func clearCache() {
 56        cache.removeAll()
 57        inFlight.values.forEach { $0.cancel() }
 58        inFlight.removeAll()
 59        nextAllowedAt.removeAll()
 60    }
 61
 62    func ownershipHistory(
 63        domain: String,
 64        currentOwnership: DomainOwnership?,
 65        historyEntries: [HistoryEntry]
 66    ) async -> CachedLookupResult<ServiceResult<[DomainOwnershipHistoryEvent]>> {
 67        let normalizedDomain = Self.normalize(domain)
 68        return await execute(
 69            key: .ownershipHistory(normalizedDomain),
 70            rateLimitBucket: .history,
 71            extract: { payload in
 72                guard case let .ownershipHistory(result) = payload else { return nil }
 73                return result
 74            },
 75            operation: { [configuration = configuration()] in
 76                let localEvents = Self.localOwnershipHistory(
 77                    domain: normalizedDomain,
 78                    currentOwnership: currentOwnership,
 79                    historyEntries: historyEntries
 80                )
 81                let externalEvents = await self.fetchOwnershipHistory(domain: normalizedDomain, configuration: configuration)
 82                return .ownershipHistory(Self.mergeOwnershipHistory(localEvents: localEvents, externalEvents: externalEvents))
 83            }
 84        )
 85    }
 86
 87    func dnsHistory(
 88        domain: String,
 89        dnsSections: [DNSSection],
 90        historyEntries: [HistoryEntry]
 91    ) async -> CachedLookupResult<ServiceResult<[DNSHistoryEvent]>> {
 92        let normalizedDomain = Self.normalize(domain)
 93        return await execute(
 94            key: .dnsHistory(normalizedDomain),
 95            rateLimitBucket: .history,
 96            extract: { payload in
 97                guard case let .dnsHistory(result) = payload else { return nil }
 98                return result
 99            },
100            operation: { [configuration = configuration()] in
101                let localEvents = Self.localDNSHistory(
102                    domain: normalizedDomain,
103                    dnsSections: dnsSections,
104                    historyEntries: historyEntries
105                )
106                let externalEvents = await self.fetchDNSHistory(domain: normalizedDomain, configuration: configuration)
107                return .dnsHistory(Self.mergeDNSHistory(localEvents: localEvents, externalEvents: externalEvents))
108            }
109        )
110    }
111
112    func extendedSubdomains(
113        domain: String,
114        existing: [DiscoveredSubdomain]
115    ) async -> CachedLookupResult<ServiceResult<[DiscoveredSubdomain]>> {
116        let normalizedDomain = Self.normalize(domain)
117        return await execute(
118            key: .extendedSubdomains(normalizedDomain),
119            rateLimitBucket: .subdomains,
120            extract: { payload in
121                guard case let .extendedSubdomains(result) = payload else { return nil }
122                return result
123            },
124            operation: { [configuration = configuration()] in
125                var merged = existing
126                switch await SubdomainDiscoveryService.discover(for: normalizedDomain, limit: 100) {
127                case let .success(results):
128                    merged = Self.mergeSubdomains(primary: merged, additional: results.map {
129                        DiscoveredSubdomain(hostname: $0.hostname, source: $0.source ?? "crt.sh", isExtended: true)
130                    })
131                case .empty, .error:
132                    break
133                }
134
135                if let external = await self.fetchExtendedSubdomains(domain: normalizedDomain, configuration: configuration) {
136                    merged = Self.mergeSubdomains(primary: merged, additional: external)
137                }
138
139                if merged.count <= existing.count {
140                    return .extendedSubdomains(.empty("No extended subdomains available"))
141                }
142
143                let onlyExtended = merged.filter(\.isExtended)
144                return .extendedSubdomains(.success(onlyExtended.sorted { $0.hostname < $1.hostname }))
145            }
146        )
147    }
148
149    func pricing(domain: String) async -> CachedLookupResult<ServiceResult<DomainPricingInsight>> {
150        let normalizedDomain = Self.normalize(domain)
151        return await execute(
152            key: .pricing(normalizedDomain),
153            rateLimitBucket: .pricing,
154            extract: { payload in
155                guard case let .pricing(result) = payload else { return nil }
156                return result
157            },
158            operation: { [configuration = configuration()] in
159                .pricing(await self.fetchPricing(domain: normalizedDomain, configuration: configuration))
160            }
161        )
162    }
163
164    private func execute<T>(
165        key: RequestKey,
166        rateLimitBucket: RateLimitBucket,
167        extract: @escaping (CachedPayload) -> T?,
168        operation: @escaping @Sendable () async -> CachedPayload
169    ) async -> CachedLookupResult<T> {
170        if let cachedEntry = cache[key], cachedEntry.expiresAt > Date(), let value = extract(cachedEntry.payload) {
171            return CachedLookupResult(value: value, source: .cached)
172        }
173
174        if let task = inFlight[key], let value = extract(await task.value) {
175            return CachedLookupResult(value: value, source: .mixed)
176        }
177
178        let task = Task<CachedPayload, Never> {
179            await self.enforceRateLimit(for: rateLimitBucket)
180            return await operation()
181        }
182        inFlight[key] = task
183
184        let payload = await task.value
185        cache[key] = CacheEntry(payload: payload, expiresAt: Date().addingTimeInterval(ttl))
186        inFlight[key] = nil
187
188        guard let value = extract(payload) else {
189            fatalError("ExternalDataService payload extraction mismatch")
190        }
191
192        return CachedLookupResult(value: value, source: .live)
193    }
194
195    private func enforceRateLimit(for bucket: RateLimitBucket) async {
196        let now = Date()
197        if let nextAllowed = nextAllowedAt[bucket], nextAllowed > now {
198            let delay = nextAllowed.timeIntervalSince(now)
199            if delay > 0 {
200                try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
201            }
202        }
203        nextAllowedAt[bucket] = Date().addingTimeInterval(bucket.minimumSpacing)
204    }
205
206    private func configuration() -> Configuration {
207        let defaults = UserDefaults.standard
208        return Configuration(
209            ownershipHistoryURL: defaults.string(forKey: "externalData.ownershipHistoryURL")
210                ?? Bundle.main.object(forInfoDictionaryKey: "ExternalOwnershipHistoryURL") as? String,
211            dnsHistoryURL: defaults.string(forKey: "externalData.dnsHistoryURL")
212                ?? Bundle.main.object(forInfoDictionaryKey: "ExternalDNSHistoryURL") as? String,
213            extendedSubdomainsURL: defaults.string(forKey: "externalData.extendedSubdomainsURL")
214                ?? Bundle.main.object(forInfoDictionaryKey: "ExternalExtendedSubdomainsURL") as? String,
215            pricingURL: defaults.string(forKey: "externalData.pricingURL")
216                ?? Bundle.main.object(forInfoDictionaryKey: "ExternalPricingURL") as? String
217        )
218    }
219
220    private func fetchOwnershipHistory(
221        domain: String,
222        configuration: Configuration
223    ) async -> [DomainOwnershipHistoryEvent] {
224        guard let template = configuration.ownershipHistoryURL,
225              let url = Self.url(from: template, domain: domain) else {
226            return []
227        }
228
229        switch await requestData(url: url) {
230        case let .success(data):
231            return parseOwnershipHistoryEvents(from: data)
232        case .empty, .error:
233            return []
234        }
235    }
236
237    private func fetchDNSHistory(
238        domain: String,
239        configuration: Configuration
240    ) async -> [DNSHistoryEvent] {
241        guard let template = configuration.dnsHistoryURL,
242              let url = Self.url(from: template, domain: domain) else {
243            return []
244        }
245
246        switch await requestData(url: url) {
247        case let .success(data):
248            return parseDNSHistoryEvents(from: data)
249        case .empty, .error:
250            return []
251        }
252    }
253
254    private func fetchExtendedSubdomains(
255        domain: String,
256        configuration: Configuration
257    ) async -> [DiscoveredSubdomain]? {
258        guard let template = configuration.extendedSubdomainsURL,
259              let url = Self.url(from: template, domain: domain) else {
260            return nil
261        }
262
263        switch await requestData(url: url) {
264        case let .success(data):
265            return parseSubdomains(from: data)
266        case .empty, .error:
267            return nil
268        }
269    }
270
271    private func fetchPricing(
272        domain: String,
273        configuration: Configuration
274    ) async -> ServiceResult<DomainPricingInsight> {
275        guard let template = configuration.pricingURL,
276              let url = Self.url(from: template, domain: domain) else {
277            return .empty("External pricing unavailable")
278        }
279
280        switch await requestData(url: url) {
281        case let .success(data):
282            guard let pricing = parsePricing(from: data) else {
283                return .error("Invalid external response")
284            }
285            return .success(pricing)
286        case let .empty(message):
287            return .empty(message)
288        case let .error(message):
289            return .error(message)
290        }
291    }
292
293    private func requestData(url: URL) async -> ServiceResult<Data> {
294        for attempt in 0..<2 {
295            do {
296                let (data, response) = try await session.data(for: URLRequest(url: url, timeoutInterval: 8))
297                guard let httpResponse = response as? HTTPURLResponse else {
298                    return .error("External data unavailable")
299                }
300
301                switch httpResponse.statusCode {
302                case 200:
303                    return .success(data)
304                case 204, 404:
305                    return .empty("No external data available")
306                case 429:
307                    return .error("Rate limit reached")
308                case 500...599 where attempt == 0:
309                    continue
310                default:
311                    return .error("External provider failed")
312                }
313            } catch is DecodingError {
314                return .error("Invalid external response")
315            } catch {
316                if attempt == 0 {
317                    continue
318                }
319                return .error(error.localizedDescription)
320            }
321        }
322
323        return .error("External provider failed")
324    }
325
326    private func parseOwnershipHistoryEvents(from data: Data) -> [DomainOwnershipHistoryEvent] {
327        guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
328              let rawEvents = json["events"] as? [[String: Any]] else {
329            return []
330        }
331
332        return rawEvents.compactMap { event in
333            guard let dateString = event["date"] as? String,
334                  let date = Self.iso8601DateFormatter.date(from: dateString) else {
335                return nil
336            }
337
338            return DomainOwnershipHistoryEvent(
339                date: date,
340                summary: event["summary"] as? String ?? "Ownership change observed",
341                registrar: event["registrar"] as? String,
342                registrant: event["registrant"] as? String,
343                nameservers: event["nameservers"] as? [String] ?? [],
344                source: event["source"] as? String ?? "Configured external history feed",
345                isExternal: true
346            )
347        }
348    }
349
350    private func parseDNSHistoryEvents(from data: Data) -> [DNSHistoryEvent] {
351        guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
352              let rawEvents = json["events"] as? [[String: Any]] else {
353            return []
354        }
355
356        return rawEvents.compactMap { event in
357            guard let dateString = event["date"] as? String,
358                  let date = Self.iso8601DateFormatter.date(from: dateString) else {
359                return nil
360            }
361
362            return DNSHistoryEvent(
363                date: date,
364                summary: event["summary"] as? String ?? "DNS change observed",
365                aRecords: event["a_records"] as? [String] ?? [],
366                nameservers: event["nameservers"] as? [String] ?? [],
367                recordSnapshots: parseDNSRecordSnapshots(from: event),
368                changedRecordTypes: parseDNSChangedRecordTypes(from: event),
369                source: event["source"] as? String ?? "Configured external history feed",
370                isExternal: true
371            )
372        }
373    }
374
375    private func parseSubdomains(from data: Data) -> [DiscoveredSubdomain] {
376        guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
377              let rawSubdomains = json["subdomains"] as? [[String: Any]] else {
378            return []
379        }
380
381        return rawSubdomains.compactMap { item in
382            guard let hostname = item["hostname"] as? String else { return nil }
383            return DiscoveredSubdomain(
384                hostname: hostname,
385                source: item["source"] as? String ?? "Configured external subdomain feed",
386                isExtended: true
387            )
388        }
389    }
390
391    private func parsePricing(from data: Data) -> DomainPricingInsight? {
392        guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
393            return nil
394        }
395
396        return DomainPricingInsight(
397            estimatedPrice: json["estimated_price"] as? String,
398            premiumIndicator: json["premium"] as? Bool,
399            resaleSignal: json["resale_signal"] as? String,
400            auctionSignal: json["auction_signal"] as? String,
401            source: json["source"] as? String ?? "Configured external pricing feed",
402            collectedAt: Date()
403        )
404    }
405
406    private static func localOwnershipHistory(
407        domain: String,
408        currentOwnership: DomainOwnership?,
409        historyEntries: [HistoryEntry]
410    ) -> [DomainOwnershipHistoryEvent] {
411        let domainHistory = historyEntries
412            .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
413            .sorted { $0.timestamp < $1.timestamp }
414
415        var events: [DomainOwnershipHistoryEvent] = []
416        var previousOwnership: DomainOwnership?
417
418        for entry in domainHistory {
419            guard let ownership = entry.ownership else { continue }
420            let summary = ownershipSummaryChange(previous: previousOwnership, current: ownership)
421            if let summary {
422                events.append(
423                    DomainOwnershipHistoryEvent(
424                        date: entry.timestamp,
425                        summary: summary,
426                        registrar: ownership.registrar,
427                        registrant: ownership.registrant,
428                        nameservers: ownership.nameservers,
429                        source: "Local observations",
430                        isExternal: false
431                    )
432                )
433            }
434            previousOwnership = ownership
435        }
436
437        if let currentOwnership, events.isEmpty {
438            events.append(
439                DomainOwnershipHistoryEvent(
440                    date: Date(),
441                    summary: "Current ownership snapshot",
442                    registrar: currentOwnership.registrar,
443                    registrant: currentOwnership.registrant,
444                    nameservers: currentOwnership.nameservers,
445                    source: "Local observations",
446                    isExternal: false
447                )
448            )
449        }
450
451        return events.sorted { $0.date > $1.date }
452    }
453
454    private static func localDNSHistory(
455        domain: String,
456        dnsSections: [DNSSection],
457        historyEntries: [HistoryEntry]
458    ) -> [DNSHistoryEvent] {
459        let domainHistory = historyEntries
460            .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
461            .sorted { $0.timestamp < $1.timestamp }
462
463        var events: [DNSHistoryEvent] = []
464        var previousRecordValues: [DNSRecordType: [String]] = [:]
465
466        for entry in domainHistory {
467            let currentRecordValues = Self.historyRecordValues(in: entry.dnsSections)
468            let changedRecordTypes = Self.changedRecordTypes(previous: previousRecordValues, current: currentRecordValues)
469            let summary = dnsSummaryChange(previous: previousRecordValues, current: currentRecordValues)
470
471            if let summary {
472                events.append(
473                    DNSHistoryEvent(
474                        date: entry.timestamp,
475                        summary: summary,
476                        aRecords: currentRecordValues[.A] ?? [],
477                        nameservers: currentRecordValues[.NS] ?? [],
478                        recordSnapshots: currentRecordValues.map { DNSHistoryRecordSnapshot(recordType: $0.key, values: $0.value) }
479                            .sorted { $0.recordType.rawValue < $1.recordType.rawValue },
480                        changedRecordTypes: changedRecordTypes,
481                        source: "Local observations",
482                        isExternal: false
483                    )
484                )
485            }
486
487            previousRecordValues = currentRecordValues
488        }
489
490        if events.isEmpty {
491            let currentRecordValues = Self.historyRecordValues(in: dnsSections)
492            if !currentRecordValues.isEmpty {
493                events.append(
494                    DNSHistoryEvent(
495                        date: Date(),
496                        summary: "Current DNS snapshot",
497                        aRecords: currentRecordValues[.A] ?? [],
498                        nameservers: currentRecordValues[.NS] ?? [],
499                        recordSnapshots: currentRecordValues.map { DNSHistoryRecordSnapshot(recordType: $0.key, values: $0.value) }
500                            .sorted { $0.recordType.rawValue < $1.recordType.rawValue },
501                        changedRecordTypes: Array(currentRecordValues.keys).sorted { $0.rawValue < $1.rawValue },
502                        source: "Local observations",
503                        isExternal: false
504                    )
505                )
506            }
507        }
508
509        return events.sorted { $0.date > $1.date }
510    }
511
512    private static func mergeOwnershipHistory(
513        localEvents: [DomainOwnershipHistoryEvent],
514        externalEvents: [DomainOwnershipHistoryEvent]
515    ) -> ServiceResult<[DomainOwnershipHistoryEvent]> {
516        let merged = (externalEvents + localEvents)
517            .sorted { $0.date > $1.date }
518            .reduce(into: [DomainOwnershipHistoryEvent]()) { partialResult, event in
519                let duplicate = partialResult.contains {
520                    $0.date == event.date
521                        && $0.summary == event.summary
522                        && $0.registrar == event.registrar
523                        && $0.nameservers == event.nameservers
524                }
525                if !duplicate {
526                    partialResult.append(event)
527                }
528            }
529
530        return merged.isEmpty ? .empty("No ownership history available") : .success(merged)
531    }
532
533    private static func mergeDNSHistory(
534        localEvents: [DNSHistoryEvent],
535        externalEvents: [DNSHistoryEvent]
536    ) -> ServiceResult<[DNSHistoryEvent]> {
537        let merged = (externalEvents + localEvents)
538            .sorted { $0.date > $1.date }
539            .reduce(into: [DNSHistoryEvent]()) { partialResult, event in
540                let duplicate = partialResult.contains {
541                    $0.date == event.date
542                        && $0.summary == event.summary
543                        && compareDNSRecordSnapshots($0.recordSnapshots, event.recordSnapshots)
544                }
545                if !duplicate {
546                    partialResult.append(event)
547                }
548            }
549
550        return merged.isEmpty ? .empty("No DNS history available") : .success(merged)
551    }
552
553    private static func mergeSubdomains(
554        primary: [DiscoveredSubdomain],
555        additional: [DiscoveredSubdomain]
556    ) -> [DiscoveredSubdomain] {
557        var seen = Set(primary.map { $0.hostname.lowercased() })
558        var merged = primary
559
560        for subdomain in additional where seen.insert(subdomain.hostname.lowercased()).inserted {
561            merged.append(subdomain)
562        }
563
564        return merged.sorted { $0.hostname < $1.hostname }
565    }
566
567    private static func ownershipSummaryChange(previous: DomainOwnership?, current: DomainOwnership) -> String? {
568        guard let previous else {
569            return "Initial ownership observation"
570        }
571
572        var changes: [String] = []
573        if previous.registrar != current.registrar {
574            changes.append("Registrar changed")
575        }
576        if previous.registrant != current.registrant, current.registrant != nil {
577            changes.append("Ownership changed")
578        }
579        if previous.nameservers != current.nameservers {
580            changes.append("Nameservers changed")
581        }
582
583        return changes.isEmpty ? nil : changes.joined(separator: "")
584    }
585
586    private static func dnsSummaryChange(
587        previous: [DNSRecordType: [String]],
588        current: [DNSRecordType: [String]]
589    ) -> String? {
590        var changes: [String] = []
591        for type in [DNSRecordType.A, .AAAA, .MX, .NS, .TXT, .CNAME] {
592            let previousValues = previous[type] ?? []
593            let currentValues = current[type] ?? []
594            if previousValues != currentValues, !currentValues.isEmpty {
595                changes.append("\(type.rawValue) records changed")
596            }
597        }
598        if previous.isEmpty && !current.isEmpty {
599            changes.append("Initial DNS observation")
600        }
601        return changes.isEmpty ? nil : changes.joined(separator: "")
602    }
603
604    private static func url(from template: String, domain: String) -> URL? {
605        URL(string: template.replacingOccurrences(of: "{domain}", with: domain))
606    }
607
608    private static func normalize(_ domain: String) -> String {
609        domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
610    }
611
612    private static func dnsValues(for type: DNSRecordType, in sections: [DNSSection]) -> [String] {
613        sections
614            .first(where: { $0.recordType == type })?
615            .records
616            .map(\.value)
617            .sorted() ?? []
618    }
619
620    private func parseDNSRecordSnapshots(from event: [String: Any]) -> [DNSHistoryRecordSnapshot] {
621        if let snapshots = event["record_snapshots"] as? [[String: Any]] {
622            return snapshots.compactMap { item in
623                guard let typeName = item["type"] as? String,
624                      let type = DNSRecordType(rawValue: typeName) else {
625                    return nil
626                }
627                return DNSHistoryRecordSnapshot(recordType: type, values: item["values"] as? [String] ?? [])
628            }
629        }
630        var snapshots: [DNSHistoryRecordSnapshot] = []
631        if let aRecords = event["a_records"] as? [String], !aRecords.isEmpty {
632            snapshots.append(DNSHistoryRecordSnapshot(recordType: .A, values: aRecords))
633        }
634        if let nameservers = event["nameservers"] as? [String], !nameservers.isEmpty {
635            snapshots.append(DNSHistoryRecordSnapshot(recordType: .NS, values: nameservers))
636        }
637        return snapshots
638    }
639
640    private func parseDNSChangedRecordTypes(from event: [String: Any]) -> [DNSRecordType] {
641        if let rawTypes = event["changed_record_types"] as? [String] {
642            return rawTypes.compactMap(DNSRecordType.init(rawValue:))
643        }
644        return parseDNSRecordSnapshots(from: event).map(\.recordType)
645    }
646
647    private static func historyRecordValues(in sections: [DNSSection]) -> [DNSRecordType: [String]] {
648        let trackedTypes: [DNSRecordType] = [.A, .AAAA, .MX, .NS, .TXT, .CNAME]
649        return trackedTypes.reduce(into: [DNSRecordType: [String]]()) { result, type in
650            let values = dnsValues(for: type, in: sections)
651            if !values.isEmpty {
652                result[type] = values
653            }
654        }
655    }
656
657    private static func changedRecordTypes(
658        previous: [DNSRecordType: [String]],
659        current: [DNSRecordType: [String]]
660    ) -> [DNSRecordType] {
661        Array(Set(previous.keys).union(current.keys))
662            .filter { previous[$0] != current[$0] }
663            .sorted { $0.rawValue < $1.rawValue }
664    }
665
666    private static func compareDNSRecordSnapshots(
667        _ lhs: [DNSHistoryRecordSnapshot],
668        _ rhs: [DNSHistoryRecordSnapshot]
669    ) -> Bool {
670        guard lhs.count == rhs.count else { return false }
671        return zip(lhs, rhs).allSatisfy { left, right in
672            left.recordType == right.recordType && left.values == right.values
673        }
674    }
675
676    private static let iso8601DateFormatter: ISO8601DateFormatter = {
677        let formatter = ISO8601DateFormatter()
678        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
679        return formatter
680    }()
681}