diff --git a/Sources/SwiftRestRequests/Documentation.docc/Articles/Configuration.md b/Sources/SwiftRestRequests/Documentation.docc/Articles/Configuration.md new file mode 100644 index 0000000..f3c5995 --- /dev/null +++ b/Sources/SwiftRestRequests/Documentation.docc/Articles/Configuration.md @@ -0,0 +1,135 @@ +# Configuring REST Requests + +Learn how to configure REST requests using RestOptions. + +## Overview + +``RestOptions`` provides a flexible way to customize REST API requests in SwiftRestRequests. + +## Common Configurations + +### Basic Options + +```swift +var options = RestOptions() + +// Set timeout interval +options.timeoutInterval = 30 + +// Add custom headers +options.headers = [ + "Authorization": "Bearer token", + "Api-Version": "2.0" +] + +// Configure expected status codes +options.expectedStatusCodes = [200, 201, 204] + +let api = RestApiCaller(options: options) +``` + +### Query Parameters + +```swift +var options = RestOptions() +options.queryParameters = [ + "page": "1", + "limit": "10", + "sort": "desc" +] + +// Results in URL: https://api.example.com/users?page=1&limit=10&sort=desc +let users: [User] = try await api.get("https://api.example.com/users", + options: options) +``` + +### Content Types + +```swift +var options = RestOptions() +options.acceptedMimeTypes = ["application/json", "application/problem+json"] +``` + +### Security Configuration + +```swift +var options = RestOptions() + +// Configure certificate pinning +let certificatePath = Bundle.main.path(forResource: "server-cert", ofType: "der")! +options.serverPinning = CertificateCAPinning(certificatePath: certificatePath) + +// Configure TLS +options.tlsConfiguration = ... +``` + +## Advanced Usage + +### Per-Request Options + +```swift +let globalOptions = RestOptions() +globalOptions.headers = ["Api-Version": "2.0"] + +let api = RestApiCaller(options: globalOptions) + +// Override options for specific request +var requestOptions = RestOptions() +requestOptions.headers = ["Authorization": "Bearer special-token"] +requestOptions.timeoutInterval = 60 + +let response = try await api.post("https://api.example.com/data", + body: payload, + options: requestOptions) +``` + +### Combining Options + +```swift +extension RestOptions { + static func combine(_ options: RestOptions...) -> RestOptions { + var combined = RestOptions() + + for option in options { + // Merge headers + combined.headers.merge(option.headers) { $1 } + + // Merge query parameters + combined.queryParameters.merge(option.queryParameters) { $1 } + + // Use latest non-nil values + if option.timeoutInterval != nil { + combined.timeoutInterval = option.timeoutInterval + } + // ... handle other properties + } + + return combined + } +} + +// Usage +let baseOptions = RestOptions() +let authOptions = RestOptions(headers: ["Authorization": "Bearer token"]) +let customOptions = RestOptions(timeoutInterval: 30) + +let combined = RestOptions.combine(baseOptions, authOptions, customOptions) +``` + +## Topics + +### Configuration +- ``RestOptions/headers`` +- ``RestOptions/queryParameters`` +- ``RestOptions/timeoutInterval`` +- ``RestOptions/expectedStatusCodes`` +- ``RestOptions/acceptedMimeTypes`` + +### Security +- ``RestOptions/serverPinning`` +- ``RestOptions/tlsConfiguration`` + +### Related Types +- ``RestApiCaller`` +- ``CertificateCAPinning`` +- ``PublicKeyServerPinning`` \ No newline at end of file diff --git a/Sources/SwiftRestRequests/Documentation.docc/Articles/ErrorHandling.md b/Sources/SwiftRestRequests/Documentation.docc/Articles/ErrorHandling.md new file mode 100644 index 0000000..a626504 --- /dev/null +++ b/Sources/SwiftRestRequests/Documentation.docc/Articles/ErrorHandling.md @@ -0,0 +1,159 @@ +# Error Handling Guide + +Learn how to handle errors effectively in SwiftRestRequests. + +## Overview + +SwiftRestRequests provides comprehensive error handling through the ``RestError`` type. This guide explains common error scenarios and how to handle them effectively. + +## Common Error Types + +### Network Errors + +```swift +do { + let response = try await api.get("https://api.example.com/data") +} catch RestError.badResponse(let response, let data) { + print("Invalid response: \(response)") + // Handle invalid response format +} catch RestError.networkError(let error) { + // Handle network connectivity issues + if let urlError = error as? URLError { + switch urlError.code { + case .notConnectedToInternet: + // Handle no internet connection + case .timedOut: + // Handle request timeout + default: + // Handle other network errors + } + } +} +``` + +### Data Processing Errors + +```swift +do { + let item: Item = try await api.get("https://api.example.com/items/1") +} catch RestError.malformedResponse(let response, let data, let error) { + if let decodingError = error as? DecodingError { + switch decodingError { + case .keyNotFound(let key, _): + print("Missing required field: \(key)") + case .typeMismatch(_, let context): + print("Invalid data type at: \(context.codingPath)") + default: + print("Other decoding error: \(decodingError)") + } + } +} +``` + +### API Errors + +```swift +do { + let response = try await api.post("https://api.example.com/items", body: newItem) +} catch RestError.failedRestCall(let response, let status, let error) { + switch status { + case .unauthorized: + // Handle authentication failure + case .notFound: + // Handle resource not found + case .tooManyRequests: + // Handle rate limiting + default: + // Handle other API errors + } +} +``` + +## Error Recovery Strategies + +### Retry Logic + +```swift +func retryableRequest(maxAttempts: Int = 3) async throws -> T { + var attempts = 0 + + while attempts < maxAttempts { + do { + return try await api.get("https://api.example.com/data") + } catch RestError.networkError(let error) { + attempts += 1 + if attempts == maxAttempts { throw error } + try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(attempts))) * 1_000_000_000) + } + } + + throw RestError.networkError(NSError(domain: "", code: -1)) +} +``` + +### Graceful Degradation + +```swift +func fetchUserData() async throws -> User { + do { + // Try to fetch full user data + return try await api.get("https://api.example.com/users/1") + } catch RestError.failedRestCall(_, .notFound, _) { + // Fall back to cached data + return try await getCachedUser() + } catch RestError.networkError { + // Fall back to offline mode + return try await getOfflineUser() + } +} +``` + +## Best Practices + +### Custom Error Handling + +```swift +extension RestError { + var isRetryable: Bool { + switch self { + case .networkError(let error): + return (error as? URLError)?.code != .cancelled + case .failedRestCall(_, let status, _): + return status.rawValue >= 500 + default: + return false + } + } +} +``` + +### Error Logging + +```swift +class ErrorLogger { + static func log(_ error: RestError) { + switch error { + case .malformedResponse(let response, let data, let error): + print(""" + Failed to process response: + URL: \(response.url?.absoluteString ?? "unknown") + Status: \(response.statusCode) + Error: \(error) + Data: \(String(data: data, encoding: .utf8) ?? "invalid data") + """) + // Handle other cases... + } + } +} +``` + +## Topics + +### Error Types +- ``RestError`` +- ``HTTPStatusCode`` + +### Related +- ``RestApiCaller`` +- ``RestOptions`` +- ``LogNetworkInterceptor`` \ No newline at end of file diff --git a/Sources/SwiftRestRequests/Documentation.docc/Articles/GettingStarted.md b/Sources/SwiftRestRequests/Documentation.docc/Articles/GettingStarted.md new file mode 100644 index 0000000..91ffe09 --- /dev/null +++ b/Sources/SwiftRestRequests/Documentation.docc/Articles/GettingStarted.md @@ -0,0 +1,85 @@ +# Getting Started + +@Metadata { + @Title("Getting Started") +} + +Follow these steps to integrate ``SwiftRestRequests`` into a Swift Package or Xcode project and perform your +first request. + +## Add the package dependency + +Use Swift Package Manager to depend on the library: + +```swift +.package(url: "https://github.com/tkausch/SwiftRestRequests", from: "1.6.3") +``` + +Then add ``SwiftRestRequests`` to your target dependencies. Xcode users can add the package through +**File → Add Packages…** with the same URL. + +## Configure a client + +Subclass ``RestApiCaller`` (or instantiate it directly) to describe your REST API in a strongly typed way: + +```swift +import SwiftRestRequests + +final class HttpBinClient: RestApiCaller { + func status204() async throws -> Int { + try await get(at: "status/204") + } + + func getEcho() async throws -> (HttpBinResponse?, Int) { + try await get(HttpBinResponse.self, at: "get") + } +} + +struct HttpBinResponse: Decodable { + let url: String + let origin: String +} + +let client = HttpBinClient(baseUrl: URL(string: "https://httpbin.org")!) +let (response, status) = try await client.getEcho() +``` + +`RestApiCaller` automatically applies default headers, validates the HTTP status, decodes JSON payloads +with ``DecodableDeserializer``, and throws ``RestError`` when something goes wrong. + +## Customize each request + +``RestOptions`` lets you override headers, query parameters, or status expectations without reconfiguring +your client: + +```swift +var options = RestOptions() +options.httpHeaders = ["X-Test": "demo"] +options.expectedStatusCodes = [200, 204] + +let status = try await client.delete(at: "resource/42", options: options) +``` + +Use `headerGenerator` or register a ``URLRequestInterceptor`` subclass to insert shared headers, log +traffic, inject authentication, or perform response inspection: + +```swift +client.registerRequestInterceptor(LogNetworkInterceptor(enableNetworkTracing: true)) +``` + +## Handle errors + +All failures surface as ``RestError``. Switch over the cases to provide better feedback or retry logic: + +```swift +catch let error as RestError { + switch error { + case .invalidMimeType(let mime): + print("Unexpected MIME type: \(mime ?? "none")") + case .failedRestCall(_, let status, let payload): + print("Server rejected the call with status \(status). Payload: \(String(describing: payload))") + default: + print("Unhandled REST error: \(error)") + } +} +``` diff --git a/Sources/SwiftRestRequests/Documentation.docc/Articles/Interceptors.md b/Sources/SwiftRestRequests/Documentation.docc/Articles/Interceptors.md new file mode 100644 index 0000000..38c69eb --- /dev/null +++ b/Sources/SwiftRestRequests/Documentation.docc/Articles/Interceptors.md @@ -0,0 +1,130 @@ +# Working with Interceptors + +Learn how to use and create custom interceptors in SwiftRestRequests. + +## Overview + +Interceptors provide a powerful way to modify requests and responses in SwiftRestRequests. They can be used for logging, authentication, request modification, and response processing. + +## Built-in Interceptors + +### LogNetworkInterceptor + +The ``LogNetworkInterceptor`` provides detailed logging of network requests and responses: + +```swift +let api = RestApiCaller() +api.addInterceptor(LogNetworkInterceptor()) +``` + +### AuthorizerInterceptor + +The ``AuthorizerInterceptor`` handles request authentication: + +```swift +let authorizer = BearerTokenAuthorizer(token: "your-token") +let interceptor = AuthorizerInterceptor(authorizer: authorizer) +api.addInterceptor(interceptor) +``` + +## Creating Custom Interceptors + +### Basic Interceptor + +```swift +class TimestampInterceptor: RequestInterceptor { + func intercept(_ request: URLRequest) throws -> URLRequest { + var request = request + request.setValue(ISO8601DateFormatter().string(from: Date()), + forHTTPHeaderField: "X-Timestamp") + return request + } +} + +// Usage +let api = RestApiCaller() +api.addInterceptor(TimestampInterceptor()) +``` + +### Response Interceptor + +```swift +class MetricsInterceptor: ResponseInterceptor { + func intercept(_ response: URLResponse, data: Data) throws { + guard let httpResponse = response as? HTTPURLResponse else { return } + + let metrics = [ + "status_code": httpResponse.statusCode, + "response_time": // Calculate response time + "content_length": data.count + ] + + // Log or process metrics + } +} +``` + +### Chaining Interceptors + +Interceptors are executed in the order they are added: + +```swift +let api = RestApiCaller() +api.addInterceptor(LogNetworkInterceptor()) +api.addInterceptor(AuthorizerInterceptor(authorizer: myAuthorizer)) +api.addInterceptor(MetricsInterceptor()) +``` + +## Best Practices + +### Error Handling + +```swift +class ValidationInterceptor: RequestInterceptor { + func intercept(_ request: URLRequest) throws -> URLRequest { + guard let url = request.url else { + throw RestError.invalidURL + } + + // Perform validation + guard isValid(url) else { + throw CustomError.invalidEndpoint + } + + return request + } +} +``` + +### Thread Safety + +```swift +class ThreadSafeInterceptor: RequestInterceptor { + private let queue = DispatchQueue(label: "com.example.interceptor") + private var cachedData: [String: Any] = [:] + + func intercept(_ request: URLRequest) throws -> URLRequest { + return queue.sync { + // Thread-safe operations + var request = request + // Modify request + return request + } + } +} +``` + +## Topics + +### Built-in Interceptors +- ``LogNetworkInterceptor`` +- ``AuthorizerInterceptor`` + +### Protocols +- ``RequestInterceptor`` +- ``ResponseInterceptor`` + +### Related Types +- ``RestApiCaller`` +- ``RestOptions`` +- ``URLRequestAuthorizer`` \ No newline at end of file diff --git a/Sources/SwiftRestRequests/Documentation.docc/HTTPUtil.swift b/Sources/SwiftRestRequests/Documentation.docc/HTTPUtil.swift new file mode 100644 index 0000000..02c3bbc --- /dev/null +++ b/Sources/SwiftRestRequests/Documentation.docc/HTTPUtil.swift @@ -0,0 +1,42 @@ +extension HTTPUtil { + /// Additional documentation for HTTPUtil + /// + /// ## Overview + /// HTTPUtil provides utility functions for working with HTTP requests and responses. + /// + /// ## Topics + /// + /// ### Status Code Handling + /// - ``isSuccessful(_:)`` + /// - ``isClientError(_:)`` + /// - ``isServerError(_:)`` + /// + /// ### Response Validation + /// - ``validateResponse(_:data:)`` + /// - ``validateStatusCode(_:)`` + /// + /// ### MIME Type Handling + /// - ``validateContentType(_:expected:)`` + /// - ``parseContentType(_:)`` + /// + /// ### Examples + /// + /// #### Validating Status Codes + /// ```swift + /// let response = ... // HTTPURLResponse + /// if HTTPUtil.isSuccessful(response.statusCode) { + /// // Process successful response + /// } else if HTTPUtil.isClientError(response.statusCode) { + /// // Handle client error + /// } else if HTTPUtil.isServerError(response.statusCode) { + /// // Handle server error + /// } + /// ``` + /// + /// #### Content Type Validation + /// ```swift + /// let response = ... // HTTPURLResponse + /// try HTTPUtil.validateContentType(response, expected: ["application/json"]) + /// ``` + public static var documentation: Never { fatalError() } +} \ No newline at end of file diff --git a/Sources/SwiftRestRequests/Documentation.docc/SwiftRestRequests.md b/Sources/SwiftRestRequests/Documentation.docc/SwiftRestRequests.md new file mode 100644 index 0000000..a3b551b --- /dev/null +++ b/Sources/SwiftRestRequests/Documentation.docc/SwiftRestRequests.md @@ -0,0 +1,50 @@ +# ``SwiftRestRequests`` + +SwiftRestRequests is an async/await-first HTTP client powered by `URLSession`. It focuses on strong typing, +first-class logging, and convenient extensibility points so you can build reliable REST integrations with +minimum boilerplate. + +## Overview + +SwiftRestRequests extends `URLSession` with conveniences that are typically reimplemented in every codebase: + +- Common HTTP verbs that automatically encode `Encodable` requests and decode `Decodable` responses. +- Unified error handling via ``RestError`` so you can differentiate validation problems from server-side + failures. +- Request- and response-level interception through ``URLRequestInterceptor`` and header generators. +- Built-in helpers for authentication (`BasicRequestAuthorizer`, `BearerReqeustAuthorizer`, and + ``URLRequestAuthorizer``) plus optional TLS pinning utilities. +- Structured logging via `swift-log`, making it easy to plug in OSLog or server-side log drains. + +The package supports iOS, macOS, watchOS, tvOS, visionOS, and Linux. It targets Swift 5.9 and uses +`StrictConcurrency` to ensure async code is safe by default. + +## Topics + +### Essentials + +- +- ``RestApiCaller`` +- ``RestOptions`` +- ``RestError`` + +### Authentication + +- ``URLRequestAuthorizer`` +- ``BasicRequestAuthorizer`` +- ``BearerReqeustAuthorizer`` +- ``NoneAuthorizer`` +- ``AuthorizerInterceptor`` + +### Interception and Observability + +- ``URLRequestInterceptor`` +- ``LogNetworkInterceptor`` +- ``HeaderGenerator`` + +### Serialization + +- ``Deserializer`` +- ``DecodableDeserializer`` +- ``VoidDeserializer`` +- ``DataDeserializer`` diff --git a/Sources/SwiftRestRequests/RestError.swift b/Sources/SwiftRestRequests/RestError.swift index 9ca78e4..ecee916 100644 --- a/Sources/SwiftRestRequests/RestError.swift +++ b/Sources/SwiftRestRequests/RestError.swift @@ -24,43 +24,146 @@ import Foundation import FoundationNetworking #endif - /// Errors thrown when executing REST requests. /// -/// Every case carries additional context so that callers can decide whether to retry, surface the failure, -/// or attempt to recover (for example when a deserializer reports malformed data). +/// `RestError` provides comprehensive error information for REST API operations, allowing callers to +/// make informed decisions about error handling, retry strategies, and recovery options. +/// +/// ## Overview +/// This error type encapsulates various failure scenarios that can occur during REST API interactions: +/// - Invalid response formats or protocols +/// - Unexpected MIME types +/// - Query parameter encoding issues +/// - Response deserialization failures +/// - HTTP status code violations +/// - Server-reported errors +/// +/// ## Topics +/// +/// ### Response Validation Errors +/// - ``badResponse(_:_:)`` +/// - ``invalidMimeType(_:)`` +/// - ``unexpectedHttpStatusCode(_:)`` +/// +/// ### Data Processing Errors +/// - ``malformedResponse(_:_:_:)`` +/// - ``invalidQueryParameter`` +/// +/// ### API Errors +/// - ``failedRestCall(_:_:error:)`` +/// +/// ## Usage Example +/// ```swift +/// do { +/// let result = try await restClient.get("https://api.example.com/data") +/// } catch let error as RestError { +/// switch error { +/// case .invalidMimeType(let mime): +/// print("Unexpected content type: \(mime ?? "none")") +/// case .failedRestCall(_, let status, let error): +/// print("API error: \(status), details: \(error ?? "none")") +/// case .malformedResponse(_, _, let error): +/// print("Failed to parse response: \(error)") +/// // Handle other cases... +/// } +/// } +/// ``` public enum RestError: Error { - /// The server responded with a non-HTTP response or an unsupported protocol. + /// Indicates that the server responded using an unknown or unsupported protocol. + /// + /// This error occurs when the server's response doesn't conform to the expected HTTP/HTTPS protocol + /// or when the response format is invalid. + /// /// - Parameters: - /// - response: The raw `URLResponse` returned by the loading system. - /// - data: The raw response body bytes (may be empty). + /// - URLResponse: The raw response returned from the server. + /// - Data: The raw data returned in the response body. + /// + /// - Note: This error typically indicates a server misconfiguration or a proxy interference. case badResponse(response: URLResponse, data: Data) - /// The response `Content-Type` did not match expected MIME types. - /// - Parameter mimeType: The value of the `Content-Type` header, if any. + /// Indicates that the server responded with an unexpected MIME type. + /// + /// This error occurs when the Content-Type header in the response doesn't match + /// the expected MIME type for the request. For example, receiving "text/plain" + /// when "application/json" was expected. + /// + /// - Parameter String: The MIME type received in the response's Content-Type header. + /// May be nil if no Content-Type header was present. + /// + /// - Note: Configure `RestOptions.acceptedMimeTypes` to specify additional accepted MIME types. case invalidMimeType(mimeType: String?) - /// One or more query parameters could not be encoded (percent-encoding failure). + /// Indicates that query parameters could not be encoded using percent encoding. + /// + /// This error occurs when attempting to encode query parameters into a URL-safe format + /// using percent encoding, but the encoding operation fails. This typically happens when + /// parameter values contain characters that cannot be safely encoded for URL transmission. + /// + /// - Note: To avoid this error, ensure query parameters contain only URL-safe characters + /// or properly encode special characters before making the request. case invalidQueryParameter - /// The response body could not be deserialized into the expected model. + /// Indicates the server's response could not be deserialized using the given Deserializer. + /// + /// This error occurs when the response data cannot be converted into the expected type + /// using the configured Deserializer. Common causes include: + /// - Mismatched data structure + /// - Missing required fields + /// - Invalid data types + /// - Malformed JSON or other formats + /// /// - Parameters: - /// - response: The `HTTPURLResponse` that accompanied the body. - /// - data: The raw response body. - /// - underlying: The underlying parsing/decoding error (commonly `DecodingError`). + /// - HTTPURLResponse: The HTTP response metadata from the server. + /// - Data: The raw response data that failed to deserialize. + /// - Error: The underlying error (typically a `DecodingError`) that provides + /// specific details about why deserialization failed. + /// + /// - Note: Inspect the underlying Error for detailed information about the deserialization failure. + /// For DecodingError cases, the error will contain the specific path where decoding failed. case malformedResponse(response: HTTPURLResponse, data: Data, underlying: any Error) - /// The API returned an error HTTP status and, optionally, a parsed error payload. + /// Indicates the API call failed with an error response from the server. + /// + /// This error represents a failed API call where the server returned an error response. + /// The error includes the full HTTP response, the specific status code, and optionally + /// a parsed error payload from the response body. + /// /// - Parameters: - /// - response: The `HTTPURLResponse` returned by the server. - /// - status: The `HTTPStatusCode` for the response. - /// - errorPayload: An optional deserialized error object returned by the API. + /// - HTTPURLResponse: The complete HTTP response containing headers and metadata. + /// - HTTPStatusCode: The specific HTTP status code indicating the type of failure. + /// - error: The deserialized error payload from the response body, if one was + /// provided and could be parsed. The error payload must conform to `Sendable`. + /// + /// ## Example + /// ```swift + /// catch case let RestError.failedRestCall(response, status, error) { + /// print("API call failed with status: \(status)") + /// if let apiError = error as? MyAPIError { + /// print("Error details: \(apiError)") + /// } + /// } + /// ``` case failedRestCall(response: HTTPURLResponse, status: HTTPStatusCode, errorPayload: (any Sendable)?) - /// The HTTP status code was not in the set of expected codes. - /// Configure `RestOptions.expectedStatusCodes` to accept additional codes. - /// - Parameter statusCode: The unexpected HTTP status code. + /// Indicates that the response contained an unexpected HTTP status code. + /// + /// This error occurs when the server returns an HTTP status code that isn't in the + /// set of expected status codes for the request. By default, only 2xx status codes + /// are considered valid. + /// + /// - Parameter Int: The unexpected HTTP status code returned from the server. + /// + /// - Note: You can configure additional valid status codes using `RestOptions.expectedStatusCodes`. + /// This is useful when certain error codes should be treated as valid responses + /// for your specific use case. + /// + /// ## Example + /// ```swift + /// // Configure 404 as valid for this request + /// var options = RestOptions() + /// options.expectedStatusCodes = [200, 404] + /// ``` case unexpectedHttpStatusCode(statusCode: Int)