diff options
| author | Christian Cleberg <[email protected]> | 2026-04-13 14:20:27 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-13 14:20:27 -0500 |
| commit | 5e63048ab6a741ae947f7fe42efcb5206a3f3963 (patch) | |
| tree | cf8eb2e3bc3b2edb20ef96a2599e684378120e5b /Hutch/Networking | |
| parent | 0b84ecaf7ba0fb2cd3f3867da8dc732450b55b50 (diff) | |
| download | hutch-5e63048ab6a741ae947f7fe42efcb5206a3f3963.tar.gz hutch-5e63048ab6a741ae947f7fe42efcb5206a3f3963.tar.bz2 hutch-5e63048ab6a741ae947f7fe42efcb5206a3f3963.zip | |
fix: mercurial parity and graphql handlingv3.0.4
- implements consistent UX between repo types
- ensures centralized services and routes are used between repo types
- graphql error handling is centralized
- error messages are consistent
Implements: https://todo.sr.ht/~ccleberg/hutch/58
Implements: https://todo.sr.ht/~ccleberg/hutch/59
Diffstat (limited to 'Hutch/Networking')
| -rw-r--r-- | Hutch/Networking/SRHTClient.swift | 33 | ||||
| -rw-r--r-- | Hutch/Networking/SRHTError.swift | 88 | ||||
| -rw-r--r-- | Hutch/Networking/SystemStatusRepository.swift | 51 |
3 files changed, 97 insertions, 75 deletions
diff --git a/Hutch/Networking/SRHTClient.swift b/Hutch/Networking/SRHTClient.swift index 1b8b9b3..7e1f619 100644 --- a/Hutch/Networking/SRHTClient.swift +++ b/Hutch/Networking/SRHTClient.swift @@ -89,19 +89,12 @@ final class SRHTClient: Sendable { throw SRHTError.unauthorized } if !(200...299).contains(http.statusCode) { - // Try to extract GraphQL errors from the response body even on non-2xx - if let gqlResponse = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), - let errors = gqlResponse.errors, !errors.isEmpty { - throw SRHTError.graphQLErrors(errors) - } + try throwGraphQLErrorsIfPresent(in: data) throw SRHTError.httpError(http.statusCode) } } - if let errorEnvelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), - let errors = errorEnvelope.errors, !errors.isEmpty { - throw SRHTError.graphQLErrors(errors) - } + try throwGraphQLErrorsIfPresent(in: data) // Decode GraphQL response envelope let graphQLResponse: GraphQLResponse<T> @@ -249,14 +242,12 @@ final class SRHTClient: Sendable { throw SRHTError.unauthorized } if !(200...299).contains(http.statusCode) { + try throwGraphQLErrorsIfPresent(in: data) throw SRHTError.httpError(http.statusCode) } } - if let errorEnvelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), - let errors = errorEnvelope.errors, !errors.isEmpty { - throw SRHTError.graphQLErrors(errors) - } + try throwGraphQLErrorsIfPresent(in: data) let graphQLResponse: GraphQLResponse<T> do { @@ -381,14 +372,13 @@ final class SRHTClient: Sendable { throw SRHTError.unauthorized } if !(200...299).contains(http.statusCode) { - if let gqlResponse = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), - let errors = gqlResponse.errors, !errors.isEmpty { - throw SRHTError.graphQLErrors(errors) - } + try throwGraphQLErrorsIfPresent(in: data) throw SRHTError.httpError(http.statusCode) } } + try throwGraphQLErrorsIfPresent(in: data) + let graphQLResponse: GraphQLResponse<T> do { graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data) @@ -474,6 +464,7 @@ final class SRHTClient: Sendable { throw SRHTError.unauthorized } if !(200...299).contains(http.statusCode) { + try throwGraphQLErrorsIfPresent(in: data) throw SRHTError.httpError(http.statusCode) } } @@ -627,6 +618,14 @@ final class SRHTClient: Sendable { // MARK: - Data Helper private extension SRHTClient { + func throwGraphQLErrorsIfPresent(in data: Data) throws { + if let envelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), + let errors = envelope.errors, + !errors.isEmpty { + throw SRHTError.graphQLErrors(errors) + } + } + static func isTrustedAuthenticatedTextURL(_ url: URL) -> Bool { guard url.scheme?.localizedCaseInsensitiveCompare("https") == .orderedSame, let host = url.host?.lowercased() else { diff --git a/Hutch/Networking/SRHTError.swift b/Hutch/Networking/SRHTError.swift index b9fc4c8..e946774 100644 --- a/Hutch/Networking/SRHTError.swift +++ b/Hutch/Networking/SRHTError.swift @@ -18,7 +18,7 @@ enum SRHTError: LocalizedError, Sendable { var errorDescription: String? { switch self { case .graphQLErrors(let errors): - let messages = errors.map(\.message).joined(separator: "\n") + let messages = errors.diagnosticSummary return "GraphQL error: \(messages)" case .httpError(let code): return "Server returned HTTP \(code)." @@ -33,17 +33,21 @@ enum SRHTError: LocalizedError, Sendable { } } - var userFacingMessage: String { + nonisolated var userFacingMessage: String { switch self { case .graphQLErrors(let errors): - let firstMessage = errors.first?.message.lowercased() ?? "" - if firstMessage.contains("unauthorized") || firstMessage.contains("forbidden") { + switch errors.classification { + case .unauthorized, .forbidden: return "You do not have permission to do that." - } - if firstMessage.contains("not found") || firstMessage.contains("no rows in result set") { + case .notFound, .noRows, .missingReference, .unknownRevision: return "That content is no longer available." + case .serviceNotProvisioned: + return "That account needs to activate this SourceHut service before this action can succeed." + case .validation: + return errors.primaryMessage ?? "Please review your changes and try again." + case .other: + return "Something went wrong. Please try again." } - return "Something went wrong. Please try again." case .httpError(let code): if code == 401 { return "Please sign in again." @@ -105,7 +109,7 @@ enum SRHTError: LocalizedError, Sendable { } extension Error { - var userFacingMessage: String { + nonisolated var userFacingMessage: String { if let error = self as? SRHTError { return error.userFacingMessage } @@ -125,6 +129,22 @@ extension Error { return "Something went wrong. Please try again." } } + + nonisolated var graphQLErrors: [GraphQLError]? { + guard let srhtError = self as? SRHTError, + case let SRHTError.graphQLErrors(errors) = srhtError else { + return nil + } + return errors + } + + nonisolated func matchesGraphQLErrorClassification(_ classification: GraphQLErrorClassification) -> Bool { + graphQLErrors?.classification == classification + } + + nonisolated func containsGraphQLErrorMessage(_ fragment: String) -> Bool { + graphQLErrors?.containsMessage(fragment) == true + } } /// A single error entry from the GraphQL `errors` array. @@ -133,6 +153,58 @@ struct GraphQLError: Decodable, Sendable { let locations: [GraphQLErrorLocation]? } +enum GraphQLErrorClassification: Sendable { + case unauthorized + case forbidden + case notFound + case noRows + case missingReference + case unknownRevision + case serviceNotProvisioned + case validation + case other +} + +extension Array where Element == GraphQLError { + nonisolated var classification: GraphQLErrorClassification { + if containsMessage("unauthorized") { return .unauthorized } + if containsMessage("forbidden") { return .forbidden } + if containsMessage("reference not found") { return .missingReference } + if containsMessage("no rows in result set") { return .noRows } + if containsMessage("unknown revision") || containsMessage("path not in the working tree") { + return .unknownRevision + } + if containsMessage("not found") || containsMessage("no such") || containsMessage("missing revision") { + return .notFound + } + if containsMessage("no such repository or user found") { + return .serviceNotProvisioned + } + if let primaryMessage, !primaryMessage.isEmpty { + return .validation + } + return .other + } + + nonisolated var primaryMessage: String? { + let candidates = map(\.message) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return candidates.first + } + + nonisolated var diagnosticSummary: String { + map(\.message) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: "\n") + } + + nonisolated func containsMessage(_ fragment: String) -> Bool { + contains { $0.message.localizedCaseInsensitiveContains(fragment) } + } +} + struct GraphQLErrorLocation: Decodable, Sendable { let line: Int let column: Int diff --git a/Hutch/Networking/SystemStatusRepository.swift b/Hutch/Networking/SystemStatusRepository.swift index 5f51315..028c64e 100644 --- a/Hutch/Networking/SystemStatusRepository.swift +++ b/Hutch/Networking/SystemStatusRepository.swift @@ -130,56 +130,7 @@ actor SystemStatusRepository { } private func refreshErrorMessage(from error: any Error) -> String { - if let error = error as? SRHTError { - switch error { - case .graphQLErrors(let errors): - let firstMessage = errors.first?.message.lowercased() ?? "" - if firstMessage.contains("unauthorized") || firstMessage.contains("forbidden") { - return "You do not have permission to do that." - } - if firstMessage.contains("not found") || firstMessage.contains("no rows in result set") { - return "That content is no longer available." - } - return "Something went wrong. Please try again." - case .httpError(let code): - if code == 401 { - return "Please sign in again." - } - if code == 403 { - return "You do not have permission to do that." - } - if code == 404 { - return "That content is no longer available." - } - if (500...599).contains(code) { - return "The server is unavailable right now. Please try again." - } - return "Something went wrong. Please try again." - case .invalidAuthenticatedURL: - return "That request could not be completed." - case .decodingError: - return "The response could not be loaded right now." - case .networkError(let underlyingError): - return refreshErrorMessage(from: underlyingError) - case .unauthorized: - return "Please sign in again." - } - } - - let nsError = error as NSError - switch nsError.code { - case NSURLErrorNotConnectedToInternet, - NSURLErrorNetworkConnectionLost, - NSURLErrorTimedOut, - NSURLErrorCannotFindHost, - NSURLErrorCannotConnectToHost, - NSURLErrorDNSLookupFailed, - NSURLErrorInternationalRoamingOff, - NSURLErrorDataNotAllowed: - return "Check your connection and try again." - default: - return "Something went wrong. Please try again." - } + error.userFacingMessage } } |
