From bcc01a7a54f011d8449eb05b3c390382309dc053 Mon Sep 17 00:00:00 2001 From: Miquel Rodoreda Date: Tue, 25 Nov 2025 10:40:12 +0100 Subject: [PATCH 01/11] Update to common Swift package structure --- .gitignore | 3 + Package.swift | 6 +- RevoHttp/AppDelegate.swift | 37 ------- .../AppIcon.appiconset/Contents.json | 98 ------------------- RevoHttp/Assets.xcassets/Contents.json | 6 -- RevoHttp/Base.lproj/LaunchScreen.storyboard | 25 ----- RevoHttp/Base.lproj/Main.storyboard | 24 ----- RevoHttp/Info.plist | 64 ------------ RevoHttp/SceneDelegate.swift | 53 ---------- RevoHttp/ViewController.swift | 20 ---- RevoHttpTests/Info.plist | 22 ----- .../RevoHttp}/Fake/HttpFake.swift | 0 {RevoHttp/src => Sources/RevoHttp}/Http.swift | 0 .../src => Sources/RevoHttp}/HttpError.swift | 0 .../RevoHttp}/HttpRequest.swift | 0 .../RevoHttp}/HttpResponse.swift | 0 .../RevoHttp}/HttpStaticExtension.swift | 0 .../RevoHttp}/InsecureUrlSession.swift | 0 .../RevoHttp}/MultipartHttpRequest.swift | 0 .../RevoHttpTests}/RevoHttpTests.swift | 0 20 files changed, 5 insertions(+), 353 deletions(-) delete mode 100644 RevoHttp/AppDelegate.swift delete mode 100644 RevoHttp/Assets.xcassets/AppIcon.appiconset/Contents.json delete mode 100644 RevoHttp/Assets.xcassets/Contents.json delete mode 100644 RevoHttp/Base.lproj/LaunchScreen.storyboard delete mode 100644 RevoHttp/Base.lproj/Main.storyboard delete mode 100644 RevoHttp/Info.plist delete mode 100644 RevoHttp/SceneDelegate.swift delete mode 100644 RevoHttp/ViewController.swift delete mode 100644 RevoHttpTests/Info.plist rename {RevoHttp/src => Sources/RevoHttp}/Fake/HttpFake.swift (100%) rename {RevoHttp/src => Sources/RevoHttp}/Http.swift (100%) rename {RevoHttp/src => Sources/RevoHttp}/HttpError.swift (100%) rename {RevoHttp/src => Sources/RevoHttp}/HttpRequest.swift (100%) rename {RevoHttp/src => Sources/RevoHttp}/HttpResponse.swift (100%) rename {RevoHttp/src => Sources/RevoHttp}/HttpStaticExtension.swift (100%) rename {RevoHttp/src => Sources/RevoHttp}/InsecureUrlSession.swift (100%) rename {RevoHttp/src => Sources/RevoHttp}/MultipartHttpRequest.swift (100%) rename {RevoHttpTests => Tests/RevoHttpTests}/RevoHttpTests.swift (100%) diff --git a/.gitignore b/.gitignore index 389a2b2..7991b78 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ Pods/ +Package.resolved +.swiftpm/ +.build/ diff --git a/Package.swift b/Package.swift index e12ee5f..7467143 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.5 +// swift-tools-version:5.10 import PackageDescription let package = Package( @@ -15,15 +15,13 @@ let package = Package( ], dependencies: [ .package( - name:"RevoFoundation", url: "https://github.com/revosystems/foundation", .upToNextMinor(from: "0.3.1") ), ], targets: [ .target( name: "RevoHttp", - dependencies: ["RevoFoundation"], - path: "RevoHttp/src" + dependencies: ["foundation"] ), .testTarget( name: "RevoHttpTests", diff --git a/RevoHttp/AppDelegate.swift b/RevoHttp/AppDelegate.swift deleted file mode 100644 index 04dfcb4..0000000 --- a/RevoHttp/AppDelegate.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// AppDelegate.swift -// RevoHttp -// -// Created by Jordi Puigdellívol on 05/12/2019. -// Copyright © 2019 Revo Systems. All rights reserved. -// - -import UIKit - -@UIApplicationMain -class AppDelegate: UIResponder, UIApplicationDelegate { - - - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. - return true - } - - // MARK: UISceneSession Lifecycle - - func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { - // Called when a new scene session is being created. - // Use this method to select a configuration to create the new scene with. - return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) - } - - func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { - // Called when the user discards a scene session. - // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. - // Use this method to release any resources that were specific to the discarded scenes, as they will not return. - } - - -} - diff --git a/RevoHttp/Assets.xcassets/AppIcon.appiconset/Contents.json b/RevoHttp/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index d8db8d6..0000000 --- a/RevoHttp/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "images" : [ - { - "idiom" : "iphone", - "size" : "20x20", - "scale" : "2x" - }, - { - "idiom" : "iphone", - "size" : "20x20", - "scale" : "3x" - }, - { - "idiom" : "iphone", - "size" : "29x29", - "scale" : "2x" - }, - { - "idiom" : "iphone", - "size" : "29x29", - "scale" : "3x" - }, - { - "idiom" : "iphone", - "size" : "40x40", - "scale" : "2x" - }, - { - "idiom" : "iphone", - "size" : "40x40", - "scale" : "3x" - }, - { - "idiom" : "iphone", - "size" : "60x60", - "scale" : "2x" - }, - { - "idiom" : "iphone", - "size" : "60x60", - "scale" : "3x" - }, - { - "idiom" : "ipad", - "size" : "20x20", - "scale" : "1x" - }, - { - "idiom" : "ipad", - "size" : "20x20", - "scale" : "2x" - }, - { - "idiom" : "ipad", - "size" : "29x29", - "scale" : "1x" - }, - { - "idiom" : "ipad", - "size" : "29x29", - "scale" : "2x" - }, - { - "idiom" : "ipad", - "size" : "40x40", - "scale" : "1x" - }, - { - "idiom" : "ipad", - "size" : "40x40", - "scale" : "2x" - }, - { - "idiom" : "ipad", - "size" : "76x76", - "scale" : "1x" - }, - { - "idiom" : "ipad", - "size" : "76x76", - "scale" : "2x" - }, - { - "idiom" : "ipad", - "size" : "83.5x83.5", - "scale" : "2x" - }, - { - "idiom" : "ios-marketing", - "size" : "1024x1024", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/RevoHttp/Assets.xcassets/Contents.json b/RevoHttp/Assets.xcassets/Contents.json deleted file mode 100644 index da4a164..0000000 --- a/RevoHttp/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/RevoHttp/Base.lproj/LaunchScreen.storyboard b/RevoHttp/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index 865e932..0000000 --- a/RevoHttp/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/RevoHttp/Base.lproj/Main.storyboard b/RevoHttp/Base.lproj/Main.storyboard deleted file mode 100644 index 25a7638..0000000 --- a/RevoHttp/Base.lproj/Main.storyboard +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/RevoHttp/Info.plist b/RevoHttp/Info.plist deleted file mode 100644 index 2a3483c..0000000 --- a/RevoHttp/Info.plist +++ /dev/null @@ -1,64 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - LSRequiresIPhoneOS - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Default Configuration - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main - - - - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UIRequiredDeviceCapabilities - - armv7 - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/RevoHttp/SceneDelegate.swift b/RevoHttp/SceneDelegate.swift deleted file mode 100644 index 80ec184..0000000 --- a/RevoHttp/SceneDelegate.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// SceneDelegate.swift -// RevoHttp -// -// Created by Jordi Puigdellívol on 05/12/2019. -// Copyright © 2019 Revo Systems. All rights reserved. -// - -import UIKit - -class SceneDelegate: UIResponder, UIWindowSceneDelegate { - - var window: UIWindow? - - - func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. - // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. - // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). - guard let _ = (scene as? UIWindowScene) else { return } - } - - func sceneDidDisconnect(_ scene: UIScene) { - // Called as the scene is being released by the system. - // This occurs shortly after the scene enters the background, or when its session is discarded. - // Release any resources associated with this scene that can be re-created the next time the scene connects. - // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). - } - - func sceneDidBecomeActive(_ scene: UIScene) { - // Called when the scene has moved from an inactive state to an active state. - // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. - } - - func sceneWillResignActive(_ scene: UIScene) { - // Called when the scene will move from an active state to an inactive state. - // This may occur due to temporary interruptions (ex. an incoming phone call). - } - - func sceneWillEnterForeground(_ scene: UIScene) { - // Called as the scene transitions from the background to the foreground. - // Use this method to undo the changes made on entering the background. - } - - func sceneDidEnterBackground(_ scene: UIScene) { - // Called as the scene transitions from the foreground to the background. - // Use this method to save data, release shared resources, and store enough scene-specific state information - // to restore the scene back to its current state. - } - - -} - diff --git a/RevoHttp/ViewController.swift b/RevoHttp/ViewController.swift deleted file mode 100644 index 88effe4..0000000 --- a/RevoHttp/ViewController.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// ViewController.swift -// RevoHttp -// -// Created by Jordi Puigdellívol on 05/12/2019. -// Copyright © 2019 Revo Systems. All rights reserved. -// - -import UIKit - -class ViewController: UIViewController { - - override func viewDidLoad() { - super.viewDidLoad() - // Do any additional setup after loading the view. - } - - -} - diff --git a/RevoHttpTests/Info.plist b/RevoHttpTests/Info.plist deleted file mode 100644 index 64d65ca..0000000 --- a/RevoHttpTests/Info.plist +++ /dev/null @@ -1,22 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - - diff --git a/RevoHttp/src/Fake/HttpFake.swift b/Sources/RevoHttp/Fake/HttpFake.swift similarity index 100% rename from RevoHttp/src/Fake/HttpFake.swift rename to Sources/RevoHttp/Fake/HttpFake.swift diff --git a/RevoHttp/src/Http.swift b/Sources/RevoHttp/Http.swift similarity index 100% rename from RevoHttp/src/Http.swift rename to Sources/RevoHttp/Http.swift diff --git a/RevoHttp/src/HttpError.swift b/Sources/RevoHttp/HttpError.swift similarity index 100% rename from RevoHttp/src/HttpError.swift rename to Sources/RevoHttp/HttpError.swift diff --git a/RevoHttp/src/HttpRequest.swift b/Sources/RevoHttp/HttpRequest.swift similarity index 100% rename from RevoHttp/src/HttpRequest.swift rename to Sources/RevoHttp/HttpRequest.swift diff --git a/RevoHttp/src/HttpResponse.swift b/Sources/RevoHttp/HttpResponse.swift similarity index 100% rename from RevoHttp/src/HttpResponse.swift rename to Sources/RevoHttp/HttpResponse.swift diff --git a/RevoHttp/src/HttpStaticExtension.swift b/Sources/RevoHttp/HttpStaticExtension.swift similarity index 100% rename from RevoHttp/src/HttpStaticExtension.swift rename to Sources/RevoHttp/HttpStaticExtension.swift diff --git a/RevoHttp/src/InsecureUrlSession.swift b/Sources/RevoHttp/InsecureUrlSession.swift similarity index 100% rename from RevoHttp/src/InsecureUrlSession.swift rename to Sources/RevoHttp/InsecureUrlSession.swift diff --git a/RevoHttp/src/MultipartHttpRequest.swift b/Sources/RevoHttp/MultipartHttpRequest.swift similarity index 100% rename from RevoHttp/src/MultipartHttpRequest.swift rename to Sources/RevoHttp/MultipartHttpRequest.swift diff --git a/RevoHttpTests/RevoHttpTests.swift b/Tests/RevoHttpTests/RevoHttpTests.swift similarity index 100% rename from RevoHttpTests/RevoHttpTests.swift rename to Tests/RevoHttpTests/RevoHttpTests.swift From cb8d3f522d98d419f43bdc8f8f8c39678c93c61d Mon Sep 17 00:00:00 2001 From: Miquel Rodoreda Date: Tue, 25 Nov 2025 11:21:29 +0100 Subject: [PATCH 02/11] Make createParams and toCurl in HttpRequest deterministic to make tests pass --- Package.swift | 4 +++- Sources/RevoHttp/HttpError.swift | 10 +++++----- Sources/RevoHttp/HttpRequest.swift | 29 +++++++++++++---------------- 3 files changed, 21 insertions(+), 22 deletions(-) diff --git a/Package.swift b/Package.swift index 7467143..0c45202 100644 --- a/Package.swift +++ b/Package.swift @@ -21,7 +21,9 @@ let package = Package( targets: [ .target( name: "RevoHttp", - dependencies: ["foundation"] + dependencies: [ + .product(name: "RevoFoundation", package: "foundation") + ] ), .testTarget( name: "RevoHttpTests", diff --git a/Sources/RevoHttp/HttpError.swift b/Sources/RevoHttp/HttpError.swift index 26d3e2e..19d1183 100644 --- a/Sources/RevoHttp/HttpError.swift +++ b/Sources/RevoHttp/HttpError.swift @@ -10,11 +10,11 @@ public enum HttpError : Error { 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" + 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/HttpRequest.swift b/Sources/RevoHttp/HttpRequest.swift index ef75b44..3f92804 100644 --- a/Sources/RevoHttp/HttpRequest.swift +++ b/Sources/RevoHttp/HttpRequest.swift @@ -72,8 +72,9 @@ public class HttpRequest : NSObject { result = 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)\"" }.implode(" ") if (h.count > 0){ @@ -84,7 +85,7 @@ public class HttpRequest : NSObject { } public func toString() -> String { - return "" + "" } var methodUppercased: String { @@ -99,14 +100,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 @@ -120,14 +120,11 @@ 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 ?? "" } public func encoded(urlEncoded:Bool = false) -> String { - urlEncoded ? "\(key)=\(value.urlEncoded() ?? "")" : "\(key)=\(value)" + "\(key)=\(urlEncoded ? value.urlEncoded() ?? "" : value)" } } From 2920593d490fa6ec2fc7b43d7150291b7851c31e Mon Sep 17 00:00:00 2001 From: Miquel Rodoreda Date: Tue, 25 Nov 2025 13:51:14 +0100 Subject: [PATCH 03/11] Add native async methods --- Package.swift | 19 ++++ Sources/RevoHttp/Http.swift | 112 ++++++++++++++++------ Sources/RevoHttp/HttpResponse.swift | 19 ++-- Sources/RevoHttp/InsecureUrlSession.swift | 4 +- 4 files changed, 116 insertions(+), 38 deletions(-) diff --git a/Package.swift b/Package.swift index 0c45202..9bdae4f 100644 --- a/Package.swift +++ b/Package.swift @@ -23,6 +23,25 @@ let package = Package( name: "RevoHttp", dependencies: [ .product(name: "RevoFoundation", package: "foundation") + ], + 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( diff --git a/Sources/RevoHttp/Http.swift b/Sources/RevoHttp/Http.swift index 340c655..1e3ae28 100644 --- a/Sources/RevoHttp/Http.swift +++ b/Sources/RevoHttp/Http.swift @@ -3,7 +3,7 @@ import RevoFoundation public class Http : NSObject { - public static var debugMode = false + nonisolated(unsafe) public static var debugMode = false var insecureUrlSession:InsecureUrlSession? var timeout:Int? var hmac:Hmac? @@ -60,27 +60,6 @@ public class Http : NSObject { } } - //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) - } - } - } - 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 @@ -121,9 +100,7 @@ public class Http : NSObject { } @objc dynamic public func call(_ request:HttpRequest, then:@escaping(_ response:HttpResponse)->Void) { - if (Self.debugMode) { - debugPrint("****** HTTP DEBUG ***** " + request.toCurl()) - } + debugIfNeeded(request) if let hmac = hmac { if let hash = request.buildBody().hmac256(hmac.privateKey) { @@ -148,9 +125,7 @@ public class Http : NSObject { } @objc dynamic public func callMultipart(_ request:MultipartHttpRequest, then:@escaping(_ response:HttpResponse)->Void) { - if (Self.debugMode) { - debugPrint("****** HTTP DEBUG ***** " + request.toCurl()) - } + debugIfNeeded(request) guard let urlRequest = request.generate() else { return then(HttpResponse(failed: "Invalid URL")) @@ -186,4 +161,85 @@ public class Http : NSObject { self.timeout = seconds return self } + + private func debugIfNeeded(_ request: HttpRequest) { + guard Self.debugMode else { return } + debugPrint("****** HTTP DEBUG ***** " + request.toCurl()) + } +} + + +extension Http { + 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, params: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 + } + + @objc dynamic public func call(_ request:HttpRequest) async -> HttpResponse { + debugIfNeeded(request) + + if let hmac, let hash = request.buildBody().hmac256(hmac.privateKey) { + request.headers[hmac.header] = hash + } + + if let timeout { + request.timeout = TimeInterval(timeout) + } + + 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) + } + } + + @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] = [:]) async -> HttpResponse { + await call(HttpRequest(method: .get, url: url, params: params, headers: headers)) + } + + public func post(_ url:String, params:[String:Codable] = [:], headers:[String:String] = [:]) async -> HttpResponse { + await call(HttpRequest(method: .post, url: url, params: params, headers: headers)) + } + + public func post(_ url:String, body:String, headers:[String:String] = [:]) async -> HttpResponse { + let request = HttpRequest(method: .post, url: url, headers: headers) + request.body = body + return await call(request) + } + + public func put(_ url:String, params:[String:Codable] = [:], headers:[String:String] = [:]) async -> HttpResponse { + await call(HttpRequest(method: .put, url: url, params: params, headers: headers)) + } + + public func patch(_ url:String, params:[String:Codable] = [:], headers:[String:String] = [:]) async -> HttpResponse { + await call(HttpRequest(method: .patch, url: url, params: params, headers: headers)) + } + + public func delete(_ url:String, params:[String:Codable] = [:], headers:[String:String] = [:]) async -> HttpResponse { + await call(HttpRequest(method: .delete, url: url, params: params, headers: headers)) + } } diff --git a/Sources/RevoHttp/HttpResponse.swift b/Sources/RevoHttp/HttpResponse.swift index 1162069..d0bd538 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 T.decode(from: data) } catch { 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() From 3b5f16f7464d3bc4a719e9d2e31722f1e59f7fe2 Mon Sep 17 00:00:00 2001 From: Miquel Rodoreda Date: Thu, 27 Nov 2025 14:45:58 +0100 Subject: [PATCH 04/11] Refactor HttpFake to make it async --- Sources/RevoHttp/Fake/HttpFake.swift | 105 +++++++- .../RevoHttp/Fake/ThreadSafeContainer.swift | 41 +++ Sources/RevoHttp/Http.swift | 34 ++- Sources/RevoHttp/HttpRequest.swift | 2 +- Sources/RevoHttp/HttpStaticExtension.swift | 50 ++++ Tests/RevoHttpTests/HttpFakeAsyncTests.swift | 240 ++++++++++++++++++ 6 files changed, 469 insertions(+), 3 deletions(-) create mode 100644 Sources/RevoHttp/Fake/ThreadSafeContainer.swift create mode 100644 Tests/RevoHttpTests/HttpFakeAsyncTests.swift diff --git a/Sources/RevoHttp/Fake/HttpFake.swift b/Sources/RevoHttp/Fake/HttpFake.swift index 2231a73..5c0b981 100644 --- a/Sources/RevoHttp/Fake/HttpFake.swift +++ b/Sources/RevoHttp/Fake/HttpFake.swift @@ -1,5 +1,5 @@ import Foundation - +import RevoFoundation public class HttpFake : NSObject { @@ -76,3 +76,106 @@ public class HttpFake : NSObject { Self.responses[url] = concreteResponse } } + + +actor HttpFake2State { + var calls: [HttpRequest] = [] + var responses: [String: HttpResponse] = [:] + var globalResponses: [HttpResponse] = [] + + func reset() { + calls = [] + responses = [:] + globalResponses = [] + } + + func addCall(_ request: HttpRequest) { + calls.append(request) + } + + func getResponse(for url: String) -> HttpResponse? { + responses[url] + } + + func getGlobalResponse() -> HttpResponse? { + if globalResponses.count == 1 { + return globalResponses.first + } + return globalResponses.pop() + } + + func addResponse(_ response: HttpResponse, for url: String?) { + if let url { + responses[url] = response + } else { + globalResponses.append(response) + } + } +} + +public class HttpFakeAsync : Http, @unchecked Sendable { + + public var calls: [HttpRequest] { + get async { + await state.calls + } + } + + public var globalResponses: [HttpResponse] { + get async { + await state.globalResponses + } + } + + public var responses: [String: HttpResponse] { + get async { + await state.responses + } + } + + private let state = HttpFake2State() + var swizzled = false + + public func enable() async { + await state.reset() + guard !swizzled else { return } + + await ThreadSafeContainer.shared.bind(instance: Http.self, self) + swizzled = true + } + + public func disable() async { + guard swizzled else { return } + await state.reset() + + // Unbind from ThreadSafeContainer to allow other tests to bind their own instances + await ThreadSafeContainer.shared.unbind(Http.self) + swizzled = false + } + + @discardableResult + public override func call(_ 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! response.encode() + let httpResponse = HTTPURLResponse(url: URL(string:"http://fakeUrl.com")!, statusCode: status, httpVersion: "1.0", headerFields: nil) + 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.swift b/Sources/RevoHttp/Http.swift index 1e3ae28..868785c 100644 --- a/Sources/RevoHttp/Http.swift +++ b/Sources/RevoHttp/Http.swift @@ -1,7 +1,7 @@ import Foundation import RevoFoundation -public class Http : NSObject { +public class Http : NSObject, Resolvable, @unchecked Sendable { nonisolated(unsafe) public static var debugMode = false var insecureUrlSession:InsecureUrlSession? @@ -17,6 +17,8 @@ public class Http : NSObject { let privateKey:String } + override public required init() {} + //MARK: - Call 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) @@ -170,6 +172,36 @@ public class Http : NSObject { extension Http { + public func call(_ method:HttpRequest.Method, url:String, params:[String:Codable] = [:], headers:[String:String] = [:]) async -> HttpResponse { + await call(HttpRequest(method: method, url: url, params: params, headers: headers)) + } + + 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 + return await call(request) + } + + public func call(_ method:HttpRequest.Method, _ url:String, json:Z, headers:[String:String] = [:]) async -> HttpResponse { + let request = HttpRequest(method: method, url: url, headers: headers) + + guard let data = try? JSONEncoder().encode(json) else { + return HttpResponse(failed: "Request not Encodable") + } + guard let body = String(data:data, encoding: .utf8) else { + return HttpResponse(failed: "Can't encode request data to string") + } + request.body = body + + return await call(request) + } + + 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, params:[String:Codable] = [:], headers:[String:String] = [:]) async throws(HttpError) -> T { let response = await call(HttpRequest(method: method, url: url, params:params, headers: headers)) print(response.toString) diff --git a/Sources/RevoHttp/HttpRequest.swift b/Sources/RevoHttp/HttpRequest.swift index 3f92804..771aa21 100644 --- a/Sources/RevoHttp/HttpRequest.swift +++ b/Sources/RevoHttp/HttpRequest.swift @@ -1,7 +1,7 @@ import Foundation import RevoFoundation -public class HttpRequest : NSObject { +public class HttpRequest : NSObject, @unchecked Sendable { public enum Method { case get, post, patch, put, delete diff --git a/Sources/RevoHttp/HttpStaticExtension.swift b/Sources/RevoHttp/HttpStaticExtension.swift index d8b0dc2..1f5904c 100644 --- a/Sources/RevoHttp/HttpStaticExtension.swift +++ b/Sources/RevoHttp/HttpStaticExtension.swift @@ -1,5 +1,6 @@ import Foundation +import RevoFoundation extension Http { @@ -50,3 +51,52 @@ extension Http { Http().call(request, then:then) } } + +extension Http { + private static func httpInstance() async -> Http { + await ThreadSafeContainer.shared.resolve(Http.self)! + } + + 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, 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) + } + + @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] = [:]) async -> HttpResponse { + await httpInstance().call(HttpRequest(method: .get, url: url, params: params, headers: headers)) + } + + public static func post(_ url:String, params:[String:Codable] = [:], headers:[String:String] = [:]) async -> HttpResponse { + await httpInstance().call(HttpRequest(method: .post, url: url, params: params, 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 = body + return await httpInstance().call(request) + } + + public static func put(_ url:String, params:[String:Codable] = [:], headers:[String:String] = [:]) async -> HttpResponse { + await httpInstance().call(HttpRequest(method: .put, url: url, params: params, headers: headers)) + } + + public static func patch(_ url:String, params:[String:Codable] = [:], headers:[String:String] = [:]) async -> HttpResponse { + await httpInstance().call(HttpRequest(method: .patch, url: url, params: params, headers: headers)) + } + + public static func delete(_ url:String, params:[String:Codable] = [:], headers:[String:String] = [:]) async -> HttpResponse { + await httpInstance().call(HttpRequest(method: .delete, url: url, params: params, headers: headers)) + } +} diff --git a/Tests/RevoHttpTests/HttpFakeAsyncTests.swift b/Tests/RevoHttpTests/HttpFakeAsyncTests.swift new file mode 100644 index 0000000..40011de --- /dev/null +++ b/Tests/RevoHttpTests/HttpFakeAsyncTests.swift @@ -0,0 +1,240 @@ +import Foundation +import Testing +@testable import RevoHttp + +@Suite(.serialized) // HttpFakeAsync instances are not isolated !! +struct HttpFakeAsyncTests { + + @Test("HttpFakeAsync can be enabled and disabled safely") + func testEnableDisable() async throws { + let fake = HttpFakeAsync() + + await fake.enable() + await fake.disable() + await fake.disable() + await fake.enable() + await fake.enable() + await fake.disable() + } + + @Test("HttpFakeAsync resets state when enabled") + func testResetOnEnable() async throws { + let fake = HttpFakeAsync() + await fake.enable() + + // 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("HttpFakeAsync resets state when disabled") + func testResetOnDisable() async throws { + let fake = HttpFakeAsync() + await fake.enable() + + // 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("HttpFakeAsync tracks calls correctly") + func testCallTracking() async throws { + let fake = HttpFakeAsync() + await fake.enable() + + 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("HttpFakeAsync returns URL-specific response when available") + func testUrlSpecificResponse() async throws { + let fake = HttpFakeAsync() + await fake.enable() + + 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("HttpFakeAsync returns global response when no URL-specific response") + func testGlobalResponse() async throws { + let fake = HttpFakeAsync() + await fake.enable() + + 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("HttpFakeAsync reuses single global response") + func testSingleGlobalResponseReuse() async throws { + let fake = HttpFakeAsync() + await fake.enable() + + 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("HttpFakeAsync returns empty response when no responses configured") + func testEmptyResponseWhenNoResponses() async throws { + let fake = HttpFakeAsync() + await fake.enable() + + 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("HttpFakeAsync can add encoded responses") + func testEncodedResponse() async throws { + struct TestResponse: Codable { + let name: String + let value: Int + } + + let fake = HttpFakeAsync() + await fake.enable() + + 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("HttpFakeAsync can add URL-specific encoded responses") + func testUrlSpecificEncodedResponse() async throws { + struct TestResponse: Codable { + let id: Int + } + + let fake = HttpFakeAsync() + await fake.enable() + + 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("HttpFakeAsync can handle custom status codes") + func testCustomStatusCodes() async throws { + let fake = HttpFakeAsync() + await fake.enable() + + 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("HttpFakeAsync can be safely used in parallel test scenarios") + func testParallelUsage() async throws { + let fake = HttpFakeAsync() + await fake.enable() + + // 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) + } +} + From 770c5ccf53a717cd30069012a17af256f5ccfdf0 Mon Sep 17 00:00:00 2001 From: Miquel Rodoreda Date: Mon, 1 Dec 2025 10:44:12 +0100 Subject: [PATCH 05/11] Add tests for everything --- Sources/RevoHttp/{ => Enums}/HttpError.swift | 0 Sources/RevoHttp/Enums/HttpOption.swift | 8 + Sources/RevoHttp/Fake/HttpFake.swift | 87 +------ Sources/RevoHttp/Http.swift | 180 ++----------- Sources/RevoHttp/HttpRequest.swift | 2 +- Sources/RevoHttp/HttpStaticExtension.swift | 63 +---- Sources/RevoHttp/MultipartHttpRequest.swift | 2 +- TestPlan.xctestplan | 24 ++ Tests/RevoHttpTests/HttpEdgeCasesTests.swift | 40 +++ .../HttpErrorHandlingTests.swift | 22 ++ ...keAsyncTests.swift => HttpFakeTests.swift} | 52 ++-- Tests/RevoHttpTests/HttpMethodsTests.swift | 148 +++++++++++ Tests/RevoHttpTests/HttpOptionsTests.swift | 51 ++++ Tests/RevoHttpTests/HttpRequestTests.swift | 116 +++++++++ Tests/RevoHttpTests/HttpResponseTests.swift | 98 ++++++++ Tests/RevoHttpTests/HttpStaticTests.swift | 74 ++++++ Tests/RevoHttpTests/RevoHttpTests.swift | 236 ------------------ 17 files changed, 653 insertions(+), 550 deletions(-) rename Sources/RevoHttp/{ => Enums}/HttpError.swift (100%) create mode 100644 Sources/RevoHttp/Enums/HttpOption.swift create mode 100644 TestPlan.xctestplan create mode 100644 Tests/RevoHttpTests/HttpEdgeCasesTests.swift create mode 100644 Tests/RevoHttpTests/HttpErrorHandlingTests.swift rename Tests/RevoHttpTests/{HttpFakeAsyncTests.swift => HttpFakeTests.swift} (85%) create mode 100644 Tests/RevoHttpTests/HttpMethodsTests.swift create mode 100644 Tests/RevoHttpTests/HttpOptionsTests.swift create mode 100644 Tests/RevoHttpTests/HttpRequestTests.swift create mode 100644 Tests/RevoHttpTests/HttpResponseTests.swift create mode 100644 Tests/RevoHttpTests/HttpStaticTests.swift delete mode 100644 Tests/RevoHttpTests/RevoHttpTests.swift diff --git a/Sources/RevoHttp/HttpError.swift b/Sources/RevoHttp/Enums/HttpError.swift similarity index 100% rename from Sources/RevoHttp/HttpError.swift rename to Sources/RevoHttp/Enums/HttpError.swift 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 5c0b981..e090567 100644 --- a/Sources/RevoHttp/Fake/HttpFake.swift +++ b/Sources/RevoHttp/Fake/HttpFake.swift @@ -1,84 +1,7 @@ import Foundation import RevoFoundation -public class HttpFake : NSObject { - - public static var calls:[HttpRequest] = [] - static var responses:[String:HttpResponse] = [:] - static var globalResponses:[HttpResponse] = [] - - static var swizzled = false - - 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 - } - - public static func disable(){ - if (!swizzled) { return } - swizzled = false - Self.enable() - swizzled = false - } - - @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) - } - - if (Self.globalResponses.count == 1) { - return then(Self.globalResponses.first!) - } - - if let toRespond = Self.globalResponses.pop() { - return then(toRespond) - } - - 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 static func addResponse(encoded response:T, status:Int = 200) { - let data = try! response.encode() - 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) - } - - 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 static func addResponse(for url:String, encoded response:T, status:Int = 200) { - let data = try! response.encode() - 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 - } -} - - -actor HttpFake2State { +actor HttpFakeState { var calls: [HttpRequest] = [] var responses: [String: HttpResponse] = [:] var globalResponses: [HttpResponse] = [] @@ -113,7 +36,7 @@ actor HttpFake2State { } } -public class HttpFakeAsync : Http, @unchecked Sendable { +public class HttpFake : Http, @unchecked Sendable { public var calls: [HttpRequest] { get async { @@ -133,7 +56,7 @@ public class HttpFakeAsync : Http, @unchecked Sendable { } } - private let state = HttpFake2State() + private let state = HttpFakeState() var swizzled = false public func enable() async { @@ -148,13 +71,11 @@ public class HttpFakeAsync : Http, @unchecked Sendable { guard swizzled else { return } await state.reset() - // Unbind from ThreadSafeContainer to allow other tests to bind their own instances await ThreadSafeContainer.shared.unbind(Http.self) swizzled = false } - @discardableResult - public override func call(_ request:HttpRequest) async -> HttpResponse { + public override func makeCall(_ request:HttpRequest) async -> HttpResponse { await state.addCall(request) if let urlResponse = await state.getResponse(for: request.url) { diff --git a/Sources/RevoHttp/Http.swift b/Sources/RevoHttp/Http.swift index 868785c..4dcdcd2 100644 --- a/Sources/RevoHttp/Http.swift +++ b/Sources/RevoHttp/Http.swift @@ -20,158 +20,6 @@ public class Http : NSObject, Resolvable, @unchecked Sendable { override public required init() {} //MARK: - Call - 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, body:String, headers:[String:String] = [:], then:@escaping(_ response:HttpResponse) -> Void) { - let request = HttpRequest(method: method, url: url, headers: headers) - request.body = body - call(request, then:then) - } - - public func call(_ method:HttpRequest.Method, _ url:String, json:Z, headers:[String:String] = [:], then:@escaping(_ response:HttpResponse) -> Void) { - let request = HttpRequest(method: method, url: url, headers: headers) - - 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] = [:], then:@escaping(_ response:T?, _ error:String?) -> Void) { - let request = HttpRequest(method: method, url: url, headers: headers) - - 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") - } - request.body = body - - call(request) { response in - let result:T? = response.decoded() - then(result, response.error?.localizedDescription) - } - } - - 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) - } - } - - 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 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, body:String, headers:[String:String] = [:], then:@escaping(_ response:HttpResponse) -> Void) { - let request = HttpRequest(method: .post, url: url, headers: headers) - request.body = body - call(request, then:then) - } - - 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 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 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) - } - - @objc dynamic public func call(_ request:HttpRequest, then:@escaping(_ response:HttpResponse)->Void) { - debugIfNeeded(request) - - if let hmac = hmac { - if let hash = request.buildBody().hmac256(hmac.privateKey) { - request.headers[hmac.header] = hash - } - } - - 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) { - debugIfNeeded(request) - - 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)) - } - } - 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()) - } -} - - -extension Http { public func call(_ method:HttpRequest.Method, url:String, params:[String:Codable] = [:], headers:[String:String] = [:]) async -> HttpResponse { await call(HttpRequest(method: method, url: url, params: params, headers: headers)) } @@ -211,7 +59,7 @@ extension Http { return result } - @objc dynamic public func call(_ request:HttpRequest) async -> HttpResponse { + public func call(_ request:HttpRequest) async -> HttpResponse { debugIfNeeded(request) if let hmac, let hash = request.buildBody().hmac256(hmac.privateKey) { @@ -222,6 +70,10 @@ extension Http { request.timeout = TimeInterval(timeout) } + return await makeCall(request) + } + + @objc dynamic public func makeCall(_ request:HttpRequest) async -> HttpResponse { guard let urlRequest = request.generate() else { return HttpResponse(failed: "Invalid URL") } @@ -274,4 +126,26 @@ extension Http { public func delete(_ url:String, params:[String:Codable] = [:], headers:[String:String] = [:]) async -> HttpResponse { await call(HttpRequest(method: .delete, url: url, params: params, headers: headers)) } + + 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 + } + } + return self + } + + private func debugIfNeeded(_ request: HttpRequest) { + guard Self.debugMode else { return } + debugPrint("****** HTTP DEBUG ****** " + request.toCurl()) + } } diff --git a/Sources/RevoHttp/HttpRequest.swift b/Sources/RevoHttp/HttpRequest.swift index 771aa21..f393c19 100644 --- a/Sources/RevoHttp/HttpRequest.swift +++ b/Sources/RevoHttp/HttpRequest.swift @@ -51,7 +51,7 @@ public class HttpRequest : NSObject, @unchecked Sendable { } - private func buildUrl() -> String { + func buildUrl() -> String { url + "?" + buildBody(true) } diff --git a/Sources/RevoHttp/HttpStaticExtension.swift b/Sources/RevoHttp/HttpStaticExtension.swift index 1f5904c..7c5a494 100644 --- a/Sources/RevoHttp/HttpStaticExtension.swift +++ b/Sources/RevoHttp/HttpStaticExtension.swift @@ -2,56 +2,6 @@ import Foundation import RevoFoundation -extension Http { - - 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] = [:], 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] = [:], then:@escaping(_ response:HttpResponse) -> Void) { - Http().call(method, url, json:json, headers:headers, then:then) - } - - 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) - } - - 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) - } - - 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) - } - - public static func post(_ url:String, body:String, headers:[String:String] = [:], then:@escaping(_ response:HttpResponse) -> Void) { - let request = HttpRequest(method: .post, url: url, headers: headers) - request.body = body - Http().call(request, then:then) - } - - 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) - } - - 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 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) - } -} - extension Http { private static func httpInstance() async -> Http { await ThreadSafeContainer.shared.resolve(Http.self)! @@ -69,6 +19,14 @@ extension Http { await httpInstance().call(method, url, json:json, headers:headers) } + 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, params:[String:Codable] = [:], headers:[String:String] = [:]) async throws(HttpError) -> T { + try await httpInstance().call(method, url, params:params, headers:headers) + } + @discardableResult public static func call(_ request:HttpRequest) async -> HttpResponse { await httpInstance().call(request) @@ -99,4 +57,9 @@ extension Http { public static func delete(_ url:String, params:[String:Codable] = [:], headers:[String:String] = [:]) async -> HttpResponse { await httpInstance().call(HttpRequest(method: .delete, url: url, params: params, headers: headers)) } + + 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/MultipartHttpRequest.swift b/Sources/RevoHttp/MultipartHttpRequest.swift index 7bfd818..440292b 100644 --- a/Sources/RevoHttp/MultipartHttpRequest.swift +++ b/Sources/RevoHttp/MultipartHttpRequest.swift @@ -1,7 +1,7 @@ import Foundation import UIKit -public class MultipartHttpRequest : HttpRequest { +public class MultipartHttpRequest : HttpRequest, @unchecked Sendable { var paramName:String? var fileName:String? diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan new file mode 100644 index 0000000..5c2cd5b --- /dev/null +++ b/TestPlan.xctestplan @@ -0,0 +1,24 @@ +{ + "configurations" : [ + { + "id" : "A69C2F0A-7A1A-40D5-8004-F3D3451EE8B9", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "testTimeoutsEnabled" : true + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:", + "identifier" : "RevoHttpTests", + "name" : "RevoHttpTests" + } + } + ], + "version" : 1 +} diff --git a/Tests/RevoHttpTests/HttpEdgeCasesTests.swift b/Tests/RevoHttpTests/HttpEdgeCasesTests.swift new file mode 100644 index 0000000..cec6415 --- /dev/null +++ b/Tests/RevoHttpTests/HttpEdgeCasesTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing +@testable import RevoHttp + +@Suite() +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", params: [:], 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/HttpErrorHandlingTests.swift b/Tests/RevoHttpTests/HttpErrorHandlingTests.swift new file mode 100644 index 0000000..acad136 --- /dev/null +++ b/Tests/RevoHttpTests/HttpErrorHandlingTests.swift @@ -0,0 +1,22 @@ +import Foundation +import Testing +@testable import RevoHttp + +@Suite() +struct HttpErrorHandlingTests { + + @Test("Can handle invalid URL") + func testCanHandleInvalidUrl() async throws { + let response = await Http.get("not-a-valid-url", params: [:]) + + #expect(response.error != nil) + #expect(response.toString.contains("Error")) + } + + @Test("Can handle encoding error for non-encodable JSON") + func testCanHandleEncodingError() async throws { + let response = await Http.call(.post, "https://httpbin.org/post", json: ["test": "value"] as [String: String]) + #expect(response.error == nil || response.toString.contains("Error")) + } +} + diff --git a/Tests/RevoHttpTests/HttpFakeAsyncTests.swift b/Tests/RevoHttpTests/HttpFakeTests.swift similarity index 85% rename from Tests/RevoHttpTests/HttpFakeAsyncTests.swift rename to Tests/RevoHttpTests/HttpFakeTests.swift index 40011de..dcce724 100644 --- a/Tests/RevoHttpTests/HttpFakeAsyncTests.swift +++ b/Tests/RevoHttpTests/HttpFakeTests.swift @@ -2,12 +2,12 @@ import Foundation import Testing @testable import RevoHttp -@Suite(.serialized) // HttpFakeAsync instances are not isolated !! -struct HttpFakeAsyncTests { +@Suite(.serialized) // HttpFake instances are not isolated !! +struct HttpFakeTests { - @Test("HttpFakeAsync can be enabled and disabled safely") + @Test("HttpFake can be enabled and disabled safely") func testEnableDisable() async throws { - let fake = HttpFakeAsync() + let fake = HttpFake() await fake.enable() await fake.disable() @@ -17,9 +17,9 @@ struct HttpFakeAsyncTests { await fake.disable() } - @Test("HttpFakeAsync resets state when enabled") + @Test("HttpFake resets state when enabled") func testResetOnEnable() async throws { - let fake = HttpFakeAsync() + let fake = HttpFake() await fake.enable() // Add some responses and make calls @@ -44,9 +44,9 @@ struct HttpFakeAsyncTests { #expect(await fake.responses.count == 0) } - @Test("HttpFakeAsync resets state when disabled") + @Test("HttpFake resets state when disabled") func testResetOnDisable() async throws { - let fake = HttpFakeAsync() + let fake = HttpFake() await fake.enable() // Add some responses and make calls @@ -71,9 +71,9 @@ struct HttpFakeAsyncTests { #expect(await fake.responses.count == 0) } - @Test("HttpFakeAsync tracks calls correctly") + @Test("HttpFake tracks calls correctly") func testCallTracking() async throws { - let fake = HttpFakeAsync() + let fake = HttpFake() await fake.enable() let request1 = HttpRequest(method: .get, url: "https://example.com/1") @@ -87,9 +87,9 @@ struct HttpFakeAsyncTests { #expect(await fake.calls[1].url == "https://example.com/2") } - @Test("HttpFakeAsync returns URL-specific response when available") + @Test("HttpFake returns URL-specific response when available") func testUrlSpecificResponse() async throws { - let fake = HttpFakeAsync() + let fake = HttpFake() await fake.enable() await fake.addResponse(for: "https://example.com/specific", "specific response") @@ -101,9 +101,9 @@ struct HttpFakeAsyncTests { #expect(specificResponse == "specific response") } - @Test("HttpFakeAsync returns global response when no URL-specific response") + @Test("HttpFake returns global response when no URL-specific response") func testGlobalResponse() async throws { - let fake = HttpFakeAsync() + let fake = HttpFake() await fake.enable() await fake.addResponse("first global") @@ -118,9 +118,9 @@ struct HttpFakeAsyncTests { #expect(response2 == "second global") } - @Test("HttpFakeAsync reuses single global response") + @Test("HttpFake reuses single global response") func testSingleGlobalResponseReuse() async throws { - let fake = HttpFakeAsync() + let fake = HttpFake() await fake.enable() await fake.addResponse("single response") @@ -135,9 +135,9 @@ struct HttpFakeAsyncTests { #expect(response2 == "single response") } - @Test("HttpFakeAsync returns empty response when no responses configured") + @Test("HttpFake returns empty response when no responses configured") func testEmptyResponseWhenNoResponses() async throws { - let fake = HttpFakeAsync() + let fake = HttpFake() await fake.enable() let request = HttpRequest(method: .get, url: "https://example.com") @@ -149,14 +149,14 @@ struct HttpFakeAsyncTests { #expect(response.error == nil) } - @Test("HttpFakeAsync can add encoded responses") + @Test("HttpFake can add encoded responses") func testEncodedResponse() async throws { struct TestResponse: Codable { let name: String let value: Int } - let fake = HttpFakeAsync() + let fake = HttpFake() await fake.enable() let testData = TestResponse(name: "test", value: 42) @@ -169,13 +169,13 @@ struct HttpFakeAsyncTests { #expect(decodedResponse.value == 42) } - @Test("HttpFakeAsync can add URL-specific encoded responses") + @Test("HttpFake can add URL-specific encoded responses") func testUrlSpecificEncodedResponse() async throws { struct TestResponse: Codable { let id: Int } - let fake = HttpFakeAsync() + let fake = HttpFake() await fake.enable() await fake.addResponse(for: "https://api.example.com/user/1", encoded: TestResponse(id: 1)) @@ -191,9 +191,9 @@ struct HttpFakeAsyncTests { #expect(response2.id == 2) } - @Test("HttpFakeAsync can handle custom status codes") + @Test("HttpFake can handle custom status codes") func testCustomStatusCodes() async throws { - let fake = HttpFakeAsync() + let fake = HttpFake() await fake.enable() await fake.addResponse(for: "https://example.com/404", "not found", status: 404) @@ -209,9 +209,9 @@ struct HttpFakeAsyncTests { #expect(status2 == 500) } - @Test("HttpFakeAsync can be safely used in parallel test scenarios") + @Test("HttpFake can be safely used in parallel test scenarios") func testParallelUsage() async throws { - let fake = HttpFakeAsync() + let fake = HttpFake() await fake.enable() // Add enough responses for concurrent calls diff --git a/Tests/RevoHttpTests/HttpMethodsTests.swift b/Tests/RevoHttpTests/HttpMethodsTests.swift new file mode 100644 index 0000000..eac8625 --- /dev/null +++ b/Tests/RevoHttpTests/HttpMethodsTests.swift @@ -0,0 +1,148 @@ +import Foundation +import Testing +@testable import RevoHttp + +@Suite() +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", params: ["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", params: ["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 form: [String: String] + let url: String + } + + let response = await Http.put("https://httpbin.org/put", params: ["name": "Jordi"]) + + let json: HttpBinResponse = try #require(response.decoded()) + #expect(json.form["name"] == "Jordi") + #expect(json.url == "https://httpbin.org/put") + } + + @Test("Can perform PATCH request") + func testCanPatch() async throws { + struct HttpBinResponse: Codable { + let form: [String: String] + let url: String + } + + let response = await Http.patch("https://httpbin.org/patch", params: ["name": "Jordi"]) + + let json: HttpBinResponse = try #require(response.decoded()) + #expect(json.form["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", params: ["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", params: ["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", params: ["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..fb00d78 --- /dev/null +++ b/Tests/RevoHttpTests/HttpOptionsTests.swift @@ -0,0 +1,51 @@ +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", params: ["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 { + let fake = HttpFake() + await fake.enable() + let _ = await Http.withOptions(.timeout(seconds: 10)).get("https://httpbin.org/get", params: [:]) + + let request = try #require(await fake.calls.first) + #expect(request.timeout == 10.0) + } + + @Test("Can combine multiple options") + func testCanCombineMultipleOptions() async throws { + let fake = HttpFake() + await fake.enable() + + let _ = await Http.withOptions( + .timeout(seconds: 10), + .hmacSHA256(header: "X-Auth", privateKey: "key") + ).get("https://httpbin.org/get", params: ["test": "value"]) + + let request = try #require(await fake.calls.first) + #expect(request.timeout == 10.0) + // Verify HMAC header was added by setOptions + #expect(request.headers["X-Auth"] != nil) + } +} + diff --git a/Tests/RevoHttpTests/HttpRequestTests.swift b/Tests/RevoHttpTests/HttpRequestTests.swift new file mode 100644 index 0000000..dda9923 --- /dev/null +++ b/Tests/RevoHttpTests/HttpRequestTests.swift @@ -0,0 +1,116 @@ +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", params: ["name": "Jordi", "lastName": "Puigdellívol"], headers: ["X-Header": "Value1", "X-Header2": "Value2"]) + + let result = request.toCurl() + #expect(result == "curl -d \"lastName=Puigdellívol&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", params: ["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", params: ["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 = "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() { + let nestedParams = [ + "user": [ + "name": "Jordi", + "age": 30 + ] + ] + + let request = HttpRequest(method: .post, url: "https://httpbin.org/post", params: nestedParams) + let body = request.buildBody() + + #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", params: [:]) + let body = request.buildBody() + + #expect(body.isEmpty) + } + + @Test("Can handle special characters in parameters") + func testCanHandleSpecialCharactersInParameters() { + let request = HttpRequest(method: .get, url: "https://httpbin.org/get", params: ["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() { + let request = HttpRequest(method: .post, url: "https://httpbin.org/post", params: ["nullValue": NSNull()]) + let body = request.buildBody() + + // 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", params: ["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", params: ["param1": "value1"]) + request.body = "body=value" + + let urlRequest = request.generate() + let bodyString = String(data: urlRequest!.httpBody!, encoding: .utf8) + #expect(bodyString == "body=value") + } +} + 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..fa814a3 --- /dev/null +++ b/Tests/RevoHttpTests/HttpStaticTests.swift @@ -0,0 +1,74 @@ +import Foundation +import Testing +@testable import RevoHttp + +@Suite() +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", params: ["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 form: [String: String] + let url: String + } + + let response = await Http.put("https://httpbin.org/put", params: ["name": "Jordi"]) + + let json: HttpBinResponse = try #require(response.decoded()) + #expect(json.form["name"] == "Jordi") + } + + @Test("Can use static PATCH method") + func testCanUseStaticPatchMethod() async throws { + struct HttpBinResponse: Codable { + let form: [String: String] + let url: String + } + + let response = await Http.patch("https://httpbin.org/patch", params: ["name": "Jordi"]) + + let json: HttpBinResponse = try #require(response.decoded()) + #expect(json.form["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", params: ["name": "Jordi"]) + + let json: HttpBinResponse = try #require(response.decoded()) + #expect(json.args["name"] == "Jordi") + } +} + diff --git a/Tests/RevoHttpTests/RevoHttpTests.swift b/Tests/RevoHttpTests/RevoHttpTests.swift deleted file mode 100644 index 0a2dd6b..0000000 --- a/Tests/RevoHttpTests/RevoHttpTests.swift +++ /dev/null @@ -1,236 +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() - XCTAssertEqual("curl -d \"lastName=Puigdellívol&name=Jordi\" -H \"X-Header: Value1\" -H \"X-Header2: Value2\" -X GET https://httpbin.org/get", result) - } - - 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 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() - } - } - -} From 2ac11a5e09dfec8afa76ca3575e0b561998cab76 Mon Sep 17 00:00:00 2001 From: Miquel Rodoreda Date: Mon, 1 Dec 2025 11:50:43 +0100 Subject: [PATCH 06/11] Remove the RevoFoundation dependency --- Package.swift | 8 -------- Podfile | 1 - Podfile.lock | 15 +-------------- Sources/RevoHttp/Fake/HttpFake.swift | 10 +++++----- Sources/RevoHttp/Http.swift | 16 ++++++++++++++-- Sources/RevoHttp/HttpRequest.swift | 21 ++++++++++----------- Sources/RevoHttp/HttpResponse.swift | 2 +- Sources/RevoHttp/HttpStaticExtension.swift | 1 - 8 files changed, 31 insertions(+), 43 deletions(-) diff --git a/Package.swift b/Package.swift index 9bdae4f..e0bb9a1 100644 --- a/Package.swift +++ b/Package.swift @@ -13,17 +13,9 @@ let package = Package( targets: ["RevoHttp"] ) ], - dependencies: [ - .package( - url: "https://github.com/revosystems/foundation", .upToNextMinor(from: "0.3.1") - ), - ], targets: [ .target( name: "RevoHttp", - dependencies: [ - .product(name: "RevoFoundation", package: "foundation") - ], swiftSettings: [ .enableUpcomingFeature("SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES"), .enableUpcomingFeature("SWIFT_UPCOMING_FEATURE_DYNAMIC_ACTOR_ISOLATION"), 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/Fake/HttpFake.swift b/Sources/RevoHttp/Fake/HttpFake.swift index e090567..1887374 100644 --- a/Sources/RevoHttp/Fake/HttpFake.swift +++ b/Sources/RevoHttp/Fake/HttpFake.swift @@ -1,5 +1,4 @@ import Foundation -import RevoFoundation actor HttpFakeState { var calls: [HttpRequest] = [] @@ -21,10 +20,11 @@ actor HttpFakeState { } func getGlobalResponse() -> HttpResponse? { - if globalResponses.count == 1 { - return globalResponses.first + guard let first = globalResponses.first else { return nil } + if globalResponses.count > 1 { + globalResponses.removeFirst() } - return globalResponses.pop() + return first } func addResponse(_ response: HttpResponse, for url: String?) { @@ -94,7 +94,7 @@ public class HttpFake : Http, @unchecked Sendable { } public func addResponse(for url:String? = nil, encoded response:T, status:Int = 200) async { - let data = try! response.encode() + let data = try! JSONEncoder().encode(response) let httpResponse = HTTPURLResponse(url: URL(string:"http://fakeUrl.com")!, statusCode: status, httpVersion: "1.0", headerFields: nil) let httpResponseObj = HttpResponse(data:data, response:httpResponse , error: nil) await state.addResponse(httpResponseObj, for: url) diff --git a/Sources/RevoHttp/Http.swift b/Sources/RevoHttp/Http.swift index 4dcdcd2..1c831dc 100644 --- a/Sources/RevoHttp/Http.swift +++ b/Sources/RevoHttp/Http.swift @@ -1,5 +1,4 @@ import Foundation -import RevoFoundation public class Http : NSObject, Resolvable, @unchecked Sendable { @@ -89,7 +88,7 @@ public class Http : NSObject, Resolvable, @unchecked Sendable { @objc dynamic public func callMultipart(_ request:MultipartHttpRequest) async -> HttpResponse { debugIfNeeded(request) - guard let urlRequest = request.generate() else { + guard let urlRequest = request.generate() else { return HttpResponse(failed: "Invalid URL") } @@ -149,3 +148,16 @@ public class Http : NSObject, Resolvable, @unchecked Sendable { debugPrint("****** HTTP DEBUG ****** " + request.toCurl()) } } + + +import CryptoKit +extension String { + func hmac256(_ key:String) -> String? { + guard let messageData = self.data(using: .utf8), let keyData = key.data(using: .utf8) else { + return nil + } + + let code = HMAC.authenticationCode(for: messageData, using: SymmetricKey(data: keyData)) + return Data(code).map { String(format: "%02hhx", $0) }.joined() + } +} diff --git a/Sources/RevoHttp/HttpRequest.swift b/Sources/RevoHttp/HttpRequest.swift index f393c19..f95ab21 100644 --- a/Sources/RevoHttp/HttpRequest.swift +++ b/Sources/RevoHttp/HttpRequest.swift @@ -1,5 +1,4 @@ import Foundation -import RevoFoundation public class HttpRequest : NSObject, @unchecked Sendable { @@ -45,9 +44,9 @@ public class HttpRequest : NSObject, @unchecked Sendable { } func buildBody(_ encoded:Bool = false) -> String { - return params.map { param in - param.encoded(urlEncoded: encoded) - }.implode("&") + params.map { + $0.encoded(urlEncoded: encoded) + }.joined(separator: "&") } @@ -64,21 +63,21 @@ public class HttpRequest : NSObject, @unchecked Sendable { public func toCurl() -> String { var result = "curl " - let p = params.map { param in - param.encoded() - }.implode("&") + let p = params.map { + $0.encoded() + }.joined(separator: "&") if (p.count > 0) { - result = result + "-d \"\(p)\"" + result += "-d \"\(p)\"" } let h = headers.keys.sorted().compactMap { key in guard let value = headers[key] else { return nil } return "-H \"\(key): \(value)\"" - }.implode(" ") + }.joined(separator: " ") if (h.count > 0){ - result = result + " \(h)" + result += " \(h)" } return result + " -X \(methodUppercased) \(url)" @@ -125,6 +124,6 @@ public struct HttpParam{ } public func encoded(urlEncoded:Bool = false) -> String { - "\(key)=\(urlEncoded ? value.urlEncoded() ?? "" : value)" + "\(key)=\(urlEncoded ? value.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "" : value)" } } diff --git a/Sources/RevoHttp/HttpResponse.swift b/Sources/RevoHttp/HttpResponse.swift index d0bd538..5a1ab58 100644 --- a/Sources/RevoHttp/HttpResponse.swift +++ b/Sources/RevoHttp/HttpResponse.swift @@ -39,7 +39,7 @@ public final class HttpResponse : NSObject, Sendable { public func decoded() -> T? { guard let data else { return nil } do { - return try T.decode(from: data) + return try JSONDecoder().decode(T.self, from: data) } catch { debugPrint("** Can't decode HttpResponse:" + error.localizedDescription) return nil diff --git a/Sources/RevoHttp/HttpStaticExtension.swift b/Sources/RevoHttp/HttpStaticExtension.swift index 7c5a494..132d67a 100644 --- a/Sources/RevoHttp/HttpStaticExtension.swift +++ b/Sources/RevoHttp/HttpStaticExtension.swift @@ -1,6 +1,5 @@ import Foundation -import RevoFoundation extension Http { private static func httpInstance() async -> Http { From 29f83637a06ec94fc2f1171bae3dce5205831ba4 Mon Sep 17 00:00:00 2001 From: Miquel Rodoreda Date: Thu, 12 Mar 2026 16:48:59 +0100 Subject: [PATCH 07/11] Get rid of the weird body property --- Sources/RevoHttp/Http.swift | 6 ++-- Sources/RevoHttp/HttpRequest.swift | 32 ++++++---------------- Sources/RevoHttp/HttpStaticExtension.swift | 2 +- Tests/RevoHttpTests/HttpRequestTests.swift | 4 +-- 4 files changed, 15 insertions(+), 29 deletions(-) diff --git a/Sources/RevoHttp/Http.swift b/Sources/RevoHttp/Http.swift index 900c5ab..7258ed3 100644 --- a/Sources/RevoHttp/Http.swift +++ b/Sources/RevoHttp/Http.swift @@ -22,13 +22,13 @@ public class Http : NSObject, Resolvable, @unchecked Sendable { 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 + request.body = .string(body) return await call(request) } 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 + request.body = .json(json) return await call(request) } @@ -99,7 +99,7 @@ public class Http : NSObject, Resolvable, @unchecked Sendable { public func post(_ url:String, body:String, headers:[String:String] = [:]) async -> HttpResponse { let request = HttpRequest(method: .post, url: url, headers: headers) - request.body = body + request.body = .string(body) return await call(request) } diff --git a/Sources/RevoHttp/HttpRequest.swift b/Sources/RevoHttp/HttpRequest.swift index e459e93..5f881d9 100644 --- a/Sources/RevoHttp/HttpRequest.swift +++ b/Sources/RevoHttp/HttpRequest.swift @@ -20,28 +20,14 @@ public class HttpRequest : NSObject, @unchecked Sendable { public var method: Method public var url: String public var queryParams: [HttpParam] - public var bodyStruct: BodyStruct? - public var headers: [String: String] - - public var body: Encodable? { // deprecated - get { - switch bodyStruct { - case .json(let string): string - case .string(let string): string - default: nil - } - } - set { - if let string = newValue as? String { - bodyStruct = .string(string) - } else if let newValue { + public var body: BodyStruct? { + didSet { + if case .json = body { headers["Content-Type"] = "application/json" - bodyStruct = .json(newValue) - } else { - bodyStruct = nil } } } + public var headers: [String: String] public var timeout: TimeInterval? @@ -55,7 +41,7 @@ public class HttpRequest : NSObject, @unchecked Sendable { self.method = method self.url = url self.queryParams = queryParams.createParams(nil) - self.bodyStruct = bodyStruct + self.body = bodyStruct self.headers = headers } @@ -101,7 +87,7 @@ public class HttpRequest : NSObject, @unchecked Sendable { 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?): try? JSONEncoder().encode(string) @@ -120,7 +106,7 @@ public class HttpRequest : NSObject, @unchecked Sendable { } public func withHmacHeader(_ hmac: Hmac) { - let payload = switch bodyStruct { + let payload = switch body { case .json(let encodable?): String(data: try! JSONEncoder().encode(encodable), encoding: .utf8)! case .string(let string?): string case .form: buildFormBody() ?? "" @@ -154,7 +140,7 @@ public class HttpRequest : NSObject, @unchecked Sendable { } func buildFormBody() -> String? { - guard case .form(let params?) = bodyStruct else { + guard case .form(let params?) = body else { return nil } @@ -172,7 +158,7 @@ public class HttpRequest : NSObject, @unchecked Sendable { var result = "curl " var parameters: [HttpParam] = [] - if case .form(let params?) = bodyStruct { + if case .form(let params?) = body { parameters = params } else { parameters = queryParams diff --git a/Sources/RevoHttp/HttpStaticExtension.swift b/Sources/RevoHttp/HttpStaticExtension.swift index 586872c..4bd86d0 100644 --- a/Sources/RevoHttp/HttpStaticExtension.swift +++ b/Sources/RevoHttp/HttpStaticExtension.swift @@ -42,7 +42,7 @@ extension Http { public static func post(_ url:String, body:String, headers:[String:String] = [:]) async -> HttpResponse { let request = HttpRequest(method: .post, url: url, headers: headers) - request.body = body + request.body = .string(body) return await httpInstance().call(request) } diff --git a/Tests/RevoHttpTests/HttpRequestTests.swift b/Tests/RevoHttpTests/HttpRequestTests.swift index c29c64f..f2cd173 100644 --- a/Tests/RevoHttpTests/HttpRequestTests.swift +++ b/Tests/RevoHttpTests/HttpRequestTests.swift @@ -43,7 +43,7 @@ struct HttpRequestTests { @Test("Can generate POST request with body") func testCanGeneratePostRequestWithBody() { let request = HttpRequest(method: .post, url: "https://httpbin.org/post") - request.body = "name=Jordi&age=30" + request.body = .string("name=Jordi&age=30") let urlRequest = request.generate() #expect(urlRequest?.httpMethod == "POST") @@ -106,7 +106,7 @@ struct HttpRequestTests { @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 = "body=value" + request.body = .string("body=value") let urlRequest = request.generate() let bodyString = String(data: urlRequest!.httpBody!, encoding: .utf8) From 7432ff9e9622dda332ee7c89843490ce718b8604 Mon Sep 17 00:00:00 2001 From: Miquel Rodoreda Date: Thu, 12 Mar 2026 18:22:32 +0100 Subject: [PATCH 08/11] Improve tests --- Package.swift | 2 +- Sources/RevoHttp/HttpRequest.swift | 7 +- Sources/RevoHttp/HttpStaticExtension.swift | 60 +++- TestPlan.xctestplan | 1 + .../HttpCallOverloadsTests.swift | 103 ++++++ .../HttpErrorHandlingTests.swift | 22 -- Tests/RevoHttpTests/HttpErrorTests.swift | 38 ++ Tests/RevoHttpTests/HttpFakeHelpers.swift | 31 ++ Tests/RevoHttpTests/HttpFakeTests.swift | 332 +++++++++--------- Tests/RevoHttpTests/HttpOptionsTests.swift | 32 +- Tests/RevoHttpTests/HttpRequestTests.swift | 68 ++++ Tests/RevoHttpTests/HttpStaticTests.swift | 12 + .../MultipartHttpRequestTests.swift | 85 +++++ 13 files changed, 562 insertions(+), 231 deletions(-) create mode 100644 Tests/RevoHttpTests/HttpCallOverloadsTests.swift delete mode 100644 Tests/RevoHttpTests/HttpErrorHandlingTests.swift create mode 100644 Tests/RevoHttpTests/HttpErrorTests.swift create mode 100644 Tests/RevoHttpTests/HttpFakeHelpers.swift create mode 100644 Tests/RevoHttpTests/MultipartHttpRequestTests.swift diff --git a/Package.swift b/Package.swift index e0bb9a1..7dc84e2 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.10 +// swift-tools-version:6.2 import PackageDescription let package = Package( diff --git a/Sources/RevoHttp/HttpRequest.swift b/Sources/RevoHttp/HttpRequest.swift index 5f881d9..58308a4 100644 --- a/Sources/RevoHttp/HttpRequest.swift +++ b/Sources/RevoHttp/HttpRequest.swift @@ -20,6 +20,7 @@ public class HttpRequest : NSObject, @unchecked Sendable { public var method: Method public var url: String public var queryParams: [HttpParam] + public var headers: [String: String] public var body: BodyStruct? { didSet { if case .json = body { @@ -27,7 +28,7 @@ public class HttpRequest : NSObject, @unchecked Sendable { } } } - public var headers: [String: String] + public var timeout: TimeInterval? @@ -89,8 +90,8 @@ public class HttpRequest : NSObject, @unchecked Sendable { request.httpBody = body.flatMap { body -> Data? in switch body { - case .json(let string?): - try? JSONEncoder().encode(string) + case .json(let encodable?): + try? JSONEncoder().encode(encodable) case .string(let string?): string.data(using: .utf8) case .form(let params?) where !params.isEmpty: diff --git a/Sources/RevoHttp/HttpStaticExtension.swift b/Sources/RevoHttp/HttpStaticExtension.swift index 4bd86d0..39ee042 100644 --- a/Sources/RevoHttp/HttpStaticExtension.swift +++ b/Sources/RevoHttp/HttpStaticExtension.swift @@ -6,6 +6,8 @@ extension Http { await ThreadSafeContainer.shared.resolve(Http.self)! } + // 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) } @@ -31,54 +33,80 @@ extension Http { await httpInstance().call(request) } - // MARK: Query params + + // 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)) } + + // 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, json:Z, headers:[String:String] = [:]) async -> HttpResponse { + let request = HttpRequest(method: .post, url: url, headers: headers) + request.body = .json(json) + return await httpInstance().call(request) + } + + + // 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 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 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 delete(_ url:String, queryParams:[String:Codable] = [:], headers:[String:String] = [:]) async -> HttpResponse { - await httpInstance().call(HttpRequest(method: .delete, url: url, queryParams: queryParams, 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: Form body - public static func get(_ url:String, form:[String:Codable], headers:[String:String] = [:]) async -> HttpResponse { - await httpInstance().call(HttpRequest(method: .get, url: url, form: form, 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)) - } + // MARK: PATCH methods - 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 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 delete(_ url:String, form:[String:Codable], headers:[String:String] = [:]) async -> HttpResponse { - await httpInstance().call(HttpRequest(method: .delete, url: url, form: form, headers: headers)) + 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) } + + // 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/TestPlan.xctestplan b/TestPlan.xctestplan index 5c2cd5b..803b5fe 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -13,6 +13,7 @@ }, "testTargets" : [ { + "parallelizable" : false, "target" : { "containerPath" : "container:", "identifier" : "RevoHttpTests", 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/HttpErrorHandlingTests.swift b/Tests/RevoHttpTests/HttpErrorHandlingTests.swift deleted file mode 100644 index ad4c67f..0000000 --- a/Tests/RevoHttpTests/HttpErrorHandlingTests.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Foundation -import Testing -@testable import RevoHttp - -@Suite(.serialized) -struct HttpErrorHandlingTests { - - @Test("Can handle invalid URL") - func testCanHandleInvalidUrl() async throws { - let response = await Http.get("not-a-valid-url", queryParams: [:]) - - #expect(response.error != nil) - #expect(response.toString.contains("Error")) - } - - @Test("Can handle encoding error for non-encodable JSON") - func testCanHandleEncodingError() async throws { - let response = await Http.call(.post, "https://httpbin.org/post", json: ["test": "value"] as [String: String]) - #expect(response.error == nil || response.toString.contains("Error")) - } -} - 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 index dcce724..b21e1b0 100644 --- a/Tests/RevoHttpTests/HttpFakeTests.swift +++ b/Tests/RevoHttpTests/HttpFakeTests.swift @@ -7,146 +7,139 @@ struct HttpFakeTests { @Test("HttpFake can be enabled and disabled safely") func testEnableDisable() async throws { - let fake = HttpFake() - - await fake.enable() - await fake.disable() - await fake.disable() - await fake.enable() - await fake.enable() - await fake.disable() + 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 { - let fake = HttpFake() - await fake.enable() - - // 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) + 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 { - let fake = HttpFake() - await fake.enable() - - // 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) + 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 { - let fake = HttpFake() - await fake.enable() - - 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") + 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 { - let fake = HttpFake() - await fake.enable() - - 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") + 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 { - let fake = HttpFake() - await fake.enable() - - 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") + 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 { - let fake = HttpFake() - await fake.enable() - - 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") + 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 { - let fake = HttpFake() - await fake.enable() - - 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) + 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") @@ -156,17 +149,16 @@ struct HttpFakeTests { let value: Int } - let fake = HttpFake() - await fake.enable() - - 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) + 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") @@ -175,66 +167,64 @@ struct HttpFakeTests { let id: Int } - let fake = HttpFake() - await fake.enable() - - 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) + 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 { - let fake = HttpFake() - await fake.enable() - - 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) + 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 { - let fake = HttpFake() - await fake.enable() - - // 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 + try await withHttpFake() { fake in + // Add enough responses for concurrent calls + let concurrentCalls = 10000 for i in 1...concurrentCalls { - group.addTask { - let request = HttpRequest(method: .get, url: "https://example.com/\(i)") - await Http.call(request) + 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) } - - // All calls should be tracked - #expect(await fake.calls.count == concurrentCalls) - #expect(await fake.globalResponses.count == 1) } } diff --git a/Tests/RevoHttpTests/HttpOptionsTests.swift b/Tests/RevoHttpTests/HttpOptionsTests.swift index 03f5ca3..1696ff5 100644 --- a/Tests/RevoHttpTests/HttpOptionsTests.swift +++ b/Tests/RevoHttpTests/HttpOptionsTests.swift @@ -24,28 +24,24 @@ struct HttpOptionsTests { @Test("Can set timeout on Http instance") func testCanSetTimeoutOnHttpInstance() async throws { - let fake = HttpFake() - await fake.enable() - 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) + 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 combine multiple options") func testCanCombineMultipleOptions() async throws { - let fake = HttpFake() - await fake.enable() - - 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) - // Verify HMAC header was added by setOptions - #expect(request.headers["X-Auth"] != nil) + 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 index f2cd173..18acb65 100644 --- a/Tests/RevoHttpTests/HttpRequestTests.swift +++ b/Tests/RevoHttpTests/HttpRequestTests.swift @@ -112,5 +112,73 @@ struct HttpRequestTests { 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/HttpStaticTests.swift b/Tests/RevoHttpTests/HttpStaticTests.swift index bf1a056..36e7db3 100644 --- a/Tests/RevoHttpTests/HttpStaticTests.swift +++ b/Tests/RevoHttpTests/HttpStaticTests.swift @@ -70,5 +70,17 @@ struct HttpStaticTests { 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) + } +} From 260c5d3a49ca720ede2ae3796d2d120e594b5dff Mon Sep 17 00:00:00 2001 From: Miquel Rodoreda Date: Fri, 13 Mar 2026 10:16:06 +0100 Subject: [PATCH 09/11] Conform error to LocalizedError --- Sources/RevoHttp/Enums/HttpError.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/RevoHttp/Enums/HttpError.swift b/Sources/RevoHttp/Enums/HttpError.swift index 19d1183..fbfc7af 100644 --- a/Sources/RevoHttp/Enums/HttpError.swift +++ b/Sources/RevoHttp/Enums/HttpError.swift @@ -1,6 +1,6 @@ import Foundation -public enum HttpError : Error { +public enum HttpError : LocalizedError { case invalidUrl case invalidParams @@ -8,7 +8,7 @@ public enum HttpError : Error { case reponseStatusError(response:HttpResponse) case undecodableResponse - var localizedDescription: String { + public var errorDescription: String? { switch self { case .invalidUrl: "Malformed Url" case .invalidParams: "Invalid input params" From a928a47c33a368879dac6ce099dd8e1a2465e3e3 Mon Sep 17 00:00:00 2001 From: Miquel Rodoreda Date: Fri, 13 Mar 2026 10:26:06 +0100 Subject: [PATCH 10/11] Add more options tests --- Tests/RevoHttpTests/HttpOptionsTests.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Tests/RevoHttpTests/HttpOptionsTests.swift b/Tests/RevoHttpTests/HttpOptionsTests.swift index 1696ff5..fe90262 100644 --- a/Tests/RevoHttpTests/HttpOptionsTests.swift +++ b/Tests/RevoHttpTests/HttpOptionsTests.swift @@ -31,6 +31,24 @@ struct HttpOptionsTests { } } + @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 From f4bb9bec366b1838db6aec28530439ac46bc74ce Mon Sep 17 00:00:00 2001 From: Miquel Rodoreda Date: Fri, 13 Mar 2026 11:12:40 +0100 Subject: [PATCH 11/11] Fix indentation --- Sources/RevoHttp/HttpRequest.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/RevoHttp/HttpRequest.swift b/Sources/RevoHttp/HttpRequest.swift index fb1460e..b50547a 100644 --- a/Sources/RevoHttp/HttpRequest.swift +++ b/Sources/RevoHttp/HttpRequest.swift @@ -109,9 +109,9 @@ public class HttpRequest : NSObject, @unchecked Sendable { public func withHmacHeader(_ hmac: Hmac) { 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() + case .string(let string?): string + case .form: buildFormBody() ?? "" + default: buildQueryParams() } if let hash = payload.hmac256(hmac.privateKey) { headers[hmac.header] = hash