krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.2.1: Hutch/Networking/SRHTError.swift · raw

  1import Foundation
  2
  3/// Errors produced by the SourceHut GraphQL client.
  4enum SRHTError: LocalizedError, Sendable {
  5    /// The server returned one or more GraphQL-level errors.
  6    case graphQLErrors([GraphQLError])
  7    /// The HTTP response had a non-2xx status code.
  8    case httpError(Int)
  9    /// The client refused to send credentials to an unexpected URL.
 10    case invalidAuthenticatedURL(URL)
 11    /// The response data could not be decoded.
 12    case decodingError(any Error)
 13    /// A networking error from URLSession (timeout, DNS, connectivity, etc.).
 14    case networkError(any Error)
 15    /// 401 or no authentication token configured.
 16    case unauthorized
 17
 18    var errorDescription: String? {
 19        switch self {
 20        case .graphQLErrors(let errors):
 21            let messages = errors.diagnosticSummary
 22            return "GraphQL error: \(messages)"
 23        case .httpError(let code):
 24            return "Server returned HTTP \(code)."
 25        case .invalidAuthenticatedURL(let url):
 26            return "Refused to authenticate request to unexpected URL: \(url.absoluteString)"
 27        case .decodingError(let error):
 28            return "Failed to decode response: \(error.localizedDescription)"
 29        case .networkError(let error):
 30            return "Network error: \(error.localizedDescription)"
 31        case .unauthorized:
 32            return "Authentication required. Please sign in again."
 33        }
 34    }
 35
 36    nonisolated var userFacingMessage: String {
 37        switch self {
 38        case .graphQLErrors(let errors):
 39            switch errors.classification {
 40            case .unauthorized, .forbidden:
 41                return "You do not have permission to do that."
 42            case .notFound, .noRows, .missingReference, .unknownRevision:
 43                return "That content is no longer available."
 44            case .serviceNotProvisioned:
 45                return "That account needs to activate this SourceHut service before this action can succeed."
 46            case .validation:
 47                return errors.primaryMessage ?? "Please review your changes and try again."
 48            case .other:
 49                return "Something went wrong. Please try again."
 50            }
 51        case .httpError(let code):
 52            if code == 401 {
 53                return "Please sign in again."
 54            }
 55            if code == 403 {
 56                return "You do not have permission to do that."
 57            }
 58            if code == 404 {
 59                return "That content is no longer available."
 60            }
 61            if (500...599).contains(code) {
 62                return "The server is unavailable right now. Please try again."
 63            }
 64            return "Something went wrong. Please try again."
 65        case .invalidAuthenticatedURL:
 66            return "That request could not be completed."
 67        case .decodingError:
 68            return "The response could not be loaded right now."
 69        case .networkError(let error):
 70            let nsError = error as NSError
 71            switch nsError.code {
 72            case NSURLErrorNotConnectedToInternet,
 73                 NSURLErrorNetworkConnectionLost,
 74                 NSURLErrorTimedOut,
 75                 NSURLErrorCannotFindHost,
 76                 NSURLErrorCannotConnectToHost,
 77                 NSURLErrorDNSLookupFailed,
 78                 NSURLErrorInternationalRoamingOff,
 79                 NSURLErrorDataNotAllowed:
 80                return "Check your connection and try again."
 81            default:
 82                return "The network request failed. Please try again."
 83            }
 84        case .unauthorized:
 85            return "Please sign in again."
 86        }
 87    }
 88
 89    /// Whether this error represents a connectivity issue (no internet, timeout, DNS).
 90    var isConnectivityError: Bool {
 91        switch self {
 92        case .networkError(let error):
 93            let nsError = error as NSError
 94            let connectivityCodes: Set<Int> = [
 95                NSURLErrorNotConnectedToInternet,
 96                NSURLErrorNetworkConnectionLost,
 97                NSURLErrorTimedOut,
 98                NSURLErrorCannotFindHost,
 99                NSURLErrorCannotConnectToHost,
100                NSURLErrorDNSLookupFailed,
101                NSURLErrorInternationalRoamingOff,
102                NSURLErrorDataNotAllowed
103            ]
104            return connectivityCodes.contains(nsError.code)
105        default:
106            return false
107        }
108    }
109}
110
111extension Error {
112    nonisolated var userFacingMessage: String {
113        if let error = self as? SRHTError {
114            return error.userFacingMessage
115        }
116
117        let nsError = self as NSError
118        switch nsError.code {
119        case NSURLErrorNotConnectedToInternet,
120             NSURLErrorNetworkConnectionLost,
121             NSURLErrorTimedOut,
122             NSURLErrorCannotFindHost,
123             NSURLErrorCannotConnectToHost,
124             NSURLErrorDNSLookupFailed,
125             NSURLErrorInternationalRoamingOff,
126             NSURLErrorDataNotAllowed:
127            return "Check your connection and try again."
128        default:
129            return "Something went wrong. Please try again."
130        }
131    }
132
133    nonisolated var graphQLErrors: [GraphQLError]? {
134        guard let srhtError = self as? SRHTError,
135              case let SRHTError.graphQLErrors(errors) = srhtError else {
136            return nil
137        }
138        return errors
139    }
140
141    nonisolated func matchesGraphQLErrorClassification(_ classification: GraphQLErrorClassification) -> Bool {
142        graphQLErrors?.classification == classification
143    }
144
145    nonisolated func containsGraphQLErrorMessage(_ fragment: String) -> Bool {
146        graphQLErrors?.containsMessage(fragment) == true
147    }
148}
149
150/// A single error entry from the GraphQL `errors` array.
151struct GraphQLError: Decodable, Sendable {
152    let message: String
153    let locations: [GraphQLErrorLocation]?
154}
155
156enum GraphQLErrorClassification: Sendable {
157    case unauthorized
158    case forbidden
159    case notFound
160    case noRows
161    case missingReference
162    case unknownRevision
163    case serviceNotProvisioned
164    case validation
165    case other
166}
167
168extension Array where Element == GraphQLError {
169    nonisolated var classification: GraphQLErrorClassification {
170        if containsMessage("unauthorized") { return .unauthorized }
171        if containsMessage("forbidden") { return .forbidden }
172        if containsMessage("reference not found") { return .missingReference }
173        if containsMessage("no rows in result set") { return .noRows }
174        if containsMessage("unknown revision") || containsMessage("path not in the working tree") {
175            return .unknownRevision
176        }
177        if containsMessage("not found") || containsMessage("no such") || containsMessage("missing revision") {
178            return .notFound
179        }
180        if containsMessage("no such repository or user found") {
181            return .serviceNotProvisioned
182        }
183        if let primaryMessage, !primaryMessage.isEmpty {
184            return .validation
185        }
186        return .other
187    }
188
189    nonisolated var primaryMessage: String? {
190        let candidates = map(\.message)
191            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
192            .filter { !$0.isEmpty }
193        return candidates.first
194    }
195
196    nonisolated var diagnosticSummary: String {
197        map(\.message)
198            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
199            .filter { !$0.isEmpty }
200            .joined(separator: "\n")
201    }
202
203    nonisolated func containsMessage(_ fragment: String) -> Bool {
204        contains { $0.message.localizedCaseInsensitiveContains(fragment) }
205    }
206}
207
208struct GraphQLErrorLocation: Decodable, Sendable {
209    let line: Int
210    let column: Int
211}