diff --git a/Package.swift b/Package.swift index f2df594..7dc84e2 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.5 +// swift-tools-version:6.2 import PackageDescription let package = Package( @@ -15,7 +15,26 @@ let package = Package( ], targets: [ .target( - name: "RevoHttp" + name: "RevoHttp", + swiftSettings: [ + .enableUpcomingFeature("SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES"), + .enableUpcomingFeature("SWIFT_UPCOMING_FEATURE_DYNAMIC_ACTOR_ISOLATION"), + .enableUpcomingFeature("SWIFT_UPCOMING_FEATURE_GLOBAL_ACTOR_ISOLATED_TYPES_USABILITY"), + .enableUpcomingFeature("SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY"), + .enableUpcomingFeature("SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS"), + .enableUpcomingFeature("SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS"), + .enableUpcomingFeature("SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES"), + .enableUpcomingFeature("SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES"), + .enableUpcomingFeature("SWIFT_UPCOMING_FEATURE_NONFROZEN_ENUM_EXHAUSTIVITY"), + .enableUpcomingFeature("SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT"), + .enableUpcomingFeature("SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION"), + .enableUpcomingFeature("SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION"), + .enableUpcomingFeature("SWIFT_ENABLE_BARE_SLASH_REGEX"), + .enableUpcomingFeature("SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE"), + .enableUpcomingFeature("SWIFT_UPCOMING_FEATURE_DEPRECATE_APPLICATION_MAIN"), + + .enableExperimentalFeature("StrictConcurrency") + ] ), .testTarget( name: "RevoHttpTests", diff --git a/Podfile b/Podfile index ad924c8..69ec628 100644 --- a/Podfile +++ b/Podfile @@ -6,7 +6,6 @@ target 'RevoHttp' do #use_frameworks! # Pods for RevoHttp - pod 'RevoFoundation' target 'RevoHttpTests' do inherit! :search_paths diff --git a/Podfile.lock b/Podfile.lock index 055473e..26bb447 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -1,16 +1,3 @@ -PODS: - - RevoFoundation (0.2.0) - -DEPENDENCIES: - - RevoFoundation - -SPEC REPOS: - trunk: - - RevoFoundation - -SPEC CHECKSUMS: - RevoFoundation: f00513750bfbbb9a07476450728063e921bdf5db - -PODFILE CHECKSUM: 9cb5cf14b95b566960199e173b82560ca9dcf6e6 +PODFILE CHECKSUM: 3c4539269f16d249c8a415f7262c3b68209f9f46 COCOAPODS: 1.16.2 diff --git a/Sources/RevoHttp/Enums/HttpError.swift b/Sources/RevoHttp/Enums/HttpError.swift new file mode 100644 index 0000000..fbfc7af --- /dev/null +++ b/Sources/RevoHttp/Enums/HttpError.swift @@ -0,0 +1,21 @@ +import Foundation + +public enum HttpError : LocalizedError { + + case invalidUrl + case invalidParams + case responseError + case reponseStatusError(response:HttpResponse) + case undecodableResponse + + public var errorDescription: String? { + switch self { + case .invalidUrl: "Malformed Url" + case .invalidParams: "Invalid input params" + case .responseError: "Response returned and error" + case .reponseStatusError: "Response returned a non 200 status" + case .undecodableResponse: "Undecodable response" + + } + } +} diff --git a/Sources/RevoHttp/Enums/HttpOption.swift b/Sources/RevoHttp/Enums/HttpOption.swift new file mode 100644 index 0000000..5090781 --- /dev/null +++ b/Sources/RevoHttp/Enums/HttpOption.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum HttpOption { + case hmacSHA256(header: String, privateKey: String) + case timeout(seconds: Int) + case session(URLSession) + case allowUnsecureUrls +} diff --git a/Sources/RevoHttp/Fake/HttpFake.swift b/Sources/RevoHttp/Fake/HttpFake.swift index 4106d59..1887374 100644 --- a/Sources/RevoHttp/Fake/HttpFake.swift +++ b/Sources/RevoHttp/Fake/HttpFake.swift @@ -1,81 +1,102 @@ import Foundation - -public class HttpFake : NSObject { +actor HttpFakeState { + var calls: [HttpRequest] = [] + var responses: [String: HttpResponse] = [:] + var globalResponses: [HttpResponse] = [] - public static var calls:[HttpRequest] = [] - static var responses:[String:HttpResponse] = [:] - static var globalResponses:[HttpResponse] = [] + func reset() { + calls = [] + responses = [:] + globalResponses = [] + } - static var swizzled = false + func addCall(_ request: HttpRequest) { + calls.append(request) + } - public static func enable(){ - Self.responses = [:] - Self.globalResponses = [] - Self.calls = [] - if (swizzled) { return } - - - guard let originalMethod = class_getInstanceMethod(Http.self, #selector(call(_:then:))), - let newMethod = class_getInstanceMethod(HttpFake.self, #selector(call(_:then:))) else { - return - } - - method_exchangeImplementations(originalMethod, newMethod) - swizzled = true + func getResponse(for url: String) -> HttpResponse? { + responses[url] } - public static func disable(){ - if (!swizzled) { return } - swizzled = false - Self.enable() - swizzled = false + func getGlobalResponse() -> HttpResponse? { + guard let first = globalResponses.first else { return nil } + if globalResponses.count > 1 { + globalResponses.removeFirst() + } + return first } - @objc dynamic public func call(_ request:HttpRequest, then:@escaping(_ response:HttpResponse)->Void) { - Self.calls.append(request) - - if let toRespond = Self.responses[request.url] { - return then(toRespond) + func addResponse(_ response: HttpResponse, for url: String?) { + if let url { + responses[url] = response + } else { + globalResponses.append(response) } + } +} + +public class HttpFake : Http, @unchecked Sendable { - if (Self.globalResponses.count == 1) { - return then(Self.globalResponses.first!) + public var calls: [HttpRequest] { + get async { + await state.calls } - - if let toRespond = Self.globalResponses.first { - if Self.globalResponses.count > 1 { - Self.globalResponses.removeFirst() - } - return then(toRespond) + } + + public var globalResponses: [HttpResponse] { + get async { + await state.globalResponses } - - then(HttpResponse(data: nil, response: nil, error: nil)) } - public static func addResponse(_ response:String, status:Int = 200) { - let httpResponse = HTTPURLResponse(url: URL(string:"http://fakeUrl.com")!, statusCode: status, httpVersion: "1.0", headerFields: nil) - let globalResponse = HttpResponse(data:response.data(using: .utf8), response:httpResponse , error: nil) - Self.globalResponses.append(globalResponse) + public var responses: [String: HttpResponse] { + get async { + await state.responses + } } + + private let state = HttpFakeState() + var swizzled = false + + public func enable() async { + await state.reset() + guard !swizzled else { return } - public static func addResponse(encoded response:T, status:Int = 200) { - let data = try! JSONEncoder().encode(response) - let httpResponse = HTTPURLResponse(url: URL(string:"http://fakeUrl.com")!, statusCode: status, httpVersion: "1.0", headerFields: nil) - let globalResponse = HttpResponse(data:data, response:httpResponse , error: nil) - Self.globalResponses.append(globalResponse) + await ThreadSafeContainer.shared.bind(instance: Http.self, self) + swizzled = true } - public static func addResponse(for url:String, _ response:String, status:Int = 200) { - let httpResponse = HTTPURLResponse(url: URL(string:"http://fakeUrl.com")!, statusCode: status, httpVersion: "1.0", headerFields: nil) - let concreteResponse = HttpResponse(data:response.data(using: .utf8), response:httpResponse , error: nil) - Self.responses[url] = concreteResponse + public func disable() async { + guard swizzled else { return } + await state.reset() + + await ThreadSafeContainer.shared.unbind(Http.self) + swizzled = false } - public static func addResponse(for url:String, encoded response:T, status:Int = 200) { + public override func makeCall(_ request:HttpRequest) async -> HttpResponse { + await state.addCall(request) + + if let urlResponse = await state.getResponse(for: request.url) { + return urlResponse + } + if let globalResponse = await state.getGlobalResponse() { + return globalResponse + } + return HttpResponse(data: nil, response: nil, error: nil) + } + + public func addResponse(for url:String? = nil, _ response:String, status:Int = 200) async { + let httpResponse = HTTPURLResponse(url: URL(string:"http://fakeUrl.com")!, statusCode: status, httpVersion: "1.0", headerFields: nil) + let httpResponseObj = HttpResponse(data:response.data(using: .utf8), response:httpResponse , error: nil) + await state.addResponse(httpResponseObj, for: url) + } + + public func addResponse(for url:String? = nil, encoded response:T, status:Int = 200) async { let data = try! JSONEncoder().encode(response) let httpResponse = HTTPURLResponse(url: URL(string:"http://fakeUrl.com")!, statusCode: status, httpVersion: "1.0", headerFields: nil) - let concreteResponse = HttpResponse(data:data, response:httpResponse , error: nil) - Self.responses[url] = concreteResponse + let httpResponseObj = HttpResponse(data:data, response:httpResponse , error: nil) + await state.addResponse(httpResponseObj, for: url) } } diff --git a/Sources/RevoHttp/Fake/ThreadSafeContainer.swift b/Sources/RevoHttp/Fake/ThreadSafeContainer.swift new file mode 100644 index 0000000..43bf64a --- /dev/null +++ b/Sources/RevoHttp/Fake/ThreadSafeContainer.swift @@ -0,0 +1,41 @@ +protocol Resolvable{ + init() +} + +actor ThreadSafeContainer { + + nonisolated static let shared = ThreadSafeContainer() + + private var resolvers: [String: Any] = [:] + + func bind(instance type: T.Type, _ resolver: Z) { + resolvers[String(describing: type)] = resolver + } + + func unbind(_ type: T.Type) { + resolvers.removeValue(forKey: String(describing: type)) + } + + func resolve(_ type: T.Type) -> T? { + resolve(withoutExtension: type) + } + + func resolve(withoutExtension type: T.Type) -> T? { + guard let resolver = resolvers[String(describing: type)] else { + if type.self is Resolvable.Type { + return (type as! Resolvable.Type).init() as? T + } + return nil + } + if let resolvable = resolver as? Resolvable.Type { + return resolvable.init() as? T + } + if let resolvable = resolver as? T { + return resolvable + } + if let resolvable = resolver as? (()->T) { + return resolvable() + } + return nil + } +} diff --git a/Sources/RevoHttp/Http+Async.swift b/Sources/RevoHttp/Http+Async.swift deleted file mode 100644 index 24a81d6..0000000 --- a/Sources/RevoHttp/Http+Async.swift +++ /dev/null @@ -1,37 +0,0 @@ - -public extension Http { - - static func call(_ method: HttpRequest.Method, url: String, queryParams: [String:Codable] = [:], body: String? = nil, headers: [String:String] = [:], timeout: Int = 30) async -> HttpResponse { - await withCheckedContinuation { continuation in - Self.call(method, url: url, queryParams: queryParams, body: body, headers: headers, timeout: timeout) { response in - continuation.resume(returning: response) - } - } - } - - static func call(_ method: HttpRequest.Method, url: String, queryParams: [String:Codable] = [:], form: [String:Codable] = [:], headers: [String:String] = [:], timeout: Int = 30) async -> HttpResponse { - await withCheckedContinuation { continuation in - Self.call(method, url: url, queryParams: queryParams, form: form, headers: headers, timeout: timeout) { response in - continuation.resume(returning: response) - } - } - } - - static func post(_ url:String, headers:[String:String] = [:]) async -> HttpResponse{ - await withCheckedContinuation { continuation in - Self.post(url, headers: headers) { response in - continuation.resume(returning: response) - } - } - } - - static func post(_ url:String, body:String, headers:[String:String] = [:]) async -> HttpResponse{ - await withCheckedContinuation { continuation in - Self.post(url, body: body, headers: headers) { response in - continuation.resume(returning: response) - } - } - } - - //TODO: add rest of methods if necessary -} diff --git a/Sources/RevoHttp/Http.swift b/Sources/RevoHttp/Http.swift index ee2b169..a87f48e 100644 --- a/Sources/RevoHttp/Http.swift +++ b/Sources/RevoHttp/Http.swift @@ -1,8 +1,8 @@ import Foundation -public class Http : NSObject { +public class Http : NSObject, Resolvable, @unchecked Sendable { - public static var debugMode = false + nonisolated(unsafe) public static var debugMode = false var insecureUrlSession:InsecureUrlSession? var timeout:Int? var hmac:Hmac? @@ -12,184 +12,128 @@ public class Http : NSObject { }() typealias Hmac = HttpRequest.Hmac - + + override public required init() {} + //MARK: - Call - public func call(_ method: HttpRequest.Method, url: String, queryParams: [String:Codable] = [:], body: String? = nil, headers: [String:String] = [:], timeout: Int = 30, then:@escaping(_ response: HttpResponse) -> Void) { - let request = HttpRequest(method: method, url: url, queryParams: queryParams, body: body, headers: headers) - request.timeout = TimeInterval(timeout) - call(request, then:then) - } - - public func call(_ method: HttpRequest.Method, url: String, queryParams: [String:Codable] = [:], form: [String:Codable] = [:], headers: [String:String] = [:], timeout: Int = 30, then:@escaping(_ response: HttpResponse) -> Void) { - let request = HttpRequest(method: method, url: url, queryParams: queryParams, form: form, headers: headers) - request.timeout = TimeInterval(timeout) - call(request, then:then) - } - - public func call(_ method:HttpRequest.Method, url:String, params:[String:Codable] = [:], headers:[String:String] = [:], then:@escaping(_ response:HttpResponse) -> Void) { - let request = HttpRequest(method: method, url: url, params: params, headers: headers) - call(request, then:then) + public func call(_ method:HttpRequest.Method, url:String, params:[String:Codable] = [:], headers:[String:String] = [:]) async -> HttpResponse { + await call(HttpRequest(method: method, url: url, queryParams: params, headers: headers)) } - public func call(_ method:HttpRequest.Method, _ url:String, body:String, headers:[String:String] = [:], then:@escaping(_ response:HttpResponse) -> Void) { + public func call(_ method:HttpRequest.Method, _ url:String, body:String, headers:[String:String] = [:]) async -> HttpResponse { let request = HttpRequest(method: method, url: url, headers: headers) - request.body = body - call(request, then:then) + request.body = .string(body) + return await call(request) } - public func call(_ method:HttpRequest.Method, _ url:String, json:Z, headers:[String:String] = [:], then:@escaping(_ response:HttpResponse) -> Void) { + public func call(_ method:HttpRequest.Method, _ url:String, json:Z, headers:[String:String] = [:]) async -> HttpResponse { let request = HttpRequest(method: method, url: url, headers: headers) + request.body = .json(json) + return await call(request) + } - guard let data = try? JSONEncoder().encode(json) else { - return then(HttpResponse(failed: "Request not Encodable")) - } - guard let body = String(data:data, encoding: .utf8) else { - return then(HttpResponse(failed: "Can't encode request data to string")) - } - request.body = body - - call(request, then: then) + public func call(_ method:HttpRequest.Method, _ url:String, json:Z, headers:[String:String] = [:]) async -> (T?, String?) { + let response = await call(method, url, json: json, headers: headers) + let result:T? = response.decoded() + return (result, response.error?.localizedDescription) } - public func call(_ method:HttpRequest.Method, _ url:String, json:Z, headers:[String:String] = [:], then:@escaping(_ response:T?, _ error:String?) -> Void) { - let request = HttpRequest(method: method, url: url, headers: headers) + public func call(_ method:HttpRequest.Method, _ url:String, params:[String:Codable] = [:], headers:[String:String] = [:]) async throws(HttpError) -> T { + let response = await call(HttpRequest(method: method, url: url, queryParams:params, headers: headers)) + print(response.toString) + guard response.error == nil else { throw .responseError } + guard response.isSuccessful else { throw .reponseStatusError(response: response) } + guard let result:T = response.decoded() else { throw .undecodableResponse } + return result + } - guard let data = try? JSONEncoder().encode(json) else { - return then(nil, "Not encodable") - } - guard let body = String(data:data, encoding: .utf8) else { - return then(nil, "Can't convert to string") + public func call(_ request:HttpRequest) async -> HttpResponse { + debugIfNeeded(request) + + if let hmac { + request.withHmacHeader(hmac) } - request.body = body - - call(request) { response in - let result:T? = response.decoded() - then(result, response.error?.localizedDescription) + + if let timeout { + request.timeout = TimeInterval(timeout) } + + return await makeCall(request) } - //MARK: Async call - public func call(_ method:HttpRequest.Method, _ url:String, params:[String:Codable] = [:], headers:[String:String] = [:]) async throws -> T { - return try await withCheckedThrowingContinuation { continuation in - let request = HttpRequest(method: method, url: url, params:params, headers: headers) - - call(request) { response in - print(response.toString) - guard response.error == nil else { - return continuation.resume(throwing: HttpError.responseError) - } - guard response.statusCode >= 200 && response.statusCode < 300 else { - return continuation.resume(throwing: HttpError.reponseStatusError(response: response)) - } - guard let result:T = response.decoded() else { - return continuation.resume(throwing: HttpError.undecodableResponse) - } - return continuation.resume(returning:result) - } + @objc dynamic public func makeCall(_ request:HttpRequest) async -> HttpResponse { + guard let urlRequest = request.generate() else { + return HttpResponse(failed: "Invalid URL") + } + + do { + let (data, urlResponse) = try await urlSession.data(for: urlRequest) + return HttpResponse(data:data, response:urlResponse) + } catch { + return HttpResponse(error: error) } } - public func call(_ method:HttpRequest.Method, url:String, params:[String:Codable] = [:], headers:[String:String] = [:], then:@escaping(_ response:T?, _ error:Error?) -> Void) { - let request = HttpRequest(method: method, url: url, params: params, headers: headers) - call(request) { response in - let result:T? = response.decoded() - then(result, response.error) + @objc dynamic public func callMultipart(_ request:MultipartHttpRequest) async -> HttpResponse { + debugIfNeeded(request) + + guard let urlRequest = request.generate() else { + return HttpResponse(failed: "Invalid URL") + } + + do { + let (data, urlResponse) = try await urlSession.upload(for: urlRequest, from: request.generateData()) + return HttpResponse(data:data, response:urlResponse) + } catch { + return HttpResponse(error: error) } } - public func get(_ url:String, params:[String:Codable] = [:], headers:[String:String] = [:], then:@escaping(_ response:HttpResponse) -> Void) { - let request = HttpRequest(method: .get, url: url, params: params, headers: headers) - call(request, then:then) + public func get(_ url:String, queryParams:[String:Codable] = [:], headers:[String:String] = [:]) async -> HttpResponse { + await call(HttpRequest(method: .get, url: url, queryParams: queryParams, headers: headers)) } - public func post(_ url:String, params:[String:Codable] = [:], headers:[String:String] = [:], then:@escaping(_ response:HttpResponse) -> Void) { - let request = HttpRequest(method: .post, url: url, params: params, headers: headers) - call(request, then:then) + public func post(_ url:String, queryParams:[String:Codable] = [:], headers:[String:String] = [:]) async -> HttpResponse { + await call(HttpRequest(method: .post, url: url, queryParams: queryParams, headers: headers)) } - public func post(_ url:String, body:String, headers:[String:String] = [:], then:@escaping(_ response:HttpResponse) -> Void) { + public func post(_ url:String, body:String, headers:[String:String] = [:]) async -> HttpResponse { let request = HttpRequest(method: .post, url: url, headers: headers) - request.body = body - call(request, then:then) + request.body = .string(body) + return await call(request) } - public func put(_ url:String, params:[String:Codable] = [:], headers:[String:String] = [:], then:@escaping(_ response:HttpResponse) -> Void) { - let request = HttpRequest(method: .put, url: url, params: params, headers: headers) - call(request, then:then) + public func put(_ url:String, queryParams:[String:Codable] = [:], headers:[String:String] = [:]) async -> HttpResponse { + await call(HttpRequest(method: .put, url: url, queryParams: queryParams, headers: headers)) } - public func patch(_ url:String, params:[String:Codable] = [:], headers:[String:String] = [:], then:@escaping(_ response:HttpResponse) -> Void) { - let request = HttpRequest(method: .patch, url: url, params: params, headers: headers) - call(request, then:then) + public func patch(_ url:String, queryParams:[String:Codable] = [:], headers:[String:String] = [:]) async -> HttpResponse { + await call(HttpRequest(method: .patch, url: url, queryParams: queryParams, headers: headers)) } - public func delete(_ url:String, params:[String:Codable] = [:], headers:[String:String] = [:], then:@escaping(_ response:HttpResponse) -> Void) { - let request = HttpRequest(method: .delete, url: url, params: params, headers: headers) - call(request, then:then) + public func delete(_ url:String, queryParams:[String:Codable] = [:], headers:[String:String] = [:]) async -> HttpResponse { + await call(HttpRequest(method: .delete, url: url, queryParams: queryParams, headers: headers)) } - @objc dynamic public func call(_ request:HttpRequest, then:@escaping(_ response:HttpResponse)->Void) { - if (Self.debugMode) { - debugPrint("****** HTTP DEBUG ***** " + request.toCurl()) - } - - if let hmac = hmac { - request.withHmacHeader(hmac) - } - - if let timeout = timeout { - request.timeout = TimeInterval(timeout) - } - - guard let urlRequest = request.generate() else { - return then(HttpResponse(failed: "Invalid URL")) - } - let session = urlSession - let dataTask = session.dataTask(with: urlRequest) { data, urlResponse, error in - DispatchQueue.main.async { - then(HttpResponse(data:data, response:urlResponse, error:error)) - } - } - dataTask.resume() - } - - @objc dynamic public func callMultipart(_ request:MultipartHttpRequest, then:@escaping(_ response:HttpResponse)->Void) { - if (Self.debugMode) { - debugPrint("****** HTTP DEBUG ***** " + request.toCurl()) - } - - guard let urlRequest = request.generate() else { - return then(HttpResponse(failed: "Invalid URL")) - } - let session = urlSession - let dataTask = session.uploadTask(with: urlRequest, from: request.generateData()) { responseData, urlResponse, error in - DispatchQueue.main.async { - then(HttpResponse(data:responseData, response:urlResponse, error:error)) + public func withOptions(_ options: [HttpOption]) -> Self { + for option in options { + switch option { + case .hmacSHA256(let header, let privateKey): + hmac = Hmac(header: header, privateKey: privateKey) + case .timeout(let seconds): + timeout = seconds + case .session(let session): + urlSession = session + case .allowUnsecureUrls: + insecureUrlSession = InsecureUrlSession() + urlSession = insecureUrlSession!.session } } - dataTask.resume() - } - - //MARK: Crypto - public func withHmacSHA256(header:String, privateKey:String) -> Self { - hmac = Hmac(header: header, privateKey: privateKey) - return self - } - - //MARK: URLSession - public func with(session: URLSession) -> Self { - urlSession = session - return self - } - - public func allowUnsecureUrls() -> Self { - insecureUrlSession = InsecureUrlSession() - urlSession = insecureUrlSession!.session return self } - public func withTimeout(seconds:Int) -> Self { - self.timeout = seconds - return self + private func debugIfNeeded(_ request: HttpRequest) { + guard Self.debugMode else { return } + debugPrint("****** HTTP DEBUG ****** " + request.toCurl()) } } diff --git a/Sources/RevoHttp/HttpError.swift b/Sources/RevoHttp/HttpError.swift deleted file mode 100644 index 26d3e2e..0000000 --- a/Sources/RevoHttp/HttpError.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation - -public enum HttpError : Error { - - case invalidUrl - case invalidParams - case responseError - case reponseStatusError(response:HttpResponse) - case undecodableResponse - - var localizedDescription: String { - switch self { - case .invalidUrl: return "Malformed Url" - case .invalidParams: return "Invalid input params" - case .responseError: return "Response returned and error" - case .reponseStatusError: return "Response returned a non 200 status" - case .undecodableResponse: return "Undecodable response" - - } - } -} diff --git a/Sources/RevoHttp/HttpRequest.swift b/Sources/RevoHttp/HttpRequest.swift index fb41c8a..b50547a 100644 --- a/Sources/RevoHttp/HttpRequest.swift +++ b/Sources/RevoHttp/HttpRequest.swift @@ -1,6 +1,6 @@ import Foundation -public class HttpRequest : NSObject { +public class HttpRequest : NSObject, @unchecked Sendable { public enum Method { case get, post, patch, put, delete @@ -8,7 +8,8 @@ public class HttpRequest : NSObject { public enum BodyStruct { case form([HttpParam]?) - case json(String?) + case string(String?) + case json(Encodable?) } public struct Hmac { @@ -19,26 +20,15 @@ public class HttpRequest : NSObject { public var method: Method public var url: String public var queryParams: [HttpParam] - public var bodyStruct: BodyStruct? public var headers: [String: String] - - public var body: String? { // deprecated - get { - switch bodyStruct { - case .json(let string): - return string - default: - return nil - } - } - set { - if let newValue = newValue { - bodyStruct = .json(newValue) - } else { - bodyStruct = nil + public var body: BodyStruct? { + didSet { + if case .json = body { + headers["Content-Type"] = "application/json" } } } + public var timeout: TimeInterval? @@ -52,7 +42,7 @@ public class HttpRequest : NSObject { self.method = method self.url = url self.queryParams = queryParams.createParams(nil) - self.bodyStruct = bodyStruct + self.body = bodyStruct self.headers = headers } @@ -98,14 +88,16 @@ public class HttpRequest : NSObject { request.url = URL(string: buildUrl()) - request.httpBody = bodyStruct.flatMap { body -> Data? in + request.httpBody = body.flatMap { body -> Data? in switch body { - case .json(let string?) where !string.isEmpty && string != "{}": - return string.data(using: .utf8) + case .json(let encodable?): + try? JSONEncoder().encode(encodable) + case .string(let string?): + string.data(using: .utf8) case .form(let params?) where !params.isEmpty: - return buildFormBody()?.data(using: .utf8) + buildFormBody()?.data(using: .utf8) default: - return nil + nil } } @@ -115,16 +107,13 @@ public class HttpRequest : NSObject { } public func withHmacHeader(_ hmac: Hmac) { - let payload: String - switch bodyStruct { - case .json(let string?): - payload = string - case .form: - payload = buildFormBody() ?? "" - default: - payload = buildQueryParams() + let payload = switch body { + case .json(let encodable?): String(data: try! JSONEncoder().encode(encodable), encoding: .utf8)! + case .string(let string?): string + case .form: buildFormBody() ?? "" + default: buildQueryParams() } - if !payload.isEmpty, let hash = payload.hmac256(hmac.privateKey) { + if let hash = payload.hmac256(hmac.privateKey) { headers[hmac.header] = hash } } @@ -135,13 +124,13 @@ public class HttpRequest : NSObject { }.joined(separator: "&") } - private func buildUrl() -> String { + func buildUrl() -> String { queryParams.isEmpty ? url : "\(url)?\(buildQueryParams())" } - private func buildQueryParams() -> String { + func buildQueryParams() -> String { buildParams(queryParams) } @@ -151,8 +140,8 @@ public class HttpRequest : NSObject { }.joined(separator: "&") } - private func buildFormBody() -> String? { - guard case .form(let params?) = bodyStruct else { + func buildFormBody() -> String? { + guard case .form(let params?) = body else { return nil } @@ -170,7 +159,7 @@ public class HttpRequest : NSObject { var result = "curl " var parameters: [HttpParam] = [] - if case .form(let params?) = bodyStruct { + if case .form(let params?) = body { parameters = params } else { parameters = queryParams @@ -180,22 +169,23 @@ public class HttpRequest : NSObject { }.joined(separator: "&") if (p.count > 0) { - result = result + "-d \"\(p)\"" + result += "-d \"\(p)\"" } - let h = headers.map { key, value in - "-H \"\(key): \(value)\"" + let h = headers.keys.sorted().compactMap { key in + guard let value = headers[key] else { return nil } + return "-H \"\(key): \(value)\"" }.joined(separator: " ") if (h.count > 0){ - result = result + " \(h)" + result += " \(h)" } return result + " -X \(methodUppercased) \(url)" } public func toString() -> String { - return "" + "" } var methodUppercased: String { @@ -210,14 +200,13 @@ public protocol HttpParamProtocol { extension Dictionary : HttpParamProtocol{ public func createParams(_ key: String?) -> [HttpParam] { var collect = [HttpParam]() - for (k, v) in self { - if let nestedKey = k as? String { - let useKey = key != nil ? "\(key!)[\(nestedKey)]" : nestedKey - if let subParam = v as? HttpParamProtocol { - collect.append(contentsOf: subParam.createParams(useKey)) - } else { - collect.append(HttpParam(key: useKey, storedValue: v as AnyObject)) - } + for k in self.keys.compactMap({ $0 as? String }).sorted() { + guard let k = k as? Key else { continue } + let useKey = key != nil ? "\(key!)[\(k)]" : "\(k)" + if let subParam = self[k] as? HttpParamProtocol { + collect.append(contentsOf: subParam.createParams(useKey)) + } else { + collect.append(HttpParam(key: useKey, storedValue: self[k] as AnyObject)) } } return collect @@ -231,11 +220,8 @@ public struct HttpParam{ var value: String { if storedValue is NSNull { return "" - } else if let v = storedValue as? String { - return v - } else { - return storedValue.description ?? "" } + return storedValue as? String ?? storedValue.description ?? "" } fileprivate func encoded() -> String { diff --git a/Sources/RevoHttp/HttpResponse.swift b/Sources/RevoHttp/HttpResponse.swift index 05ab118..5a1ab58 100644 --- a/Sources/RevoHttp/HttpResponse.swift +++ b/Sources/RevoHttp/HttpResponse.swift @@ -1,40 +1,43 @@ import Foundation -public class HttpResponse : NSObject { +public final class HttpResponse : NSObject, Sendable { public let data:Data? public let response:URLResponse? public let error:Error? - public init(data:Data?, response:URLResponse?, error:Error?){ + public init(data:Data? = nil, response:URLResponse? = nil, error:Error? = nil) { self.data = data self.response = response self.error = error } - public init(failed:String){ + public init(failed:String) { self.data = nil self.response = nil self.error = HttpError.invalidUrl } - public var statusCode:Int{ + public var statusCode:Int { (response as? HTTPURLResponse)?.statusCode ?? 0 } public var errorMessage:String? { - guard let error = error else { return nil } - return error.localizedDescription + error?.localizedDescription } public var toString:String { guard error == nil else { return "RevoHttp Error: \(errorMessage ?? "")" } - guard let data = data else { return "RevoHttp Error: No Data" } + guard let data else { return "RevoHttp Error: No Data" } return String(data:data, encoding:.utf8) ?? "RevoHttp Error: Data non convertible to string" } + public var isSuccessful:Bool { + statusCode >= 200 && statusCode < 300 + } + public func decoded() -> T? { - guard let data = data else { return nil } + guard let data else { return nil } do { return try JSONDecoder().decode(T.self, from: data) } catch { diff --git a/Sources/RevoHttp/HttpStaticExtension.swift b/Sources/RevoHttp/HttpStaticExtension.swift index 83bd6f4..39ee042 100644 --- a/Sources/RevoHttp/HttpStaticExtension.swift +++ b/Sources/RevoHttp/HttpStaticExtension.swift @@ -1,59 +1,114 @@ + import Foundation extension Http { - - public static func call(_ method: HttpRequest.Method, url: String, queryParams: [String:Codable] = [:], body: String? = nil, headers: [String:String] = [:], timeout: Int = 30, then:@escaping(_ response:HttpResponse) -> Void) { - Http().call(method, url: url, queryParams: queryParams, body: body, headers: headers, timeout: timeout, then: then) + private static func httpInstance() async -> Http { + await ThreadSafeContainer.shared.resolve(Http.self)! } - - public static func call(_ method: HttpRequest.Method, url: String, queryParams: [String:Codable] = [:], form: [String:Codable] = [:], headers: [String:String] = [:], timeout: Int = 30, then:@escaping(_ response:HttpResponse) -> Void) { - Http().call(method, url: url, queryParams: queryParams, form: form, headers: headers, timeout: timeout, then: then) + + // MARK: Generic methods + + public static func call(_ method:HttpRequest.Method, url:String, params:[String:Codable] = [:], headers:[String:String] = [:]) async -> HttpResponse { + await httpInstance().call(method, url: url, params:params, headers:headers) } - - public static func call(_ method:HttpRequest.Method, url:String, params:[String:Codable] = [:], headers:[String:String] = [:], then:@escaping(_ response:HttpResponse) -> Void) { - Http().call(method, url:url, params:params, headers:headers, then:then) + + public static func call(_ method:HttpRequest.Method, _ url:String, body:String, headers:[String:String] = [:]) async -> HttpResponse { + await httpInstance().call(method, url, body: body, headers: headers) + } + + public static func call(_ method:HttpRequest.Method, _ url:String, json:Z, headers:[String:String] = [:]) async -> HttpResponse { + await httpInstance().call(method, url, json:json, headers:headers) } - public static func call(_ method:HttpRequest.Method, _ url:String, body:String, headers:[String:String] = [:], then:@escaping(_ response:HttpResponse) -> Void) { - Http().call(method, url, body: body, headers: headers, then: then) + public static func call(_ method:HttpRequest.Method, _ url:String, json:Z, headers:[String:String] = [:]) async -> (T?, String?) { + await httpInstance().call(method, url, json:json, headers:headers) } - public static func call(_ method:HttpRequest.Method, _ url:String, json:Z, headers:[String:String] = [:], then:@escaping(_ response:HttpResponse) -> Void) { - Http().call(method, url, json:json, headers:headers, then:then) + public static func call(_ method:HttpRequest.Method, _ url:String, params:[String:Codable] = [:], headers:[String:String] = [:]) async throws(HttpError) -> T { + try await httpInstance().call(method, url, params:params, headers:headers) } - public static func call(_ method:HttpRequest.Method, _ url:String, json:Z, headers:[String:String] = [:], then:@escaping(_ response:T?, _ error:String?) -> Void) { - Http().call(method, url, json:json, headers:headers, then:then) + @discardableResult + public static func call(_ request:HttpRequest) async -> HttpResponse { + await httpInstance().call(request) } - public static func get(_ url:String, params:[String:Codable] = [:], headers:[String:String] = [:], then:@escaping(_ response:HttpResponse) -> Void) { - let request = HttpRequest(method: .get, url: url, params: params, headers: headers) - Http().call(request, then:then) + + // MARK: GET methods + + public static func get(_ url:String, queryParams:[String:Codable] = [:], headers:[String:String] = [:]) async -> HttpResponse { + await httpInstance().call(HttpRequest(method: .get, url: url, queryParams: queryParams, headers: headers)) } - public static func post(_ url:String, params:[String:Codable] = [:], headers:[String:String] = [:], then:@escaping(_ response:HttpResponse) -> Void) { - let request = HttpRequest(method: .post, url: url, params: params, headers: headers) - Http().call(request, then:then) + + // MARK: POST methods + + public static func post(_ url:String, queryParams:[String:Codable] = [:], headers:[String:String] = [:]) async -> HttpResponse { + await httpInstance().call(HttpRequest(method: .post, url: url, queryParams: queryParams, headers: headers)) + } + + public static func post(_ url:String, form:[String:Codable], headers:[String:String] = [:]) async -> HttpResponse { + await httpInstance().call(HttpRequest(method: .post, url: url, form: form, headers: headers)) + } + + public static func post(_ url:String, body:String, headers:[String:String] = [:]) async -> HttpResponse { + let request = HttpRequest(method: .post, url: url, headers: headers) + request.body = .string(body) + return await httpInstance().call(request) } - public static func post(_ url:String, body:String, headers:[String:String] = [:], then:@escaping(_ response:HttpResponse) -> Void) { + public static func post(_ url:String, json:Z, headers:[String:String] = [:]) async -> HttpResponse { let request = HttpRequest(method: .post, url: url, headers: headers) - request.body = body - Http().call(request, then:then) + request.body = .json(json) + return await httpInstance().call(request) } - public static func put(_ url:String, params:[String:Codable] = [:], headers:[String:String] = [:], then:@escaping(_ response:HttpResponse) -> Void) { - let request = HttpRequest(method: .put, url: url, params: params, headers: headers) - Http().call(request, then:then) + + // MARK: PUT methods + + public static func put(_ url:String, queryParams:[String:Codable] = [:], headers:[String:String] = [:]) async -> HttpResponse { + await httpInstance().call(HttpRequest(method: .put, url: url, queryParams: queryParams, headers: headers)) + } + + public static func put(_ url:String, form:[String:Codable], headers:[String:String] = [:]) async -> HttpResponse { + await httpInstance().call(HttpRequest(method: .put, url: url, form: form, headers: headers)) + } + + public static func put(_ url:String, json:Z, headers:[String:String] = [:]) async -> HttpResponse { + let request = HttpRequest(method: .put, url: url, headers: headers) + request.body = .json(json) + return await httpInstance().call(request) + } + + + // MARK: PATCH methods + + public static func patch(_ url:String, queryParams:[String:Codable] = [:], headers:[String:String] = [:]) async -> HttpResponse { + await httpInstance().call(HttpRequest(method: .patch, url: url, queryParams: queryParams, headers: headers)) + } + + public static func patch(_ url:String, form:[String:Codable], headers:[String:String] = [:]) async -> HttpResponse { + await httpInstance().call(HttpRequest(method: .patch, url: url, form: form, headers: headers)) } - public static func patch(_ url:String, params:[String:Codable] = [:], headers:[String:String] = [:], then:@escaping(_ response:HttpResponse) -> Void) { - let request = HttpRequest(method: .patch, url: url, params: params, headers: headers) - Http().call(request, then:then) + public static func patch(_ url:String, json:Z, headers:[String:String] = [:]) async -> HttpResponse { + let request = HttpRequest(method: .patch, url: url, headers: headers) + request.body = .json(json) + return await httpInstance().call(request) } - public static func delete(_ url:String, params:[String:Codable] = [:], headers:[String:String] = [:], then:@escaping(_ response:HttpResponse) -> Void) { - let request = HttpRequest(method: .delete, url: url, params: params, headers: headers) - Http().call(request, then:then) + + // MARK: DELETE methods + + public static func delete(_ url:String, queryParams:[String:Codable] = [:], headers:[String:String] = [:]) async -> HttpResponse { + await httpInstance().call(HttpRequest(method: .delete, url: url, queryParams: queryParams, headers: headers)) + } + + + // MARK: With Options + + public static func withOptions(_ options: HttpOption...) async -> Http { + let instance = await httpInstance() + return instance.withOptions(options) // options is already an array when variadic } } diff --git a/Sources/RevoHttp/InsecureUrlSession.swift b/Sources/RevoHttp/InsecureUrlSession.swift index b006234..0d0f82d 100644 --- a/Sources/RevoHttp/InsecureUrlSession.swift +++ b/Sources/RevoHttp/InsecureUrlSession.swift @@ -1,8 +1,8 @@ import Foundation -class InsecureUrlSession : NSObject, URLSessionDelegate { +class InsecureUrlSession : NSObject, URLSessionDelegate, @unchecked Sendable { - var session:URLSession! + private(set) var session:URLSession! override init() { super.init() diff --git a/Sources/RevoHttp/MultipartHttpRequest.swift b/Sources/RevoHttp/MultipartHttpRequest.swift index 7bfd818..a093ee2 100644 --- a/Sources/RevoHttp/MultipartHttpRequest.swift +++ b/Sources/RevoHttp/MultipartHttpRequest.swift @@ -1,14 +1,20 @@ import Foundation +#if canImport(UIKit) import UIKit +public typealias REImage = UIImage +#elseif canImport(AppKit) +import AppKit +public typealias REImage = NSImage +#endif -public class MultipartHttpRequest : HttpRequest { +public class MultipartHttpRequest : HttpRequest, @unchecked Sendable { var paramName:String? var fileName:String? - var image:UIImage? + var image:REImage? let boundary = UUID().uuidString - public func addMultipart(paramName: String, fileName: String, image: UIImage) -> MultipartHttpRequest{ + public func addMultipart(paramName: String, fileName: String, image: REImage) -> MultipartHttpRequest{ self.paramName = paramName self.fileName = fileName self.image = image @@ -36,7 +42,11 @@ public class MultipartHttpRequest : HttpRequest { data.append("\r\n--\(boundary)\r\n".data(using: .utf8)!) data.append("Content-Disposition: form-data; name=\"\(paramName)\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!) data.append("Content-Type: image/png\r\n\r\n".data(using: .utf8)!) + #if canImport(UIKit) data.append(image.pngData()!) + #elseif canImport(AppKit) + data.append(image.tiffRepresentation!) + #endif data.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) return data diff --git a/Tests/RevoHttpTests/HttpCallOverloadsTests.swift b/Tests/RevoHttpTests/HttpCallOverloadsTests.swift new file mode 100644 index 0000000..d06fc50 --- /dev/null +++ b/Tests/RevoHttpTests/HttpCallOverloadsTests.swift @@ -0,0 +1,103 @@ +import Foundation +import Testing +@testable import RevoHttp + +@Suite(.serialized) +struct HttpCallOverloadsTests { + + @Test("call returning (T?, String?) returns decoded value and nil error on success") + func testCallTupleReturnsDecodedOnSuccess() async throws { + struct Response: Codable { + let id: Int + let name: String + } + struct Body: Encodable { + let q: String = "x" + } + try await withHttpFake() { fake in + await fake.addResponse(encoded: Response(id: 1, name: "Test")) + let (result, errorMessage): (Response?, String?) = await Http.call(.post, "https://example.com", json: Body(), headers: [:]) + #expect(result != nil) + #expect(result?.id == 1) + #expect(result?.name == "Test") + #expect(errorMessage == nil) + } + } + + @Test("call returning (T?, String?) returns nil result when response is not decodable") + func testCallTupleReturnsNilWhenNotDecodable() async throws { + struct Response: Codable { + let id: Int + } + struct Body: Encodable {} + try await withHttpFake() { fake in + await fake.addResponse(for: "https://example.com", "not found", status: 404) + let (result, _): (Response?, String?) = await Http.call(.post, "https://example.com", json: Body(), headers: [:]) + #expect(result == nil) + } + } + + @Test("call throws returns decoded value on success") + func testCallThrowsReturnsDecodedOnSuccess() async throws { + struct Response: Codable { + let value: String + } + try await withHttpFake() { fake in + await fake.addResponse(encoded: Response(value: "ok"), status: 200) + let result: Response = try await Http.call(.get, "https://example.com", params: [:], headers: [:]) + #expect(result.value == "ok") + } + } + + @Test("call throws HttpError.responseError when response has error") + func testCallThrowsResponseError() async throws { + // Invalid URL causes request.generate() to return nil, so makeCall returns HttpResponse(failed:) + let http = Http() + do { + let _: EmptyResponse = try await http.call(.get, "", params: [:], headers: [:]) + #expect(Bool(false), "Should throw") + } catch HttpError.responseError { + // expected when response.error != nil + } catch { + #expect(Bool(false), "Expected responseError, got \(error)") + } + } + + @Test("call throws HttpError.undecodableResponse when body is not decodable") + func testCallThrowsUndecodableResponse() async throws { + struct Response: Codable { + let required: Int + } + try await withHttpFake() { fake in + await fake.addResponse(for: "https://example.com", "plain text", status: 200) + do { + let _: Response = try await Http.call(.get, "https://example.com", params: [:], headers: [:]) + #expect(Bool(false), "Should throw") + } catch HttpError.undecodableResponse { + // expected + } catch { + #expect(Bool(false), "Expected undecodableResponse, got \(error)") + } + } + } + + @Test("call throws HttpError.reponseStatusError when status is not 2xx") + func testCallThrowsStatusError() async throws { + struct Response: Codable { + let id: Int + } + try await withHttpFake() { fake in + await fake.addResponse(for: "https://example.com", encoded: Response(id: 1), status: 404) + do { + let _: Response = try await Http.call(.get, "https://example.com", params: [:], headers: [:]) + #expect(Bool(false), "Should throw") + } catch HttpError.reponseStatusError(let response) { + #expect(response.statusCode == 404) + } catch { + #expect(Bool(false), "Expected reponseStatusError, got \(error)") + } + } + } +} + +private struct EmptyResponse: Codable {} diff --git a/Tests/RevoHttpTests/HttpEdgeCasesTests.swift b/Tests/RevoHttpTests/HttpEdgeCasesTests.swift new file mode 100644 index 0000000..5673d35 --- /dev/null +++ b/Tests/RevoHttpTests/HttpEdgeCasesTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing +@testable import RevoHttp + +@Suite(.serialized) +struct HttpEdgeCasesTests { + + @Test("Can handle empty headers") + func testCanHandleEmptyHeaders() async throws { + struct HttpBinResponse: Codable { + let headers: [String: String] + let url: String + } + + let response = await Http.get("https://httpbin.org/get", queryParams: [:], headers: [:]) + + let json: HttpBinResponse = try #require(response.decoded()) + #expect(json.url == "https://httpbin.org/get") + } + + @Test("Can handle multiple headers") + func testCanHandleMultipleHeaders() async throws { + struct HttpBinResponse: Codable { + let headers: [String: String] + let url: String + } + + let response = await Http.get("https://httpbin.org/get", headers: [ + "X-Header1": "Value1", + "X-Header2": "Value2", + "X-Header3": "Value3" + ]) + + let json: HttpBinResponse = try #require(response.decoded()) + #expect(json.headers["X-Header1"] == "Value1") + #expect(json.headers["X-Header2"] == "Value2") + #expect(json.headers["X-Header3"] == "Value3") + } +} + diff --git a/Tests/RevoHttpTests/HttpErrorTests.swift b/Tests/RevoHttpTests/HttpErrorTests.swift new file mode 100644 index 0000000..16ef2e6 --- /dev/null +++ b/Tests/RevoHttpTests/HttpErrorTests.swift @@ -0,0 +1,38 @@ +import Foundation +import Testing +@testable import RevoHttp + +@Suite(.serialized) +struct HttpErrorTests { + + @Test("HttpError.invalidUrl has expected localized description") + func testInvalidUrlDescription() { + let error = HttpError.invalidUrl + #expect(error.localizedDescription == "Malformed Url") + } + + @Test("HttpError.invalidParams has expected localized description") + func testInvalidParamsDescription() { + let error = HttpError.invalidParams + #expect(error.localizedDescription == "Invalid input params") + } + + @Test("HttpError.responseError has expected localized description") + func testResponseErrorDescription() { + let error = HttpError.responseError + #expect(error.localizedDescription == "Response returned and error") + } + + @Test("HttpError.reponseStatusError has expected localized description") + func testReponseStatusErrorDescription() { + let response = HttpResponse(data: nil, response: nil, error: nil) + let error = HttpError.reponseStatusError(response: response) + #expect(error.localizedDescription == "Response returned a non 200 status") + } + + @Test("HttpError.undecodableResponse has expected localized description") + func testUndecodableResponseDescription() { + let error = HttpError.undecodableResponse + #expect(error.localizedDescription == "Undecodable response") + } +} diff --git a/Tests/RevoHttpTests/HttpFakeHelpers.swift b/Tests/RevoHttpTests/HttpFakeHelpers.swift new file mode 100644 index 0000000..150d820 --- /dev/null +++ b/Tests/RevoHttpTests/HttpFakeHelpers.swift @@ -0,0 +1,31 @@ +import Foundation +@testable import RevoHttp + +/// Runs the given closure with `HttpFake` enabled in the shared container. +/// The fake is always disabled after the closure completes or throws, so you get +/// teardown when needed without a separate tearDown method. +/// +/// Swift Testing has no async tearDown (deinit cannot be async). Use this helper +/// in any test that needs the fake so the container is restored afterward: +/// +/// ```swift +/// @Test func myTest() async throws { +/// let fake = HttpFake() +/// try await withHttpFake(fake) { +/// await fake.addResponse("ok") +/// let r = await Http.call(.get, "https://example.com") +/// #expect(r.toString == "ok") +/// } +/// } +/// ``` +func withHttpFake(_ body: (_ fake: HttpFake) async throws -> Void) async throws { + let fake = HttpFake() + await fake.enable() + do { + try await body(fake) + } catch { + await fake.disable() + throw error + } + await fake.disable() +} diff --git a/Tests/RevoHttpTests/HttpFakeTests.swift b/Tests/RevoHttpTests/HttpFakeTests.swift new file mode 100644 index 0000000..b21e1b0 --- /dev/null +++ b/Tests/RevoHttpTests/HttpFakeTests.swift @@ -0,0 +1,230 @@ +import Foundation +import Testing +@testable import RevoHttp + +@Suite(.serialized) // HttpFake instances are not isolated !! +struct HttpFakeTests { + + @Test("HttpFake can be enabled and disabled safely") + func testEnableDisable() async throws { + try await withHttpFake() { fake in + await fake.enable() + await fake.disable() + await fake.disable() + await fake.enable() + await fake.enable() + await fake.disable() + } + } + + @Test("HttpFake resets state when enabled") + func testResetOnEnable() async throws { + try await withHttpFake() { fake in + // Add some responses and make calls + await fake.addResponse("test1") + await fake.addResponse("test2") + await fake.addResponse(for: "https://example.com", "test3") + + #expect(await fake.calls.count == 0) + #expect(await fake.globalResponses.count == 2) + #expect(await fake.responses.count == 1) + + await Http.call(HttpRequest(method: .get, url: "https://example.com")) + await Http.call(HttpRequest(method: .get, url: "https://hello.com")) + + #expect(await fake.calls.count == 2) + #expect(await fake.globalResponses.count == 1) + #expect(await fake.responses.count == 1) + + await fake.enable() + #expect(await fake.calls.count == 0) + #expect(await fake.globalResponses.count == 0) + #expect(await fake.responses.count == 0) + } + } + + @Test("HttpFake resets state when disabled") + func testResetOnDisable() async throws { + try await withHttpFake() { fake in + // Add some responses and make calls + await fake.addResponse("test1") + await fake.addResponse("test2") + await fake.addResponse(for: "https://example.com", "test3") + + #expect(await fake.calls.count == 0) + #expect(await fake.globalResponses.count == 2) + #expect(await fake.responses.count == 1) + + await Http.call(HttpRequest(method: .get, url: "https://example.com")) + await Http.call(HttpRequest(method: .get, url: "https://hello.com")) + + #expect(await fake.calls.count == 2) + #expect(await fake.globalResponses.count == 1) + #expect(await fake.responses.count == 1) + + await fake.disable() + #expect(await fake.calls.count == 0) + #expect(await fake.globalResponses.count == 0) + #expect(await fake.responses.count == 0) + } + } + + @Test("HttpFake tracks calls correctly") + func testCallTracking() async throws { + try await withHttpFake() { fake in + let request1 = HttpRequest(method: .get, url: "https://example.com/1") + let request2 = HttpRequest(method: .post, url: "https://example.com/2") + + await Http.call(request1) + await Http.call(request2) + + #expect(await fake.calls.count == 2) + #expect(await fake.calls[0].url == "https://example.com/1") + #expect(await fake.calls[1].url == "https://example.com/2") + } + } + + @Test("HttpFake returns URL-specific response when available") + func testUrlSpecificResponse() async throws { + try await withHttpFake() { fake in + await fake.addResponse(for: "https://example.com/specific", "specific response") + await fake.addResponse("global response") + + let specificRequest = HttpRequest(method: .get, url: "https://example.com/specific") + let specificResponse: String? = await Http.call(specificRequest).toString + + #expect(specificResponse == "specific response") + } + } + + @Test("HttpFake returns global response when no URL-specific response") + func testGlobalResponse() async throws { + try await withHttpFake() { fake in + await fake.addResponse("first global") + await fake.addResponse("second global") + + let request = HttpRequest(method: .get, url: "https://example.com/unknown") + + let response1 = await Http.call(request).toString + #expect(response1 == "first global") + + let response2 = await Http.call(request).toString + #expect(response2 == "second global") + } + } + + @Test("HttpFake reuses single global response") + func testSingleGlobalResponseReuse() async throws { + try await withHttpFake() { fake in + await fake.addResponse("single response") + + let request1 = HttpRequest(method: .get, url: "https://example.com/1") + let request2 = HttpRequest(method: .get, url: "https://example.com/2") + + let response1 = await Http.call(request1).toString + #expect(response1 == "single response") + + let response2 = await Http.call(request2).toString + #expect(response2 == "single response") + } + } + + @Test("HttpFake returns empty response when no responses configured") + func testEmptyResponseWhenNoResponses() async throws { + try await withHttpFake() { fake in + let request = HttpRequest(method: .get, url: "https://example.com") + + let response = await Http.call(request) + + #expect(response.data == nil) + #expect(response.response == nil) + #expect(response.error == nil) + } + } + + @Test("HttpFake can add encoded responses") + func testEncodedResponse() async throws { + struct TestResponse: Codable { + let name: String + let value: Int + } + + try await withHttpFake() { fake in + let testData = TestResponse(name: "test", value: 42) + await fake.addResponse(encoded: testData) + + let request = HttpRequest(method: .get, url: "https://example.com") + let decodedResponse: TestResponse = try #require(await Http.call(request).decoded()) + + #expect(decodedResponse.name == "test") + #expect(decodedResponse.value == 42) + } + } + + @Test("HttpFake can add URL-specific encoded responses") + func testUrlSpecificEncodedResponse() async throws { + struct TestResponse: Codable { + let id: Int + } + + try await withHttpFake() { fake in + await fake.addResponse(for: "https://api.example.com/user/1", encoded: TestResponse(id: 1)) + await fake.addResponse(for: "https://api.example.com/user/2", encoded: TestResponse(id: 2)) + + let request1 = HttpRequest(method: .get, url: "https://api.example.com/user/1") + let request2 = HttpRequest(method: .get, url: "https://api.example.com/user/2") + + let response1: TestResponse = try #require(await Http.call(request1).decoded()) + let response2: TestResponse = try #require(await Http.call(request2).decoded()) + + #expect(response1.id == 1) + #expect(response2.id == 2) + } + } + + @Test("HttpFake can handle custom status codes") + func testCustomStatusCodes() async throws { + try await withHttpFake() { fake in + + await fake.addResponse(for: "https://example.com/404", "not found", status: 404) + await fake.addResponse(for: "https://example.com/500", "server error", status: 500) + + let request1 = HttpRequest(method: .get, url: "https://example.com/404") + let request2 = HttpRequest(method: .get, url: "https://example.com/500") + + let status1 = await Http.call(request1).statusCode + let status2 = await Http.call(request2).statusCode + + #expect(status1 == 404) + #expect(status2 == 500) + } + } + + @Test("HttpFake can be safely used in parallel test scenarios") + func testParallelUsage() async throws { + try await withHttpFake() { fake in + // Add enough responses for concurrent calls + let concurrentCalls = 10000 + for i in 1...concurrentCalls { + await fake.addResponse("response\(i)") + } + + #expect(await fake.calls.count == 0) + #expect(await fake.globalResponses.count == concurrentCalls) + + await withTaskGroup(of: Void.self) { group in + for i in 1...concurrentCalls { + group.addTask { + let request = HttpRequest(method: .get, url: "https://example.com/\(i)") + await Http.call(request) + } + } + } + + // All calls should be tracked + #expect(await fake.calls.count == concurrentCalls) + #expect(await fake.globalResponses.count == 1) + } + } +} + diff --git a/Tests/RevoHttpTests/HttpMethodsTests.swift b/Tests/RevoHttpTests/HttpMethodsTests.swift new file mode 100644 index 0000000..d7942fa --- /dev/null +++ b/Tests/RevoHttpTests/HttpMethodsTests.swift @@ -0,0 +1,148 @@ +import Foundation +import Testing +@testable import RevoHttp + +@Suite(.serialized) +struct HttpMethodsTests { + + @Test("Can perform GET request") + func testCanGet() async throws { + struct HttpBinResponse: Codable { + let args: [String: String] + let headers: [String: String] + let url: String + } + + let response = await Http.get("https://httpbin.org/get", queryParams: ["name": "Jordi"], headers: ["X-Header": "header-value"]) + + let json: HttpBinResponse = try #require(response.decoded()) + #expect(json.args["name"] == "Jordi") + #expect(json.headers["X-Header"] == "header-value") + #expect(json.url == "https://httpbin.org/get?name=Jordi") + } + + @Test("Can perform POST request") + func testCanPost() async throws { + struct HttpBinResponse: Codable { + let form: [String: String] + let headers: [String: String] + let url: String + } + + let response = await Http.post("https://httpbin.org/post", form: ["name": "Jordi"], headers: ["X-Header": "header-value"]) + + let json: HttpBinResponse = try #require(response.decoded()) + #expect(json.form["name"] == "Jordi") + #expect(json.headers["X-Header"] == "header-value") + #expect(json.url == "https://httpbin.org/post") + } + + @Test("Can perform PUT request") + func testCanPut() async throws { + struct HttpBinResponse: Codable { + let data: String + let url: String + } + + let response = await Http.put("https://httpbin.org/put", form: ["name": "Jordi"]) + + let json: HttpBinResponse = try #require(response.decoded()) + #expect(json.data == "name=Jordi") + #expect(json.url == "https://httpbin.org/put") + } + + @Test("Can perform PATCH request") + func testCanPatch() async throws { + struct HttpBinResponse: Codable { + let data: String + let url: String + } + + let response = await Http.patch("https://httpbin.org/patch", form: ["name": "Jordi"]) + + let json: HttpBinResponse = try #require(response.decoded()) + #expect(json.data == "name=Jordi") + #expect(json.url == "https://httpbin.org/patch") + } + + @Test("Can perform DELETE request") + func testCanDelete() async throws { + struct HttpBinResponse: Codable { + let args: [String: String] + let url: String + } + + let response = await Http.delete("https://httpbin.org/delete", queryParams: ["name": "Jordi"]) + + let json: HttpBinResponse = try #require(response.decoded()) + #expect(json.args["name"] == "Jordi") + #expect(json.url == "https://httpbin.org/delete?name=Jordi") + } + + @Test("Can POST with body string") + func testCanPostWithBody() async throws { + struct HttpBinResponse: Codable { + let form: [String: String] + let headers: [String: String] + let url: String + } + + let response = await Http.post("https://httpbin.org/post", body: "name=Jordi", headers: ["X-Header": "header-value"]) + + let json: HttpBinResponse = try #require(response.decoded()) + #expect(json.form["name"] == "Jordi") + #expect(json.headers["X-Header"] == "header-value") + #expect(json.url == "https://httpbin.org/post") + } + + @Test("Can POST with JSON body") + func testCanPostWithJson() async throws { + struct RequestBody: Codable { + let name: String + let age: Int + } + + struct HttpBinResponse: Codable { + let json: RequestBody + let url: String + } + + let requestBody = RequestBody(name: "Jordi", age: 30) + let response = await Http().call(.post, "https://httpbin.org/post", json: requestBody) + + let json: HttpBinResponse = try #require(response.decoded()) + #expect(json.json.name == "Jordi") + #expect(json.json.age == 30) + } + + @Test("Can send numbers as parameters") + func testCanSendNumbersAsParameters() async throws { + struct HttpBinResponse: Codable { + let form: [String: String] + let headers: [String: String] + let url: String + } + + let response = await Http.post("https://httpbin.org/post", form: ["name": 12, "age": 30], headers: ["X-Header": "header-value"]) + + let json: HttpBinResponse = try #require(response.decoded()) + #expect(json.form["name"] == "12") + #expect(json.form["age"] == "30") + #expect(json.headers["X-Header"] == "header-value") + } + + @Test("Can send boolean as parameters") + func testCanSendBooleanAsParameters() async throws { + struct HttpBinResponse: Codable { + let form: [String: String] + let url: String + } + + let response = await Http.post("https://httpbin.org/post", form: ["active": true, "inactive": false]) + + let json: HttpBinResponse = try #require(response.decoded()) + #expect(["true", "1"].contains(json.form["active"])) + #expect(["false", "0"].contains(json.form["inactive"])) + } +} + diff --git a/Tests/RevoHttpTests/HttpOptionsTests.swift b/Tests/RevoHttpTests/HttpOptionsTests.swift new file mode 100644 index 0000000..fe90262 --- /dev/null +++ b/Tests/RevoHttpTests/HttpOptionsTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing +@testable import RevoHttp + +@Suite(.serialized) +struct HttpOptionsTests { + + @Test("Can add an HMAC header") + func testCanAddAnHmacHeader() async throws { + struct HttpBinResponse: Codable { + let args: [String: String] + let headers: [String: String] + let url: String + } + + let response = await Http.withOptions(.hmacSHA256(header: "X-Header-Sha", privateKey: "PRVIATE_KEY")).get("https://httpbin.org/get", queryParams: ["name": "Jordi"], headers: ["X-Header": "header-value"]) + + let json: HttpBinResponse = try #require(response.decoded()) + #expect(json.args["name"] == "Jordi") + #expect(json.headers["X-Header"] == "header-value") + #expect(json.headers["X-Header-Sha"] == "7f2d061df8af79d74afb651641bd1b15a38ae8d22aed75120c4c020ab844da18") + #expect(json.url == "https://httpbin.org/get?name=Jordi") + } + + @Test("Can set timeout on Http instance") + func testCanSetTimeoutOnHttpInstance() async throws { + try await withHttpFake() { fake in + let _ = await Http.withOptions(.timeout(seconds: 10)).get("https://httpbin.org/get", queryParams: [:]) + let request = try #require(await fake.calls.first) + #expect(request.timeout == 10.0) + } + } + + @Test("Can allow unsecure urls") + func testCanAllowUnsecureUrls() async throws { + try await withHttpFake() { fake in + let _ = await Http.withOptions(.allowUnsecureUrls).get("https://httpbin.org/get", queryParams: [:]) + let insecureUrlSession = try #require(fake.insecureUrlSession) + #expect(fake.urlSession == insecureUrlSession.session) + } + } + + @Test("Can use custom session") + func testCanUseCustomSession() async throws { + try await withHttpFake() { fake in + let customSession = URLSession(configuration: .ephemeral) + let _ = await Http.withOptions(.session(customSession)).get("https://httpbin.org/get", queryParams: [:]) + #expect(fake.urlSession == customSession) + } + } + + @Test("Can combine multiple options") + func testCanCombineMultipleOptions() async throws { + try await withHttpFake() { fake in + let _ = await Http.withOptions( + .timeout(seconds: 10), + .hmacSHA256(header: "X-Auth", privateKey: "key") + ).get("https://httpbin.org/get", queryParams: ["test": "value"]) + let request = try #require(await fake.calls.first) + #expect(request.timeout == 10.0) + #expect(request.headers["X-Auth"] != nil) + } + } +} + diff --git a/Tests/RevoHttpTests/HttpRequestTests.swift b/Tests/RevoHttpTests/HttpRequestTests.swift new file mode 100644 index 0000000..18acb65 --- /dev/null +++ b/Tests/RevoHttpTests/HttpRequestTests.swift @@ -0,0 +1,184 @@ +import Foundation +import Testing +@testable import RevoHttp + +@Suite(.serialized) +struct HttpRequestTests { + + @Test("Can convert request to curl") + func testCanConvertRequestToCurl() { + let request = HttpRequest(method: .get, url: "https://httpbin.org/get", queryParams: ["name": "Jordi", "lastName": "Puigdellívol"], headers: ["X-Header": "Value1", "X-Header2": "Value2"]) + + let result = request.toCurl() + #expect(result == "curl -d \"lastName=Puigdell%C3%ADvol&name=Jordi\" -H \"X-Header: Value1\" -H \"X-Header2: Value2\" -X GET https://httpbin.org/get") + } + + @Test("Can convert POST request to curl") + func testCanConvertPostRequestToCurl() { + let request = HttpRequest(method: .post, url: "https://httpbin.org/post", queryParams: ["name": "Jordi"], headers: ["Content-Type": "application/json"]) + + let result = request.toCurl() + #expect(result == "curl -d \"name=Jordi\" -H \"Content-Type: application/json\" -X POST https://httpbin.org/post") + } + + @Test("Can generate URLRequest from HttpRequest") + func testCanGenerateURLRequest() { + let request = HttpRequest(method: .get, url: "https://httpbin.org/get", queryParams: ["name": "Jordi"], headers: ["X-Header": "Value1"]) + + let urlRequest = request.generate() + #expect(urlRequest != nil) + #expect(urlRequest?.httpMethod == "GET") + #expect(urlRequest?.url?.absoluteString.contains("name=Jordi") == true) + } + + @Test("Can generate URLRequest with timeout") + func testCanGenerateURLRequestWithTimeout() { + let request = HttpRequest(method: .get, url: "https://httpbin.org/get") + request.timeout = 30.0 + + let urlRequest = request.generate() + #expect(urlRequest?.timeoutInterval == 30.0) + } + + @Test("Can generate POST request with body") + func testCanGeneratePostRequestWithBody() { + let request = HttpRequest(method: .post, url: "https://httpbin.org/post") + request.body = .string("name=Jordi&age=30") + + let urlRequest = request.generate() + #expect(urlRequest?.httpMethod == "POST") + #expect(urlRequest?.httpBody != nil) + let bodyString = String(data: urlRequest!.httpBody!, encoding: .utf8) + #expect(bodyString == "name=Jordi&age=30") + } + + @Test("Can handle nested parameters") + func testCanHandleNestedParameters() throws { + let nestedParams = [ + "user": [ + "name": "Jordi", + "age": 30 + ] + ] + + let request = HttpRequest(method: .post, url: "https://httpbin.org/post", queryParams: nestedParams) + let body = request.buildQueryParams() + + #expect(body.contains("user[name]")) + #expect(body.contains("user[age]")) + } + + @Test("Can handle empty parameters") + func testCanHandleEmptyParameters() { + let request = HttpRequest(method: .get, url: "https://httpbin.org/get", queryParams: [:]) + let body = request.buildFormBody() + + #expect(body == nil) + } + + @Test("Can handle special characters in parameters") + func testCanHandleSpecialCharactersInParameters() { + let request = HttpRequest(method: .get, url: "https://httpbin.org/get", queryParams: ["name": "Jordi & Co", "email": "test@example.com"]) + let url = request.buildUrl() + + #expect(url.contains("name=")) + #expect(url.contains("email=")) + } + + @Test("Can handle NSNull in parameters") + func testCanHandleNSNullInParameters() throws { + let request = HttpRequest(method: .post, url: "https://httpbin.org/post", queryParams: ["nullValue": NSNull()]) + let body = request.buildQueryParams() + + // NSNull should be converted to empty string + #expect(body.contains("nullValue=")) + } + + @Test("Can handle URL encoding in parameters") + func testCanHandleUrlEncodingInParameters() { + let request = HttpRequest(method: .get, url: "https://httpbin.org/get", queryParams: ["name": "Jordi Puigdellívol"]) + let url = request.buildUrl() + + // URL should be properly encoded + #expect(url.contains("name=")) + } + + @Test("Can handle request with body overriding params") + func testCanHandleRequestWithBodyOverridingParams() { + let request = HttpRequest(method: .post, url: "https://httpbin.org/post", queryParams: ["param1": "value1"]) + request.body = .string("body=value") + + let urlRequest = request.generate() + let bodyString = String(data: urlRequest!.httpBody!, encoding: .utf8) + #expect(bodyString == "body=value") + } + + @Test("generate returns nil for invalid URL") + func testGenerateReturnsNilForInvalidURL() { + let request = HttpRequest(method: .get, url: "") + let urlRequest = request.generate() + #expect(urlRequest == nil) + } + + @Test("generate sets JSON body when body is json Encodable") + func testGenerateSetsJsonBodyForEncodable() { + struct Payload: Codable { + let name: String + let count: Int + } + let request = HttpRequest(method: .post, url: "https://example.com/post") + request.body = .json(Payload(name: "test", count: 42)) + + let urlRequest = request.generate() + #expect(urlRequest != nil) + #expect(urlRequest?.value(forHTTPHeaderField: "Content-Type") == "application/json") + let body = urlRequest!.httpBody! + let decoded = try? JSONDecoder().decode(Payload.self, from: body) + #expect(decoded?.name == "test") + #expect(decoded?.count == 42) + } + + @Test("withHmacHeader sets header for string body") + func testWithHmacHeaderForStringBody() { + let request = HttpRequest(method: .post, url: "https://example.com/post") + request.body = .string("payload") + let hmac = HttpRequest.Hmac(header: "X-Signature", privateKey: "secret") + request.withHmacHeader(hmac) + #expect(request.headers["X-Signature"] != nil) + #expect(request.headers["X-Signature"]?.count == 64) // SHA256 hex length + } + + @Test("withHmacHeader sets header for json body") + func testWithHmacHeaderForJsonBody() { + struct Payload: Encodable { let x: Int } + let request = HttpRequest(method: .post, url: "https://example.com/post") + request.body = .json(Payload(x: 1)) + let hmac = HttpRequest.Hmac(header: "X-Hmac", privateKey: "key") + request.withHmacHeader(hmac) + #expect(request.headers["X-Hmac"] != nil) + #expect(request.headers["X-Hmac"]?.count == 64) + } + + @Test("withHmacHeader sets header for form body") + func testWithHmacHeaderForFormBody() { + let request = HttpRequest(method: .post, url: "https://example.com/post", form: ["a": "b"]) + let hmac = HttpRequest.Hmac(header: "X-Auth", privateKey: "key") + request.withHmacHeader(hmac) + #expect(request.headers["X-Auth"] != nil) + } + + @Test("withHmacHeader uses query params when no body") + func testWithHmacHeaderUsesQueryParamsWhenNoBody() { + let request = HttpRequest(method: .get, url: "https://example.com/get", queryParams: ["q": "v"]) + let hmac = HttpRequest.Hmac(header: "X-Sig", privateKey: "k") + request.withHmacHeader(hmac) + #expect(request.headers["X-Sig"] != nil) + } + + @Test("toString returns empty string") + func testToStringReturnsEmptyString() { + let request = HttpRequest(method: .get, url: "https://example.com") + #expect(request.toString() == "") + } +} + diff --git a/Tests/RevoHttpTests/HttpResponseTests.swift b/Tests/RevoHttpTests/HttpResponseTests.swift new file mode 100644 index 0000000..ca6efb4 --- /dev/null +++ b/Tests/RevoHttpTests/HttpResponseTests.swift @@ -0,0 +1,98 @@ +import Foundation +import Testing +@testable import RevoHttp + +@Suite(.serialized) +struct HttpResponseTests { + + @Test("Can decode JSON response") + func testCanDecodeJsonResponse() { + let jsonData = """ + {"name": "Jordi", "age": 30} + """.data(using: .utf8)! + + struct Response: Codable { + let name: String + let age: Int + } + + let httpResponse = HTTPURLResponse(url: URL(string: "https://example.com")!, statusCode: 200, httpVersion: nil, headerFields: nil) + let response = HttpResponse(data: jsonData, response: httpResponse) + + let decoded: Response? = response.decoded() + #expect(decoded?.name == "Jordi") + #expect(decoded?.age == 30) + } + + @Test("Can get status code from response") + func testCanGetStatusCodeFromResponse() { + let httpResponse = HTTPURLResponse(url: URL(string: "https://example.com")!, statusCode: 404, httpVersion: nil, headerFields: nil) + let response = HttpResponse(data: nil, response: httpResponse) + + #expect(response.statusCode == 404) + } + + @Test("Can check if response is successful") + func testCanCheckIfResponseIsSuccessful() { + let httpResponse200 = HTTPURLResponse(url: URL(string: "https://example.com")!, statusCode: 200, httpVersion: nil, headerFields: nil) + let response200 = HttpResponse(data: nil, response: httpResponse200) + #expect(response200.isSuccessful == true) + + let httpResponse201 = HTTPURLResponse(url: URL(string: "https://example.com")!, statusCode: 201, httpVersion: nil, headerFields: nil) + let response201 = HttpResponse(data: nil, response: httpResponse201) + #expect(response201.isSuccessful == true) + + let httpResponse404 = HTTPURLResponse(url: URL(string: "https://example.com")!, statusCode: 404, httpVersion: nil, headerFields: nil) + let response404 = HttpResponse(data: nil, response: httpResponse404) + #expect(response404.isSuccessful == false) + } + + @Test("Can get response as string") + func testCanGetResponseAsString() { + let data = "Hello World".data(using: .utf8)! + let httpResponse = HTTPURLResponse(url: URL(string: "https://example.com")!, statusCode: 200, httpVersion: nil, headerFields: nil) + let response = HttpResponse(data: data, response: httpResponse) + + #expect(response.toString == "Hello World") + } + + @Test("Can handle response with error") + func testCanHandleResponseWithError() { + let error = NSError(domain: "TestError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Network error"]) + let response = HttpResponse(data: nil, response: nil, error: error) + + #expect(response.error != nil) + #expect(response.toString.contains("RevoHttp Error")) + } + + @Test("Can handle response with no data") + func testCanHandleResponseWithNoData() { + let httpResponse = HTTPURLResponse(url: URL(string: "https://example.com")!, statusCode: 204, httpVersion: nil, headerFields: nil) + let response = HttpResponse(data: nil, response: httpResponse) + + #expect(response.toString.contains("No Data")) + } + + @Test("Can handle invalid URL error") + func testCanHandleInvalidUrlError() { + let response = HttpResponse(failed: "Invalid URL") + + #expect(response.error != nil) + #expect(response.data == nil) + } + + @Test("Can handle decoding error") + func testCanHandleDecodingError() { + let invalidJson = "not json".data(using: .utf8)! + let httpResponse = HTTPURLResponse(url: URL(string: "https://example.com")!, statusCode: 200, httpVersion: nil, headerFields: nil) + let response = HttpResponse(data: invalidJson, response: httpResponse) + + struct ExpectedType: Codable { + let name: String + } + + let decoded: ExpectedType? = response.decoded() + #expect(decoded == nil) + } +} + diff --git a/Tests/RevoHttpTests/HttpStaticTests.swift b/Tests/RevoHttpTests/HttpStaticTests.swift new file mode 100644 index 0000000..36e7db3 --- /dev/null +++ b/Tests/RevoHttpTests/HttpStaticTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing +@testable import RevoHttp + +@Suite(.serialized) +struct HttpStaticTests { + + @Test("Can use static call method") + func testCanUseStaticCallMethod() async throws { + struct HttpBinResponse: Codable { + let args: [String: String] + let url: String + } + + let response = await Http.call(.get, url: "https://httpbin.org/get", params: ["name": "Jordi"]) + + let json: HttpBinResponse = try #require(response.decoded()) + #expect(json.args["name"] == "Jordi") + } + + @Test("Can use static call with request object") + func testCanUseStaticCallWithRequestObject() async throws { + struct HttpBinResponse: Codable { + let args: [String: String] + let url: String + } + + let request = HttpRequest(method: .get, url: "https://httpbin.org/get", queryParams: ["name": "Jordi"]) + let response = await Http.call(request) + + let json: HttpBinResponse = try #require(response.decoded()) + #expect(json.args["name"] == "Jordi") + } + + @Test("Can use static PUT method") + func testCanUseStaticPutMethod() async throws { + struct HttpBinResponse: Codable { + let data: String + let url: String + } + + let response = await Http.put("https://httpbin.org/put", form: ["name": "Jordi"]) + + let json: HttpBinResponse = try #require(response.decoded()) + #expect(json.data == "name=Jordi") + } + + @Test("Can use static PATCH method") + func testCanUseStaticPatchMethod() async throws { + struct HttpBinResponse: Codable { + let data: String + let url: String + } + + let response = await Http.patch("https://httpbin.org/patch", form: ["name": "Jordi"]) + + let json: HttpBinResponse = try #require(response.decoded()) + #expect(json.data == "name=Jordi") + } + + @Test("Can use static DELETE method") + func testCanUseStaticDeleteMethod() async throws { + struct HttpBinResponse: Codable { + let args: [String: String] + let url: String + } + + let response = await Http.delete("https://httpbin.org/delete", queryParams: ["name": "Jordi"]) + + let json: HttpBinResponse = try #require(response.decoded()) + #expect(json.args["name"] == "Jordi") + } + + @Test("Static call throwing overload returns decoded value on success") + func testStaticCallThrowsReturnsDecodedOnSuccess() async throws { + struct Response: Codable { + let ok: Bool + } + try await withHttpFake() { fake in + await fake.addResponse(encoded: Response(ok: true), status: 200) + let result: Response = try await Http.call(.get, "https://example.com", params: [:], headers: [:]) + #expect(result.ok == true) + } + } +} + diff --git a/Tests/RevoHttpTests/MultipartHttpRequestTests.swift b/Tests/RevoHttpTests/MultipartHttpRequestTests.swift new file mode 100644 index 0000000..0094c13 --- /dev/null +++ b/Tests/RevoHttpTests/MultipartHttpRequestTests.swift @@ -0,0 +1,85 @@ +import Foundation +import Testing +@testable import RevoHttp +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +@Suite(.serialized) +struct MultipartHttpRequestTests { + + @Test("addMultipart sets properties and returns self for chaining") + func testAddMultipartReturnsSelf() { + let request = MultipartHttpRequest(method: .post, url: "https://example.com/upload") + #if canImport(UIKit) + let image = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)).image { _ in } + #elseif canImport(AppKit) + let image = NSImage(size: NSSize(width: 1, height: 1)) + image.lockFocus() + NSColor.red.setFill() + NSBezierPath(rect: NSRect(origin: .zero, size: NSSize(width: 1, height: 1))).fill() + image.unlockFocus() + #endif + let result = request.addMultipart(paramName: "file", fileName: "test.png", image: image) + #expect(result === request) + } + + @Test("generate returns POST with multipart Content-Type") + func testGenerateReturnsPostWithMultipartContentType() { + let request = MultipartHttpRequest(method: .get, url: "https://example.com/upload") + let urlRequest = request.generate() + #expect(urlRequest != nil) + #expect(urlRequest?.httpMethod == "POST") + let contentType = urlRequest?.value(forHTTPHeaderField: "Content-Type") + #expect(contentType?.hasPrefix("multipart/form-data") == true) + #expect(contentType?.contains("boundary=") == true) + } + + @Test("generate returns nil for invalid URL") + func testGenerateReturnsNilForInvalidURL() { + let request = MultipartHttpRequest(method: .post, url: "") + let urlRequest = request.generate() + #expect(urlRequest == nil) + } + + @Test("generateData returns empty when addMultipart not called") + func testGenerateDataReturnsEmptyWhenNoMultipart() { + let request = MultipartHttpRequest(method: .post, url: "https://example.com/upload") + let data = request.generateData() + #expect(data.isEmpty) + } + + @Test("generateData returns non-empty body when addMultipart was called") + func testGenerateDataReturnsBodyWhenMultipartSet() { + #if canImport(UIKit) + let image = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)).image { _ in } + #elseif canImport(AppKit) + let image = NSImage(size: NSSize(width: 1, height: 1)) + image.lockFocus() + NSColor.red.setFill() + NSBezierPath(rect: NSRect(origin: .zero, size: NSSize(width: 1, height: 1))).fill() + image.unlockFocus() + #endif + let request = MultipartHttpRequest(method: .post, url: "https://example.com/upload") + _ = request.addMultipart(paramName: "photo", fileName: "image.png", image: image) + let data = request.generateData() + #expect(!data.isEmpty) + // Data includes binary image bytes so check raw bytes for expected multipart headers + let disposition = "Content-Disposition: form-data".data(using: .utf8)! + let nameParam = "name=\"photo\"".data(using: .utf8)! + let filenameParam = "filename=\"image.png\"".data(using: .utf8)! + #expect(data.range(of: disposition) != nil) + #expect(data.range(of: nameParam) != nil) + #expect(data.range(of: filenameParam) != nil) + } + + @Test("callMultipart returns failed response for invalid URL") + func testCallMultipartReturnsFailedForInvalidURL() async { + let request = MultipartHttpRequest(method: .post, url: "") + let response = await Http().callMultipart(request) + #expect(response.error != nil) + #expect(response.data == nil) + } +} diff --git a/Tests/RevoHttpTests/RevoHttpTests.swift b/Tests/RevoHttpTests/RevoHttpTests.swift deleted file mode 100644 index cac4447..0000000 --- a/Tests/RevoHttpTests/RevoHttpTests.swift +++ /dev/null @@ -1,267 +0,0 @@ -import XCTest - -@testable import RevoHttp - -class RevoHttpTests: XCTestCase { - - override func setUp() { - HttpFake.disable() - } - - override func tearDown() {} - - func test_can_get() { - - let expectation = XCTestExpectation(description: "Http request") - - struct HttpBinResponse: Codable { - let args:[String:String] - let headers:[String:String] - let url:String - } - - Http.get("https://httpbin.org/get", params:["name" : "Jordi"], headers:["X-Header": "header-value"]) { response in - - print(response.toString) - let json:HttpBinResponse = response.decoded()! - XCTAssertEqual("Jordi", json.args["name"]) - XCTAssertEqual("header-value", json.headers["X-Header"]) - XCTAssertEqual("https://httpbin.org/get?name=Jordi", json.url) - expectation.fulfill() - } - - wait(for: [expectation], timeout: 5) - } - - func test_can_send_numbers_as_parameters(){ - - let expectation = XCTestExpectation(description: "Http request") - - struct HttpBinResponse: Codable { - let form:[String:String] - let headers:[String:String] - let url:String - } - - Http.post("https://httpbin.org/post", params:["name" : 12], headers:["X-Header": "header-value"]) { response in - - print(response.toString) - let json:HttpBinResponse = response.decoded()! - XCTAssertEqual("12", json.form["name"]) - XCTAssertEqual("header-value", json.headers["X-Header"]) - XCTAssertEqual("https://httpbin.org/post", json.url) - expectation.fulfill() - } - - wait(for: [expectation], timeout: 5) - - } - - func test_can_post(){ - - let expectation = XCTestExpectation(description: "Http request") - - struct HttpBinResponse: Codable { - let form:[String:String] - let headers:[String:String] - let url:String - } - - Http.post("https://httpbin.org/post", params:["name" : "Jordi"], headers:["X-Header": "header-value"]) { response in - - print(response.toString) - let json:HttpBinResponse = response.decoded()! - XCTAssertEqual("Jordi", json.form["name"]) - XCTAssertEqual("header-value", json.headers["X-Header"]) - XCTAssertEqual("https://httpbin.org/post", json.url) - expectation.fulfill() - } - - wait(for: [expectation], timeout: 5) - - } - - func test_can_post_with_body(){ - - let expectation = XCTestExpectation(description: "Http request") - - struct HttpBinResponse: Codable { - let form:[String:String] - let headers:[String:String] - let url:String - } - - Http.post("https://httpbin.org/post", body:"name=Jordi", headers:["X-Header": "header-value"]) { response in - - print(response.toString) - let json:HttpBinResponse = response.decoded()! - XCTAssertEqual("Jordi", json.form["name"]) - XCTAssertEqual("header-value", json.headers["X-Header"]) - XCTAssertEqual("https://httpbin.org/post", json.url) - expectation.fulfill() - } - - wait(for: [expectation], timeout: 5) - } - - func test_get_call_with_automatic_decoded_response(){ - - let expectation = XCTestExpectation(description: "Http request") - - struct HttpBinResponse: Codable { - let form:[String:String] - let headers:[String:String] - let url:String - } - - Http().call(.post, url: "https://httpbin.org/post", params:["name":"Jordi"], headers:["X-Header": "header-value"]) { (response:HttpBinResponse?, error:Error?) in - guard let response = response else { return } - XCTAssertEqual("Jordi", response.form["name"]) - XCTAssertEqual("header-value", response.headers["X-Header"]) - XCTAssertEqual("https://httpbin.org/post", response.url) - expectation.fulfill() - } - - wait(for: [expectation], timeout: 5) - } - - func test_can_convert_request_to_curl() { - - let request = HttpRequest(method: .get, url: "https://httpbin.org/get", params: ["name" : "Jordi", "lastName" : "Puigdellívol"], headers: ["X-Header" : "Value1", "X-Header2": "Value2"]) - - let result = request.toCurl() - - XCTAssertTrue(result.hasPrefix("curl -d \"")) - XCTAssertTrue(result.contains("name=Jordi")) - XCTAssertTrue(result.contains("lastName=Puigdell%C3%ADvol")) - XCTAssertTrue(result.contains("-H \"X-Header: Value1\"")) - XCTAssertTrue(result.contains("-H \"X-Header2: Value2\"")) - XCTAssertTrue(result.hasSuffix("-X GET https://httpbin.org/get")) - - } - - func test_can_use_http_fake(){ - HttpFake.enable() - HttpFake.addResponse("patata") - - let expectation = XCTestExpectation(description: "Http request") - Http.post("https://httpbin.org/post", body:"name=Jordi", headers:["X-Header": "header-value"]) { response in - XCTAssertEqual(1, HttpFake.calls.count) - XCTAssertEqual("patata", response.toString) - expectation.fulfill() - } - - wait(for: [expectation], timeout: 5) - } - - func test_can_use_http_fake_with_autoEncodings(){ - HttpFake.enable() - HttpFake.addResponse(encoded: ["my-name" : "jordi"]) - - let expectation = XCTestExpectation(description: "Http request") - Http.post("https://httpbin.org/post", body:"name=Jordi", headers:["X-Header": "header-value"]) { response in - XCTAssertEqual(1, HttpFake.calls.count) - XCTAssertEqual("{\"my-name\":\"jordi\"}", response.toString) - expectation.fulfill() - } - - wait(for: [expectation], timeout: 5) - } - - func test_can_use_fake_for_concrete_urls() { - HttpFake.enable() - - HttpFake.addResponse(for:"https://test-url.org/post" , "{\"name\":\"batman\"}") - HttpFake.addResponse(for:"https://test-url-encoded.org/post" , encoded:["name" : "joker"]) - HttpFake.addResponse("{\"name\":\"robin\"}") - - let expectation = XCTestExpectation(description: "Http request") - Http.get("https://any-url.org") { response in - XCTAssertEqual(1, HttpFake.calls.count) - XCTAssertEqual("{\"name\":\"robin\"}", response.toString) - expectation.fulfill() - } - - let expectation2 = XCTestExpectation(description: "Http request") - Http.get("https://test-url.org/post") { response in - XCTAssertEqual(2, HttpFake.calls.count) - XCTAssertEqual("{\"name\":\"batman\"}", response.toString) - expectation2.fulfill() - } - - let expectation3 = XCTestExpectation(description: "Http request") - Http.get("https://test-url-encoded.org/post") { response in - XCTAssertEqual(3, HttpFake.calls.count) - XCTAssertEqual("{\"name\":\"joker\"}", response.toString) - expectation3.fulfill() - } - - - wait(for: [expectation, expectation2, expectation3], timeout: 5) - } - - func test_can_add_an_hmac_header(){ - - let expectation = XCTestExpectation(description: "Http request") - - struct HttpBinResponse: Codable { - let form:[String:String] - let headers:[String:String] - let url:String - } - - - Http().withHmacSHA256(header:"X-Header-Sha", privateKey: "PRVIATE_KEY").post("https://httpbin.org/post", params:["name" : "Jordi"], headers:["X-Header": "header-value"]) { response in - - print(response.toString) - let json:HttpBinResponse = response.decoded()! - XCTAssertEqual("Jordi", json.form["name"]) - XCTAssertEqual("header-value", json.headers["X-Header"]) - XCTAssertEqual("7f2d061df8af79d74afb651641bd1b15a38ae8d22aed75120c4c020ab844da18", json.headers["X-Header-Sha"]) - XCTAssertEqual("https://httpbin.org/post", json.url) - expectation.fulfill() - } - - wait(for: [expectation], timeout: 5) - } - - func test_can_add_an_hmac_header_with_query_params(){ - - let expectation = XCTestExpectation(description: "Http request") - - struct HttpBinResponse: Codable { - let args:[String:String] - let headers:[String:String] - let url:String - } - - Http().withHmacSHA256(header:"X-Header-Sha", privateKey: "PRVIATE_KEY").get("https://httpbin.org/get", params:["name" : "Jordi"], headers:["X-Header": "header-value"]) { response in - - print(response.toString) - let json:HttpBinResponse = response.decoded()! - XCTAssertEqual("Jordi", json.args["name"]) - XCTAssertEqual("header-value", json.headers["X-Header"]) - XCTAssertEqual("7f2d061df8af79d74afb651641bd1b15a38ae8d22aed75120c4c020ab844da18", json.headers["X-Header-Sha"]) - XCTAssertEqual("https://httpbin.org/get?name=Jordi", json.url) - expectation.fulfill() - } - - wait(for: [expectation], timeout: 5) - } - - func test_it_works_with_async() async throws { - struct BinResponse : Codable{ - let args:[String:String] - } - - do { - let response:BinResponse = try await Http().call(.get, "https://httpbin.org/get", params: ["name" : "jordi"]) - XCTAssertEqual("jordi", response.args["name"]) - XCTAssertTrue(true) - }catch{ - print(error) - XCTFail() - } - } - -}