summaryrefslogtreecommitdiff
path: root/DomainDig/HTTPHeadersService.swift
blob: 4bf175a4556df20733934c5ef33bde73a0d6b45f (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
import Foundation

enum HTTPSecurityGrade: String {
    case a = "A"
    case b = "B"
    case c = "C"
    case d = "D"
    case f = "F"

    static func grade(for headers: [HTTPHeader]) -> HTTPSecurityGrade {
        let presentHeaderNames = Set(headers.map { $0.name.lowercased() })
        let presentCount = HTTPHeader.securityHeaders.intersection(presentHeaderNames).count

        switch presentCount {
        case 5:
            return .a
        case 4:
            return .b
        case 3:
            return .c
        case 2:
            return .d
        default:
            return .f
        }
    }
}

struct HTTPHeadersResult {
    let headers: [HTTPHeader]
    let statusCode: Int?
    let responseTimeMs: Int?
    let httpProtocol: String?
    let http3Advertised: Bool
}

struct HTTPHeadersService {
    static func fetch(domain: String) async -> ServiceResult<HTTPHeadersResult> {
        let url = URL(string: "https://\(domain)")!
        var request = URLRequest(url: url, timeoutInterval: 10)
        request.httpMethod = "HEAD"
        let metricsDelegate = TaskMetricsDelegate()
        let startTime = Date()

        do {
            let (_, response) = try await URLSession.shared.data(for: request, delegate: metricsDelegate)
            let responseTimeMs = max(0, Int(Date().timeIntervalSince(startTime) * 1000))

            guard let httpResponse = response as? HTTPURLResponse else {
                return .error(URLError(.badServerResponse).localizedDescription)
            }

            let headers = httpResponse.allHeaderFields.compactMap { entry -> HTTPHeader? in
                guard let name = entry.key as? String,
                      let value = entry.value as? String else { return nil }
                return HTTPHeader(name: name, value: value)
            }
            .sorted { $0.name.lowercased() < $1.name.lowercased() }

            let networkProtocolName = metricsDelegate.metrics?.transactionMetrics
                .compactMap { $0.networkProtocolName }
                .last
            let detectedProtocol = protocolLabel(for: networkProtocolName)
            let altSvcValue = headerValue(named: "alt-svc", in: httpResponse)
            let http3Advertised = altSvcValue?.localizedCaseInsensitiveContains("h3") == true
            let result = HTTPHeadersResult(
                headers: headers,
                statusCode: httpResponse.statusCode,
                responseTimeMs: responseTimeMs,
                httpProtocol: detectedProtocol,
                http3Advertised: http3Advertised
            )

            return headers.isEmpty ? .empty("No HTTP headers returned") : .success(result)
        } catch {
            return .error(error.localizedDescription)
        }
    }

    private static func protocolLabel(for networkProtocolName: String?) -> String? {
        guard let networkProtocolName else { return nil }

        let normalized = networkProtocolName.lowercased()
        if normalized == "h2" {
            return "HTTP/2"
        }
        if normalized == "h3" || normalized.hasPrefix("quic") {
            return "HTTP/3"
        }
        if normalized.hasPrefix("http/") {
            return normalized.uppercased()
        }

        return networkProtocolName.uppercased()
    }

    private static func headerValue(named name: String, in response: HTTPURLResponse) -> String? {
        response.allHeaderFields.first { key, _ in
            guard let headerName = key as? String else { return false }
            return headerName.caseInsensitiveCompare(name) == .orderedSame
        }?.value as? String
    }
}

private final class TaskMetricsDelegate: NSObject, URLSessionTaskDelegate {
    private(set) var metrics: URLSessionTaskMetrics?

    func urlSession(
        _ _: URLSession,
        task _: URLSessionTask,
        didFinishCollecting metrics: URLSessionTaskMetrics
    ) {
        self.metrics = metrics
    }
}