Some checks failed
Publish Swift Package / build-test-publish (push) Failing after 3m27s
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
123 lines
3.9 KiB
Swift
123 lines
3.9 KiB
Swift
import Foundation
|
|
|
|
// MARK: - Paginated Response
|
|
|
|
/// A paginated API response wrapper.
|
|
public struct PaginatedResponse<T: Decodable & Sendable>: Decodable, Sendable {
|
|
public let data: [T]
|
|
public let total: Int
|
|
public let page: Int
|
|
public let limit: Int
|
|
|
|
/// Whether more pages are available beyond the current one.
|
|
public var hasMore: Bool {
|
|
page * limit < total
|
|
}
|
|
}
|
|
|
|
// MARK: - Response Decoder
|
|
|
|
/// Decodes JSON API responses with standardized error handling.
|
|
public struct ResponseDecoder: Sendable {
|
|
|
|
public init() {}
|
|
|
|
/// Decode a typed response from raw data and HTTP status code.
|
|
/// - Parameters:
|
|
/// - data: The response body data.
|
|
/// - status: The HTTP status code.
|
|
/// - Returns: The decoded response object.
|
|
/// - Throws: `APIError` on non-2xx status or decoding failure.
|
|
public func decode<T: Decodable>(_ data: Data, status: Int) throws -> T {
|
|
guard (200...299).contains(status) else {
|
|
throw classifiedError(statusCode: status, data: data)
|
|
}
|
|
|
|
do {
|
|
return try JSONDecoder.apiDecoder.decode(T.self, from: data)
|
|
} catch {
|
|
throw APIError.decodingError(error)
|
|
}
|
|
}
|
|
|
|
/// Validate a void response (no body expected).
|
|
/// - Parameters:
|
|
/// - status: The HTTP status code.
|
|
/// - data: The response body data (used for error messages on failure).
|
|
/// - Throws: `APIError` on non-2xx status.
|
|
public func validateVoid(status: Int, data: Data) throws {
|
|
guard (200...299).contains(status) else {
|
|
throw classifiedError(statusCode: status, data: data)
|
|
}
|
|
}
|
|
|
|
/// Classify an HTTP status code into a typed error.
|
|
private func classifiedError(statusCode: Int, data: Data) -> APIError {
|
|
switch statusCode {
|
|
case 401:
|
|
return .unauthorized
|
|
case 403:
|
|
return .forbidden
|
|
case 404:
|
|
return .notFound("resource")
|
|
case 429:
|
|
return .rateLimited(retryAfter: nil)
|
|
case 500...599:
|
|
return .serverError(statusCode: statusCode)
|
|
default:
|
|
return .httpError(statusCode: statusCode, data: data)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - JSON Decoder Extensions
|
|
|
|
extension JSONDecoder {
|
|
/// Shared API decoder with snake_case key conversion and ISO 8601 date handling.
|
|
public static let apiDecoder: JSONDecoder = {
|
|
let decoder = JSONDecoder()
|
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
decoder.dateDecodingStrategy = .custom { decoder in
|
|
let container = try decoder.singleValueContainer()
|
|
let dateString = try container.decode(String.self)
|
|
|
|
if let date = ISO8601DateFormatter.full.date(from: dateString) {
|
|
return date
|
|
}
|
|
if let date = ISO8601DateFormatter.basic.date(from: dateString) {
|
|
return date
|
|
}
|
|
throw DecodingError.dataCorruptedError(
|
|
in: container,
|
|
debugDescription: "Cannot decode date: \(dateString)"
|
|
)
|
|
}
|
|
return decoder
|
|
}()
|
|
}
|
|
|
|
extension JSONEncoder {
|
|
/// Shared API encoder with snake_case key conversion and ISO 8601 date formatting.
|
|
public static let apiEncoder: JSONEncoder = {
|
|
let encoder = JSONEncoder()
|
|
encoder.keyEncodingStrategy = .convertToSnakeCase
|
|
encoder.dateEncodingStrategy = .iso8601
|
|
return encoder
|
|
}()
|
|
}
|
|
|
|
extension ISO8601DateFormatter {
|
|
/// ISO 8601 formatter with fractional seconds.
|
|
static let full: ISO8601DateFormatter = {
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
return formatter
|
|
}()
|
|
|
|
/// ISO 8601 formatter without fractional seconds.
|
|
static let basic: ISO8601DateFormatter = {
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime]
|
|
return formatter
|
|
}()
|
|
}
|