From 2ae6f8ea036cb8ba87e44fc5645ee30998930168 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Mon, 9 Dec 2024 19:45:17 +0100 Subject: [PATCH 1/3] Rename localizedDescription to userFriendlyError & provide first overloads --- .../ErrorKit+CoreData.swift | 14 +- .../ErrorKit+Foundation.swift | 26 +- .../ErrorKit+MapKit.swift | 12 +- Sources/ErrorKit/ErrorKit.swift | 6 +- Sources/ErrorKit/Throwable.swift | 22 +- .../TypedOverloads/FileManager+ErrorKit.swift | 250 ++++++++++++++++++ .../TypedOverloads/URLSession+ErrorKit.swift | 119 +++++++++ 7 files changed, 409 insertions(+), 40 deletions(-) rename Sources/ErrorKit/{CommonErrors => EnhancedDescriptions}/ErrorKit+CoreData.swift (73%) rename Sources/ErrorKit/{CommonErrors => EnhancedDescriptions}/ErrorKit+Foundation.swift (76%) rename Sources/ErrorKit/{CommonErrors => EnhancedDescriptions}/ErrorKit+MapKit.swift (77%) create mode 100644 Sources/ErrorKit/TypedOverloads/FileManager+ErrorKit.swift create mode 100644 Sources/ErrorKit/TypedOverloads/URLSession+ErrorKit.swift diff --git a/Sources/ErrorKit/CommonErrors/ErrorKit+CoreData.swift b/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+CoreData.swift similarity index 73% rename from Sources/ErrorKit/CommonErrors/ErrorKit+CoreData.swift rename to Sources/ErrorKit/EnhancedDescriptions/ErrorKit+CoreData.swift index c02a697..4759740 100644 --- a/Sources/ErrorKit/CommonErrors/ErrorKit+CoreData.swift +++ b/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+CoreData.swift @@ -12,43 +12,43 @@ extension ErrorKit { case NSPersistentStoreSaveError: return String( - localized: "CommonErrors.CoreData.NSPersistentStoreSaveError", + localized: "EnhancedDescriptions.CoreData.NSPersistentStoreSaveError", defaultValue: "Failed to save the data. Please try again.", bundle: .module ) case NSValidationMultipleErrorsError: return String( - localized: "CommonErrors.CoreData.NSValidationMultipleErrorsError", + localized: "EnhancedDescriptions.CoreData.NSValidationMultipleErrorsError", defaultValue: "Multiple validation errors occurred while saving.", bundle: .module ) case NSValidationMissingMandatoryPropertyError: return String( - localized: "CommonErrors.CoreData.NSValidationMissingMandatoryPropertyError", + localized: "EnhancedDescriptions.CoreData.NSValidationMissingMandatoryPropertyError", defaultValue: "A mandatory property is missing. Please fill all required fields.", bundle: .module ) case NSValidationRelationshipLacksMinimumCountError: return String( - localized: "CommonErrors.CoreData.NSValidationRelationshipLacksMinimumCountError", + localized: "EnhancedDescriptions.CoreData.NSValidationRelationshipLacksMinimumCountError", defaultValue: "A relationship is missing required related objects.", bundle: .module ) case NSPersistentStoreIncompatibleVersionHashError: return String( - localized: "CommonErrors.CoreData.NSPersistentStoreIncompatibleVersionHashError", + localized: "EnhancedDescriptions.CoreData.NSPersistentStoreIncompatibleVersionHashError", defaultValue: "The data store is incompatible with the current model version.", bundle: .module ) case NSPersistentStoreOpenError: return String( - localized: "CommonErrors.CoreData.NSPersistentStoreOpenError", + localized: "EnhancedDescriptions.CoreData.NSPersistentStoreOpenError", defaultValue: "Unable to open the persistent store. Please check your storage or permissions.", bundle: .module ) case NSManagedObjectValidationError: return String( - localized: "CommonErrors.CoreData.NSManagedObjectValidationError", + localized: "EnhancedDescriptions.CoreData.NSManagedObjectValidationError", defaultValue: "An object validation error occurred.", bundle: .module ) diff --git a/Sources/ErrorKit/CommonErrors/ErrorKit+Foundation.swift b/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+Foundation.swift similarity index 76% rename from Sources/ErrorKit/CommonErrors/ErrorKit+Foundation.swift rename to Sources/ErrorKit/EnhancedDescriptions/ErrorKit+Foundation.swift index 995b451..88a735f 100644 --- a/Sources/ErrorKit/CommonErrors/ErrorKit+Foundation.swift +++ b/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+Foundation.swift @@ -12,31 +12,31 @@ extension ErrorKit { switch urlError.code { case .notConnectedToInternet: return String( - localized: "CommonErrors.URLError.notConnectedToInternet", + localized: "EnhancedDescriptions.URLError.notConnectedToInternet", defaultValue: "You are not connected to the Internet. Please check your connection.", bundle: .module ) case .timedOut: return String( - localized: "CommonErrors.URLError.timedOut", + localized: "EnhancedDescriptions.URLError.timedOut", defaultValue: "The request timed out. Please try again later.", bundle: .module ) case .cannotFindHost: return String( - localized: "CommonErrors.URLError.cannotFindHost", + localized: "EnhancedDescriptions.URLError.cannotFindHost", defaultValue: "Unable to find the server. Please check the URL or your network.", bundle: .module ) case .networkConnectionLost: return String( - localized: "CommonErrors.URLError.networkConnectionLost", + localized: "EnhancedDescriptions.URLError.networkConnectionLost", defaultValue: "The network connection was lost. Please try again.", bundle: .module ) default: return String( - localized: "CommonErrors.URLError.default", + localized: "EnhancedDescriptions.URLError.default", defaultValue: "A network error occurred: \(urlError.localizedDescription)", bundle: .module ) @@ -47,25 +47,25 @@ extension ErrorKit { switch cocoaError.code { case .fileNoSuchFile: return String( - localized: "CommonErrors.CocoaError.fileNoSuchFile", + localized: "EnhancedDescriptions.CocoaError.fileNoSuchFile", defaultValue: "The file could not be found.", bundle: .module ) case .fileReadNoPermission: return String( - localized: "CommonErrors.CocoaError.fileReadNoPermission", + localized: "EnhancedDescriptions.CocoaError.fileReadNoPermission", defaultValue: "You do not have permission to read this file.", bundle: .module ) case .fileWriteOutOfSpace: return String( - localized: "CommonErrors.CocoaError.fileWriteOutOfSpace", + localized: "EnhancedDescriptions.CocoaError.fileWriteOutOfSpace", defaultValue: "There is not enough disk space to complete the operation.", bundle: .module ) default: return String( - localized: "CommonErrors.CocoaError.default", + localized: "EnhancedDescriptions.CocoaError.default", defaultValue: "A file system error occurred: \(cocoaError.localizedDescription)", bundle: .module ) @@ -76,25 +76,25 @@ extension ErrorKit { switch posixError.code { case .ENOSPC: return String( - localized: "CommonErrors.POSIXError.ENOSPC", + localized: "EnhancedDescriptions.POSIXError.ENOSPC", defaultValue: "There is no space left on the device.", bundle: .module ) case .EACCES: return String( - localized: "CommonErrors.POSIXError.EACCES", + localized: "EnhancedDescriptions.POSIXError.EACCES", defaultValue: "Permission denied. Please check your file permissions.", bundle: .module ) case .EBADF: return String( - localized: "CommonErrors.POSIXError.EBADF", + localized: "EnhancedDescriptions.POSIXError.EBADF", defaultValue: "Bad file descriptor. The file may be closed or invalid.", bundle: .module ) default: return String( - localized: "CommonErrors.POSIXError.default", + localized: "EnhancedDescriptions.POSIXError.default", defaultValue: "A system error occurred: \(posixError.localizedDescription)", bundle: .module ) diff --git a/Sources/ErrorKit/CommonErrors/ErrorKit+MapKit.swift b/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+MapKit.swift similarity index 77% rename from Sources/ErrorKit/CommonErrors/ErrorKit+MapKit.swift rename to Sources/ErrorKit/EnhancedDescriptions/ErrorKit+MapKit.swift index deedebf..6ca9502 100644 --- a/Sources/ErrorKit/CommonErrors/ErrorKit+MapKit.swift +++ b/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+MapKit.swift @@ -9,37 +9,37 @@ extension ErrorKit { switch mkError.code { case .unknown: return String( - localized: "CommonErrors.MKError.unknown", + localized: "EnhancedDescriptions.MKError.unknown", defaultValue: "An unknown error occurred in MapKit.", bundle: .module ) case .serverFailure: return String( - localized: "CommonErrors.MKError.serverFailure", + localized: "EnhancedDescriptions.MKError.serverFailure", defaultValue: "The MapKit server returned an error. Please try again later.", bundle: .module ) case .loadingThrottled: return String( - localized: "CommonErrors.MKError.loadingThrottled", + localized: "EnhancedDescriptions.MKError.loadingThrottled", defaultValue: "Map loading is being throttled. Please wait a moment and try again.", bundle: .module ) case .placemarkNotFound: return String( - localized: "CommonErrors.MKError.placemarkNotFound", + localized: "EnhancedDescriptions.MKError.placemarkNotFound", defaultValue: "The requested placemark could not be found. Please check the location details.", bundle: .module ) case .directionsNotFound: return String( - localized: "CommonErrors.MKError.directionsNotFound", + localized: "EnhancedDescriptions.MKError.directionsNotFound", defaultValue: "No directions could be found for the specified route.", bundle: .module ) default: return String( - localized: "CommonErrors.MKError.default", + localized: "EnhancedDescriptions.MKError.default", defaultValue: "A MapKit error occurred: \(mkError.localizedDescription)", bundle: .module ) diff --git a/Sources/ErrorKit/ErrorKit.swift b/Sources/ErrorKit/ErrorKit.swift index 1ac1731..60ce4f9 100644 --- a/Sources/ErrorKit/ErrorKit.swift +++ b/Sources/ErrorKit/ErrorKit.swift @@ -20,14 +20,14 @@ public enum ErrorKit { /// let url = URL(string: "https://example.com")! /// let _ = try Data(contentsOf: url) /// } catch { - /// print(ErrorKit.enhancedDescription(for: error)) + /// print(ErrorKit.userFriendlyMessage(for: error)) /// // Output: "You are not connected to the Internet. Please check your connection." (if applicable) /// } /// ``` - public static func enhancedDescription(for error: Error) -> String { + public static func userFriendlyMessage(for error: Error) -> String { // Any types conforming to `Throwable` are assumed to already have a good description if let throwable = error as? Throwable { - return throwable.localizedDescription + return throwable.userFriendlyMessage } if let foundationDescription = Self.enhancedFoundationDescription(for: error) { diff --git a/Sources/ErrorKit/Throwable.swift b/Sources/ErrorKit/Throwable.swift index 841fdc0..e21f6ef 100644 --- a/Sources/ErrorKit/Throwable.swift +++ b/Sources/ErrorKit/Throwable.swift @@ -1,29 +1,29 @@ import Foundation -/// A protocol that makes error handling in Swift more intuitive by requiring a user-friendly `localizedDescription` property. +/// A protocol that makes error handling in Swift more intuitive by requiring a `userFriendlyMessage` property. /// /// `Throwable` extends `LocalizedError` and simplifies the process of defining error messages, /// ensuring that developers can provide meaningful feedback for errors without the confusion associated with Swift's native `Error` and `LocalizedError` types. /// /// ### Key Features: -/// - Requires a `localizedDescription`, making it easier to provide custom error messages. -/// - Offers a default implementation for `errorDescription`, ensuring smooth integration with `LocalizedError`. +/// - Requires a `userFriendlyMessage`, making it easier to provide custom error messages. +/// - Offers a default implementation for `errorDescription`, ensuring smooth integration with `LocalizedError` and `.localizedDescription`. /// - Supports `RawRepresentable` enums with `String` as `RawValue` to minimize boilerplate. /// /// ### Why Use `Throwable`? -/// - **Simplified API**: Unlike `LocalizedError`, `Throwable` focuses on a single requirement: `localizedDescription`. +/// - **Simplified API**: Unlike `LocalizedError`, `Throwable` focuses on a single requirement: `userFriendlyMessage`. /// - **Intuitive Naming**: The name aligns with Swift's `throw` keyword and other common `-able` protocols like `Codable`. /// - **Readable Error Handling**: Provides concise, human-readable error descriptions. /// /// ### Usage Example: /// -/// #### 1. Custom Error with Manual `localizedDescription`: +/// #### 1. Custom Error with Manual `userFriendlyMessage`: /// ```swift /// enum NetworkError: Throwable { /// case noConnectionToServer /// case parsingFailed /// -/// var localizedDescription: String { +/// var userFriendlyMessage: String { /// switch self { /// case .noConnectionToServer: "Unable to connect to the server." /// case .parsingFailed: "Data parsing failed." @@ -61,23 +61,23 @@ import Foundation /// public protocol Throwable: LocalizedError { /// A human-readable error message describing the error. - var localizedDescription: String { get } + var userFriendlyMessage: String { get } } // MARK: - Default Implementations /// Provides a default implementation for `Throwable` when the conforming type is a `RawRepresentable` with a `String` raw value. /// -/// This allows enums with `String` raw values to automatically use the raw value as the error's `localizedDescription`. +/// This allows enums with `String` raw values to automatically use the raw value as the error's `userFriendlyMessage`. extension Throwable where Self: RawRepresentable, RawValue == String { - public var localizedDescription: String { + public var userFriendlyMessage: String { self.rawValue } } -/// Provides a default implementation for `errorDescription` required by `LocalizedError`, ensuring it returns the value of `localizedDescription`. +/// Provides a default implementation for `errorDescription` required by `LocalizedError`, ensuring it returns the value of `userFriendlyMessage`. extension Throwable { public var errorDescription: String? { - self.localizedDescription + self.userFriendlyMessage } } diff --git a/Sources/ErrorKit/TypedOverloads/FileManager+ErrorKit.swift b/Sources/ErrorKit/TypedOverloads/FileManager+ErrorKit.swift new file mode 100644 index 0000000..12c4c28 --- /dev/null +++ b/Sources/ErrorKit/TypedOverloads/FileManager+ErrorKit.swift @@ -0,0 +1,250 @@ +import Foundation + +/// An enumeration that represents various errors that can occur when performing file management operations. +public enum FileManagerError: Throwable { + /// The specified file or directory could not be found. + /// - This error occurs when an operation targets a file or directory that doesn't exist. + case fileNotFound + + /// You do not have permission to read the specified file or directory. + /// - This error happens when the system denies read access due to permission restrictions. + case noReadPermission + + /// You do not have permission to write to the specified file or directory. + /// - This error occurs when the system denies write access due to permission restrictions. + case noWritePermission + + /// There is not enough disk space to complete the operation. + /// - This error occurs when the file system runs out of space while attempting to write or copy files. + case outOfSpace + + /// The file name is invalid and cannot be used. + /// - This error happens when the specified file name contains illegal characters or formats. + case invalidFileName + + /// The file is corrupted or in an unreadable format. + /// - This error occurs when attempting to read a file that is damaged or in an unsupported format. + case corruptFile + + /// The file is locked and cannot be modified. + /// - This error happens when attempting to modify or delete a file that is locked by another process or the system. + case fileLocked + + /// An unknown error occurred while reading the file. + /// - This error is thrown when an unexpected issue happens during a read operation that doesn't match other error types. + case readError + + /// An unknown error occurred while writing the file. + /// - This error is thrown when an unexpected issue happens during a write operation that doesn't match other error types. + case writeError + + /// The file's character encoding is not supported. + /// - This error occurs when the system cannot decode the file due to an unsupported character encoding. + case unsupportedEncoding + + /// The file is too large to be processed. + /// - This error occurs when a file exceeds system limits, such as memory constraints, making it impossible to handle. + case fileTooLarge + + /// The storage volume is read-only and cannot be modified. + /// - This error happens when attempting to modify a file on a read-only volume, such as a disk or network drive. + case volumeReadOnly + + /// The file or directory already exists. + /// - This error is thrown when attempting to create a file or directory that already exists at the specified location. + case fileExists + + /// A general error case for any other unforeseen errors. + /// - This error is used when the underlying error does not match any of the predefined cases and is passed as a wrapped error. + case other(Error) + + /// Returns a user-friendly error message based on the error case. + /// + /// The message is localized for the user, with a default fallback message. + public var userFriendlyMessage: String { + switch self { + case .fileNotFound: + String( + localized: "TypedOverloads.FileManager.fileNotFound", + defaultValue: "The specified file or directory could not be found.", + bundle: .module + ) + case .noReadPermission: + String( + localized: "TypedOverloads.FileManager.noReadPermission", + defaultValue: "You do not have permission to read this file or directory.", + bundle: .module + ) + case .noWritePermission: + String( + localized: "TypedOverloads.FileManager.noWritePermission", + defaultValue: "You do not have permission to write to this file or directory.", + bundle: .module + ) + case .outOfSpace: + String( + localized: "TypedOverloads.FileManager.outOfSpace", + defaultValue: "There is not enough disk space to complete the operation.", + bundle: .module + ) + case .invalidFileName: + String( + localized: "TypedOverloads.FileManager.invalidFileName", + defaultValue: "The file name is invalid and cannot be used.", + bundle: .module + ) + case .corruptFile: + String( + localized: "TypedOverloads.FileManager.corruptFile", + defaultValue: "The file is corrupted or in an unreadable format.", + bundle: .module + ) + case .fileLocked: + String( + localized: "TypedOverloads.FileManager.fileLocked", + defaultValue: "The file is locked and cannot be modified.", + bundle: .module + ) + case .readError: + String( + localized: "TypedOverloads.FileManager.readError", + defaultValue: "An unknown error occurred while reading the file.", + bundle: .module + ) + case .writeError: + String( + localized: "TypedOverloads.FileManager.writeError", + defaultValue: "An unknown error occurred while writing the file.", + bundle: .module + ) + case .unsupportedEncoding: + String( + localized: "TypedOverloads.FileManager.unsupportedEncoding", + defaultValue: "The file's character encoding is not supported.", + bundle: .module + ) + case .fileTooLarge: + String( + localized: "TypedOverloads.FileManager.fileTooLarge", + defaultValue: "The file is too large to be processed.", + bundle: .module + ) + case .volumeReadOnly: + String( + localized: "TypedOverloads.FileManager.volumeReadOnly", + defaultValue: "The storage volume is read-only and cannot be modified.", + bundle: .module + ) + case .fileExists: + String( + localized: "TypedOverloads.FileManager.fileExists", + defaultValue: "The file or directory already exists.", + bundle: .module + ) + case .other(let error): + ErrorKit.userFriendlyMessage(for: error) + } + } +} + +extension FileManager { + /// A typed-throws overload of `createDirectory(at:)` that maps known errors to a custom ``FileManagerError`` enum for enhanced error handling. + public func throwableCreateDirectory(at url: URL, withIntermediateDirectories createIntermediates: Bool = false, attributes: [FileAttributeKey : Any]? = nil) throws(FileManagerError) { + do { + try self.createDirectory(at: url, withIntermediateDirectories: createIntermediates, attributes: attributes) + } catch let error as NSError { + throw self.mapToThrowable(error: error) + } + } + + /// A typed-throws overload of `createDirectory(atPath:)` that maps known errors to a custom ``FileManagerError`` enum for enhanced error handling. + public func throwableCreateDirectory(atPath path: String, withIntermediateDirectories createIntermediates: Bool = false, attributes: [FileAttributeKey : Any]? = nil) throws(FileManagerError) { + do { + try self.createDirectory(atPath: path, withIntermediateDirectories: createIntermediates, attributes: attributes) + } catch let error as NSError { + throw self.mapToThrowable(error: error) + } + } + + /// A typed-throws overload of `removeItem(at:)` that maps known errors to a custom ``FileManagerError`` enum for enhanced error handling. + public func throwableRemoveItem(at url: URL) throws(FileManagerError) { + do { + try self.removeItem(at: url) + } catch let error as NSError { + throw self.mapToThrowable(error: error) + } + } + + /// A typed-throws overload of `removeItem(atPath:)` that maps known errors to a custom ``FileManagerError`` enum for enhanced error handling. + public func throwableRemoveItem(atPath path: String) throws(FileManagerError) { + do { + try self.removeItem(atPath: path) + } catch let error as NSError { + throw self.mapToThrowable(error: error) + } + } + + /// A typed-throws overload of `copyItem(at:)` that maps known errors to a custom ``FileManagerError`` enum for enhanced error handling. + public func throwableCopyItem(at sourceURL: URL, to destinationURL: URL) throws(FileManagerError) { + do { + try self.copyItem(at: sourceURL, to: destinationURL) + } catch let error as NSError { + throw self.mapToThrowable(error: error) + } + } + + /// A typed-throws overload of `copyItem(atPath:)` that maps known errors to a custom ``FileManagerError`` enum for enhanced error handling. + public func throwableCopyItem(atPath sourcePath: String, toPath destinationPath: String) throws(FileManagerError) { + do { + try self.copyItem(atPath: sourcePath, toPath: destinationPath) + } catch let error as NSError { + throw self.mapToThrowable(error: error) + } + } + + /// A typed-throws overload of `moveItem(at:)` that maps known errors to a custom ``FileManagerError`` enum for enhanced error handling. + public func throwableMoveItem(at sourceURL: URL, to destinationURL: URL) throws(FileManagerError) { + do { + try self.moveItem(at: sourceURL, to: destinationURL) + } catch let error as NSError { + throw self.mapToThrowable(error: error) + } + } + + /// A typed-throws overload of `moveItem(atPath:)` that maps known errors to a custom ``FileManagerError`` enum for enhanced error handling. + public func throwableMoveItem(atPath sourcePath: String, toPath destinationPath: String) throws(FileManagerError) { + do { + try self.moveItem(atPath: sourcePath, toPath: destinationPath) + } catch let error as NSError { + throw self.mapToThrowable(error: error) + } + } + + /// A typed-throws overload of `attributesOfItem(atPath:)` that maps known errors to a custom ``FileManagerError`` enum for enhanced error handling. + public func throwableAttributesOfItem(atPath path: String) throws(FileManagerError) -> [FileAttributeKey: Any] { + do { + return try self.attributesOfItem(atPath: path) + } catch let error as NSError { + throw self.mapToThrowable(error: error) + } + } + + private func mapToThrowable(error: NSError) -> FileManagerError { + switch (error.domain, error.code) { + case (NSCocoaErrorDomain, NSFileNoSuchFileError): .fileNotFound + case (NSCocoaErrorDomain, NSFileReadNoPermissionError): .noReadPermission + case (NSCocoaErrorDomain, NSFileWriteNoPermissionError): .noWritePermission + case (NSCocoaErrorDomain, NSFileWriteOutOfSpaceError): .outOfSpace + case (NSCocoaErrorDomain, NSFileWriteInvalidFileNameError): .invalidFileName + case (NSCocoaErrorDomain, NSFileReadCorruptFileError): .corruptFile + case (NSCocoaErrorDomain, NSFileLockingError): .fileLocked + case (NSCocoaErrorDomain, NSFileReadUnknownError): .readError + case (NSCocoaErrorDomain, NSFileWriteUnknownError): .writeError + case (NSCocoaErrorDomain, NSFileReadInapplicableStringEncodingError): .unsupportedEncoding + case (NSCocoaErrorDomain, NSFileReadTooLargeError): .fileTooLarge + case (NSCocoaErrorDomain, NSFileWriteVolumeReadOnlyError): .volumeReadOnly + case (NSCocoaErrorDomain, NSFileWriteFileExistsError): .fileExists + default: .other(error) + } + } +} diff --git a/Sources/ErrorKit/TypedOverloads/URLSession+ErrorKit.swift b/Sources/ErrorKit/TypedOverloads/URLSession+ErrorKit.swift new file mode 100644 index 0000000..480de95 --- /dev/null +++ b/Sources/ErrorKit/TypedOverloads/URLSession+ErrorKit.swift @@ -0,0 +1,119 @@ +import Foundation + +/// An enumeration that represents various errors that can occur when performing network requests with `URLSession`. +public enum URLSessionError: Throwable { + /// The request timed out. + case timeout + + /// The network connection was lost. + case connectionLost + + /// The server returned a 404 Not Found status code. + case notFound + + /// The server returned a 500 Internal Server Error status code. + case serverError + + /// A network error occurred that doesn't match a specific case. + case networkError(Error) + + /// An unknown error occurred. + case unknownStatusCode(Int) + + /// A general error case for any other unforeseen errors. + case other(Error) + + public var userFriendlyMessage: String { + switch self { + case .timeout: + return String( + localized: "TypedOverloads.URLSession.timeout", + defaultValue: "The request timed out. Please try again.", + bundle: .module + ) + case .connectionLost: + return String( + localized: "TypedOverloads.URLSession.connectionLost", + defaultValue: "The network connection was lost. Please check your connection.", + bundle: .module + ) + case .notFound: + return String( + localized: "TypedOverloads.URLSession.notFound", + defaultValue: "The requested resource could not be found (404).", + bundle: .module + ) + case .serverError: + return String( + localized: "TypedOverloads.URLSession.serverError", + defaultValue: "The server encountered an error (500). Please try again later.", + bundle: .module + ) + case .networkError(let error): + return ErrorKit.userFriendlyMessage(for: error) + case .unknownStatusCode(let statusCode): + return String( + localized: "TypedOverloads.URLSession.unknown", + defaultValue: "An unknown status code was received from the server: \(statusCode)", + bundle: .module + ) + case .other(let error): + return ErrorKit.userFriendlyMessage(for: error) + } + } +} + +extension URLSession { + /// A typed-throws overload of `data(for:)` that maps known errors to a custom `URLSessionError` enum for enhanced error handling. + public func throwableData(for request: URLRequest) async throws -> (Data, URLResponse) { + do { + return try await self.data(for: request) + } catch let error as NSError { + throw mapToThrowable(error: error) + } + } + + /// A typed-throws overload of `data(from:)` that maps known errors to a custom `URLSessionError` enum for enhanced error handling. + public func throwableData(from url: URL) async throws -> (Data, URLResponse) { + do { + return try await self.data(from: url) + } catch let error as NSError { + throw mapToThrowable(error: error) + } + } + + private func mapToThrowable(error: NSError) -> URLSessionError { + switch (error.domain, error.code) { + case (NSURLErrorDomain, NSURLErrorTimedOut): + return .timeout + case (NSURLErrorDomain, NSURLErrorNetworkConnectionLost): + return .connectionLost + case (NSURLErrorDomain, NSURLErrorCannotFindHost): + return .notFound + case (NSURLErrorDomain, NSURLErrorCannotConnectToHost): + return .serverError + default: + return .networkError(error) + } + } + + // TODO: continue here by fixing that status code is not being used at all & improve error cases overall + + /// A method to handle HTTP status codes. + public func handleHTTPStatusCode(_ statusCode: Int, data: Data?) throws -> Data? { + switch statusCode { + case 200...299: // Success range + return data + case 400...499: // Client errors (handle only 404 as a generic error) + if statusCode == 404 { + throw URLSessionError.notFound + } else { + return data // treat it as valid data response + } + case 500...599: // Server errors + throw URLSessionError.serverError + default: + throw URLSessionError.unknownStatusCode(statusCode) + } + } +} From bff16625650839e6072a0161b72ce4781d208e23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Thu, 12 Dec 2024 13:54:22 +0100 Subject: [PATCH 2/3] Apply renaming everywhere + finalize FileManager & URLSession overloads --- README.md | 22 +- .../ErrorKit+CoreData.swift | 2 +- .../ErrorKit+Foundation.swift | 2 +- .../ErrorKit+MapKit.swift | 2 +- Sources/ErrorKit/ErrorKit.swift | 10 +- .../ErrorKit/Resources/Localizable.xcstrings | 404 ++++++++++++++++-- .../TypedOverloads/FileManager+ErrorKit.swift | 36 +- .../TypedOverloads/URLSession+ErrorKit.swift | 208 +++++++-- Tests/ErrorKitTests/ErrorKitTests.swift | 14 +- Tests/ErrorKitTests/ThrowableTests.swift | 2 +- 10 files changed, 598 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index ea8ae21..c81258e 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ The correct approach is to conform to `LocalizedError`, which defines the follow - `recoverySuggestion: String?` - `helpAnchor: String?` -However, since all of these properties are optional, you won’t get any compiler errors if you forget to implement them. Worse, only `errorDescription` affects `localizedDescription`. Fields like `failureReason` and `recoverySuggestion` are ignored, while `helpAnchor` is rarely used today. +However, since all of these properties are optional, you won’t get any compiler errors if you forget to implement them. Worse, only `errorDescription` affects `localizedDescription`. Fields like `failureReason` and `recoverySuggestion` are ignored, while `helpAnchor` is rarely used nowadays. This makes `LocalizedError` both confusing and error-prone. @@ -71,11 +71,11 @@ To address these issues, **ErrorKit** introduces the `Throwable` protocol: ```swift public protocol Throwable: LocalizedError { - var localizedDescription: String { get } + var userFriendlyMessage: String { get } } ``` -This protocol is simple and clear. It’s named `Throwable` to align with Swift’s `throw` keyword and follows Swift’s convention of using the `able` suffix (like `Codable` and `Identifiable`). Most importantly, it requires the `localizedDescription` property, ensuring your errors behave exactly as expected. +This protocol is simple and clear. It’s named `Throwable` to align with Swift’s `throw` keyword and follows Swift’s convention of using the `able` suffix (like `Codable` and `Identifiable`). Most importantly, it requires the `userFriendlyMessage` property, ensuring your errors behave exactly as expected. Here’s how you use it: @@ -84,7 +84,7 @@ enum NetworkError: Throwable { case noConnectionToServer case parsingFailed - var localizedDescription: String { + var userFriendlyMessage: String { switch self { case .noConnectionToServer: "Unable to connect to the server." case .parsingFailed: "Data parsing failed." @@ -110,16 +110,16 @@ This approach eliminates boilerplate code while keeping the error definitions co ### Summary -> Conform your custom error types to `Throwable` instead of `Error` or `LocalizedError`. The `Throwable` protocol requires only `localizedDescription: String`, ensuring your error messages are exactly what you expect – no surprises. +> Conform your custom error types to `Throwable` instead of `Error` or `LocalizedError`. The `Throwable` protocol requires only `userFriendlyMessage: String`, ensuring your error messages are exactly what you expect – no surprises. -## Enhanced Error Descriptions with `enhancedDescription(for:)` +## Enhanced Error Descriptions with `userFriendlyMessage(for:)` -ErrorKit goes beyond simplifying error handling — it enhances the clarity of error messages by providing improved, localized descriptions. With the `ErrorKit.enhancedDescription(for:)` function, developers can deliver clear, user-friendly error messages tailored to their audience. +ErrorKit goes beyond simplifying error handling — it enhances the clarity of error messages by providing improved, localized descriptions. With the `ErrorKit.userFriendlyMessage(for:)` function, developers can deliver clear, user-friendly error messages tailored to their audience. ### How It Works -The `enhancedDescription(for:)` function analyzes the provided `Error` and returns an enhanced, localized message. It draws on a community-maintained collection of descriptions to ensure the messages are accurate, helpful, and continuously evolving. +The `userFriendlyMessage(for:)` function analyzes the provided `Error` and returns an enhanced, localized message. It draws on a community-maintained collection of descriptions to ensure the messages are accurate, helpful, and continuously evolving. ### Supported Error Domains @@ -127,7 +127,7 @@ ErrorKit supports errors from various domains such as `Foundation`, `CoreData`, ### Usage Example -Here’s how to use `enhancedDescription(for:)` to handle errors gracefully: +Here’s how to use `userFriendlyMessage(for:)` to handle errors gracefully: ```swift do { @@ -136,12 +136,12 @@ do { let _ = try Data(contentsOf: url) } catch { // Print or show the enhanced error message to a user - print(ErrorKit.enhancedDescription(for: error)) + print(ErrorKit.userFriendlyMessage(for: error)) // Example output: "You are not connected to the Internet. Please check your connection." } ``` -### Why Use `enhancedDescription(for:)`? +### Why Use `userFriendlyMessage(for:)`? - **Localization**: Error messages are localized to ~40 languages to provide a better user experience. - **Clarity**: Returns clear and concise error messages, avoiding cryptic system-generated descriptions. diff --git a/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+CoreData.swift b/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+CoreData.swift index 4759740..b0691cc 100644 --- a/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+CoreData.swift +++ b/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+CoreData.swift @@ -3,7 +3,7 @@ import CoreData #endif extension ErrorKit { - static func enhancedCoreDataDescription(for error: Error) -> String? { + static func userFriendlyCoreDataMessage(for error: Error) -> String? { #if canImport(CoreData) let nsError = error as NSError diff --git a/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+Foundation.swift b/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+Foundation.swift index 88a735f..10bc893 100644 --- a/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+Foundation.swift +++ b/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+Foundation.swift @@ -4,7 +4,7 @@ import FoundationNetworking #endif extension ErrorKit { - static func enhancedFoundationDescription(for error: Error) -> String? { + static func userFriendlyFoundationMessage(for error: Error) -> String? { switch error { // URLError: Networking errors diff --git a/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+MapKit.swift b/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+MapKit.swift index 6ca9502..9439c7f 100644 --- a/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+MapKit.swift +++ b/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+MapKit.swift @@ -3,7 +3,7 @@ import MapKit #endif extension ErrorKit { - static func enhancedMapKitDescription(for error: Error) -> String? { + static func userFriendlyMapKitMessage(for error: Error) -> String? { #if canImport(MapKit) if let mkError = error as? MKError { switch mkError.code { diff --git a/Sources/ErrorKit/ErrorKit.swift b/Sources/ErrorKit/ErrorKit.swift index 60ce4f9..a1dbcf7 100644 --- a/Sources/ErrorKit/ErrorKit.swift +++ b/Sources/ErrorKit/ErrorKit.swift @@ -6,11 +6,11 @@ public enum ErrorKit { /// This function analyzes the given `Error` and returns a clearer, more helpful message than the default system-provided description. /// All descriptions are localized, ensuring that users receive messages in their preferred language where available. /// - /// The list of enhanced descriptions is maintained and regularly improved by the developer community. Contributions are welcome—if you find bugs or encounter new errors, feel free to submit a pull request (PR) for review. + /// The list of user-friendly messages is maintained and regularly improved by the developer community. Contributions are welcome—if you find bugs or encounter new errors, feel free to submit a pull request (PR) for review. /// /// Errors from various domains, such as `Foundation`, `CoreData`, `MapKit`, and more, are supported. As the project evolves, additional domains may be included to ensure comprehensive coverage. /// - /// - Parameter error: The `Error` instance for which an enhanced description is needed. + /// - Parameter error: The `Error` instance for which a user-friendly message is needed. /// - Returns: A `String` containing an enhanced, localized, user-readable error message. /// /// ## Usage Example: @@ -30,15 +30,15 @@ public enum ErrorKit { return throwable.userFriendlyMessage } - if let foundationDescription = Self.enhancedFoundationDescription(for: error) { + if let foundationDescription = Self.userFriendlyFoundationMessage(for: error) { return foundationDescription } - if let coreDataDescription = Self.enhancedCoreDataDescription(for: error) { + if let coreDataDescription = Self.userFriendlyCoreDataMessage(for: error) { return coreDataDescription } - if let mapKitDescription = Self.enhancedMapKitDescription(for: error) { + if let mapKitDescription = Self.userFriendlyMapKitMessage(for: error) { return mapKitDescription } diff --git a/Sources/ErrorKit/Resources/Localizable.xcstrings b/Sources/ErrorKit/Resources/Localizable.xcstrings index 972e9ad..b9c39de 100644 --- a/Sources/ErrorKit/Resources/Localizable.xcstrings +++ b/Sources/ErrorKit/Resources/Localizable.xcstrings @@ -1,7 +1,7 @@ { "sourceLanguage" : "en", "strings" : { - "CommonErrors.CocoaError.default" : { + "EnhancedDescriptions.CocoaError.default" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -12,7 +12,7 @@ } } }, - "CommonErrors.CocoaError.fileNoSuchFile" : { + "EnhancedDescriptions.CocoaError.fileNoSuchFile" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -23,7 +23,7 @@ } } }, - "CommonErrors.CocoaError.fileReadNoPermission" : { + "EnhancedDescriptions.CocoaError.fileReadNoPermission" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -34,7 +34,7 @@ } } }, - "CommonErrors.CocoaError.fileWriteOutOfSpace" : { + "EnhancedDescriptions.CocoaError.fileWriteOutOfSpace" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -45,7 +45,7 @@ } } }, - "CommonErrors.CoreData.NSManagedObjectValidationError" : { + "EnhancedDescriptions.CoreData.NSManagedObjectValidationError" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -56,7 +56,7 @@ } } }, - "CommonErrors.CoreData.NSPersistentStoreIncompatibleVersionHashError" : { + "EnhancedDescriptions.CoreData.NSPersistentStoreIncompatibleVersionHashError" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -67,7 +67,7 @@ } } }, - "CommonErrors.CoreData.NSPersistentStoreOpenError" : { + "EnhancedDescriptions.CoreData.NSPersistentStoreOpenError" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -78,7 +78,7 @@ } } }, - "CommonErrors.CoreData.NSPersistentStoreSaveError" : { + "EnhancedDescriptions.CoreData.NSPersistentStoreSaveError" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -89,7 +89,7 @@ } } }, - "CommonErrors.CoreData.NSValidationMissingMandatoryPropertyError" : { + "EnhancedDescriptions.CoreData.NSValidationMissingMandatoryPropertyError" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -100,7 +100,7 @@ } } }, - "CommonErrors.CoreData.NSValidationMultipleErrorsError" : { + "EnhancedDescriptions.CoreData.NSValidationMultipleErrorsError" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -111,7 +111,7 @@ } } }, - "CommonErrors.CoreData.NSValidationRelationshipLacksMinimumCountError" : { + "EnhancedDescriptions.CoreData.NSValidationRelationshipLacksMinimumCountError" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -122,7 +122,7 @@ } } }, - "CommonErrors.MKError.default" : { + "EnhancedDescriptions.MKError.default" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -133,7 +133,7 @@ } } }, - "CommonErrors.MKError.directionsNotFound" : { + "EnhancedDescriptions.MKError.directionsNotFound" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -144,7 +144,7 @@ } } }, - "CommonErrors.MKError.loadingThrottled" : { + "EnhancedDescriptions.MKError.loadingThrottled" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -155,7 +155,7 @@ } } }, - "CommonErrors.MKError.placemarkNotFound" : { + "EnhancedDescriptions.MKError.placemarkNotFound" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -166,7 +166,7 @@ } } }, - "CommonErrors.MKError.serverFailure" : { + "EnhancedDescriptions.MKError.serverFailure" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -177,7 +177,7 @@ } } }, - "CommonErrors.MKError.unknown" : { + "EnhancedDescriptions.MKError.unknown" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -188,7 +188,7 @@ } } }, - "CommonErrors.POSIXError.default" : { + "EnhancedDescriptions.POSIXError.default" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -199,7 +199,7 @@ } } }, - "CommonErrors.POSIXError.EACCES" : { + "EnhancedDescriptions.POSIXError.EACCES" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -210,7 +210,7 @@ } } }, - "CommonErrors.POSIXError.EBADF" : { + "EnhancedDescriptions.POSIXError.EBADF" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -221,7 +221,7 @@ } } }, - "CommonErrors.POSIXError.ENOSPC" : { + "EnhancedDescriptions.POSIXError.ENOSPC" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -232,7 +232,7 @@ } } }, - "CommonErrors.URLError.cannotFindHost" : { + "EnhancedDescriptions.URLError.cannotFindHost" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -243,7 +243,7 @@ } } }, - "CommonErrors.URLError.default" : { + "EnhancedDescriptions.URLError.default" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -254,7 +254,7 @@ } } }, - "CommonErrors.URLError.networkConnectionLost" : { + "EnhancedDescriptions.URLError.networkConnectionLost" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -265,7 +265,7 @@ } } }, - "CommonErrors.URLError.notConnectedToInternet" : { + "EnhancedDescriptions.URLError.notConnectedToInternet" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -276,7 +276,7 @@ } } }, - "CommonErrors.URLError.timedOut" : { + "EnhancedDescriptions.URLError.timedOut" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -286,6 +286,358 @@ } } } + }, + "TypedOverloads.FileManager.corruptFile" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The file is corrupted or in an unreadable format." + } + } + } + }, + "TypedOverloads.FileManager.fileExists" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The file or directory already exists." + } + } + } + }, + "TypedOverloads.FileManager.fileLocked" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The file is locked and cannot be modified." + } + } + } + }, + "TypedOverloads.FileManager.fileNotFound" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The specified file or directory could not be found." + } + } + } + }, + "TypedOverloads.FileManager.fileTooLarge" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The file is too large to be processed." + } + } + } + }, + "TypedOverloads.FileManager.invalidFileName" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The file name is invalid and cannot be used." + } + } + } + }, + "TypedOverloads.FileManager.noReadPermission" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You do not have permission to read this file or directory." + } + } + } + }, + "TypedOverloads.FileManager.noWritePermission" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You do not have permission to write to this file or directory." + } + } + } + }, + "TypedOverloads.FileManager.outOfSpace" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "There is not enough disk space to complete the operation." + } + } + } + }, + "TypedOverloads.FileManager.readError" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "An unknown error occurred while reading the file." + } + } + } + }, + "TypedOverloads.FileManager.unsupportedEncoding" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The file's character encoding is not supported." + } + } + } + }, + "TypedOverloads.FileManager.volumeReadOnly" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The storage volume is read-only and cannot be modified." + } + } + } + }, + "TypedOverloads.FileManager.writeError" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "An unknown error occurred while writing the file." + } + } + } + }, + "TypedOverloads.URLSession.badRequest" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The request was malformed (400). Please review and try again." + } + } + } + }, + "TypedOverloads.URLSession.badURL" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The URL is malformed. Please check it and try again or report a bug." + } + } + } + }, + "TypedOverloads.URLSession.cancelled" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The request was cancelled. Please try again if this wasn't intended." + } + } + } + }, + "TypedOverloads.URLSession.cannotFindHost" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cannot find host. Please check your internet connection and try again." + } + } + } + }, + "TypedOverloads.URLSession.conflict" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "There was a conflict with the request (409). Please review and try again." + } + } + } + }, + "TypedOverloads.URLSession.forbidden" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You do not have permission to access this resource (403)." + } + } + } + }, + "TypedOverloads.URLSession.methodNotAllowed" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The HTTP method is not allowed for this resource (405)." + } + } + } + }, + "TypedOverloads.URLSession.noNetwork" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No network connection found. Please check your internet." + } + } + } + }, + "TypedOverloads.URLSession.notAcceptable" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The requested resource cannot produce an acceptable response (406)." + } + } + } + }, + "TypedOverloads.URLSession.notFound" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The requested resource could not be found (404)." + } + } + } + }, + "TypedOverloads.URLSession.paymentRequired" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Payment is required to access this resource (402)." + } + } + } + }, + "TypedOverloads.URLSession.requestTimeout" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The request timed out (408). Please try again." + } + } + } + }, + "TypedOverloads.URLSession.serverError" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The server encountered an error (500). Please try again later." + } + } + } + }, + "TypedOverloads.URLSession.sslError" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "There was an SSL error. Please check the server's certificate." + } + } + } + }, + "TypedOverloads.URLSession.timeout" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The request timed out. Please try again." + } + } + } + }, + "TypedOverloads.URLSession.tooManyRequests" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Too many requests have been sent. Please wait and try again (429)." + } + } + } + }, + "TypedOverloads.URLSession.unauthorized" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You are not authorized to access this resource (401)." + } + } + } + }, + "TypedOverloads.URLSession.unknown" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "An unknown status code was received from the server: %lld" + } + } + } + }, + "TypedOverloads.URLSession.unsupportedMediaType" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The request entity has an unsupported media type (415)." + } + } + } } }, "version" : "1.0" diff --git a/Sources/ErrorKit/TypedOverloads/FileManager+ErrorKit.swift b/Sources/ErrorKit/TypedOverloads/FileManager+ErrorKit.swift index 12c4c28..9a4a931 100644 --- a/Sources/ErrorKit/TypedOverloads/FileManager+ErrorKit.swift +++ b/Sources/ErrorKit/TypedOverloads/FileManager+ErrorKit.swift @@ -152,8 +152,8 @@ extension FileManager { public func throwableCreateDirectory(at url: URL, withIntermediateDirectories createIntermediates: Bool = false, attributes: [FileAttributeKey : Any]? = nil) throws(FileManagerError) { do { try self.createDirectory(at: url, withIntermediateDirectories: createIntermediates, attributes: attributes) - } catch let error as NSError { - throw self.mapToThrowable(error: error) + } catch { + throw self.mapToThrowable(error: error as NSError) } } @@ -161,8 +161,8 @@ extension FileManager { public func throwableCreateDirectory(atPath path: String, withIntermediateDirectories createIntermediates: Bool = false, attributes: [FileAttributeKey : Any]? = nil) throws(FileManagerError) { do { try self.createDirectory(atPath: path, withIntermediateDirectories: createIntermediates, attributes: attributes) - } catch let error as NSError { - throw self.mapToThrowable(error: error) + } catch { + throw self.mapToThrowable(error: error as NSError) } } @@ -170,8 +170,8 @@ extension FileManager { public func throwableRemoveItem(at url: URL) throws(FileManagerError) { do { try self.removeItem(at: url) - } catch let error as NSError { - throw self.mapToThrowable(error: error) + } catch { + throw self.mapToThrowable(error: error as NSError) } } @@ -179,8 +179,8 @@ extension FileManager { public func throwableRemoveItem(atPath path: String) throws(FileManagerError) { do { try self.removeItem(atPath: path) - } catch let error as NSError { - throw self.mapToThrowable(error: error) + } catch { + throw self.mapToThrowable(error: error as NSError) } } @@ -188,8 +188,8 @@ extension FileManager { public func throwableCopyItem(at sourceURL: URL, to destinationURL: URL) throws(FileManagerError) { do { try self.copyItem(at: sourceURL, to: destinationURL) - } catch let error as NSError { - throw self.mapToThrowable(error: error) + } catch { + throw self.mapToThrowable(error: error as NSError) } } @@ -197,8 +197,8 @@ extension FileManager { public func throwableCopyItem(atPath sourcePath: String, toPath destinationPath: String) throws(FileManagerError) { do { try self.copyItem(atPath: sourcePath, toPath: destinationPath) - } catch let error as NSError { - throw self.mapToThrowable(error: error) + } catch { + throw self.mapToThrowable(error: error as NSError) } } @@ -206,8 +206,8 @@ extension FileManager { public func throwableMoveItem(at sourceURL: URL, to destinationURL: URL) throws(FileManagerError) { do { try self.moveItem(at: sourceURL, to: destinationURL) - } catch let error as NSError { - throw self.mapToThrowable(error: error) + } catch { + throw self.mapToThrowable(error: error as NSError) } } @@ -215,8 +215,8 @@ extension FileManager { public func throwableMoveItem(atPath sourcePath: String, toPath destinationPath: String) throws(FileManagerError) { do { try self.moveItem(atPath: sourcePath, toPath: destinationPath) - } catch let error as NSError { - throw self.mapToThrowable(error: error) + } catch { + throw self.mapToThrowable(error: error as NSError) } } @@ -224,8 +224,8 @@ extension FileManager { public func throwableAttributesOfItem(atPath path: String) throws(FileManagerError) -> [FileAttributeKey: Any] { do { return try self.attributesOfItem(atPath: path) - } catch let error as NSError { - throw self.mapToThrowable(error: error) + } catch { + throw self.mapToThrowable(error: error as NSError) } } diff --git a/Sources/ErrorKit/TypedOverloads/URLSession+ErrorKit.swift b/Sources/ErrorKit/TypedOverloads/URLSession+ErrorKit.swift index 480de95..8836ba0 100644 --- a/Sources/ErrorKit/TypedOverloads/URLSession+ErrorKit.swift +++ b/Sources/ErrorKit/TypedOverloads/URLSession+ErrorKit.swift @@ -5,18 +5,60 @@ public enum URLSessionError: Throwable { /// The request timed out. case timeout - /// The network connection was lost. - case connectionLost + /// The're no network connection. + case noNetwork - /// The server returned a 404 Not Found status code. - case notFound + /// The host could not be found. + case cannotFindHost - /// The server returned a 500 Internal Server Error status code. - case serverError + /// Something was wrong with the URL. + case badURL + + /// The network request was cancelled. + case cancelled + + /// An SSL error occurred during the request. + case sslError /// A network error occurred that doesn't match a specific case. case networkError(Error) + /// The server returned a 401 Unauthorized status code. + case unauthorized(bodyData: Data?) + + /// The server returned a 402 Payment Required status code. + case paymentRequired(bodyData: Data?) + + /// The server returned a 403 Forbidden status code. + case forbidden(bodyData: Data?) + + /// The server returned a 404 Not Found status code. + case notFound(bodyData: Data?) + + /// The server returned a 405 Method Not Allowed status code. + case methodNotAllowed(bodyData: Data?) + + /// The server returned a 406 Not Acceptable status code. + case notAcceptable(bodyData: Data?) + + /// The server returned a 408 Request Timeout status code. + case requestTimeout(bodyData: Data?) + + /// The server returned a 409 Conflict status code. + case conflict(bodyData: Data?) + + /// The server returned a 415 Unsupported Media Type status code. + case unsupportedMediaType(bodyData: Data?) + + /// The server returned a 429 Too Many Requests status code. + case tooManyRequests(bodyData: Data?) + + /// The server returned a generic 4xx Bad Request status code (fallback). + case badRequest(bodyData: Data?) + + /// The server returned a 500 Internal Server Error status code. + case serverError + /// An unknown error occurred. case unknownStatusCode(Int) @@ -31,10 +73,54 @@ public enum URLSessionError: Throwable { defaultValue: "The request timed out. Please try again.", bundle: .module ) - case .connectionLost: + case .noNetwork: + return String( + localized: "TypedOverloads.URLSession.noNetwork", + defaultValue: "No network connection found. Please check your internet.", + bundle: .module + ) + case .cannotFindHost: + return String( + localized: "TypedOverloads.URLSession.cannotFindHost", + defaultValue: "Cannot find host. Please check your internet connection and try again.", + bundle: .module + ) + case .badURL: + return String( + localized: "TypedOverloads.URLSession.badURL", + defaultValue: "The URL is malformed. Please check it and try again or report a bug.", + bundle: .module + ) + case .cancelled: + return String( + localized: "TypedOverloads.URLSession.cancelled", + defaultValue: "The request was cancelled. Please try again if this wasn't intended.", + bundle: .module + ) + case .sslError: + return String( + localized: "TypedOverloads.URLSession.sslError", + defaultValue: "There was an SSL error. Please check the server's certificate.", + bundle: .module + ) + case .networkError(let error): + return ErrorKit.userFriendlyMessage(for: error) + case .unauthorized: + return String( + localized: "TypedOverloads.URLSession.unauthorized", + defaultValue: "You are not authorized to access this resource (401).", + bundle: .module + ) + case .paymentRequired: + return String( + localized: "TypedOverloads.URLSession.paymentRequired", + defaultValue: "Payment is required to access this resource (402).", + bundle: .module + ) + case .forbidden: return String( - localized: "TypedOverloads.URLSession.connectionLost", - defaultValue: "The network connection was lost. Please check your connection.", + localized: "TypedOverloads.URLSession.forbidden", + defaultValue: "You do not have permission to access this resource (403).", bundle: .module ) case .notFound: @@ -43,14 +129,54 @@ public enum URLSessionError: Throwable { defaultValue: "The requested resource could not be found (404).", bundle: .module ) + case .methodNotAllowed: + return String( + localized: "TypedOverloads.URLSession.methodNotAllowed", + defaultValue: "The HTTP method is not allowed for this resource (405).", + bundle: .module + ) + case .notAcceptable: + return String( + localized: "TypedOverloads.URLSession.notAcceptable", + defaultValue: "The requested resource cannot produce an acceptable response (406).", + bundle: .module + ) + case .requestTimeout: + return String( + localized: "TypedOverloads.URLSession.requestTimeout", + defaultValue: "The request timed out (408). Please try again.", + bundle: .module + ) + case .conflict: + return String( + localized: "TypedOverloads.URLSession.conflict", + defaultValue: "There was a conflict with the request (409). Please review and try again.", + bundle: .module + ) + case .unsupportedMediaType: + return String( + localized: "TypedOverloads.URLSession.unsupportedMediaType", + defaultValue: "The request entity has an unsupported media type (415).", + bundle: .module + ) + case .tooManyRequests: + return String( + localized: "TypedOverloads.URLSession.tooManyRequests", + defaultValue: "Too many requests have been sent. Please wait and try again (429).", + bundle: .module + ) + case .badRequest: + return String( + localized: "TypedOverloads.URLSession.badRequest", + defaultValue: "The request was malformed (400). Please review and try again.", + bundle: .module + ) case .serverError: return String( localized: "TypedOverloads.URLSession.serverError", defaultValue: "The server encountered an error (500). Please try again later.", bundle: .module ) - case .networkError(let error): - return ErrorKit.userFriendlyMessage(for: error) case .unknownStatusCode(let statusCode): return String( localized: "TypedOverloads.URLSession.unknown", @@ -68,8 +194,8 @@ extension URLSession { public func throwableData(for request: URLRequest) async throws -> (Data, URLResponse) { do { return try await self.data(for: request) - } catch let error as NSError { - throw mapToThrowable(error: error) + } catch { + throw mapToThrowable(error: error as NSError) } } @@ -77,38 +203,54 @@ extension URLSession { public func throwableData(from url: URL) async throws -> (Data, URLResponse) { do { return try await self.data(from: url) - } catch let error as NSError { - throw mapToThrowable(error: error) + } catch { + throw mapToThrowable(error: error as NSError) } } private func mapToThrowable(error: NSError) -> URLSessionError { switch (error.domain, error.code) { - case (NSURLErrorDomain, NSURLErrorTimedOut): - return .timeout - case (NSURLErrorDomain, NSURLErrorNetworkConnectionLost): - return .connectionLost - case (NSURLErrorDomain, NSURLErrorCannotFindHost): - return .notFound - case (NSURLErrorDomain, NSURLErrorCannotConnectToHost): - return .serverError - default: - return .networkError(error) + case (NSURLErrorDomain, NSURLErrorTimedOut): .timeout + case (NSURLErrorDomain, NSURLErrorNetworkConnectionLost): .noNetwork + case (NSURLErrorDomain, NSURLErrorNotConnectedToInternet): .noNetwork + case (NSURLErrorDomain, NSURLErrorCannotFindHost): .cannotFindHost + case (NSURLErrorDomain, NSURLErrorCannotConnectToHost): .serverError + case (NSURLErrorDomain, NSURLErrorCancelled): .cancelled + case (NSURLErrorDomain, NSURLErrorBadURL): .badURL + case (NSURLErrorDomain, NSURLErrorSecureConnectionFailed): .sslError + default: .networkError(error) } } - // TODO: continue here by fixing that status code is not being used at all & improve error cases overall - - /// A method to handle HTTP status codes. + /// A method to handle HTTP status codes and provide better error handling for different cases. public func handleHTTPStatusCode(_ statusCode: Int, data: Data?) throws -> Data? { switch statusCode { case 200...299: // Success range return data - case 400...499: // Client errors (handle only 404 as a generic error) - if statusCode == 404 { - throw URLSessionError.notFound - } else { - return data // treat it as valid data response + case 400...499: // Client errors + switch statusCode { + case 401: + throw URLSessionError.unauthorized(bodyData: data) + case 402: + throw URLSessionError.paymentRequired(bodyData: data) + case 403: + throw URLSessionError.forbidden(bodyData: data) + case 404: + throw URLSessionError.notFound(bodyData: data) + case 405: + throw URLSessionError.methodNotAllowed(bodyData: data) + case 406: + throw URLSessionError.notAcceptable(bodyData: data) + case 408: + throw URLSessionError.requestTimeout(bodyData: data) + case 409: + throw URLSessionError.conflict(bodyData: data) + case 415: + throw URLSessionError.unsupportedMediaType(bodyData: data) + case 429: + throw URLSessionError.tooManyRequests(bodyData: data) + default: + throw URLSessionError.badRequest(bodyData: data) } case 500...599: // Server errors throw URLSessionError.serverError diff --git a/Tests/ErrorKitTests/ErrorKitTests.swift b/Tests/ErrorKitTests/ErrorKitTests.swift index 2ec63fc..052f5b9 100644 --- a/Tests/ErrorKitTests/ErrorKitTests.swift +++ b/Tests/ErrorKitTests/ErrorKitTests.swift @@ -10,23 +10,23 @@ struct SomeLocalizedError: LocalizedError { } @Test -func enhancedDescriptionLocalizedError() { - #expect(ErrorKit.enhancedDescription(for: SomeLocalizedError()) == "Something failed. It failed because it wanted to. Try again later.") +func userFriendlyMessageLocalizedError() { + #expect(ErrorKit.userFriendlyMessage(for: SomeLocalizedError()) == "Something failed. It failed because it wanted to. Try again later.") } @Test -func enhancedDescriptionNSError() { +func userFriendlyMessageNSError() { let nsError = NSError(domain: "SOME", code: 1245, userInfo: [NSLocalizedDescriptionKey: "Something failed."]) - #expect(ErrorKit.enhancedDescription(for: nsError) == "[SOME: 1245] Something failed.") + #expect(ErrorKit.userFriendlyMessage(for: nsError) == "[SOME: 1245] Something failed.") } struct SomeThrowable: Throwable { - let localizedDescription: String = "Something failed hard." + let userFriendlyMessage: String = "Something failed hard." } @Test -func enhancedDescriptionThrowable() async throws { - #expect(ErrorKit.enhancedDescription(for: SomeThrowable()) == "Something failed hard.") +func userFriendlyMessageThrowable() async throws { + #expect(ErrorKit.userFriendlyMessage(for: SomeThrowable()) == "Something failed hard.") } // TODO: add more tests for more specific errors such as CoreData, MapKit, etc. diff --git a/Tests/ErrorKitTests/ThrowableTests.swift b/Tests/ErrorKitTests/ThrowableTests.swift index d41c42f..95681fe 100644 --- a/Tests/ErrorKitTests/ThrowableTests.swift +++ b/Tests/ErrorKitTests/ThrowableTests.swift @@ -5,7 +5,7 @@ enum ExplicitDescriptionError: Throwable { case somethingFailed case reuestTimeout - var localizedDescription: String { + var userFriendlyMessage: String { switch self { case .somethingFailed: "Something failed unexpectedly" case .reuestTimeout: "Request timed out" From a94aa072b733fb528d0817a5f38e0a4db2b56236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Thu, 12 Dec 2024 14:17:42 +0100 Subject: [PATCH 3/3] Write a basic explanation section in the README --- README.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/README.md b/README.md index c81258e..9b86d29 100644 --- a/README.md +++ b/README.md @@ -152,3 +152,60 @@ do { Found a bug or missing description? We welcome your contributions! Submit a pull request (PR), and we’ll gladly review and merge it to enhance the library further. > **Note:** The enhanced error descriptions are constantly evolving, and we’re committed to making them as accurate and helpful as possible. + +## Overloads of Common System Functions with Typed Throws + +ErrorKit introduces typed-throws overloads for common system APIs like `FileManager` and `URLSession`, providing more granular error handling and improved code clarity. These overloads allow you to handle specific error scenarios with tailored responses, making your code more robust and easier to maintain. + +To streamline discovery, ErrorKit uses the same API names prefixed with `throwable`. These functions throw specific errors that conform to `Throwable`, allowing for clear and informative error messages. + +**Enhanced User-Friendly Error Messages:** + +One of the key advantages of ErrorKit's typed throws is the improved `localizedDescription` property. This property provides user-friendly error messages that are tailored to the specific error type. This eliminates the need for manual error message construction and ensures a consistent and informative user experience. + +**Example: Creating a Directory** + +```swift +do { + try FileManager.default.throwableCreateDirectory(at: URL(string: "file:///path/to/directory")!) +} catch { + switch error { + case FileManagerError.noWritePermission: + // Request write permission from the user intead of showing error message + default: + // Common error cases have a more descriptive message + showErrorDialog(error.localizedDescription) + } +} +``` + +The code demonstrates how to handle errors for specific error cases with an improved UX rather than just showing an error message to the user, which can still be the fallback. And the error cases are easy to discover thanks to the typed enum error. + +**Example: Handling network request errors** + +```swift +do { + let (data, response) = try await URLSession.shared.throwableData(from: URL(string: "https://api.example.com/data")!) + // Process the data and response +} catch { + // Error is of type `URLSessionError` + print(error.localizedDescription) + + switch error { + case .timeout, .requestTimeout, .tooManyRequests: + // Automatically retry the request with a backoff strategy + case .noNetwork: + // Show an SF Symbol indicating the user is offline plus a retry button + case .unauthorized: + // Redirect the user to your login-flow (e.g. because token expired) + default: + // Fall back to showing error message + } +} +``` + +Here, the code leverages the specific error types to implement various kinds of custom logic. This demonstrates the power of typed throws in providing fine-grained control over error handling. + +### Summary + +By utilizing these typed-throws overloads, you can write more robust and maintainable code. ErrorKit's enhanced user-friendly messages and ability to handle specific errors with code lead to a better developer and user experience. As the library continues to evolve, we encourage the community to contribute additional overloads and error types for common system APIs to further enhance its capabilities.