diff --git a/README.md b/README.md index 701ea44..3c66dd8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ ![ErrorKit Logo](https://github.com/FlineDev/ErrorKit/blob/main/Logo.png?raw=true) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FFlineDev%2FErrorKit%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/FlineDev/ErrorKit) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FFlineDev%2FErrorKit%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/FlineDev/ErrorKit) # ErrorKit @@ -69,7 +70,7 @@ do { } ``` -These enhanced descriptions are community-provided and fully localized mappings of common system errors to clearer, more actionable messages. +These enhanced descriptions are community-provided and fully localized mappings of common system errors to clearer, more actionable messages. ErrorKit comes with built-in mappers for Foundation, CoreData, MapKit, and more. You can also create custom mappers for third-party libraries or your own error types. [Read more about Enhanced Error Descriptions →](https://swiftpackageindex.com/FlineDev/ErrorKit/documentation/errorkit/enhanced-error-descriptions) @@ -179,7 +180,7 @@ Button("Report a Problem") { ) ``` -With just a simple SwiftUI modifier, you can automatically include all log messages from Apple's unified logging system. +With just a simple built-in SwiftUI modifier and the `logAttachment` helper function, you can easily include all log messages from Apple's unified logging system and let your users send them to you via email. Other integrations are also supported. [Read more about User Feedback and Logging →](https://swiftpackageindex.com/FlineDev/ErrorKit/documentation/errorkit/user-feedback-with-logs) @@ -193,6 +194,8 @@ ErrorKit's features are designed to complement each other while remaining indepe 3. **Save time with ready-made tools**: built-in error types for common scenarios and simple log collection for user feedback. +4. **Extend with custom mappers**: Create error mappers for any library to improve error messages across your entire application. + ## Adoption Path Here's a practical adoption strategy: diff --git a/Sources/ErrorKit/ErrorKit.docc/Guides/Enhanced-Error-Descriptions.md b/Sources/ErrorKit/ErrorKit.docc/Guides/Enhanced-Error-Descriptions.md index cf0cf76..ce872c3 100644 --- a/Sources/ErrorKit/ErrorKit.docc/Guides/Enhanced-Error-Descriptions.md +++ b/Sources/ErrorKit/ErrorKit.docc/Guides/Enhanced-Error-Descriptions.md @@ -75,18 +75,18 @@ do { } ``` -If the error already conforms to `Throwable`, its `userFriendlyMessage` is used. For system errors, ErrorKit provides an enhanced description from its built-in mappings. +If the error already conforms to `Throwable`, its `userFriendlyMessage` is used. For system errors, ErrorKit provides an enhanced description from its built-in mappers. ### Localization Support -All enhanced error messages are fully localized using the `String.localized(key:defaultValue:)` pattern, ensuring users receive messages in their preferred language where available. +All enhanced error messages are fully localized using the `String(localized:)` pattern, ensuring users receive messages in their preferred language where available. ### How It Works The `userFriendlyMessage(for:)` function follows this process to determine the best error message: 1. If the error conforms to `Throwable`, it uses the error's own `userFriendlyMessage` -2. It tries domain-specific handlers to find available enhanced versions +2. It queries registered error mappers to find enhanced descriptions 3. If the error conforms to `LocalizedError`, it combines its localized properties 4. As a fallback, it formats the NSError domain and code along with the standard `localizedDescription` @@ -95,32 +95,57 @@ The `userFriendlyMessage(for:)` function follows this process to determine the b You can help improve ErrorKit by contributing better error descriptions for common error types: 1. Identify cryptic error messages from system frameworks -2. Implement domain-specific handlers or extend existing ones (see folder `EnhancedDescriptions`) +2. Implement domain-specific handlers or extend existing ones (see folder `ErrorMappers`) 3. Use clear, actionable language that helps users understand what went wrong 4. Include localization support for all messages (no need to actually localize, we'll take care) Example contribution to handle a new error type: ```swift -// In ErrorKit+Foundation.swift +// In FoundationErrorMapper.swift case let jsonError as NSError where jsonError.domain == NSCocoaErrorDomain && jsonError.code == 3840: - return String.localized( - key: "EnhancedDescriptions.JSONError.invalidFormat", - defaultValue: "The data couldn't be read because it isn't in the correct format." - ) + return String(localized: "The data couldn't be read because it isn't in the correct format.") ``` +### Custom Error Mappers + +While ErrorKit focuses on enhancing system and framework errors, you can also create custom mappers for any library: + +```swift +enum MyLibraryErrorMapper: ErrorMapper { + static func userFriendlyMessage(for error: Error) -> String? { + switch error { + case let libraryError as MyLibrary.Error: + switch libraryError { + case .apiKeyExpired: + return String(localized: "API key expired. Please update your credentials.") + default: + return nil + } + default: + return nil + } + } +} + +// On app start: +ErrorKit.registerMapper(MyLibraryErrorMapper.self) +``` + +This extensibility allows the community to create mappers for 3rd-party libraries with known error issues. + ## Topics ### Essentials - ``ErrorKit/userFriendlyMessage(for:)`` +- ``ErrorMapper`` -### Domain-Specific Handlers +### Built-in Mappers -- ``ErrorKit/userFriendlyFoundationMessage(for:)`` -- ``ErrorKit/userFriendlyCoreDataMessage(for:)`` -- ``ErrorKit/userFriendlyMapKitMessage(for:)`` +- ``FoundationErrorMapper`` +- ``CoreDataErrorMapper`` +- ``MapKitErrorMapper`` ### Continue Reading diff --git a/Sources/ErrorKit/ErrorKit.swift b/Sources/ErrorKit/ErrorKit.swift index 3f9b717..277cfcb 100644 --- a/Sources/ErrorKit/ErrorKit.swift +++ b/Sources/ErrorKit/ErrorKit.swift @@ -11,9 +11,13 @@ 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 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. + /// The function uses registered error mappers to generate contextual messages for errors from different frameworks and libraries. + /// ErrorKit includes built-in mappers for `Foundation`, `CoreData`, `MapKit`, and more. + /// You can extend ErrorKit's capabilities by registering custom mappers using ``registerMapper(_:)``. + /// Custom mappers are queried in reverse order, meaning user-provided mappers take precedence over built-in ones. /// - /// 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. + /// 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. /// /// - Parameter error: The `Error` instance for which a user-friendly message is needed. /// - Returns: A `String` containing an enhanced, localized, user-readable error message. @@ -35,19 +39,14 @@ public enum ErrorKit { return throwable.userFriendlyMessage } - if let foundationDescription = Self.userFriendlyFoundationMessage(for: error) { - return foundationDescription - } - - if let coreDataDescription = Self.userFriendlyCoreDataMessage(for: error) { - return coreDataDescription - } - - if let mapKitDescription = Self.userFriendlyMapKitMessage(for: error) { - return mapKitDescription + // Check if a custom mapping was registered (in reverse order to prefer user-provided over built-in mappings) + for errorMapper in self.errorMappers.reversed() { + if let mappedMessage = errorMapper.userFriendlyMessage(for: error) { + return mappedMessage + } } - // LocalizedError: The recommended error type to conform to in Swift by default. + // LocalizedError: The officially recommended error type to conform to in Swift, prefer over NSError if let localizedError = error as? LocalizedError { return [ localizedError.errorDescription, @@ -56,11 +55,13 @@ public enum ErrorKit { ].compactMap(\.self).joined(separator: " ") } - // Default fallback (adds domain & code at least) + // Default fallback (adds domain & code at least) – since all errors conform to NSError let nsError = error as NSError return "[\(nsError.domain): \(nsError.code)] \(nsError.localizedDescription)" } + // MARK: - Error Chain + /// Generates a detailed, hierarchical description of an error chain for debugging purposes. /// /// This function provides a comprehensive view of nested errors, particularly useful when errors are wrapped through multiple layers @@ -153,6 +154,46 @@ public enum ErrorKit { return Self.chainDescription(for: error, indent: "", enclosingType: type(of: error)) } + private static func chainDescription(for error: Error, indent: String, enclosingType: Any.Type?) -> String { + let mirror = Mirror(reflecting: error) + + // Helper function to format the type name with optional metadata + func typeDescription(_ error: Error, enclosingType: Any.Type?) -> String { + let typeName = String(describing: type(of: error)) + + // For structs and classes (non-enums), append [Struct] or [Class] + if mirror.displayStyle != .enum { + let isClass = Swift.type(of: error) is AnyClass + return "\(typeName) [\(isClass ? "Class" : "Struct")]" + } else { + // For enums, include the full case description with type name + if let enclosingType { + return "\(enclosingType).\(error)" + } else { + return String(describing: error) + } + } + } + + // Check if this is a nested error (conforms to Catching and has a caught case) + if let caughtError = mirror.children.first(where: { $0.label == "caught" })?.value as? Error { + let currentErrorType = type(of: error) + let nextIndent = indent + " " + return """ + \(currentErrorType) + \(indent)└─ \(Self.chainDescription(for: caughtError, indent: nextIndent, enclosingType: type(of: caughtError))) + """ + } else { + // This is a leaf node + return """ + \(typeDescription(error, enclosingType: enclosingType)) + \(indent)└─ userFriendlyMessage: \"\(Self.userFriendlyMessage(for: error))\" + """ + } + } + + // MARK: - Grouping ID + /// Generates a stable identifier that groups similar errors based on their type structure. /// /// While ``errorChainDescription(for:)`` provides a detailed view of an error chain including all parameters and messages, @@ -224,41 +265,78 @@ public enum ErrorKit { return String(fullHash.prefix(6)) } - private static func chainDescription(for error: Error, indent: String, enclosingType: Any.Type?) -> String { - let mirror = Mirror(reflecting: error) + // MARK: - Error Mapping - // Helper function to format the type name with optional metadata - func typeDescription(_ error: Error, enclosingType: Any.Type?) -> String { - let typeName = String(describing: type(of: error)) - - // For structs and classes (non-enums), append [Struct] or [Class] - if mirror.displayStyle != .enum { - let isClass = Swift.type(of: error) is AnyClass - return "\(typeName) [\(isClass ? "Class" : "Struct")]" - } else { - // For enums, include the full case description with type name - if let enclosingType { - return "\(enclosingType).\(error)" - } else { - return String(describing: error) - } - } + /// Registers a custom error mapper to extend ErrorKit's error mapping capabilities. + /// + /// This function allows you to add your own error mapper for specific frameworks, libraries, or custom error types. + /// Registered mappers are queried in reverse order to ensure user-provided mappers takes precedence over built-in ones. + /// + /// # Usage + /// Register error mappers during your app's initialization, typically in the App's initializer or main function: + /// ```swift + /// @main + /// struct MyApp: App { + /// init() { + /// ErrorKit.registerMapper(MyDatabaseErrorMapper.self) + /// ErrorKit.registerMapper(AuthenticationErrorMapper.self) + /// } + /// + /// var body: some Scene { + /// // ... + /// } + /// } + /// ``` + /// + /// # Best Practices + /// - Register mappers early in your app's lifecycle + /// - Order matters: Register more specific mappers after general ones (last added is checked first) + /// - Avoid redundant mappers for the same error types (as this may lead to confusion) + /// + /// # Example Mapper + /// ```swift + /// enum PaymentServiceErrorMapper: ErrorMapper { + /// static func userFriendlyMessage(for error: Error) -> String? { + /// switch error { + /// case let paymentError as PaymentService.Error: + /// switch paymentError { + /// case .cardDeclined: + /// return String(localized: "Payment declined. Please try a different card.") + /// case .insufficientFunds: + /// return String(localized: "Insufficient funds. Please add money to your account.") + /// case .expiredCard: + /// return String(localized: "Card expired. Please update your payment method.") + /// default: + /// return nil + /// } + /// default: + /// return nil + /// } + /// } + /// } + /// + /// ErrorKit.registerMapper(PaymentServiceErrorMapper.self) + /// ``` + /// + /// - Parameter mapper: The error mapper type to register + public static func registerMapper(_ mapper: ErrorMapper.Type) { + self.errorMappersQueue.async(flags: .barrier) { + self._errorMappers.append(mapper) } + } - // Check if this is a nested error (conforms to Catching and has a caught case) - if let caughtError = mirror.children.first(where: { $0.label == "caught" })?.value as? Error { - let currentErrorType = type(of: error) - let nextIndent = indent + " " - return """ - \(currentErrorType) - \(indent)└─ \(Self.chainDescription(for: caughtError, indent: nextIndent, enclosingType: type(of: caughtError))) - """ - } else { - // This is a leaf node - return """ - \(typeDescription(error, enclosingType: enclosingType)) - \(indent)└─ userFriendlyMessage: \"\(Self.userFriendlyMessage(for: error))\" - """ - } + /// A built-in sync mechanism to avoid concurrent access to ``errorMappers``. + private static let errorMappersQueue = DispatchQueue(label: "ErrorKit.ErrorMappers", attributes: .concurrent) + + /// The collection of error mappers that ErrorKit uses to generate user-friendly messages. + nonisolated(unsafe) private static var _errorMappers: [ErrorMapper.Type] = [ + FoundationErrorMapper.self, + CoreDataErrorMapper.self, + MapKitErrorMapper.self, + ] + + /// Provides thread-safe read access to `_errorMappers` using a concurrent queue. + private static var errorMappers: [ErrorMapper.Type] { + self.errorMappersQueue.sync { self._errorMappers } } } diff --git a/Sources/ErrorKit/ErrorMapper.swift b/Sources/ErrorKit/ErrorMapper.swift new file mode 100644 index 0000000..30e0a36 --- /dev/null +++ b/Sources/ErrorKit/ErrorMapper.swift @@ -0,0 +1,101 @@ +/// A protocol for mapping domain-specific errors to user-friendly messages. +/// +/// `ErrorMapper` allows users to extend ErrorKit's error mapping capabilities by providing custom mappings for errors from specific frameworks, libraries, or domains. +/// +/// # Overview +/// ErrorKit comes with built-in mappers for Foundation, CoreData, and MapKit errors. +/// You can add your own mappers for other frameworks or custom error types using the ``ErrorKit/registerMapper(_:)`` function. +/// ErrorKit will query all registered mappers in reverse order until one returns a non-nil result. This means, the last added mapper takes precedence. +/// +/// # Example Implementation +/// ```swift +/// enum FirebaseErrorMapper: ErrorMapper { +/// static func userFriendlyMessage(for error: Error) -> String? { +/// switch error { +/// case let authError as AuthErrorCode: +/// switch authError.code { +/// case .wrongPassword: +/// return String(localized: "The password is incorrect. Please try again.") +/// case .userNotFound: +/// return String(localized: "No account found with this email address.") +/// default: +/// return nil +/// } +/// +/// case let firestoreError as FirestoreErrorCode.Code: +/// switch firestoreError { +/// case .permissionDenied: +/// return String(localized: "You don't have permission to access this data.") +/// case .unavailable: +/// return String(localized: "The service is temporarily unavailable. Please try again later.") +/// default: +/// return nil +/// } +/// +/// case let storageError as StorageErrorCode: +/// switch storageError { +/// case .objectNotFound: +/// return String(localized: "The requested file could not be found.") +/// case .quotaExceeded: +/// return String(localized: "Storage quota exceeded. Please try again later.") +/// default: +/// return nil +/// } +/// +/// default: +/// return nil +/// } +/// } +/// } +/// +/// // Register during app initialization +/// ErrorKit.registerMapper(FirebaseErrorMapper.self) +/// ``` +/// +/// Your mapper will be called automatically when using ``ErrorKit/userFriendlyMessage(for:)``: +/// ```swift +/// do { +/// let user = try await Auth.auth().signIn(withEmail: email, password: password) +/// } catch { +/// let message = ErrorKit.userFriendlyMessage(for: error) +/// // Message will be generated from FirebaseErrorMapper for Auth/Firestore/Storage errors +/// } +/// ``` +public protocol ErrorMapper { + /// Maps a given error to a user-friendly message if possible. + /// + /// This function is called by ErrorKit when attempting to generate a user-friendly error message. + /// It should check if the error is of a type it can handle and return an appropriate message, or return nil to allow other mappers to process the error. + /// + /// # Implementation Guidelines + /// - Return nil for errors your mapper doesn't handle + /// - Always use String(localized:) for message localization + /// - Keep messages clear, actionable, and non-technical + /// - Avoid revealing sensitive information + /// - Consider the user experience when crafting messages + /// + /// # Example + /// ```swift + /// static func userFriendlyMessage(for error: Error) -> String? { + /// switch error { + /// case let databaseError as DatabaseLibraryError: + /// switch databaseError { + /// case .connectionTimeout: + /// return String(localized: "Database connection timed out. Please try again.") + /// case .queryExecution: + /// return String(localized: "Database query failed. Please contact support.") + /// default: + /// return nil + /// } + /// default: + /// return nil + /// } + /// } + /// ``` + /// + /// - Note: Any error cases you don't provide a return value for will simply keep their original message. So only override the unclear ones or those that are not localized or you want other kinds of improvements for. No need to handle all possible cases just for the sake of it. + /// + /// - Parameter error: The error to potentially map to a user-friendly message + /// - Returns: A user-friendly message if this mapper can handle the error, or nil otherwise + static func userFriendlyMessage(for error: Error) -> String? +} diff --git a/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+CoreData.swift b/Sources/ErrorKit/ErrorMappers/CoreDataErrorMapper.swift similarity index 95% rename from Sources/ErrorKit/EnhancedDescriptions/ErrorKit+CoreData.swift rename to Sources/ErrorKit/ErrorMappers/CoreDataErrorMapper.swift index 111d913..dadb15c 100644 --- a/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+CoreData.swift +++ b/Sources/ErrorKit/ErrorMappers/CoreDataErrorMapper.swift @@ -2,8 +2,8 @@ import CoreData #endif -extension ErrorKit { - static func userFriendlyCoreDataMessage(for error: Error) -> String? { +enum CoreDataErrorMapper: ErrorMapper { + static func userFriendlyMessage(for error: Error) -> String? { #if canImport(CoreData) let nsError = error as NSError diff --git a/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+Foundation.swift b/Sources/ErrorKit/ErrorMappers/FoundationErrorMapper.swift similarity index 97% rename from Sources/ErrorKit/EnhancedDescriptions/ErrorKit+Foundation.swift rename to Sources/ErrorKit/ErrorMappers/FoundationErrorMapper.swift index 28692a0..79869fd 100644 --- a/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+Foundation.swift +++ b/Sources/ErrorKit/ErrorMappers/FoundationErrorMapper.swift @@ -3,8 +3,8 @@ import Foundation import FoundationNetworking #endif -extension ErrorKit { - static func userFriendlyFoundationMessage(for error: Error) -> String? { +enum FoundationErrorMapper: ErrorMapper { + static func userFriendlyMessage(for error: Error) -> String? { switch error { // URLError: Networking errors diff --git a/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+MapKit.swift b/Sources/ErrorKit/ErrorMappers/MapKitErrorMapper.swift similarity index 93% rename from Sources/ErrorKit/EnhancedDescriptions/ErrorKit+MapKit.swift rename to Sources/ErrorKit/ErrorMappers/MapKitErrorMapper.swift index be067d1..54f6c42 100644 --- a/Sources/ErrorKit/EnhancedDescriptions/ErrorKit+MapKit.swift +++ b/Sources/ErrorKit/ErrorMappers/MapKitErrorMapper.swift @@ -2,8 +2,8 @@ import MapKit #endif -extension ErrorKit { - static func userFriendlyMapKitMessage(for error: Error) -> String? { +enum MapKitErrorMapper: ErrorMapper { + static func userFriendlyMessage(for error: Error) -> String? { #if canImport(MapKit) if let mkError = error as? MKError { switch mkError.code {