swift-api-client/Sources/MessagingAPIClient/REST/ResponseDecoder.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
}()
}