[api-upload-refactor] Replace request surface: typed body, pathParams, bounded retry
- request(): drop params: Any? and jsonEncoding:; add body: (any HTTPBody)? and pathParams: [String: String] - remove legacy magic-"file" multipart branch (-> upload() in T6) and the now-unused mimeTypeForPath - decompose into makeURL / buildRequest / attach / send / classify / decodeResponse / friendlyError (each < 30 lines) - headers now merge over defaults (custom wins per key) instead of replacing - fix unbounded persistConnection recursion on permanent 4xx: cap at maxPersistRetries (3) - APIRequestTests: 7 cases over StubURLProtocol
This commit is contained in:
@@ -101,187 +101,142 @@ public actor API {
|
|||||||
persistConnectionDelay = seconds
|
persistConnectionDelay = seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Performs an asynchronous network request and decodes the response into a `Codable` type.
|
/// Maximum number of extra attempts made when `persistConnection` is set and
|
||||||
|
/// the server keeps returning a 4xx. Bounds what was previously an unbounded
|
||||||
|
/// recursion on a permanent client error.
|
||||||
|
public static let maxPersistRetries = 3
|
||||||
|
|
||||||
|
/// Performs an asynchronous network request and decodes the JSON response.
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - url: The URL string for the request.
|
/// - url: The URL string. `{name}` placeholders are filled from `pathParams`.
|
||||||
/// - params: Optional parameters for the request. Can be `[String: Any]` for JSON/form-data, or `Data` for raw body.
|
/// - method: The HTTP method (`.get`, `.post`, `.put`, `.delete`, `.patch`).
|
||||||
/// - method: The HTTP method to use for the request (`.get`, `.post`, `.put`, `.delete`,`.patch` ).
|
/// - body: Optional typed request body (``JSONBody``, ``FormURLEncodedBody``,
|
||||||
/// - headers: Optional custom HTTP headers to be added to the request. These override default headers if there are conflicts.
|
/// ``RawBody``, or any ``HTTPBody``). Its `Content-Type` and
|
||||||
/// - jsonEncoding: A boolean indicating whether parameters should be JSON encoded. Defaults to `true`.
|
/// `Content-Length` are set automatically.
|
||||||
/// - debug: A boolean indicating whether to print debug logs for the request and response. Defaults to `true`.
|
/// - pathParams: Values substituted into `{name}` placeholders in `url`.
|
||||||
/// - timeoutInterval: The timeout interval in seconds for the request. Defaults to `30`.
|
/// - headers: Custom headers, merged over the defaults (custom wins).
|
||||||
/// - networkServiceType: The `URLRequest.NetworkServiceType` for the request. Defaults to `.default`.
|
/// - debug: Print request/response debug logs. Defaults to `true`.
|
||||||
/// - persistConnection: A boolean indicating whether to persist the connection on certain error codes (e.g., 4xx). Defaults to `false`.
|
/// - timeoutInterval: Request timeout in seconds. Defaults to `30`.
|
||||||
/// - Returns: An instance of the `T` type, decoded from the response data.
|
/// - networkServiceType: `URLRequest.NetworkServiceType`. Defaults to `.default`.
|
||||||
/// - Throws: An `Error` if the request fails, including `URLError` for network issues or `DecodingError` for JSON decoding failures.
|
/// - persistConnection: Retry (bounded by ``maxPersistRetries``) on a 4xx.
|
||||||
public func request<T: Decodable & Sendable>(url: String,
|
/// - Returns: `T` decoded from the response body, or the raw string when `T == String`.
|
||||||
params: Any? = nil,
|
/// - Throws: `URLError` for transport failures, an `NSError` carrying the HTTP
|
||||||
|
/// status for non-2xx responses, or `DecodingError` on a malformed body.
|
||||||
|
public func request<T: Decodable & Sendable>(
|
||||||
|
url: String,
|
||||||
method: httpMethod,
|
method: httpMethod,
|
||||||
|
body: (any HTTPBody)? = nil,
|
||||||
|
pathParams: [String: String] = [:],
|
||||||
headers: [String: String] = [:],
|
headers: [String: String] = [:],
|
||||||
jsonEncoding: Bool = true,
|
|
||||||
debug: Bool = true,
|
debug: Bool = true,
|
||||||
timeoutInterval: TimeInterval = 30,
|
timeoutInterval: TimeInterval = 30,
|
||||||
networkServiceType: URLRequest.NetworkServiceType = .default,
|
networkServiceType: URLRequest.NetworkServiceType = .default,
|
||||||
persistConnection: Bool = false) async throws -> T {
|
persistConnection: Bool = false
|
||||||
|
) async throws -> T {
|
||||||
if let urlReq = URL(string: url.replaceURL(params as? [String: Any] ?? [:] )) {
|
let resolvedURL = try makeURL(url, pathParams: pathParams)
|
||||||
var request = URLRequest(url: urlReq, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeoutInterval)
|
var urlRequest = buildRequest(url: resolvedURL, method: method, headers: headers,
|
||||||
if method == .post || method == .put || method == .delete || method == .patch {
|
timeout: timeoutInterval, serviceType: networkServiceType)
|
||||||
if let params = params as? [String: Any],
|
if let body {
|
||||||
let pathFile = params["file"] as? String,
|
try Self.attach(body: body, to: &urlRequest)
|
||||||
let fileURL = URL(string: pathFile) {
|
}
|
||||||
let boundary = UUID().uuidString
|
return try await send(urlRequest, method: method, debug: debug,
|
||||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
persistConnection: persistConnection, retriesLeft: Self.maxPersistRetries)
|
||||||
|
|
||||||
var body = Data()
|
|
||||||
|
|
||||||
// Add additional fields (if any)
|
|
||||||
for (key, value) in params where key != "file" {
|
|
||||||
let stringValue = "\(value)"
|
|
||||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
|
||||||
body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!)
|
|
||||||
body.append("\(stringValue)\r\n".data(using: .utf8)!)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the file
|
/// Sends a fully-built request, handles the response, and applies bounded
|
||||||
let fileName = fileURL.lastPathComponent
|
/// `persistConnection` retries.
|
||||||
let mimeType = mimeTypeForPath(path: fileName)
|
private func send<T: Decodable & Sendable>(
|
||||||
printInfo(title: "Body size before", msg: "\(body.count) bytes")
|
_ urlRequest: URLRequest,
|
||||||
|
method: httpMethod,
|
||||||
let fileData: Data
|
debug: Bool,
|
||||||
do {
|
persistConnection: Bool,
|
||||||
fileData = try Data(contentsOf: fileURL)
|
retriesLeft: Int
|
||||||
printInfo(title: "Body size after", msg: "\(body.count) bytes")
|
) async throws -> T {
|
||||||
} catch {
|
if debug { API.requestLOG(method: method, request: urlRequest) }
|
||||||
printError(title: "Upload File", msg: error.localizedDescription)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
|
||||||
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!)
|
|
||||||
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
|
|
||||||
let fileDataCopy = Data(fileData)
|
|
||||||
body.append(fileDataCopy)
|
|
||||||
let dataUTF8 = "\r\n".data(using: .utf8)!
|
|
||||||
body.append(dataUTF8)
|
|
||||||
printInfo(title: "Body size after", msg: "\(body.count) bytes")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finalize the request body
|
|
||||||
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
|
|
||||||
request.httpBody = body
|
|
||||||
request.setValue("\(body.count)", forHTTPHeaderField: "Content-Length")
|
|
||||||
// Debug logs
|
|
||||||
printLog(title: "Boundary", msg: boundary)
|
|
||||||
if let bodyString = String(data: body, encoding: .utf8) {
|
|
||||||
printLog(title: "Body Content", msg: bodyString)
|
|
||||||
}
|
|
||||||
} else if jsonEncoding, let params = params as? [String: Any] {
|
|
||||||
let requestObject = try JSONSerialization.data(withJSONObject: params)
|
|
||||||
request.httpBody = requestObject
|
|
||||||
} else if let params = params as? [String: Any] {
|
|
||||||
var bodyComponents = URLComponents()
|
|
||||||
params.forEach({ (key, value) in
|
|
||||||
bodyComponents.queryItems?.append(URLQueryItem(name: key, value: value as? String))
|
|
||||||
})
|
|
||||||
request.httpBody = bodyComponents.query?.data(using: .utf8)
|
|
||||||
} else if let params = params as? Data {
|
|
||||||
request.httpBody = params
|
|
||||||
}
|
|
||||||
}
|
|
||||||
request.httpMethod = method.rawValue
|
|
||||||
request.timeoutInterval = timeoutInterval
|
|
||||||
request.networkServiceType = networkServiceType
|
|
||||||
|
|
||||||
// - Put Default Headers together with user defined params
|
|
||||||
if !headers.isEmpty {
|
|
||||||
// - Add it to request
|
|
||||||
headers.forEach { (key, value) in
|
|
||||||
request.addValue(value, forHTTPHeaderField: key)
|
|
||||||
}
|
|
||||||
}else{
|
|
||||||
defaultHeaders.forEach { (key, value) in
|
|
||||||
request.addValue(value, forHTTPHeaderField: key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if debug {
|
|
||||||
API.requestLOG(method: method, request: request)
|
|
||||||
}
|
|
||||||
|
|
||||||
let (session, mustInvalidate) = makeSession()
|
let (session, mustInvalidate) = makeSession()
|
||||||
defer {
|
defer { if mustInvalidate { session.finishTasksAndInvalidate() } }
|
||||||
if mustInvalidate {
|
|
||||||
session.finishTasksAndInvalidate()
|
let (data, response) = try await session.data(for: urlRequest)
|
||||||
|
let code = (response as? HTTPURLResponse)?.statusCode ?? LCEssentials.DEFAULT_ERROR_CODE
|
||||||
|
|
||||||
|
switch Self.classify(code: code, data: data, method: method, request: urlRequest, debug: debug) {
|
||||||
|
case .success:
|
||||||
|
return try Self.decodeResponse(data)
|
||||||
|
case .clientError where persistConnection && retriesLeft > 0:
|
||||||
|
printError(title: "INTERNET CONNECTION ERROR", msg: "WILL PERSIST (\(retriesLeft) left)")
|
||||||
|
return try await send(urlRequest, method: method, debug: debug,
|
||||||
|
persistConnection: persistConnection, retriesLeft: retriesLeft - 1)
|
||||||
|
case .clientError, .otherError:
|
||||||
|
throw Self.friendlyError(code: code, data: data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
do {
|
|
||||||
let (data, response) = try await session.data(for: request)
|
|
||||||
|
|
||||||
|
// MARK: - Request building
|
||||||
|
|
||||||
var code: Int = LCEssentials.DEFAULT_ERROR_CODE
|
private func makeURL(_ template: String, pathParams: [String: String]) throws -> URL {
|
||||||
let httpResponse = response as? HTTPURLResponse ?? HTTPURLResponse()
|
guard let url = URL(string: template.replaceURL(pathParams)) else { throw API.defaultError }
|
||||||
code = httpResponse.statusCode
|
return url
|
||||||
let error = URLError(URLError.Code(rawValue: code))
|
}
|
||||||
switch code {
|
|
||||||
case 200..<300:
|
private func buildRequest(url: URL,
|
||||||
// - Debug LOG
|
method: httpMethod,
|
||||||
|
headers: [String: String],
|
||||||
|
timeout: TimeInterval,
|
||||||
|
serviceType: URLRequest.NetworkServiceType) -> URLRequest {
|
||||||
|
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeout)
|
||||||
|
request.httpMethod = method.rawValue
|
||||||
|
request.timeoutInterval = timeout
|
||||||
|
request.networkServiceType = serviceType
|
||||||
|
defaultHeaders.merging(headers) { _, custom in custom }
|
||||||
|
.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the body plus its `Content-Type` and `Content-Length`. The body's own
|
||||||
|
/// content type wins over any set through `headers`.
|
||||||
|
private static func attach(body: any HTTPBody, to request: inout URLRequest) throws {
|
||||||
|
let (data, contentType) = try body.encoded()
|
||||||
|
request.httpBody = data
|
||||||
|
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue("\(data.count)", forHTTPHeaderField: "Content-Length")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Response handling
|
||||||
|
|
||||||
|
private enum ResponseDisposition { case success, clientError, otherError }
|
||||||
|
|
||||||
|
private static func classify(code: Int, data: Data, method: httpMethod,
|
||||||
|
request: URLRequest, debug: Bool) -> ResponseDisposition {
|
||||||
|
let error = (200..<300).contains(code) ? nil : URLError(URLError.Code(rawValue: code))
|
||||||
if debug {
|
if debug {
|
||||||
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: nil)
|
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
|
||||||
|
}
|
||||||
|
switch code {
|
||||||
|
case 200..<300: return .success
|
||||||
|
case 400..<500: return .clientError
|
||||||
|
default: return .otherError
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// - Check if is JSON result and try decode it
|
private static func decodeResponse<T: Decodable & Sendable>(_ data: Data) throws -> T {
|
||||||
if let string = data.string as? T, T.self == String.self {
|
if T.self == String.self, let string = String(data: data, encoding: .utf8) as? T {
|
||||||
return string
|
return string
|
||||||
}
|
}
|
||||||
// - Normal decoding
|
|
||||||
do {
|
do {
|
||||||
return try JSONDecoder.decode(data: data)
|
return try JSONDecoder.decode(data: data)
|
||||||
} catch {
|
} catch {
|
||||||
printError(title: "JSONDecoder", msg: error.localizedDescription)
|
printError(title: "JSONDecoder", msg: error.localizedDescription)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
case 400..<500:
|
|
||||||
// - Debug LOG
|
|
||||||
if debug {
|
|
||||||
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
|
|
||||||
}
|
}
|
||||||
if persistConnection {
|
|
||||||
printError(title: "INTERNET CONNECTION ERROR", msg: "WILL PERSIST")
|
private static func friendlyError(code: Int, data: Data) -> NSError {
|
||||||
// Recursive call for persistence
|
let urlError = URLError(URLError.Code(rawValue: code))
|
||||||
let persist: T = try await self.request(
|
return NSError.createErrorWith(code: code,
|
||||||
url: url,
|
description: urlError.localizedDescription,
|
||||||
params: params,
|
reasonForError: data.prettyJson ?? "")
|
||||||
method: method,
|
|
||||||
headers: headers,
|
|
||||||
jsonEncoding: jsonEncoding,
|
|
||||||
debug: debug,
|
|
||||||
timeoutInterval: timeoutInterval,
|
|
||||||
networkServiceType: networkServiceType,
|
|
||||||
persistConnection: persistConnection
|
|
||||||
)
|
|
||||||
return persist
|
|
||||||
} else {
|
|
||||||
if debug {
|
|
||||||
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
|
|
||||||
}
|
|
||||||
let friendlyError = NSError.createErrorWith(code: code, description: error.localizedDescription, reasonForError: data.prettyJson ?? "")
|
|
||||||
throw friendlyError
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// - Debug LOG
|
|
||||||
if debug {
|
|
||||||
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
|
|
||||||
}
|
|
||||||
let friendlyError = NSError.createErrorWith(code: code, description: error.localizedDescription, reasonForError: data.prettyJson ?? "")
|
|
||||||
throw friendlyError
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw API.defaultError
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets up client certificate data and an optional password for authentication.
|
/// Sets up client certificate data and an optional password for authentication.
|
||||||
@@ -533,41 +488,5 @@ extension API {
|
|||||||
//
|
//
|
||||||
print("<======================================================================================>")
|
print("<======================================================================================>")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Determines the MIME type for a given file path based on its extension.
|
|
||||||
///
|
|
||||||
/// - Parameter path: The file path string.
|
|
||||||
/// - Returns: A string representing the MIME type. Defaults to "application/octet-stream" if the type is unknown.
|
|
||||||
func mimeTypeForPath(path: String) -> String {
|
|
||||||
let url = URL(fileURLWithPath: path)
|
|
||||||
let pathExtension = url.pathExtension.lowercased()
|
|
||||||
|
|
||||||
// Dictionary of common extensions and MIME types
|
|
||||||
let mimeTypes: [String: String] = [
|
|
||||||
"jpg": "image/jpeg",
|
|
||||||
"jpeg": "image/jpeg",
|
|
||||||
"png": "image/png",
|
|
||||||
"gif": "image/gif",
|
|
||||||
"pdf": "application/pdf",
|
|
||||||
"txt": "text/plain",
|
|
||||||
"html": "text/html",
|
|
||||||
"htm": "text/html",
|
|
||||||
"json": "application/json",
|
|
||||||
"xml": "application/xml",
|
|
||||||
"zip": "application/zip",
|
|
||||||
"mp3": "audio/mpeg",
|
|
||||||
"mp4": "video/mp4",
|
|
||||||
"mov": "video/quicktime",
|
|
||||||
"doc": "application/msword",
|
|
||||||
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
||||||
"xls": "application/vnd.ms-excel",
|
|
||||||
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
||||||
"ppt": "application/vnd.ms-powerpoint",
|
|
||||||
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
|
||||||
]
|
|
||||||
|
|
||||||
// Returns the corresponding MIME type for the extension, or "application/octet-stream" as default
|
|
||||||
return mimeTypes[pathExtension] ?? "application/octet-stream"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
122
Tests/LCEssentialsTests/APIRequestTests.swift
Normal file
122
Tests/LCEssentialsTests/APIRequestTests.swift
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import LCEssentials
|
||||||
|
|
||||||
|
private struct Echo: Decodable, Sendable, Equatable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CreateDTO: Encodable, Sendable {
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
final class APIRequestTests: XCTestCase {
|
||||||
|
|
||||||
|
private var api: API!
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
let cfg = URLSessionConfiguration.ephemeral
|
||||||
|
cfg.protocolClasses = [StubURLProtocol.self]
|
||||||
|
api = API(testConfiguration: cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
StubURLProtocol.reset()
|
||||||
|
api = nil
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testJSONBodyRequestSendsEncodedBodyAndDecodesResponse() async throws {
|
||||||
|
var stub = StubURLProtocol.Stub()
|
||||||
|
stub.statusCode = 200
|
||||||
|
stub.body = Data(#"{"id":10,"name":"x"}"#.utf8)
|
||||||
|
StubURLProtocol.setStub(stub)
|
||||||
|
|
||||||
|
let result: Echo = try await api.request(
|
||||||
|
url: "https://api.example.com/users",
|
||||||
|
method: .post,
|
||||||
|
body: jsonBody(CreateDTO(name: "x"))
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(result, Echo(id: 10, name: "x"))
|
||||||
|
let sent = StubURLProtocol.capturedRequests.first
|
||||||
|
XCTAssertEqual(sent?.httpMethod, "POST")
|
||||||
|
XCTAssertEqual(sent?.value(forHTTPHeaderField: "Content-Type"), "application/json; charset=UTF-8")
|
||||||
|
XCTAssertEqual(StubURLProtocol.lastCapturedBody, Data(#"{"name":"x"}"#.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCustomHeaderOverridesDefault() async throws {
|
||||||
|
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"id":1,"name":"a"}"#.utf8)))
|
||||||
|
|
||||||
|
let _: Echo = try await api.request(
|
||||||
|
url: "https://api.example.com/x",
|
||||||
|
method: .get,
|
||||||
|
headers: ["Accept": "application/xml"]
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(StubURLProtocol.capturedRequests.first?.value(forHTTPHeaderField: "Accept"),
|
||||||
|
"application/xml")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPathParamsSubstitution() async throws {
|
||||||
|
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"id":42,"name":"a"}"#.utf8)))
|
||||||
|
|
||||||
|
let _: Echo = try await api.request(
|
||||||
|
url: "https://api.example.com/users/{id}/posts",
|
||||||
|
method: .get,
|
||||||
|
pathParams: ["id": "42"]
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(StubURLProtocol.capturedRequests.first?.url?.absoluteString,
|
||||||
|
"https://api.example.com/users/42/posts")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStringResponsePassthroughSkipsJSONDecoding() async throws {
|
||||||
|
StubURLProtocol.setStub(.init(statusCode: 200,
|
||||||
|
headers: ["Content-Type": "text/plain"],
|
||||||
|
body: Data("plain hello".utf8)))
|
||||||
|
|
||||||
|
let text: String = try await api.request(url: "https://api.example.com/ping", method: .get)
|
||||||
|
XCTAssertEqual(text, "plain hello")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClientErrorThrowsNSErrorWithStatusAndBody() async {
|
||||||
|
StubURLProtocol.setStub(.init(statusCode: 422,
|
||||||
|
body: Data(#"{"error":"invalid"}"#.utf8)))
|
||||||
|
|
||||||
|
do {
|
||||||
|
let _: Echo = try await api.request(url: "https://api.example.com/x", method: .post,
|
||||||
|
body: jsonBody(CreateDTO(name: "")))
|
||||||
|
XCTFail("expected throw")
|
||||||
|
} catch let error as NSError {
|
||||||
|
XCTAssertEqual(error.code, 422)
|
||||||
|
XCTAssertTrue(error.localizedFailureReason?.contains("invalid") ?? false,
|
||||||
|
"reason: \(error.localizedFailureReason ?? "nil")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPersistConnectionRetriesButIsBoundedOnPermanentClientError() async {
|
||||||
|
StubURLProtocol.setStub(.init(statusCode: 400, body: Data(#"{"error":"nope"}"#.utf8)))
|
||||||
|
|
||||||
|
do {
|
||||||
|
let _: Echo = try await api.request(url: "https://api.example.com/x",
|
||||||
|
method: .get, persistConnection: true)
|
||||||
|
XCTFail("expected throw after retries exhausted")
|
||||||
|
} catch let error as NSError {
|
||||||
|
XCTAssertEqual(error.code, 400)
|
||||||
|
} catch {
|
||||||
|
XCTFail("unexpected error type: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// retried, but did NOT loop forever
|
||||||
|
XCTAssertGreaterThanOrEqual(StubURLProtocol.requestCount, 2)
|
||||||
|
XCTAssertLessThanOrEqual(StubURLProtocol.requestCount, API.maxPersistRetries + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNoPersistConnectionDoesNotRetry() async {
|
||||||
|
StubURLProtocol.setStub(.init(statusCode: 400, body: Data(#"{"error":"nope"}"#.utf8)))
|
||||||
|
let _: Echo? = try? await api.request(url: "https://api.example.com/x", method: .get)
|
||||||
|
XCTAssertEqual(StubURLProtocol.requestCount, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user