├── .dockerignore ├── .gitignore ├── .swiftlint.yml ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── contents.xcworkspacedata │ └── xcshareddata │ └── xcschemes │ ├── NetworkHalpers.xcscheme │ ├── NetworkHandler-Package.xcscheme │ └── NetworkHandler.xcscheme ├── DemoApp ├── DemoApp.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcshareddata │ │ └── xcschemes │ │ └── DemoApp.xcscheme ├── DemoApp │ ├── AppDelegate.swift │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── Base.lproj │ │ ├── LaunchScreen.storyboard │ │ └── Main.storyboard │ ├── CreateViewController.swift │ ├── DemoViewController.swift │ └── Info.plist └── en.lproj │ ├── LaunchScreen.strings │ └── Main.strings ├── Dockerfile ├── LICENSE.md ├── Misc └── Halp.jpg ├── Package.resolved ├── Package.swift ├── Readme.md ├── Sources ├── NetworkHalpers │ ├── AWSv4AuthHelper.swift │ ├── CodingHelpers.swift │ ├── CryptoConveniences.swift │ ├── HTTPMethod.swift │ ├── Headers │ │ ├── HTTPHeaders.Header.Key.swift │ │ ├── HTTPHeaders.Header.Value.swift │ │ ├── HTTPHeaders.Header.swift │ │ └── HTTPHeaders.swift │ └── Multipart │ │ ├── ConcatenatedInputStream.swift │ │ ├── MultipartFormInputStream.Part.swift │ │ ├── MultipartFormInputStream.swift │ │ ├── MultipartFormInputTempFile.Part.swift │ │ └── MultipartFormInputTempFile.swift ├── NetworkHandler │ ├── AWSv4AuthHelper+NetworkRequest.swift │ ├── Docs.docc │ │ └── index.md │ ├── EngineRequests │ │ ├── EngineRequestMetadata.swift │ │ ├── GeneralEngineRequest.swift │ │ ├── UploadEngineRequest.swift │ │ └── UploadFile.swift │ ├── EngineResponseHeader.swift │ ├── Helpers │ │ ├── ETask.swift │ │ ├── InputStream+Sendable.swift │ │ ├── InputStreamImprovements.swift │ │ ├── NHActor.swift │ │ └── Sendify.swift │ ├── NetworkCache.swift │ ├── NetworkCancellationToken.swift │ ├── NetworkDiskCache.swift │ ├── NetworkEngine.swift │ ├── NetworkError.swift │ ├── NetworkHandler.RetryOptions.swift │ ├── NetworkHandler.swift │ ├── NetworkHandlerTaskDelegate.swift │ ├── NetworkRequest.swift │ └── URL+NetworkRequest.swift ├── NetworkHandlerAHCEngine │ ├── EngineResponseHeader+HTTPClientResponse.swift │ ├── GeneralEngineRequest+HTTPClientRequest.swift │ ├── HTTPClientConformance.swift │ ├── TimeoutDebouncer.swift │ └── UploadEngineRequest+HTTPClientRequest.swift ├── NetworkHandlerMockingEngine │ └── MockingEngine.swift ├── NetworkHandlerURLSessionEngine │ ├── EngineRequestMetadata+URLRequestProperties.swift │ ├── EngineResponseHeader+HTTPURLResponse.swift │ ├── GeneralEngineRequest+URLRequest.swift │ ├── URLSession+UploadSessionDelegate.swift │ ├── URLSessionConfiguration+NHDefault.swift │ ├── URLSessionConformance.swift │ ├── URLSessionTask.State+DebugDescription.swift │ └── UploadEngineRequest+URLRequest.swift └── TestSupport │ ├── AtomicValue.swift │ ├── DemoModel.swift │ ├── DemoModelController.swift │ ├── DemoText.swift │ ├── DummyModel.swift │ ├── HMAC Signing.swift │ ├── NSImage+PNG.swift │ ├── NetworkHandlerBaseTest.swift │ ├── NetworkHandlerCommonTests.swift │ ├── Resources │ └── lighthouse.jpg │ ├── SimpleTestError.swift │ ├── TestBundle.swift │ └── TestEnvironment.swift ├── Styleguide.md ├── Tests ├── NetworkHalpersTests │ ├── AWSv4AuthTests.swift │ ├── HTTPMethodTests.swift │ ├── MultipartInputStreamTests.swift │ └── NetworkHeadersTests.swift ├── NetworkHandlerAHCTests │ └── NetworkHandlerAHCTests.swift ├── NetworkHandlerMockingTests │ └── NetworkHandlerMockingTests.swift ├── NetworkHandlerTests │ ├── EngineHeaderTests.swift │ ├── NetworkCacheTest.swift │ ├── NetworkCacheTests.swift │ ├── NetworkDiskCacheTests.swift │ ├── NetworkErrorTests.swift │ └── NetworkRequestTests.swift └── NetworkHandlerURLSessionTests │ ├── EngineRequestMetadata+URLRequestPropertiesTests.swift │ └── NetworkHandlerURLSessionTests.swift ├── docker-compose.yml └── env.tests.sample /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | .dockerignore 4 | .env* 5 | Dockerfile 6 | .fuse_hidden* 7 | .DS_Store 8 | .AppleDouble 9 | .LSOverride 10 | ._* 11 | .DocumentRevisions-V100 12 | .fseventsd 13 | .Spotlight-V100 14 | .TemporaryItems 15 | .Trashes 16 | .VolumeIcon.icns 17 | .com.apple.timemachine.donotpresent 18 | .AppleDB 19 | .AppleDesktop 20 | Network Trash Folder 21 | Temporary Items 22 | .apdisk 23 | build/ 24 | DerivedData/ 25 | *.moved-aside 26 | *.pbxuser 27 | !default.pbxuser 28 | .build/ 29 | NetworkHandler-Documentation.docset/ 30 | .env* 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /Carthage 2 | 3 | 4 | # Created by https://www.toptal.com/developers/gitignore/api/linux,swift,xcode,macos,carthage,cocoapods,objective-c 5 | # Edit at https://www.toptal.com/developers/gitignore?templates=linux,swift,xcode,macos,carthage,cocoapods,objective-c 6 | 7 | ### Carthage ### 8 | # Carthage 9 | # 10 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 11 | # Carthage/Checkouts 12 | 13 | Carthage/Build 14 | 15 | ### CocoaPods ### 16 | ## CocoaPods GitIgnore Template 17 | 18 | # CocoaPods - Only use to conserve bandwidth / Save time on Pushing 19 | # - Also handy if you have a large number of dependant pods 20 | # - AS PER https://guides.cocoapods.org/using/using-cocoapods.html NEVER IGNORE THE LOCK FILE 21 | Pods/ 22 | 23 | ### Linux ### 24 | *~ 25 | 26 | # temporary files which can be created if a process still has a handle open of a deleted file 27 | .fuse_hidden* 28 | 29 | # KDE directory preferences 30 | .directory 31 | 32 | # Linux trash folder which might appear on any partition or disk 33 | .Trash-* 34 | 35 | # .nfs files are created when an open file is removed but is still being accessed 36 | .nfs* 37 | 38 | ### macOS ### 39 | # General 40 | .DS_Store 41 | .AppleDouble 42 | .LSOverride 43 | 44 | # Icon must end with two \r 45 | Icon 46 | 47 | # Thumbnails 48 | ._* 49 | 50 | # Files that might appear in the root of a volume 51 | .DocumentRevisions-V100 52 | .fseventsd 53 | .Spotlight-V100 54 | .TemporaryItems 55 | .Trashes 56 | .VolumeIcon.icns 57 | .com.apple.timemachine.donotpresent 58 | 59 | # Directories potentially created on remote AFP share 60 | .AppleDB 61 | .AppleDesktop 62 | Network Trash Folder 63 | Temporary Items 64 | .apdisk 65 | 66 | ### Objective-C ### 67 | # Xcode 68 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 69 | 70 | ## User settings 71 | xcuserdata/ 72 | 73 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 74 | *.xcscmblueprint 75 | *.xccheckout 76 | 77 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 78 | build/ 79 | DerivedData/ 80 | *.moved-aside 81 | *.pbxuser 82 | !default.pbxuser 83 | *.mode1v3 84 | !default.mode1v3 85 | *.mode2v3 86 | !default.mode2v3 87 | *.perspectivev3 88 | !default.perspectivev3 89 | 90 | ## Obj-C/Swift specific 91 | *.hmap 92 | 93 | ## App packaging 94 | *.ipa 95 | *.dSYM.zip 96 | *.dSYM 97 | 98 | # CocoaPods 99 | # We recommend against adding the Pods directory to your .gitignore. However 100 | # you should judge for yourself, the pros and cons are mentioned at: 101 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 102 | # Pods/ 103 | # Add this line if you want to avoid checking in source code from the Xcode workspace 104 | # *.xcworkspace 105 | 106 | # Carthage 107 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 108 | # Carthage/Checkouts 109 | 110 | Carthage/Build/ 111 | 112 | # fastlane 113 | # It is recommended to not store the screenshots in the git repo. 114 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 115 | # For more information about the recommended setup visit: 116 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 117 | 118 | fastlane/report.xml 119 | fastlane/Preview.html 120 | fastlane/screenshots/**/*.png 121 | fastlane/test_output 122 | 123 | # Code Injection 124 | # After new code Injection tools there's a generated folder /iOSInjectionProject 125 | # https://github.com/johnno1962/injectionforxcode 126 | 127 | iOSInjectionProject/ 128 | 129 | ### Objective-C Patch ### 130 | 131 | ### Swift ### 132 | # Xcode 133 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 134 | 135 | 136 | 137 | 138 | 139 | 140 | ## Playgrounds 141 | timeline.xctimeline 142 | playground.xcworkspace 143 | 144 | # Swift Package Manager 145 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 146 | # Packages/ 147 | # Package.pins 148 | # Package.resolved 149 | # *.xcodeproj 150 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 151 | # hence it is not needed unless you have added a package configuration file to your project 152 | # .swiftpm 153 | 154 | .build/ 155 | 156 | # CocoaPods 157 | # We recommend against adding the Pods directory to your .gitignore. However 158 | # you should judge for yourself, the pros and cons are mentioned at: 159 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 160 | # Pods/ 161 | # Add this line if you want to avoid checking in source code from the Xcode workspace 162 | # *.xcworkspace 163 | 164 | # Carthage 165 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 166 | # Carthage/Checkouts 167 | 168 | 169 | # Accio dependency management 170 | Dependencies/ 171 | .accio/ 172 | 173 | # fastlane 174 | # It is recommended to not store the screenshots in the git repo. 175 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 176 | # For more information about the recommended setup visit: 177 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 178 | 179 | 180 | # Code Injection 181 | # After new code Injection tools there's a generated folder /iOSInjectionProject 182 | # https://github.com/johnno1962/injectionforxcode 183 | 184 | 185 | ### Xcode ### 186 | # Xcode 187 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 188 | 189 | 190 | 191 | 192 | ## Gcc Patch 193 | /*.gcno 194 | 195 | ### Xcode Patch ### 196 | #*.xcodeproj/* 197 | #!*.xcodeproj/project.pbxproj 198 | #!*.xcodeproj/xcshareddata/ 199 | #!*.xcworkspace/contents.xcworkspacedata 200 | #**/xcshareddata/WorkspaceSettings.xcsettings 201 | 202 | # End of https://www.toptal.com/developers/gitignore/api/linux,swift,xcode,macos,carthage,cocoapods,objective-c 203 | NetworkHandler-Documentation.docset/ 204 | .env* 205 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | identifier_name: 2 | min_length: 2 3 | excluded: 4 | - x 5 | - y 6 | - T 7 | type_name: 8 | allowed_symbols: 9 | - "_" 10 | max_length: 100 11 | line_length: 120 12 | file_length: 800 13 | type_body_length: 14 | warning: 500 15 | number_separator: 16 | minimum_length: 5 17 | trailing_comma: 18 | mandatory_comma: true 19 | function_body_length: 20 | warning: 120 21 | error: 150 22 | opening_brace: 23 | ignore_multiline_statement_conditions: true 24 | attributes: 25 | always_on_same_line: ["@IBAction", "@NSManaged", "@Test"] 26 | always_on_line_above: ["@NHActor"] 27 | 28 | disabled_rules: 29 | - nesting 30 | - closing_brace 31 | opt_in_rules: 32 | - number_separator 33 | - closure_spacing 34 | - overridden_super_call 35 | - attributes 36 | - fatal_error_message 37 | - empty_count 38 | - redundant_nil_coalescing 39 | - first_where 40 | - operator_usage_whitespace 41 | - prohibited_super_call 42 | excluded: 43 | - .build 44 | 45 | custom_rules: 46 | leading_whitespace: 47 | name: Tabs 48 | message: Use tab indentation 49 | regex: ^\t* +\t*\S 50 | severity: error 51 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/NetworkHalpers.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/NetworkHandler.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 48 | 49 | 53 | 54 | 55 | 56 | 62 | 63 | 69 | 70 | 71 | 72 | 74 | 75 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /DemoApp/DemoApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /DemoApp/DemoApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /DemoApp/DemoApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "SaferContinuation", 6 | "repositoryURL": "https://github.com/mredig/SaferContinuation.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "fad80a60c3e10e413fb3d66badb41f4b28ff874f", 10 | "version": "1.1.5" 11 | } 12 | }, 13 | { 14 | "package": "swift-crypto", 15 | "repositoryURL": "https://github.com/apple/swift-crypto.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "067254c79435de759aeef4a6a03e43d087d61312", 19 | "version": "2.0.5" 20 | } 21 | }, 22 | { 23 | "package": "Swiftwood", 24 | "repositoryURL": "https://github.com/KnowMeGit/Swiftwood.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "bc19c3059ceeef770e6fe330fea3864cb3fc3e5e", 28 | "version": "0.1.2" 29 | } 30 | } 31 | ] 32 | }, 33 | "version": 1 34 | } 35 | -------------------------------------------------------------------------------- /DemoApp/DemoApp.xcodeproj/xcshareddata/xcschemes/DemoApp.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /DemoApp/DemoApp/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @UIApplicationMain 4 | class AppDelegate: UIResponder, UIApplicationDelegate { 5 | 6 | var window: UIWindow? 7 | 8 | func application( 9 | _ application: UIApplication, 10 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 11 | ) -> Bool { true } 12 | } 13 | -------------------------------------------------------------------------------- /DemoApp/DemoApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /DemoApp/DemoApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /DemoApp/DemoApp/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /DemoApp/DemoApp/CreateViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateViewController.swift 3 | // Demo 4 | // 5 | // Created by Michael Redig on 6/15/19. 6 | // Copyright © 2019 Red_Egg Productions. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class CreateViewController: UIViewController { 12 | 13 | // MARK: - Properties 14 | var demoModelController: DemoModelController? 15 | 16 | // MARK: - Outlets 17 | @IBOutlet private var titleTextField: UITextField! 18 | @IBOutlet private var subtitleTextField: UITextField! 19 | @IBOutlet private var widthTextField: UITextField! 20 | @IBOutlet private var heightTextField: UITextField! 21 | 22 | // MARK: - Actions 23 | @IBAction func saveButtonPressed(_ sender: UIBarButtonItem) { 24 | guard let title = titleTextField.text, !title.isEmpty, 25 | let subtitle = subtitleTextField.text, !subtitle.isEmpty, 26 | let width = Int(widthTextField.text ?? ""), 27 | let height = Int(heightTextField.text ?? "") else { return } 28 | 29 | let baseURL = URL(string: "https://placekitten.com/")! 30 | let kittenURL = baseURL 31 | .appendingPathComponent("\(width)") 32 | .appendingPathComponent("\(height)") 33 | 34 | demoModelController?.create(modelWithTitle: title, andSubtitle: subtitle, imageURL: kittenURL) 35 | navigationController?.popViewController(animated: true) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /DemoApp/DemoApp/DemoViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DemoViewController.swift 3 | // Demo 4 | // 5 | // Created by Michael Redig on 6/15/19. 6 | // Copyright © 2019 Red_Egg Productions. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import NetworkHandler 11 | 12 | class DemoViewController: UITableViewController { 13 | 14 | // MARK: - Properties 15 | let demoModelController = DemoModelController() 16 | private var tasks = [UITableViewCell: Task]() 17 | 18 | // MARK: - Outlets 19 | @IBOutlet private var generateDemoDataButton: UIButton! 20 | 21 | // MARK: - Actions 22 | @IBAction func generateDemoDataButtonPressed(_ sender: UIButton) { 23 | sender.isEnabled = false 24 | // demoModelController.generateDemoData { [weak self] in 25 | // self?.demoModelController.fetchDemoModels(completion: { [weak self] error in 26 | // if let error = error { 27 | // NSLog("There was an error \(error)") 28 | // } 29 | // DispatchQueue.main.async { 30 | // self?.tableView.reloadData() 31 | // sender.isEnabled = true 32 | // } 33 | // }) 34 | // } 35 | Task { 36 | do { 37 | try await demoModelController.generateDemoData() 38 | } catch { 39 | print("Error generating data: \(error)") 40 | } 41 | refreshData() 42 | sender.isEnabled = true 43 | } 44 | } 45 | 46 | @objc func refreshData() { 47 | Task { 48 | do { 49 | try await demoModelController.fetchDemoModels() 50 | } catch { 51 | let alertVC = UIAlertController(title: "Error", message: "Error loading data: \(error)", preferredStyle: .alert) 52 | 53 | let button = UIAlertAction(title: "Okay", style: .default, handler: nil) 54 | 55 | alertVC.addAction(button) 56 | 57 | present(alertVC, animated: true) 58 | } 59 | 60 | tableView.refreshControl?.endRefreshing() 61 | tableView.reloadData() 62 | } 63 | } 64 | 65 | // MARK: - VC Lifecycle 66 | override func viewDidLoad() { 67 | super.viewDidLoad() 68 | tableView.refreshControl = UIRefreshControl() 69 | tableView.refreshControl?.addTarget(self, action: #selector(refreshData), for: .valueChanged) 70 | } 71 | 72 | override func viewWillAppear(_ animated: Bool) { 73 | super.viewWillAppear(animated) 74 | refreshData() 75 | } 76 | 77 | override func prepare(for segue: UIStoryboardSegue, sender: Any?) { 78 | if let dest = segue.destination as? CreateViewController { 79 | dest.demoModelController = demoModelController 80 | } 81 | } 82 | } 83 | 84 | // MARK: tableview stuff 85 | extension DemoViewController { 86 | // MARK: - Tableview 87 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 88 | demoModelController.demoModels.count 89 | } 90 | 91 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 92 | let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) 93 | 94 | let demoModel = demoModelController.demoModels[indexPath.row] 95 | cell.textLabel?.text = demoModel.title 96 | cell.detailTextLabel?.text = demoModel.subtitle 97 | cell.imageView?.image = nil 98 | loadImage(for: cell, at: indexPath) 99 | return cell 100 | } 101 | 102 | override func tableView( 103 | _ tableView: UITableView, 104 | commit editingStyle: UITableViewCell.EditingStyle, 105 | forRowAt indexPath: IndexPath 106 | ) { 107 | if editingStyle == .delete { 108 | let demoModel = demoModelController.demoModels[indexPath.row] 109 | Task { 110 | try await demoModelController.delete(model: demoModel) 111 | tableView.deleteRows(at: [indexPath], with: .automatic) 112 | } 113 | } 114 | } 115 | 116 | // MARK: - Methods 117 | func loadImage(for cell: UITableViewCell, at indexPath: IndexPath) { 118 | tasks[cell]?.cancel() 119 | tasks[cell] = nil 120 | 121 | let demoModel = demoModelController.demoModels[indexPath.row] 122 | 123 | tasks[cell] = Task { 124 | do { 125 | let (data, _) = try await NetworkHandler.default.transferMahDatas( 126 | for: demoModel.imageURL.request, 127 | delegate: nil, 128 | usingCache: true, 129 | sessionConfiguration: nil) 130 | 131 | try Task.checkCancellation() 132 | cell.imageView?.image = UIImage(data: data) 133 | cell.layoutSubviews() 134 | tasks[cell] = nil 135 | } catch { 136 | if case NetworkError.otherError(error: let otherError) = error { 137 | if (otherError as NSError).code == -999 { 138 | // cancelled 139 | return 140 | } 141 | } 142 | print("Error loading image from url: '\(demoModel.imageURL)': \(error)") 143 | } 144 | } 145 | // tasks[cell] = NetworkHandler.default.transferMahDatas( 146 | // with: demoModel.imageURL.request, 147 | // usingCache: true, 148 | // completion: { [weak self] (result: Result) in 149 | // DispatchQueue.main.async { 150 | // do { 151 | // let imageData = try result.get() 152 | // cell.imageView?.image = UIImage(data: imageData) 153 | // cell.layoutSubviews() 154 | // self?.tasks[cell] = nil 155 | // } catch { 156 | // if case NetworkError.otherError(error: let otherError) = error { 157 | // if (otherError as NSError).code == -999 { 158 | // // cancelled 159 | // return 160 | // } 161 | // } 162 | // NSLog("error loading image from url '\(demoModel.imageURL)': \(error)") 163 | // } 164 | // } 165 | // }) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /DemoApp/DemoApp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UILaunchStoryboardName 24 | LaunchScreen 25 | UIMainStoryboardFile 26 | Main 27 | UIRequiredDeviceCapabilities 28 | 29 | armv7 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /DemoApp/en.lproj/LaunchScreen.strings: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /DemoApp/en.lproj/Main.strings: -------------------------------------------------------------------------------- 1 | 2 | /* Class = "UITextField"; placeholder = "Placekitten Height"; ObjectID = "4YS-uX-PVJ"; */ 3 | "4YS-uX-PVJ.placeholder" = "Placekitten Height"; 4 | 5 | /* Class = "UILabel"; text = "Title"; ObjectID = "501-4N-yhU"; */ 6 | "501-4N-yhU.text" = "Title"; 7 | 8 | /* Class = "UILabel"; text = "Subtitle"; ObjectID = "7y4-rW-1Ee"; */ 9 | "7y4-rW-1Ee.text" = "Subtitle"; 10 | 11 | /* Class = "UIButton"; normalTitle = "Generate Some Demo Data"; ObjectID = "hjS-1R-ueb"; */ 12 | "hjS-1R-ueb.normalTitle" = "Generate Some Demo Data"; 13 | 14 | /* Class = "UINavigationItem"; title = "Create New Demo Model"; ObjectID = "kK7-oi-gwa"; */ 15 | "kK7-oi-gwa.title" = "Create New Demo Model"; 16 | 17 | /* Class = "UITextField"; placeholder = "Create Title"; ObjectID = "kwx-V0-Xm4"; */ 18 | "kwx-V0-Xm4.placeholder" = "Create Title"; 19 | 20 | /* Class = "UITextField"; placeholder = "Create Subtitle"; ObjectID = "pVT-ri-dTC"; */ 21 | "pVT-ri-dTC.placeholder" = "Create Subtitle"; 22 | 23 | /* Class = "UITextField"; placeholder = "Placekitten Width"; ObjectID = "uEb-7s-fLE"; */ 24 | "uEb-7s-fLE.placeholder" = "Placekitten Width"; 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ================================ 2 | # Build image 3 | # ================================ 4 | FROM swift:5.10-jammy as build 5 | 6 | # Install OS updates and, if needed, sqlite3 7 | RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ 8 | && apt-get -q update \ 9 | && apt-get -q dist-upgrade -y\ 10 | && rm -rf /var/lib/apt/lists/* 11 | 12 | # Set up a build area 13 | WORKDIR /build 14 | 15 | # First just resolve dependencies. 16 | # This creates a cached layer that can be reused 17 | # as long as your Package.swift/Package.resolved 18 | # files do not change. 19 | COPY ./Package.* ./ 20 | RUN swift package resolve 21 | 22 | # Copy entire repo into container 23 | COPY . . 24 | 25 | ARG CONFIG 26 | ENV CONFIG=${CONFIG} 27 | 28 | # Build everything, with optimizations 29 | RUN --mount=type=cache,target=/build/.build \ 30 | swift \ 31 | build \ 32 | -c $CONFIG \ 33 | --target NetworkHandler \ 34 | --static-swift-stdlib 35 | 36 | # Switch to the staging area 37 | WORKDIR /staging 38 | 39 | # # # Copy main executable to staging area 40 | # RUN --mount=type=cache,target=/build/.build cp "$(swift build --package-path /build -c $CONFIG --show-bin-path)/Server" ./ 41 | 42 | # # Copy resources bundled by SPM to staging area 43 | # RUN --mount=type=cache,target=/build/.build find -L "$(swift build --package-path /build -c $CONFIG --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \; 44 | 45 | # # Copy any resources from the public directory and views directory if the directories exist 46 | # # Ensure that by default, neither the directory nor any of its contents are writable. 47 | # RUN --mount=type=cache,target=/build/.build [ -d /build/Public ] && { mv /build/Public ./Public && chmod -R a-w ./Public; } || true 48 | # RUN --mount=type=cache,target=/build/.build [ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w ./Resources; } || true 49 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Michael Redig 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Misc/Halp.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mredig/NetworkHandler/4b65e64dba3743b4911d0115f669f1d4a772f2e6/Misc/Halp.jpg -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "4de57a43983a1a55189df14b15d6e46e2165378926c706323849b9c5f2e399ef", 3 | "pins" : [ 4 | { 5 | "identity" : "async-http-client", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/swift-server/async-http-client", 8 | "state" : { 9 | "revision" : "333f51104b75d1a5b94cb3b99e4c58a3b442c9f7", 10 | "version" : "1.25.2" 11 | } 12 | }, 13 | { 14 | "identity" : "pizzamacros", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/mredig/PizzaMacros.git", 17 | "state" : { 18 | "revision" : "df7635ebef07d129fd92c9d9ec21e92c446ccde1", 19 | "version" : "0.1.4" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-algorithms", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/apple/swift-algorithms.git", 26 | "state" : { 27 | "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", 28 | "version" : "1.2.1" 29 | } 30 | }, 31 | { 32 | "identity" : "swift-atomics", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/apple/swift-atomics.git", 35 | "state" : { 36 | "revision" : "cd142fd2f64be2100422d658e7411e39489da985", 37 | "version" : "1.2.0" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-collections", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/apple/swift-collections.git", 44 | "state" : { 45 | "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", 46 | "version" : "1.1.4" 47 | } 48 | }, 49 | { 50 | "identity" : "swift-crypto", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/apple/swift-crypto.git", 53 | "state" : { 54 | "revision" : "81bee98e706aee68d39ed5996db069ef2b313d62", 55 | "version" : "3.7.1" 56 | } 57 | }, 58 | { 59 | "identity" : "swift-http-structured-headers", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/apple/swift-http-structured-headers.git", 62 | "state" : { 63 | "revision" : "d01361d32e14ae9b70ea5bd308a3794a198a2706", 64 | "version" : "1.2.0" 65 | } 66 | }, 67 | { 68 | "identity" : "swift-http-types", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/apple/swift-http-types.git", 71 | "state" : { 72 | "revision" : "ef18d829e8b92d731ad27bb81583edd2094d1ce3", 73 | "version" : "1.3.1" 74 | } 75 | }, 76 | { 77 | "identity" : "swift-log", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/apple/swift-log.git", 80 | "state" : { 81 | "revision" : "96a2f8a0fa41e9e09af4585e2724c4e825410b91", 82 | "version" : "1.6.2" 83 | } 84 | }, 85 | { 86 | "identity" : "swift-nio", 87 | "kind" : "remoteSourceControl", 88 | "location" : "https://github.com/apple/swift-nio.git", 89 | "state" : { 90 | "revision" : "c51907a839e63ebf0ba2076bba73dd96436bd1b9", 91 | "version" : "2.81.0" 92 | } 93 | }, 94 | { 95 | "identity" : "swift-nio-extras", 96 | "kind" : "remoteSourceControl", 97 | "location" : "https://github.com/apple/swift-nio-extras.git", 98 | "state" : { 99 | "revision" : "00f3f72d2f9942d0e2dc96057ab50a37ced150d4", 100 | "version" : "1.25.0" 101 | } 102 | }, 103 | { 104 | "identity" : "swift-nio-http2", 105 | "kind" : "remoteSourceControl", 106 | "location" : "https://github.com/apple/swift-nio-http2.git", 107 | "state" : { 108 | "revision" : "170f4ca06b6a9c57b811293cebcb96e81b661310", 109 | "version" : "1.35.0" 110 | } 111 | }, 112 | { 113 | "identity" : "swift-nio-ssl", 114 | "kind" : "remoteSourceControl", 115 | "location" : "https://github.com/apple/swift-nio-ssl.git", 116 | "state" : { 117 | "revision" : "0cc3528ff48129d64ab9cab0b1cd621634edfc6b", 118 | "version" : "2.29.3" 119 | } 120 | }, 121 | { 122 | "identity" : "swift-nio-transport-services", 123 | "kind" : "remoteSourceControl", 124 | "location" : "https://github.com/apple/swift-nio-transport-services.git", 125 | "state" : { 126 | "revision" : "3c394067c08d1225ba8442e9cffb520ded417b64", 127 | "version" : "1.23.1" 128 | } 129 | }, 130 | { 131 | "identity" : "swift-numerics", 132 | "kind" : "remoteSourceControl", 133 | "location" : "https://github.com/apple/swift-numerics.git", 134 | "state" : { 135 | "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", 136 | "version" : "1.0.2" 137 | } 138 | }, 139 | { 140 | "identity" : "swift-syntax", 141 | "kind" : "remoteSourceControl", 142 | "location" : "https://github.com/apple/swift-syntax.git", 143 | "state" : { 144 | "revision" : "6ad4ea24b01559dde0773e3d091f1b9e36175036", 145 | "version" : "509.0.2" 146 | } 147 | }, 148 | { 149 | "identity" : "swift-system", 150 | "kind" : "remoteSourceControl", 151 | "location" : "https://github.com/apple/swift-system.git", 152 | "state" : { 153 | "revision" : "a34201439c74b53f0fd71ef11741af7e7caf01e1", 154 | "version" : "1.4.2" 155 | } 156 | }, 157 | { 158 | "identity" : "swiftlintplugins", 159 | "kind" : "remoteSourceControl", 160 | "location" : "https://github.com/SimplyDanny/SwiftLintPlugins", 161 | "state" : { 162 | "revision" : "7a3d77f3dd9f91d5cea138e52c20cfceabf352de", 163 | "version" : "0.58.2" 164 | } 165 | }, 166 | { 167 | "identity" : "swiftlydotenv", 168 | "kind" : "remoteSourceControl", 169 | "location" : "https://github.com/mredig/SwiftlyDotEnv.git", 170 | "state" : { 171 | "revision" : "9357bed1e9c5fec2d6612c2dbc9ccd7a472794cd", 172 | "version" : "0.2.9" 173 | } 174 | }, 175 | { 176 | "identity" : "swiftpizzasnips", 177 | "kind" : "remoteSourceControl", 178 | "location" : "https://github.com/mredig/SwiftPizzaSnips.git", 179 | "state" : { 180 | "revision" : "f0ddd13fab192ec26261da080aad3b3886160c42", 181 | "version" : "0.4.35" 182 | } 183 | } 184 | ], 185 | "version" : 3 186 | } 187 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | var products: [Product] = [ 7 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 8 | .library( 9 | name: "NetworkHandler", 10 | targets: ["NetworkHandler"]), 11 | .library( 12 | name: "NetworkHalpers", 13 | targets: ["NetworkHalpers"]), 14 | .library( 15 | name: "NetworkHandlerAHCEngine", 16 | targets: ["NetworkHandlerAHCEngine"]), 17 | .library( 18 | name: "NetworkHandlerMockingEngine", 19 | targets: ["NetworkHandlerMockingEngine"]) 20 | ] 21 | 22 | var targets: [Target] = [ 23 | .target( 24 | name: "NetworkHandler", 25 | dependencies: [ 26 | .product(name: "Crypto", package: "swift-crypto"), 27 | "NetworkHalpers", 28 | "SwiftPizzaSnips", 29 | .product(name: "AsyncHTTPClient", package: "async-http-client"), 30 | .product(name: "Logging", package: "swift-log"), 31 | ], 32 | plugins: [ 33 | .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLintPlugins") 34 | ]), 35 | .target( 36 | name: "NetworkHandlerAHCEngine", 37 | dependencies: [ 38 | "NetworkHandler", 39 | "NetworkHalpers", 40 | "SwiftPizzaSnips", 41 | .product(name: "AsyncHTTPClient", package: "async-http-client"), 42 | .product(name: "Logging", package: "swift-log"), 43 | ], 44 | plugins: [ 45 | .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLintPlugins") 46 | ]), 47 | .target( 48 | name: "NetworkHandlerMockingEngine", 49 | dependencies: [ 50 | "NetworkHandler", 51 | "NetworkHalpers", 52 | "SwiftPizzaSnips", 53 | .product(name: "Logging", package: "swift-log"), 54 | .product(name: "Algorithms", package: "swift-algorithms"), 55 | ], 56 | plugins: [ 57 | .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLintPlugins") 58 | ]), 59 | .target( 60 | name: "NetworkHalpers", 61 | dependencies: [ 62 | .product(name: "Crypto", package: "swift-crypto"), 63 | "SwiftPizzaSnips", 64 | .product(name: "Logging", package: "swift-log"), 65 | ], 66 | plugins: [ 67 | .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLintPlugins") 68 | ]), 69 | .target( 70 | name: "TestSupport", 71 | dependencies: [ 72 | "PizzaMacros", 73 | "NetworkHandler", 74 | "SwiftlyDotEnv", 75 | "NetworkHandlerAHCEngine", 76 | "NetworkHandlerMockingEngine", 77 | ], 78 | resources: [ 79 | .copy("Resources") 80 | ], 81 | plugins: [ 82 | .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLintPlugins") 83 | ]), 84 | .testTarget( 85 | name: "NetworkHandlerTests", 86 | dependencies: [ 87 | "NetworkHandler", 88 | "TestSupport", 89 | "PizzaMacros", 90 | .product(name: "Logging", package: "swift-log"), 91 | "NetworkHandlerMockingEngine", 92 | ], 93 | plugins: [ 94 | .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLintPlugins") 95 | ]), 96 | .testTarget( 97 | name: "NetworkHandlerMockingTests", 98 | dependencies: [ 99 | "NetworkHandler", 100 | "TestSupport", 101 | "PizzaMacros", 102 | .product(name: "Logging", package: "swift-log"), 103 | "NetworkHandlerMockingEngine", 104 | ], 105 | plugins: [ 106 | .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLintPlugins") 107 | ]), 108 | .testTarget( 109 | name: "NetworkHandlerAHCTests", 110 | dependencies: [ 111 | "NetworkHandler", 112 | "TestSupport", 113 | "PizzaMacros", 114 | .product(name: "Logging", package: "swift-log"), 115 | "NetworkHandlerAHCEngine", 116 | ], 117 | plugins: [ 118 | .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLintPlugins") 119 | ]), 120 | .testTarget( 121 | name: "NetworkHalpersTests", 122 | dependencies: [ 123 | "NetworkHalpers", 124 | "TestSupport", 125 | "PizzaMacros", 126 | ], 127 | plugins: [ 128 | .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLintPlugins") 129 | ]), 130 | ] 131 | 132 | #if !canImport(FoundationNetworking) 133 | products.append( 134 | .library( 135 | name: "NetworkHandlerURLSessionEngine", 136 | targets: ["NetworkHandlerURLSessionEngine"])) 137 | 138 | targets.append( 139 | .target( 140 | name: "NetworkHandlerURLSessionEngine", 141 | dependencies: [ 142 | "NetworkHandler", 143 | "NetworkHalpers", 144 | "SwiftPizzaSnips", 145 | .product(name: "Logging", package: "swift-log"), 146 | ], 147 | plugins: [ 148 | .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLintPlugins") 149 | ])) 150 | 151 | targets.append( 152 | .testTarget( 153 | name: "NetworkHandlerURLSessionTests", 154 | dependencies: [ 155 | "NetworkHandler", 156 | "TestSupport", 157 | "PizzaMacros", 158 | .product(name: "Logging", package: "swift-log"), 159 | "NetworkHandlerURLSessionEngine", 160 | ], 161 | plugins: [ 162 | .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLintPlugins") 163 | ])) 164 | #endif 165 | 166 | let package = Package( 167 | name: "NetworkHandler", 168 | platforms: [ 169 | .macOS(.v13), 170 | .iOS(.v16), 171 | .tvOS(.v16), 172 | .watchOS(.v8), 173 | ], 174 | products: products, 175 | dependencies: [ 176 | .package(url: "https://github.com/apple/swift-crypto.git", .upToNextMajor(from: "3.0.0")), 177 | .package(url: "https://github.com/mredig/PizzaMacros.git", .upToNextMajor(from: "0.1.0")), 178 | .package(url: "https://github.com/mredig/SwiftPizzaSnips.git", .upToNextMajor(from: "0.4.35")), 179 | // .package(url: "https://github.com/mredig/SwiftPizzaSnips.git", branch: "0.4.34h"), 180 | .package(url: "https://github.com/mredig/SwiftlyDotEnv.git", .upToNextMinor(from: "0.2.3")), 181 | .package(url: "https://github.com/swift-server/async-http-client", .upToNextMajor(from: "1.25.2")), 182 | .package(url: "https://github.com/apple/swift-log.git", .upToNextMajor(from: "1.6.2")), 183 | .package(url: "https://github.com/apple/swift-algorithms.git", .upToNextMajor(from: "1.2.1")), 184 | .package(url: "https://github.com/SimplyDanny/SwiftLintPlugins", from: "0.58.2") 185 | ], 186 | targets: targets) 187 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # NetworkHandler 2 | 3 | ✅ Reduce boilerplate. 4 | ✅ Consistent interface. 5 | ✅ Cross platform support. 6 | ❌ Headache. 7 | 8 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fmredig%2FNetworkHandler%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/mredig/NetworkHandler) 9 | 10 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fmredig%2FNetworkHandler%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/mredig/NetworkHandler) 11 | 12 | NetworkHandler was originally created to reduce boilerplate when using `URLSession`. However, it's since grown into a unified, consistent abstraction built on top any engine conforming to `NetworkEngine` 13 | 14 | By default, `NetworkEngine` implementations are provided for both `URLSession` if you have full access to `Foundation` (aka Apple platforms) and `AsyncHTTPClient` when you don't (or do, I'm not your mom). 15 | 16 | 17 | ### Getting Started 18 | 19 | 1. Add the line 20 | ```swift 21 | .package(url: "https://github.com/mredig/NetworkHandler.git", from: "3.0.0")) 22 | ``` 23 | to the appropriate section of your Package.swift 24 | 1. Add the dependency `NetworkHandlerURLSessionEngine` or `NetworkHandlerAHCEngine`, whichever you wish to use (The URLSession engine is unavailable on Linux) to your target. 25 | 1. Add `import NetworkHandler` to the top of any file you with to use it in 26 | 1. Here's a simple demo usage: 27 | 28 | ```swift 29 | public struct DemoModel: Codable, Equatable, Sendable { 30 | public let id: UUID 31 | public var title: String 32 | public var subtitle: String 33 | public var imageURL: URL 34 | 35 | public init(id: UUID = UUID(), title: String, subtitle: String, imageURL: URL) { 36 | self.id = id 37 | self.title = title 38 | self.subtitle = subtitle 39 | self.imageURL = imageURL 40 | } 41 | } 42 | 43 | func getDemoModel() async throws(NetworkError) { 44 | let urlSession = URLSession.asEngine(withConfiguration: .networkHandlerDefault) 45 | 46 | let networkHander = NetworkHandler(name: "Jimbob", engine: urlSession) 47 | 48 | let url = URL(string: "https://s3.wasabisys.com/network-handler-tests/coding/demoModel.json") 49 | 50 | let resultModel: DemoModel = try await nh.downloadMahCodableDatas(for: url.downloadRequest).decoded 51 | 52 | print(resultModel) 53 | } 54 | ``` 55 | 56 | 57 | 58 | Further documentation is available on [SwiftPackageIndex](https://swiftpackageindex.com/mredig/NetworkHandler/main/documentation/networkhandler) -------------------------------------------------------------------------------- /Sources/NetworkHalpers/CodingHelpers.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(FoundationNetworking) 3 | import FoundationNetworking 4 | #endif 5 | 6 | /// Allows you to conform to this protocol to become compatible with `NetworkRequest.encodeData` 7 | public protocol NHEncoder: Sendable { 8 | func encode(_ encodable: T) throws -> Data 9 | } 10 | 11 | /// Allows you to conform to this protocol to become compatible with `NetworkHandler.transferMahCodableDatas` 12 | public protocol NHDecoder: Sendable { 13 | func decode(_ type: T.Type, from data: Data) throws -> T 14 | } 15 | 16 | extension JSONEncoder: NHEncoder {} 17 | extension PropertyListEncoder: NHEncoder {} 18 | extension JSONDecoder: NHDecoder {} 19 | extension PropertyListDecoder: NHDecoder {} 20 | -------------------------------------------------------------------------------- /Sources/NetworkHalpers/CryptoConveniences.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Crypto 3 | 4 | package protocol DigestToValues: Sequence { 5 | func bytes() -> Data 6 | func hex() -> String 7 | func base64(options: Data.Base64EncodingOptions) -> String 8 | } 9 | 10 | package extension DigestToValues where Element == UInt8 { 11 | func bytes() -> Data { 12 | Data(self) 13 | } 14 | 15 | func hex() -> String { 16 | self 17 | .map { 18 | let value = String($0, radix: 16) 19 | return value.count == 2 ? value : "0\(value)" 20 | } 21 | .joined() 22 | } 23 | 24 | func base64(options: Data.Base64EncodingOptions) -> String { 25 | bytes().base64EncodedString(options: options) 26 | } 27 | } 28 | 29 | extension SHA256Digest: DigestToValues {} 30 | extension SHA384Digest: DigestToValues {} 31 | extension SHA512Digest: DigestToValues {} 32 | extension Insecure.MD5Digest: DigestToValues {} 33 | extension Insecure.SHA1Digest: DigestToValues {} 34 | 35 | extension HashedAuthenticationCode: DigestToValues {} 36 | -------------------------------------------------------------------------------- /Sources/NetworkHalpers/HTTPMethod.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftPizzaSnips 3 | 4 | /// Pre-typed strings for use with `NetworkRequest`, `GeneralEngineRequest`, and `UploadEngineRequest` 5 | public struct HTTPMethod: 6 | RawRepresentable, 7 | Sendable, 8 | Hashable, 9 | Withable, 10 | ExpressibleByStringLiteral, 11 | ExpressibleByStringInterpolation { 12 | 13 | public let rawValue: String 14 | 15 | public init?(rawValue: String) { 16 | self.rawValue = rawValue 17 | } 18 | 19 | public init(stringLiteral value: String) { 20 | self.rawValue = value 21 | } 22 | 23 | /// Convenience for the `POST` HTTP method 24 | static public let post: HTTPMethod = "POST" 25 | /// Convenience for the `PUT` HTTP method 26 | static public let put: HTTPMethod = "PUT" 27 | /// Convenience for the `DELETE` HTTP method 28 | static public let delete: HTTPMethod = "DELETE" 29 | /// Convenience for the `GET` HTTP method 30 | static public let get: HTTPMethod = "GET" 31 | /// Convenience for the `HEAD` HTTP method 32 | static public let head: HTTPMethod = "HEAD" 33 | /// Convenience for the `PATCH` HTTP method 34 | static public let patch: HTTPMethod = "PATCH" 35 | /// Convenience for the `OPTIONS` HTTP method 36 | static public let options: HTTPMethod = "OPTIONS" 37 | } 38 | -------------------------------------------------------------------------------- /Sources/NetworkHalpers/Headers/HTTPHeaders.Header.Key.swift: -------------------------------------------------------------------------------- 1 | extension HTTPHeaders.Header { 2 | /// Pre-typed strings for use with formatting headers 3 | public struct Key: 4 | RawRepresentable, 5 | Codable, 6 | Hashable, 7 | Sendable, 8 | ExpressibleByStringLiteral, 9 | ExpressibleByStringInterpolation { 10 | 11 | /// A normalized, lowercased version of the `canonical` value. This allows for case insensitive equality and hashing. 12 | public var key: String { rawValue } 13 | /// Required for `RawRepresentable`. Duplicates the `key` value. 14 | public var rawValue: String { canonical.lowercased() } 15 | /// Value that will be stored as the key in the HTTP Header. 16 | public var canonical: String 17 | 18 | public init(stringLiteral value: StringLiteralType) { 19 | self.init(rawValue: value) 20 | } 21 | 22 | public init(rawValue: String) { 23 | self.canonical = rawValue 24 | } 25 | 26 | /// Convenience for the `Accept` HTTP Header name 27 | public static let accept: Key = "Accept" 28 | /// Convenience for the `Accept-Charset` HTTP Header name 29 | public static let acceptCharset: Key = "Accept-Charset" 30 | /// Convenience for the `Accept-Datetime` HTTP Header name 31 | public static let acceptDatetime: Key = "Accept-Datetime" 32 | /// Convenience for the `Accept-Encoding` HTTP Header name 33 | public static let acceptEncoding: Key = "Accept-Encoding" 34 | /// Convenience for the `Accept-Language` HTTP Header name 35 | public static let acceptLanguage: Key = "Accept-Language" 36 | /// Convenience for the `Allow` HTTP Header name 37 | public static let allow: Key = "Allow" 38 | /// Convenience for the `Authorization` HTTP Header name 39 | public static let authorization: Key = "Authorization" 40 | /// Convenience for the `Cache-Control` HTTP Header name 41 | public static let cacheControl: Key = "Cache-Control" 42 | /// Convenience for the `Content-Disposition` HTTP Header name 43 | public static let contentDisposition: Key = "Content-Disposition" 44 | /// Convenience for the `Content-Encoding` HTTP Header name 45 | public static let contentEncoding: Key = "Content-Encoding" 46 | /// Convenience for the `Content-Language` HTTP Header name 47 | public static let contentLanguage: Key = "Content-Language" 48 | /// Convenience for the `Content-Length` HTTP Header name 49 | public static let contentLength: Key = "Content-Length" 50 | /// Convenience for the `Content-Location` HTTP Header name 51 | public static let contentLocation: Key = "Content-Location" 52 | /// Convenience for the `Content-Type` HTTP Header name 53 | public static let contentType: Key = "Content-Type" 54 | /// Convenience for the `Cookie` HTTP Header name 55 | public static let cookie: Key = "Cookie" 56 | /// Convenience for the `Date` HTTP Header name 57 | public static let date: Key = "Date" 58 | /// Convenience for the `Expect` HTTP Header name 59 | public static let expect: Key = "Expect" 60 | /// Convenience for the `Front-End-Https` HTTP Header name 61 | public static let frontEndHttps: Key = "Front-End-Https" 62 | /// Convenience for the `If-Match` HTTP Header name 63 | public static let ifMatch: Key = "If-Match" 64 | /// Convenience for the `If-Modified-Since` HTTP Header name 65 | public static let ifModifiedSince: Key = "If-Modified-Since" 66 | /// Convenience for the `If-None-Match` HTTP Header name 67 | public static let ifNoneMatch: Key = "If-None-Match" 68 | /// Convenience for the `If-Range` HTTP Header name 69 | public static let ifRange: Key = "If-Range" 70 | /// Convenience for the `If-Unmodified-Since` HTTP Header name 71 | public static let ifUnmodifiedSince: Key = "If-Unmodified-Since" 72 | /// Convenience for the `Max-Forwards` HTTP Header name 73 | public static let maxForwards: Key = "Max-Forwards" 74 | /// Convenience for the `Pragma` HTTP Header name 75 | public static let pragma: Key = "Pragma" 76 | /// Convenience for the `Proxy-Authorization` HTTP Header name 77 | public static let proxyAuthorization: Key = "Proxy-Authorization" 78 | /// Convenience for the `Proxy-Connection` HTTP Header name 79 | public static let proxyConnection: Key = "Proxy-Connection" 80 | /// Convenience for the `Range` HTTP Header name 81 | public static let range: Key = "Range" 82 | /// Convenience for the `Referer` HTTP Header name 83 | public static let referer: Key = "Referer" 84 | /// Convenience for the `Server` HTTP Header name 85 | public static let server: Key = "Server" 86 | /// Convenience for the `Set-Cookie` HTTP Header name 87 | public static let setCookie: Key = "Set-Cookie" 88 | /// Convenience for the `TE` HTTP Header name 89 | public static let TE: Key = "TE" 90 | /// Convenience for the `Upgrade` HTTP Header name 91 | public static let upgrade: Key = "Upgrade" 92 | /// Convenience for the `User-Agent` HTTP Header name 93 | public static let userAgent: Key = "User-Agent" 94 | /// Convenience for the `Via` HTTP Header name 95 | public static let via: Key = "Via" 96 | /// Convenience for the `Warning` HTTP Header name 97 | public static let warning: Key = "Warning" 98 | /// Convenience for the `X-Request-ID` HTTP Header name 99 | public static let xRequestID: Key = "X-Request-ID" 100 | 101 | public static func == (lhs: Key, rhs: Key) -> Bool { 102 | lhs.key == rhs.key 103 | } 104 | 105 | public func hash(into hasher: inout Hasher) { 106 | hasher.combine(key) 107 | } 108 | 109 | public static func == (lhs: Key, rhs: String?) -> Bool { 110 | lhs.key == rhs?.lowercased() 111 | } 112 | 113 | public static func == (lhs: String?, rhs: Key) -> Bool { 114 | rhs == lhs 115 | } 116 | 117 | public static func != (lhs: Key, rhs: String?) -> Bool { 118 | !(lhs == rhs) 119 | } 120 | 121 | public static func != (lhs: String?, rhs: Key) -> Bool { 122 | rhs != lhs 123 | } 124 | } 125 | } 126 | 127 | extension HTTPHeaders.Header.Key: CustomStringConvertible, CustomDebugStringConvertible { 128 | public var description: String { canonical } 129 | public var debugDescription: String { 130 | "HeaderKey: \(description)" 131 | } 132 | } 133 | 134 | extension HTTPHeaders.Header.Key: Comparable { 135 | public static func < (lhs: HTTPHeaders.Header.Key, rhs: HTTPHeaders.Header.Key) -> Bool { 136 | lhs.rawValue < rhs.rawValue 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /Sources/NetworkHalpers/Headers/HTTPHeaders.Header.Value.swift: -------------------------------------------------------------------------------- 1 | extension HTTPHeaders.Header { 2 | public struct Value: 3 | RawRepresentable, 4 | Codable, 5 | Hashable, 6 | Sendable, 7 | ExpressibleByStringLiteral, 8 | ExpressibleByStringInterpolation { 9 | 10 | public let rawValue: String 11 | public var value: String { rawValue } 12 | 13 | public init(stringLiteral value: StringLiteralType) { 14 | self.rawValue = value 15 | } 16 | 17 | public init(rawValue: String) { 18 | self.rawValue = rawValue 19 | } 20 | 21 | // common Content-Type values 22 | /// Convenience for the `application/javascript` `Content-Type` value 23 | public static let javascript: Value = "application/javascript" 24 | /// Convenience for the `application/json` `Content-Type` value 25 | public static let json: Value = "application/json" 26 | /// Convenience for the `application/octet-stream` `Content-Type` value 27 | public static let octetStream: Value = "application/octet-stream" 28 | /// Convenience for the `application/x-font-woff` `Content-Type` value 29 | public static let xFontWoff: Value = "application/x-font-woff" 30 | /// Convenience for the `application/xml` `Content-Type` value 31 | public static let xml: Value = "application/xml" 32 | /// Convenience for the `audio/mp4` `Content-Type` value 33 | public static let audioMp4: Value = "audio/mp4" 34 | /// Convenience for the `audio/ogg` `Content-Type` value 35 | public static let ogg: Value = "audio/ogg" 36 | /// Convenience for the `font/opentype` `Content-Type` value 37 | public static let opentype: Value = "font/opentype" 38 | /// Convenience for the `image/svg+xml` `Content-Type` value 39 | public static let svgXml: Value = "image/svg+xml" 40 | /// Convenience for the `image/webp` `Content-Type` value 41 | public static let webp: Value = "image/webp" 42 | /// Convenience for the `image/x-icon` `Content-Type` value 43 | public static let xIcon: Value = "image/x-icon" 44 | /// Convenience for the `text/cache-manifest` `Content-Type` value 45 | public static let cacheManifest: Value = "text/cache-manifest" 46 | /// Convenience for the `text/v-card` `Content-Type` value 47 | public static let vCard: Value = "text/v-card" 48 | /// Convenience for the `text/vtt` `Content-Type` value 49 | public static let vtt: Value = "text/vtt" 50 | /// Convenience for the `video/mp4` `Content-Type` value 51 | public static let videoMp4: Value = "video/mp4" 52 | /// Convenience for the `video/ogg` `Content-Type` value 53 | public static let videoOgg: Value = "video/ogg" 54 | /// Convenience for the `video/webm` `Content-Type` value 55 | public static let webm: Value = "video/webm" 56 | /// Convenience for the `video/x-flv` `Content-Type` value 57 | public static let xFlv: Value = "video/x-flv" 58 | /// Convenience for the `image/png` `Content-Type` value 59 | public static let png: Value = "image/png" 60 | /// Convenience for the `image/jpeg` `Content-Type` value 61 | public static let jpeg: Value = "image/jpeg" 62 | /// Convenience for the `image/bmp` `Content-Type` value 63 | public static let bmp: Value = "image/bmp" 64 | /// Convenience for the `text/css` `Content-Type` value 65 | public static let css: Value = "text/css" 66 | /// Convenience for the `image/gif` `Content-Type` value 67 | public static let gif: Value = "image/gif" 68 | /// Convenience for the `text/html` `Content-Type` value 69 | public static let html: Value = "text/html" 70 | /// Convenience for the `audio/mpeg` `Content-Type` value 71 | public static let audioMpeg: Value = "audio/mpeg" 72 | /// Convenience for the `video/mpeg` `Content-Type` value 73 | public static let videoMpeg: Value = "video/mpeg" 74 | /// Convenience for the `application/pdf` `Content-Type` value 75 | public static let pdf: Value = "application/pdf" 76 | /// Convenience for the `video/quicktime` `Content-Type` value 77 | public static let quicktime: Value = "video/quicktime" 78 | /// Convenience for the `application/rtf` `Content-Type` value 79 | public static let rtf: Value = "application/rtf" 80 | /// Convenience for the `image/tiff` `Content-Type` value 81 | public static let tiff: Value = "image/tiff" 82 | /// Convenience for the `text/plain` `Content-Type` value 83 | public static let plain: Value = "text/plain" 84 | /// Convenience for the `application/zip` `Content-Type` value 85 | public static let zip: Value = "application/zip" 86 | /// Convenience for the `application/x-plist` `Content-Type` value 87 | public static let plist: Value = "application/x-plist" 88 | /// If using built in multipart form support, look into `MultipartFormInputStream.multipartContentTypeHeaderValue` 89 | public static func multipart(boundary: String) -> Value { 90 | "multipart/form-data; boundary=\(boundary)" 91 | } 92 | 93 | public static func == (lhs: Value, rhs: String?) -> Bool { 94 | lhs.value == rhs 95 | } 96 | 97 | public static func == (lhs: String?, rhs: Value) -> Bool { 98 | rhs == lhs 99 | } 100 | 101 | public static func != (lhs: Value, rhs: String?) -> Bool { 102 | !(lhs == rhs) 103 | } 104 | 105 | public static func != (lhs: String?, rhs: Value) -> Bool { 106 | rhs != lhs 107 | } 108 | } 109 | } 110 | 111 | extension HTTPHeaders.Header.Value: CustomStringConvertible, CustomDebugStringConvertible { 112 | public var description: String { value } 113 | public var debugDescription: String { 114 | "HeaderValue: \(description)" 115 | } 116 | } 117 | 118 | extension HTTPHeaders.Header.Value: Comparable { 119 | public static func < (lhs: HTTPHeaders.Header.Value, rhs: HTTPHeaders.Header.Value) -> Bool { 120 | lhs.rawValue < rhs.rawValue 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Sources/NetworkHalpers/Headers/HTTPHeaders.Header.swift: -------------------------------------------------------------------------------- 1 | extension HTTPHeaders { 2 | /// A single HTTP header, represented by a key-value pair. 3 | /// Encapsulates a key (`Key`) and its associated value (`Value`), providing type safety and protocol conformance. 4 | public struct Header: Hashable, Sendable, Codable { 5 | /// The key of the HTTP header. 6 | public let key: Key 7 | /// The value of the HTTP header. 8 | public let value: Value 9 | 10 | /// Creates a new HTTP header instance with the given key and value. 11 | /// - Parameters: 12 | /// - key: The key of the header. Must conform to `Header.Key`. 13 | /// - value: The value of the header. Must conform to `Header.Value`. 14 | public init(key: Key, value: Value) { 15 | self.key = key 16 | self.value = value 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/NetworkHalpers/Multipart/ConcatenatedInputStream.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Logging 3 | 4 | /// A class delightfully engineered to seamlessly concatenate multiple `InputStream` instances. 5 | /// 6 | /// Think of it as a marvelous utilitarian tool akin to the Pan Galactic Gargle Blaster. 7 | /// It elegantly takes multiple `InputStream`s—each potentially brimming with untold data— 8 | /// and melds them into a single cohesive stream. Whether used for combining files, network 9 | /// responses, or other miscellany of data, its sole mission is to deliver your concatenated 10 | /// content with the smooth precision of a hyperspace bypass. 11 | /// 12 | /// Overrides the standard `InputStream` methods for proper functionality, sprinkling in 13 | /// just the right amount of additional logic to achieve its heroic task of unification. 14 | public class ConcatenatedInputStream: InputStream { 15 | 16 | public private(set) var streams: [InputStream] = [] 17 | 18 | private var streamIndex = 0 19 | 20 | public override var hasBytesAvailable: Bool { 21 | (try? getCurrentStream().hasBytesAvailable) ?? false 22 | } 23 | 24 | private var _streamStatus: Stream.Status = .notOpen 25 | public override var streamStatus: Stream.Status { _streamStatus } 26 | 27 | public init(streams: [InputStream], logger: Logger? = nil) throws { 28 | super.init(data: Data()) 29 | 30 | try streams.forEach { 31 | try addStream($0) 32 | } 33 | } 34 | 35 | public var logger: Logger? 36 | 37 | public init(logger: Logger? = nil) { 38 | super.init(data: Data()) 39 | } 40 | 41 | public override func open() { 42 | _streamStatus = .open 43 | } 44 | 45 | public override func close() { 46 | _streamStatus = .closed 47 | } 48 | 49 | private weak var _delegate: StreamDelegate? 50 | public override var delegate: StreamDelegate? { 51 | get { _delegate } 52 | set { _delegate = newValue } 53 | } 54 | 55 | public func addStream(_ stream: InputStream) throws { 56 | guard _streamStatus == .notOpen else { throw StreamConcatError.cannotAddStreamsOnceOpen } 57 | switch stream.streamStatus { 58 | case .open: 59 | logger?.warning(""" 60 | Warning: stream already open after adding to concatenation. When reading, it will continue where \ 61 | it left off, if already read. 62 | """) 63 | case .notOpen: 64 | break 65 | default: 66 | throw StreamConcatError.mustStartInNotOpenState 67 | } 68 | streams.append(stream) 69 | } 70 | 71 | public override func read(_ buffer: UnsafeMutablePointer, maxLength len: Int) -> Int { 72 | _streamStatus = .reading 73 | var statusOnExit: Stream.Status = .open 74 | defer { _streamStatus = statusOnExit } 75 | 76 | var count = 0 77 | while count < len { 78 | do { 79 | let stream = try getCurrentStream() 80 | count += read(stream: stream, into: buffer, writingIntoPointerAt: count, maxLength: len - count) 81 | } catch StreamConcatError.atEndOfStreams { 82 | statusOnExit = .atEnd 83 | return count 84 | } catch { 85 | logger?.error("Error getting current stream: \(error)") 86 | } 87 | } 88 | return count 89 | } 90 | 91 | private func read( 92 | stream: InputStream, 93 | into pointer: UnsafeMutablePointer, 94 | writingIntoPointerAt startOffset: Int, 95 | maxLength: Int 96 | ) -> Int { 97 | let pointerWithOffset = pointer.advanced(by: startOffset) 98 | return stream.read(pointerWithOffset, maxLength: maxLength) 99 | } 100 | 101 | private func getCurrentStream() throws -> InputStream { 102 | guard streamIndex < streams.count else { throw StreamConcatError.atEndOfStreams } 103 | let stream = streams[streamIndex] 104 | switch stream.streamStatus { 105 | case .open: 106 | if stream.hasBytesAvailable { 107 | return stream 108 | } else { 109 | stream.close() 110 | return try getCurrentStream() 111 | } 112 | case .notOpen: 113 | stream.open() 114 | return try getCurrentStream() 115 | case .atEnd: 116 | stream.close() 117 | streamIndex += 1 118 | return try getCurrentStream() 119 | case .error: 120 | throw stream.streamError ?? StreamConcatError.unknownError 121 | case .closed: 122 | if streamIndex >= streams.count { 123 | throw StreamConcatError.atEndOfStreams 124 | } else { 125 | streamIndex += 1 126 | return try getCurrentStream() 127 | } 128 | default: 129 | logger?.error("Unexpected status: \(stream.streamStatus)") 130 | throw StreamConcatError.unexpectedStatus(stream.streamStatus) 131 | } 132 | } 133 | 134 | public override func getBuffer( 135 | _ buffer: UnsafeMutablePointer?>, 136 | length len: UnsafeMutablePointer 137 | ) -> Bool { false } 138 | 139 | public override func schedule(in aRunLoop: RunLoop, forMode mode: RunLoop.Mode) {} 140 | public override func remove(from aRunLoop: RunLoop, forMode mode: RunLoop.Mode) {} 141 | #if canImport(FoundationNetworking) 142 | public override func property(forKey key: Stream.PropertyKey) -> AnyObject? { nil } 143 | public override func setProperty(_ property: AnyObject?, forKey key: Stream.PropertyKey) -> Bool { false } 144 | #else 145 | public override func property(forKey key: Stream.PropertyKey) -> Any? { nil } 146 | public override func setProperty(_ property: Any?, forKey key: Stream.PropertyKey) -> Bool { false } 147 | #endif 148 | 149 | public enum StreamConcatError: Error { 150 | case atEndOfStreams 151 | case unexpectedStatus(Stream.Status) 152 | case mustStartInNotOpenState 153 | case cannotAddStreamsOnceOpen 154 | case unknownError 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /Sources/NetworkHalpers/Multipart/MultipartFormInputStream.Part.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(UniformTypeIdentifiers) 3 | import UniformTypeIdentifiers 4 | #endif 5 | 6 | extension MultipartFormInputStream { 7 | static let genericBinaryMimeType = "application/octet-stream" 8 | static func getMimeType(forFileExtension pathExt: String) -> String { 9 | #if canImport(UniformTypeIdentifiers) 10 | let type = UTType(filenameExtension: pathExt) 11 | return type?.preferredMIMEType ?? genericBinaryMimeType 12 | #else 13 | genericBinaryMimeType 14 | #endif 15 | } 16 | 17 | class Part: ConcatenatedInputStream { 18 | let copyGenerator: () -> Part 19 | 20 | let headers: Data 21 | let body: InputStream 22 | var bodyLength: Int 23 | var headersLength: Int { headers.count } 24 | var length: Int { headersLength + bodyLength + 2 } 25 | 26 | private lazy var headerStream: InputStream = { 27 | let stream = InputStream(data: headers) 28 | return stream 29 | }() 30 | 31 | private let footerStream: InputStream = { 32 | let stream = InputStream(data: Data("\r\n".utf8)) 33 | return stream 34 | }() 35 | 36 | init(withName name: String, boundary: String, string: String) { 37 | let headerStr = "--\(boundary)\r\nContent-Disposition: form-data; name=\"\(name)\"\r\n\r\n" 38 | self.headers = headerStr.data(using: .utf8) ?? Data(headerStr.utf8) 39 | let strData = string.data(using: .utf8) ?? Data(string.utf8) 40 | self.body = InputStream(data: strData) 41 | self.bodyLength = strData.count 42 | self.copyGenerator = { 43 | Part(withName: name, boundary: boundary, string: string) 44 | } 45 | 46 | super.init() 47 | } 48 | 49 | init(withName name: String, boundary: String, data: Data, contentType: String, filename: String? = nil) { 50 | let headerStr: String 51 | if let filename = filename { 52 | headerStr = """ 53 | --\(boundary)\r\nContent-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\nContent-\ 54 | Type: \(contentType)\r\n\r\n 55 | """ 56 | } else { 57 | headerStr = """ 58 | --\(boundary)\r\nContent-Disposition: form-data; name=\"\(name)\"\r\nContent-Type: \(contentType)\r\n\r\n 59 | """ 60 | } 61 | self.headers = headerStr.data(using: .utf8) ?? Data(headerStr.utf8) 62 | self.body = InputStream(data: data) 63 | self.bodyLength = data.count 64 | self.copyGenerator = { 65 | Part(withName: name, boundary: boundary, data: data, contentType: contentType, filename: filename) 66 | } 67 | super.init() 68 | } 69 | 70 | init( 71 | withName name: String, 72 | boundary: String, 73 | filename: String? = nil, 74 | fileURL: URL, 75 | contentType: String? = nil 76 | ) throws { 77 | let contentType = contentType ?? MultipartFormInputStream.getMimeType(forFileExtension: fileURL.pathExtension) 78 | 79 | let headerStr = """ 80 | --\(boundary)\r\nContent-Disposition: form-data; name=\"\(name)\"; \ 81 | filename=\"\(filename ?? fileURL.lastPathComponent)\"\r\nContent-Type: \(contentType)\r\n\r\n 82 | """ 83 | self.headers = headerStr.data(using: .utf8) ?? Data(headerStr.utf8) 84 | guard 85 | let fileStream = InputStream(url: fileURL), 86 | let attributes = try? FileManager.default.attributesOfItem(atPath: fileURL.path), 87 | let fileSize = attributes[.size] as? Int 88 | else { throw PartError.fileAttributesInaccessible } 89 | self.body = fileStream 90 | self.bodyLength = fileSize 91 | self.copyGenerator = { 92 | // swiftlint:disable:next force_try 93 | try! Part(withName: name, boundary: boundary, filename: filename, fileURL: fileURL, contentType: contentType) 94 | } 95 | super.init() 96 | } 97 | 98 | init(footerStreamWithBoundary boundary: String) { 99 | let headerStr = "--" 100 | self.headers = headerStr.data(using: .utf8) ?? Data(headerStr.utf8) 101 | let bodyStr = "\(boundary)--" 102 | let body = bodyStr.data(using: .utf8) ?? Data(bodyStr.utf8) 103 | self.body = InputStream(data: body) 104 | self.bodyLength = body.count 105 | self.copyGenerator = { 106 | Part(footerStreamWithBoundary: boundary) 107 | } 108 | super.init() 109 | } 110 | 111 | // intentionally keeping close overriden for context next to `open` 112 | // swiftlint:disable:next unneeded_override 113 | override func close() { super.close() } 114 | 115 | override func open() { 116 | do { 117 | try addStream(headerStream) 118 | try addStream(body) 119 | try addStream(footerStream) 120 | } catch { 121 | logger?.error("Error concatenating streams: \(error)") 122 | } 123 | super.open() 124 | } 125 | 126 | enum PartError: Error { 127 | case fileAttributesInaccessible 128 | } 129 | } 130 | } 131 | 132 | extension MultipartFormInputStream.Part: NSCopying { 133 | func copy(with zone: NSZone? = nil) -> Any { 134 | copyGenerator() 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /Sources/NetworkHalpers/Multipart/MultipartFormInputStream.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /** 4 | Use this to generate a stream to upload multipart form data. This can be very efficient as it generates meta data on 5 | the fly and streams data from existing sources (both Data in memory and bytes from disk) instead of copying any data 6 | elsewhere. However, it does NOT allow for accurate progress when uploading and ultimately may result in highly 7 | inaccurate progress reports. Because of this, I'm not sure if it's worth holding onto this class and am considering 8 | deprecating it. 9 | */ 10 | public class MultipartFormInputStream: ConcatenatedInputStream { 11 | public let boundary: String 12 | private let originalBoundary: String 13 | 14 | private var addedFooter = false 15 | 16 | /// intended to be an approximation, not exact. Will not include footer if has not been added yet. 17 | public var totalSize: Int { 18 | streams.reduce(0) { $0 + (($1 as? Part)?.length ?? 0) } 19 | } 20 | 21 | public var multipartContentTypeHeaderValue: HTTPHeaders.Header.Value { 22 | "multipart/form-data; boundary=\(boundary)" 23 | } 24 | 25 | private weak var _delegate: StreamDelegate? 26 | public override var delegate: StreamDelegate? { 27 | get { _delegate } 28 | set { _delegate = newValue } 29 | } 30 | 31 | public init(boundary: String = UUID().uuidString) { 32 | self.originalBoundary = boundary 33 | self.boundary = "Boundary-\(boundary)" 34 | super.init() 35 | } 36 | 37 | public func addPart(named name: String, string: String) { 38 | let part = Part(withName: name, boundary: boundary, string: string) 39 | // there is no way this should be able to fail 40 | try! addStream(part) // swiftlint:disable:this force_try 41 | } 42 | 43 | public func addPart( 44 | named name: String, 45 | data: Data, 46 | filename: String? = nil, 47 | contentType: String = "application/octet-stream" 48 | ) { 49 | let part = Part(withName: name, boundary: boundary, data: data, contentType: contentType, filename: filename) 50 | // there is no way this should be able to fail 51 | try! addStream(part) // swiftlint:disable:this force_try 52 | } 53 | 54 | public func addPart( 55 | named name: String, 56 | fileURL: URL, 57 | filename: String? = nil, 58 | contentType: String 59 | ) throws { 60 | let part = try Part( 61 | withName: name, 62 | boundary: boundary, 63 | filename: filename, 64 | fileURL: fileURL, 65 | contentType: contentType) 66 | try addStream(part) 67 | } 68 | 69 | public override func open() { 70 | if addedFooter == false { 71 | // there is no way this should be able to fail 72 | try! addStream(Part(footerStreamWithBoundary: boundary)) // swiftlint:disable:this force_try 73 | addedFooter = true 74 | } 75 | super.open() 76 | } 77 | 78 | public override func getBuffer( 79 | _ buffer: UnsafeMutablePointer?>, 80 | length len: UnsafeMutablePointer 81 | ) -> Bool { false } 82 | 83 | public override func schedule(in aRunLoop: RunLoop, forMode mode: RunLoop.Mode) {} 84 | public override func remove(from aRunLoop: RunLoop, forMode mode: RunLoop.Mode) {} 85 | 86 | public override func addStream(_ stream: InputStream) throws { 87 | guard type(of: stream) == Part.self else { throw MultipartError.streamNotPart } 88 | try super.addStream(stream) 89 | } 90 | 91 | enum MultipartError: Error { 92 | case streamNotPart 93 | } 94 | } 95 | 96 | extension MultipartFormInputStream: NSCopying { 97 | public func copy(with zone: NSZone? = nil) -> Any { 98 | safeCopy() 99 | } 100 | 101 | public func safeCopy() -> MultipartFormInputStream { 102 | let newCopy = MultipartFormInputStream(boundary: originalBoundary) 103 | 104 | streams.forEach { 105 | guard let streamCopy = $0.copy() as? Part else { fatalError("Can't copy stream") } 106 | try! newCopy.addStream(streamCopy) // swiftlint:disable:this force_try 107 | } 108 | 109 | newCopy.addedFooter = addedFooter 110 | 111 | return newCopy 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Sources/NetworkHalpers/Multipart/MultipartFormInputTempFile.Part.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension MultipartFormInputTempFile { 4 | struct Part { 5 | var headers: Data { 6 | headersString.data(using: .utf8) ?? Data(headersString.utf8) 7 | } 8 | var headersString: String { 9 | var out = "--\(boundary)\r\n" 10 | 11 | var contentDispositionInfo: [String] = [] 12 | if content != nil { 13 | contentDispositionInfo.append("Content-Disposition: form-data") 14 | } 15 | if let name = name { 16 | contentDispositionInfo.append("name=\"\(name)\"") 17 | } 18 | if let filename = filename { 19 | contentDispositionInfo.append("filename=\"\(filename)\"") 20 | } 21 | out += contentDispositionInfo.joined(separator: "; ") 22 | 23 | if let contentType = contentType { 24 | out += "\r\nContent-Type: \(contentType)\r\n\r\n" 25 | } else { 26 | out += "\r\n\r\n" 27 | } 28 | return out 29 | } 30 | 31 | var footer: Data { 32 | footerString.data(using: .utf8) ?? Data(footerString.utf8) 33 | } 34 | var footerString: String { 35 | "\r\n" 36 | } 37 | 38 | let name: String? 39 | let boundary: String 40 | let filename: String? 41 | let contentType: String? 42 | let content: Content? 43 | 44 | init( 45 | name: String?, 46 | boundary: String, 47 | filename: String? = nil, 48 | contentType: String? = nil, 49 | content: MultipartFormInputTempFile.Part.Content?) { 50 | self.name = name 51 | self.boundary = boundary 52 | self.filename = filename ?? content?.filename 53 | self.contentType = contentType ?? content? 54 | .filename 55 | .map { MultipartFormInputStream.getMimeType(forFileExtension: ($0 as NSString).pathExtension ) } 56 | self.content = content 57 | } 58 | 59 | enum Content { 60 | case localURL(URL) 61 | case data(Data) 62 | 63 | var filename: String? { 64 | guard case .localURL(let url) = self else { return nil } 65 | return url.lastPathComponent 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/NetworkHalpers/Multipart/MultipartFormInputTempFile.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftPizzaSnips 3 | 4 | /** 5 | Use this to generate a binary file to upload multipart form data. This copies source data and files into a single 6 | blob prior to uploading a file, so be aware of this behavior. Be sure to use a `URLSessionConfig.background` instance 7 | to get proper progress reporting (for some reason? This is just from some minimal personal testing, but has been 8 | semi consistent in my experience). 9 | */ 10 | public class MultipartFormInputTempFile: @unchecked Sendable { 11 | 12 | public let boundary: String 13 | private let originalBoundary: String 14 | 15 | private var parts: [Part] = [] 16 | 17 | private let lock = MutexLock() 18 | 19 | public var multipartContentTypeHeaderValue: HTTPHeaders.Header.Value { 20 | "multipart/form-data; boundary=\(boundary)" 21 | } 22 | 23 | public init(boundary: String = UUID().uuidString) { 24 | self.originalBoundary = boundary 25 | self.boundary = "Boundary-\(boundary)" 26 | } 27 | 28 | public func addPart(named name: String, string: String) { 29 | let part = Part(name: name, boundary: boundary, content: .data(Data(string.utf8))) 30 | addPart(part) 31 | } 32 | 33 | public func addPart( 34 | named name: String, 35 | data: Data, 36 | filename: String? = nil, 37 | contentType: String = "application/octet-stream" 38 | ) { 39 | let part = Part( 40 | name: name, 41 | boundary: boundary, 42 | filename: filename, 43 | contentType: contentType, 44 | content: .data(data)) 45 | addPart(part) 46 | } 47 | 48 | public func addPart( 49 | named name: String, 50 | fileURL: URL, 51 | filename: String? = nil, 52 | contentType: String 53 | ) { 54 | let part = Part( 55 | name: name, 56 | boundary: boundary, 57 | filename: filename, 58 | contentType: contentType, 59 | content: .localURL(fileURL)) 60 | addPart(part) 61 | } 62 | 63 | private func addPart(_ part: Part) { 64 | lock.withLock { 65 | parts.append(part) 66 | } 67 | } 68 | 69 | // swiftlint:disable:next cyclomatic_complexity 70 | public func renderToFile() throws -> URL { 71 | let tempDir = FileManager 72 | .default 73 | .temporaryDirectory 74 | .appendingPathComponent("multipartUploads") 75 | try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) 76 | 77 | let multipartTempFile = tempDir 78 | .appendingPathComponent(UUID().uuidString) 79 | .appendingPathExtension("tmp") 80 | 81 | // let filehandle = FileHandle(forWritingTo: multipartTempFile) 82 | let fileHandle = OutputStream(url: multipartTempFile, append: true) 83 | fileHandle?.open() 84 | 85 | let bufferSize = 1024 // KB 86 | * 1024 // MB 87 | * 25 // count of MB 88 | let buffer = UnsafeMutableBufferPointer.allocate(capacity: bufferSize) 89 | buffer.initialize(repeating: 0) 90 | 91 | guard 92 | let pointer = buffer.baseAddress 93 | else { throw MultipartError.cannotInitializeBufferPointer } 94 | 95 | let parts = lock.withLock { self.parts } 96 | 97 | for part in parts { 98 | for (index, byte) in part.headers.enumerated() { 99 | buffer[index] = byte 100 | } 101 | let headerBytesWritten = fileHandle?.write(pointer, maxLength: part.headers.count) 102 | guard headerBytesWritten == part.headers.count else { throw MultipartError.headerWritingNotCompleted } 103 | 104 | let inputStream: InputStream? 105 | switch part.content { 106 | case .localURL(let url): 107 | guard let inStream = InputStream(url: url) else { throw MultipartError.cannotOpenInputFile } 108 | inputStream = inStream 109 | case .data(let data): 110 | inputStream = InputStream(data: data) 111 | default: inputStream = nil 112 | } 113 | 114 | if let inputStream = inputStream { 115 | inputStream.open() 116 | while inputStream.hasBytesAvailable { 117 | let inputBytes = inputStream.read(pointer, maxLength: bufferSize) 118 | guard inputBytes >= 0 else { throw MultipartError.cannotReadInputFile } 119 | guard inputBytes > 0 else { break } 120 | fileHandle?.write(pointer, maxLength: inputBytes) 121 | } 122 | } 123 | 124 | for (index, byte) in part.footer.enumerated() { 125 | buffer[index] = byte 126 | } 127 | let footerBytesWritten = fileHandle?.write(pointer, maxLength: part.footer.count) 128 | guard footerBytesWritten == part.footer.count else { throw MultipartError.footerWritingNotCompleted } 129 | } 130 | 131 | let formFooter = Data("--\(boundary)--\r\n".utf8) 132 | for (index, byte) in formFooter.enumerated() { 133 | buffer[index] = byte 134 | } 135 | let footerBytesWritten = fileHandle?.write(pointer, maxLength: formFooter.count) 136 | guard footerBytesWritten == formFooter.count else { throw MultipartError.footerWritingNotCompleted } 137 | 138 | return multipartTempFile 139 | } 140 | 141 | public func renderToFile() async throws -> URL { 142 | let task = Task.detached(priority: .utility) { 143 | try self.renderToFile() 144 | } 145 | return try await task.value 146 | } 147 | 148 | enum MultipartError: CustomDebugStringConvertible, Sendable, LocalizedError { 149 | case streamNotPart 150 | case cannotInitializeBufferPointer 151 | case headerWritingNotCompleted 152 | case footerWritingNotCompleted 153 | case cannotOpenInputFile 154 | case cannotReadInputFile 155 | 156 | var debugDescription: String { 157 | switch self { 158 | case .streamNotPart: return "streamNotPart" 159 | case .cannotInitializeBufferPointer: return "cannotInitializeBufferPointer" 160 | case .headerWritingNotCompleted: return "headerWritingNotCompleted" 161 | case .footerWritingNotCompleted: return "footerWritingNotCompleted" 162 | case .cannotOpenInputFile: return "cannotOpenInputFile" 163 | case .cannotReadInputFile: return "cannotReadInputFile" 164 | } 165 | } 166 | 167 | public var errorDescription: String? { debugDescription } 168 | 169 | public var failureReason: String? { debugDescription } 170 | 171 | public var helpAnchor: String? { debugDescription } 172 | 173 | public var recoverySuggestion: String? { debugDescription } 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /Sources/NetworkHandler/AWSv4AuthHelper+NetworkRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension AWSV4Signature { 4 | /// Initializes an `AWSV4Signature` instance for a `NetworkRequest`. 5 | /// 6 | /// This initializer extracts the relevant metadata from a `NetworkRequest` to construct an AWS Signature V4 context. 7 | /// The `hexContentHash` argument must reflect the SHA-256 hash of the body payload specific to the signing process. 8 | /// 9 | /// - Parameters: 10 | /// - request: A `NetworkRequest` object encapsulating details like method and URL. 11 | /// - date: The date and time for the request signature. Defaults to the current system date. 12 | /// - awsKey: The AWS access key string. 13 | /// - awsSecret: The AWS secret access key string. 14 | /// - awsRegion: The AWS region identifier for the request. 15 | /// - awsService: The AWS service name (e.g., `s3`). 16 | /// - hexContentHash: The precomputed SHA-256 hash of the request payload, as a hex string. 17 | public init( 18 | for request: NetworkRequest, 19 | date: Date = Date(), 20 | awsKey: String, 21 | awsSecret: String, 22 | awsRegion: AWSV4Signature.AWSRegion, 23 | awsService: AWSV4Signature.AWSService, 24 | hexContentHash: AWSContentHash 25 | ) { 26 | self.init( 27 | requestMethod: request.method, 28 | url: request.url, 29 | date: date, 30 | awsKey: awsKey, 31 | awsSecret: awsSecret, 32 | awsRegion: awsRegion, 33 | awsService: awsService, 34 | hexContentHash: hexContentHash, 35 | additionalSignedHeaders: [:]) 36 | } 37 | 38 | /// Initializes an `AWSV4Signature` instance for an `UploadEngineRequest`. 39 | /// 40 | /// - Parameters: 41 | /// - request: An `UploadEngineRequest` object representing an HTTP upload. 42 | /// - date: The date and time for the request signature. Defaults to the current system date. 43 | /// - awsKey: The AWS access key string. 44 | /// - awsSecret: The AWS secret access key string. 45 | /// - awsRegion: The AWS region identifier for the request. 46 | /// - awsService: The AWS service name (e.g., `s3`). 47 | /// - hexContentHash: The precomputed SHA-256 hash of the request payload, as a hex string. 48 | public init( 49 | for request: UploadEngineRequest, 50 | date: Date = Date(), 51 | awsKey: String, 52 | awsSecret: String, 53 | awsRegion: AWSV4Signature.AWSRegion, 54 | awsService: AWSV4Signature.AWSService, 55 | hexContentHash: AWSContentHash 56 | ) { 57 | self.init( 58 | for: .upload(request, payload: .data(Data())), 59 | date: date, 60 | awsKey: awsKey, 61 | awsSecret: awsSecret, 62 | awsRegion: awsRegion, 63 | awsService: awsService, 64 | hexContentHash: hexContentHash) 65 | } 66 | 67 | /// Initializes an `AWSV4Signature` instance for a `GeneralEngineRequest`. 68 | /// 69 | /// - Parameters: 70 | /// - request: A `GeneralEngineRequest` object representing an HTTP download. 71 | /// - date: The date and time for the request signature. Defaults to the current system date. 72 | /// - awsKey: The AWS access key string. 73 | /// - awsSecret: The AWS secret access key string. 74 | /// - awsRegion: The AWS region identifier for the request. 75 | /// - awsService: The AWS service name (e.g., `s3`). 76 | /// - hexContentHash: The precomputed SHA-256 hash of the request payload, as a hex string. 77 | public init( 78 | for request: GeneralEngineRequest, 79 | date: Date = Date(), 80 | awsKey: String, 81 | awsSecret: String, 82 | awsRegion: AWSV4Signature.AWSRegion, 83 | awsService: AWSV4Signature.AWSService, 84 | hexContentHash: AWSContentHash 85 | ) { 86 | self.init( 87 | for: .general(request), 88 | date: date, 89 | awsKey: awsKey, 90 | awsSecret: awsSecret, 91 | awsRegion: awsRegion, 92 | awsService: awsService, 93 | hexContentHash: hexContentHash) 94 | } 95 | 96 | /// Processes an existing `NetworkRequest` by attaching AWS-signed headers. 97 | /// 98 | /// This function validates the `url` and `method` of the incoming request, generates AWS-specific headers, 99 | /// and merges them into the existing request's headers. The updated request is then returned. 100 | /// 101 | /// - Parameter request: A `NetworkRequest` to be signed. 102 | /// - Returns: The updated `NetworkRequest` with the signed headers integrated. 103 | /// - Throws: `AWSAuthError` if the `url` or `method` on the request does not match 104 | /// those defined in the signature context. 105 | public func processRequest(_ request: NetworkRequest) throws -> NetworkRequest { 106 | try processRequestInfo(url: request.url, method: request.method) { newHeaders in 107 | var new = request 108 | new.headers += newHeaders 109 | return new 110 | } 111 | } 112 | 113 | /// Processes an `UploadEngineRequest` by attaching AWS-signed headers. 114 | /// 115 | /// This function validates the `url` and `method`, generates AWS-specific headers, and 116 | /// merges them into the request. A new `UploadEngineRequest` is returned post-processing. 117 | /// 118 | /// - Parameter request: An `UploadEngineRequest` to be signed. 119 | /// - Returns: The updated `UploadEngineRequest` with the signed headers integrated. 120 | /// - Throws: `AWSAuthError` if the `url` or `method` on the request does not match 121 | /// those defined in the signature context. 122 | public func processRequest(_ request: UploadEngineRequest) throws -> UploadEngineRequest { 123 | let processed = try processRequest(.upload(request, payload: .data(Data()))) 124 | guard case .upload(let request, _) = processed else { 125 | fatalError("Illegal request") 126 | } 127 | return request 128 | } 129 | 130 | /// Processes a `GeneralEngineRequest` by attaching AWS-signed headers. 131 | /// 132 | /// This function validates the `url` and `method`, generates AWS-specific headers, and merges 133 | /// them into the request. The final `GeneralEngineRequest` is returned post-processing. 134 | /// 135 | /// - Parameter request: A `GeneralEngineRequest` to be signed. 136 | /// - Returns: The updated `GeneralEngineRequest` with the signed headers integrated. 137 | /// - Throws: `AWSAuthError` if the `url` or `method` on the request does not match 138 | /// those defined in the signature context. 139 | public func processRequest(_ request: GeneralEngineRequest) throws -> GeneralEngineRequest { 140 | let processed = try processRequest(.general(request)) 141 | guard case .general(let request) = processed else { 142 | fatalError("Illegal request") 143 | } 144 | return request 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /Sources/NetworkHandler/Docs.docc/index.md: -------------------------------------------------------------------------------- 1 | ../../../Readme.md -------------------------------------------------------------------------------- /Sources/NetworkHandler/EngineRequests/EngineRequestMetadata.swift: -------------------------------------------------------------------------------- 1 | import NetworkHalpers 2 | import Foundation 3 | import SwiftPizzaSnips 4 | 5 | /// Encapsulates shared metadata for a network engine request, such as headers, response codes, 6 | /// HTTP method, and URL. Designed to be shared across related request types (`GeneralEngineRequest` 7 | /// and `UploadEngineRequest`) for centralized management of common attributes. 8 | public struct EngineRequestMetadata: Hashable, @unchecked Sendable, Withable { 9 | /// Defines the range of acceptable HTTP response status codes for a request. 10 | /// This type encapsulates response codes as a set of integers and provides 11 | /// conveniences for constructing it from individual integers, ranges, or arrays. 12 | /// 13 | /// Example: 14 | /// ```swift 15 | /// let successCodes: ResponseCodes = [200, 201, 202] 16 | /// let any2xxCode = ResponseCodes(range: 200..<300) 17 | /// ``` 18 | public struct ResponseCodes: 19 | Hashable, 20 | Sendable, 21 | Withable, 22 | RawRepresentable, 23 | ExpressibleByIntegerLiteral, 24 | ExpressibleByArrayLiteral { 25 | 26 | public var rawValue: Set 27 | 28 | public init(rawValue: Set) { 29 | self.rawValue = rawValue 30 | } 31 | 32 | public init(arrayLiteral elements: Int...) { 33 | self.init(rawValue: Set(elements)) 34 | } 35 | 36 | public init(integerLiteral value: IntegerLiteralType) { 37 | self.init(rawValue: [value]) 38 | } 39 | 40 | public init(range: Range) { 41 | self.init(rawValue: range.reduce(into: .init(), { $0.insert($1) } )) 42 | } 43 | } 44 | 45 | /// Specifies the range or list of HTTP response codes that are considered valid for this request. 46 | /// Responses falling outside this range may be treated as errors, depending on the network engine's logic. 47 | /// 48 | /// Example: 49 | /// ```swift 50 | /// metadata.expectedResponseCodes = [200, 201, 202] 51 | /// metadata.expectedResponseCodes = ResponseCodes(range: 200..<300) 52 | /// ``` 53 | public var expectedResponseCodes: ResponseCodes 54 | 55 | /// Gets or sets the expected size of the response payload in bytes via the `Content-Length` header. 56 | /// 57 | /// When set, the `Content-Length` header is automatically updated. 58 | /// Setting this to `nil` removes the header from the metadata. 59 | public var expectedContentLength: Int? { 60 | get { headers[.contentLength].flatMap { Int($0.rawValue) } } 61 | set { 62 | guard let newValue else { 63 | headers[.contentLength] = nil 64 | return 65 | } 66 | headers[.contentLength] = "\(newValue)" 67 | } 68 | } 69 | 70 | public var headers: HTTPHeaders = [] 71 | 72 | public var method: HTTPMethod = .get 73 | 74 | public var url: URL 75 | 76 | public var timeoutInterval: TimeInterval = 60 77 | 78 | private var extensionStorage: [String: AnyHashable] = [:] 79 | 80 | /// The unique ID used to identify this request. Follows the `X-Request-ID` HTTP header convention. 81 | /// 82 | /// Automatically populated with a UUID string upon initialization if autogeneration is enabled. 83 | /// To disable this behavior, pass `autogenerateRequestID: false` during construction. 84 | /// 85 | /// See [X-Request-ID](https://http.dev/x-request-id) for more info. Note that while it's an optional header, 86 | /// convention dictates that it should be the same when retrying a request. 87 | public var requestID: String? { 88 | get { headers.value(for: .xRequestID) } 89 | set { 90 | guard let newValue else { 91 | headers[.xRequestID] = nil 92 | return 93 | } 94 | headers[.xRequestID] = "\(newValue)" 95 | } 96 | } 97 | 98 | /// Stores platform or library-specific metadata in a key-value dictionary. 99 | /// 100 | /// This mechanism allows extending `EngineRequestMetadata` with custom properties, especially 101 | /// in extensions (since extensions cannot introduce stored properties). 102 | /// 103 | /// - Parameters: 104 | /// - value: The value to store. Setting `nil` removes the key from the storage. 105 | /// - key: A unique identifier for the metadata entry. 106 | /// 107 | /// For example, if you want to use Foundation's networking as your engine and use URLRequest, you could add 108 | /// 109 | /// ```swift 110 | /// extension NetworkEngine { 111 | /// var allowsCellularAccess: Bool { 112 | /// get { (extensionStorageRetrieve(valueForKey: "allowsCellularAccess") ?? true } 113 | /// set { extensionStorage(store: newValue, with: "allowsCellularAccess") } 114 | /// } 115 | /// } 116 | /// ``` 117 | public mutating func extensionStorage(store value: T?, with key: String) { 118 | extensionStorage[key] = AnyHashable(value) 119 | } 120 | 121 | /// To support specialized properties for your platform, you can create an extension that stores its values here 122 | /// (since extensions only support computed properties) 123 | /// 124 | /// For example, if you want to use Foundation's networking as your engine and use URLRequest, you could add 125 | /// 126 | /// ```swift 127 | /// extension NetworkEngine { 128 | /// var allowsCellularAccess: Bool { 129 | /// get { (extensionStorageRetrieve(valueForKey: "allowsCellularAccess") ?? true } 130 | /// set { extensionStorage(store: newValue, with: "allowsCellularAccess") } 131 | /// } 132 | /// } 133 | /// ``` 134 | public func extensionStorageRetrieve(valueForKey key: String) -> T? { 135 | extensionStorage[key] as? T 136 | } 137 | 138 | /// Untyped variant of `extensionStorageRetrieve(valueForKey:)` to get whatever is stored, regardless of data type 139 | public func extensionStorageRetrieve(objectForKey key: String) -> Any? { 140 | extensionStorage[key] 141 | } 142 | 143 | public init( 144 | expectedResponseCodes: ResponseCodes = .init(range: 200..<299), 145 | headers: HTTPHeaders = [:], 146 | method: HTTPMethod = .get, 147 | url: URL, 148 | autogenerateRequestID: Bool 149 | ) { 150 | self.expectedResponseCodes = expectedResponseCodes 151 | self.headers = headers 152 | self.method = method 153 | self.url = url 154 | guard autogenerateRequestID else { return } 155 | self.requestID = UUID().uuidString 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /Sources/NetworkHandler/EngineRequests/GeneralEngineRequest.swift: -------------------------------------------------------------------------------- 1 | import NetworkHalpers 2 | import Foundation 3 | import SwiftPizzaSnips 4 | 5 | /// Represents an HTTP request for most HTTP interactions, such as sending or retrieving JSON or binary responses. 6 | /// While upload progress is not tracked, download progress is monitored. 7 | /// 8 | /// This is a lowst common denominator representation of an HTTP request. If you're conforming your own 9 | /// engine to `NetworkEngine`, you'll most likely want to add a computed property or function to convert 10 | /// a `GeneralEngineRequest` to the request type native to your engine. 11 | @dynamicMemberLookup 12 | public struct GeneralEngineRequest: Hashable, Sendable, Withable { 13 | /// Internal metadata used to store common HTTP request properties, such as HTTP headers, response codes, 14 | /// and URLs. This allows `GeneralEngineRequest` to provide a lightweight wrapper around core functionality 15 | /// without duplicating state or logic. 16 | package var metadata: EngineRequestMetadata 17 | 18 | public subscript(dynamicMember member: WritableKeyPath) -> T { 19 | get { metadata[keyPath: member] } 20 | set { metadata[keyPath: member] = newValue } 21 | } 22 | 23 | public subscript(dynamicMember member: KeyPath) -> T { 24 | metadata[keyPath: member] 25 | } 26 | 27 | nonisolated(unsafe) 28 | private static var _defaultEncoder: NHEncoder = JSONEncoder() 29 | nonisolated(unsafe) 30 | private static var _defaultDecoder: NHDecoder = JSONDecoder() 31 | private static let coderLock = MutexLock() 32 | 33 | /// Default encoder used to encode with the `encodeData` function. 34 | /// 35 | /// Default value is `JSONEncoder()` along with all of its defaults. Being that this 36 | /// is a static property, it will affect *all* instances. 37 | public static var defaultEncoder: NHEncoder { 38 | get { coderLock.withLock { _defaultEncoder } } 39 | set { coderLock.withLock { _defaultEncoder = newValue } } 40 | } 41 | 42 | /// Default decoder used to decode data received from this request. 43 | /// 44 | /// Default value is `JSONDecoder()` along with all of its defaults. Being that this 45 | /// is a static property, it will affect *all* instances. 46 | public static var defaultDecoder: NHDecoder { 47 | get { coderLock.withLock { _defaultDecoder } } 48 | set { coderLock.withLock { _defaultDecoder = newValue } } 49 | } 50 | 51 | /// Optional raw data intended to be sent as part of the HTTP request body. 52 | /// This is commonly used for POST or PUT requests where structured data is required. 53 | /// To streamline JSON, PropertyList, or custom encoding, use the `encodeData` method. 54 | public var payload: Data? 55 | 56 | public init( 57 | expectedResponseCodes: ResponseCodes = [200], 58 | headers: HTTPHeaders = [:], 59 | method: HTTPMethod = .get, 60 | url: URL, 61 | payload: Data? = nil, 62 | autogenerateRequestID: Bool = true 63 | ) { 64 | self.payload = payload 65 | self.metadata = EngineRequestMetadata( 66 | expectedResponseCodes: expectedResponseCodes, 67 | headers: headers, 68 | method: method, 69 | url: url, 70 | autogenerateRequestID: autogenerateRequestID) 71 | } 72 | public typealias ResponseCodes = EngineRequestMetadata.ResponseCodes 73 | 74 | /// Encodes an object conforming to `Encodable` into a `Data` payload using the specified or default encoder. 75 | /// 76 | /// Automatically updates the `payload` property with the resulting serialized data upon success. 77 | /// 78 | /// - Parameters: 79 | /// - encodableType: The object to encode into the `payload`. 80 | /// - encoder: An optional encoder conforming to `NHEncoder`. Uses `defaultEncoder` if not explicitly provided. 81 | /// - Returns: The serialized data now stored in the request's `payload`. 82 | /// - Throws: Errors from the encoder if the object cannot be serialized. 83 | @discardableResult 84 | public mutating func encodeData( 85 | _ encodableType: EncodableType, 86 | withEncoder encoder: NHEncoder? = nil 87 | ) throws -> Data { 88 | let encoder = encoder ?? GeneralEngineRequest.defaultEncoder 89 | 90 | let data = try encoder.encode(encodableType) 91 | 92 | self.payload = data 93 | 94 | return data 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Sources/NetworkHandler/EngineRequests/UploadEngineRequest.swift: -------------------------------------------------------------------------------- 1 | import NetworkHalpers 2 | import Foundation 3 | import SwiftPizzaSnips 4 | 5 | /// An HTTP request type designed specifically for uploading larger payloads, such as files or 6 | /// large binary data. Unlike `GeneralEngineRequest`, this tracks both upload and download progress. 7 | /// 8 | /// The request metadata is shared with `EngineRequestMetadata`, simplifying configuration for things 9 | /// like headers and request IDs. 10 | /// 11 | /// This is a lowst common denominator representation of an HTTP request. If you're conforming your own 12 | /// engine to `NetworkEngine`, you'll most likely want to add a computed property or function to convert 13 | /// a `UploadEngineRequest` to the request type native to your engine. 14 | @dynamicMemberLookup 15 | public struct UploadEngineRequest: Hashable, Sendable, Withable { 16 | package var metadata: EngineRequestMetadata 17 | 18 | public subscript(dynamicMember member: WritableKeyPath) -> T { 19 | get { metadata[keyPath: member] } 20 | set { metadata[keyPath: member] = newValue } 21 | } 22 | 23 | public subscript(dynamicMember member: KeyPath) -> T { 24 | metadata[keyPath: member] 25 | } 26 | 27 | /// - Parameters: 28 | /// - expectedResponseCodes: Accepted response status codes from the server. 29 | /// - headers: Headers for the request 30 | /// - method: HTTP Method to use for the request. Defaults to `.post` 31 | /// - url: URL for the request. 32 | /// - autogenerateRequestID: When set to `true`(default) a UUID is generated and put in the request ID header. 33 | public init( 34 | expectedResponseCodes: ResponseCodes = [200], 35 | headers: HTTPHeaders = [:], 36 | method: HTTPMethod = .post, 37 | url: URL, 38 | autogenerateRequestID: Bool = true 39 | ) { 40 | self.metadata = EngineRequestMetadata( 41 | expectedResponseCodes: expectedResponseCodes, 42 | headers: headers, 43 | method: method, 44 | url: url, 45 | autogenerateRequestID: autogenerateRequestID) 46 | } 47 | 48 | public typealias ResponseCodes = EngineRequestMetadata.ResponseCodes 49 | } 50 | -------------------------------------------------------------------------------- /Sources/NetworkHandler/EngineRequests/UploadFile.swift: -------------------------------------------------------------------------------- 1 | import NetworkHalpers 2 | import Foundation 3 | import SwiftPizzaSnips 4 | 5 | /// Represents different ways to supply upload data: 6 | /// - `.localFile(URL)`: A file located on disk, referenced by a URL. 7 | /// - `.data(Data)`: In-memory data to upload. 8 | /// - `.inputStream(InputStream)`: A stream for uploading data incrementally. 9 | /// 10 | /// Used in conjunction with `UploadEngineRequest` to define upload sources dynamically. 11 | public enum UploadFile: Hashable, Sendable, Withable { 12 | case localFile(URL) 13 | case data(Data) 14 | case inputStream(InputStream) 15 | } 16 | -------------------------------------------------------------------------------- /Sources/NetworkHandler/EngineResponseHeader.swift: -------------------------------------------------------------------------------- 1 | import NetworkHalpers 2 | import Foundation 3 | 4 | /// Represents the metadata of an HTTP response, including the status code, headers, 5 | /// and additional derived properties for easier access to common response attributes. 6 | /// 7 | /// This is the lowest common denominator of a response header, needed as a result of 8 | /// conforming to `NetworkEngine` method requirements. It is recommended to 9 | /// extend `EngineResponseHeader` with a convenience initializer consuming the 10 | /// native response header type for your engine. 11 | public struct EngineResponseHeader: Hashable, Sendable, Codable { 12 | /// The HTTP status code of the response. Indicates the outcome of the request, 13 | /// such as `200` for success or `404` for not found. 14 | public let status: Int 15 | /// The collection of HTTP headers returned in the response. 16 | /// Provides access to both raw header values and convenience properties such as 17 | /// `expectedContentLength` and `mimeType` derived from specific headers. 18 | public let headers: HTTPHeaders 19 | 20 | /// Extracts the `Content-Length` header value and converts it to a `Int64`, if present. 21 | /// Represents the expected size of the HTTP response body in bytes. 22 | public var expectedContentLength: Int64? { headers[.contentLength].flatMap { Int64($0.rawValue) } } 23 | /// Extracts a suggested filename from the `Content-Disposition` header, if provided. 24 | /// 25 | /// This is commonly used to determine a file name when downloading an attachment from the server. 26 | /// The value is parsed from the header using a regular expression to locate the `filename` attribute. 27 | /// 28 | /// - Returns: The suggested filename as a `String`, or `nil` if the header is not present or improperly formatted. 29 | public var suggestedFilename: String? { 30 | guard let contentDisp = headers[.contentDisposition]?.rawValue else { return nil } 31 | let name = contentDisp.firstMatch(of: /filename="(?[^"]+)"/)?.output.filename 32 | return name.map(String.init) 33 | } 34 | /// Extracts the `Content-Type` header value, which specifies the MIME type of the response data. 35 | /// 36 | /// Common MIME types: 37 | /// - `application/json` 38 | /// - `text/plain` 39 | /// - `image/jpeg` 40 | /// 41 | /// - Returns: The MIME type as a `String`, or `nil` if the header is not present. 42 | public var mimeType: String? { headers[.contentType]?.rawValue } 43 | /// The final URL for the response. This value may differ from the original request's URL 44 | /// if redirects occurred during the network operation. 45 | /// 46 | /// For example, a request to `http://example.com` might redirect to 47 | /// `https://www.example.com`, and this property would reflect the final resolved URL. 48 | public let url: URL? 49 | 50 | /// Initializes a new instance of `EngineResponseHeader`. 51 | /// 52 | /// - Parameters: 53 | /// - status: The HTTP status code of the response, such as `200`. 54 | /// - url: The final URL for the response, accounting for any redirects. 55 | /// - headers: The HTTP headers returned in the response. 56 | public init(status: Int, url: URL?, headers: HTTPHeaders) { 57 | self.status = status 58 | self.headers = headers 59 | self.url = url 60 | } 61 | } 62 | 63 | extension EngineResponseHeader: CustomStringConvertible, CustomDebugStringConvertible { 64 | public var description: String { 65 | var accumulator: [String] = [] 66 | 67 | accumulator.append("Status - \(status)") 68 | if let url { 69 | accumulator.append("URL - \(url)") 70 | } 71 | if let expectedContentLength { 72 | accumulator.append("Expected length - \(expectedContentLength)") 73 | } 74 | if let mimeType { 75 | accumulator.append("MIME Type - \(mimeType)") 76 | } 77 | if let suggestedFilename { 78 | accumulator.append("Suggested Filename - \(suggestedFilename)") 79 | } 80 | accumulator.append("All Headers:") 81 | accumulator.append(headers.description.prefixingLines(with: "\t")) 82 | 83 | accumulator = accumulator.map { $0.prefixingLines(with: "\t") } 84 | accumulator = ["EngineResponse:"] + accumulator 85 | 86 | return accumulator.joined(separator: "\n") 87 | } 88 | 89 | public var debugDescription: String { 90 | description 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Sources/NetworkHandler/Helpers/ETask.swift: -------------------------------------------------------------------------------- 1 | /// A (hopefully) temporary stand in for `Task`. Currently, `Task` only supports throwing `any Error`, but this project 2 | /// has a goal for typed throws. This abstracts away the need to manually verify the thrown error conforms to 3 | /// `Failure` at the call site and instead just lets you make the `ETask` properly typed in the first place. 4 | /// (`ETask` is short for "typedError-Task") 5 | /// 6 | /// Once `Swift.Task` gets updated with support for typed throws, this should be removed. In theory, it should allow 7 | /// for a drop in replacement, assuming there's no significant API change to `Swift.Task` 8 | public struct ETask: Sendable, Hashable { 9 | private let underlyingTask: Task 10 | 11 | /// Same as `Task.value` 12 | public var value: Success { 13 | get async throws(Failure) { 14 | try await result.get() 15 | } 16 | } 17 | 18 | /// Same as `Task.result` 19 | public var result: Result { 20 | get async { 21 | do { 22 | let resultA = await underlyingTask.result 23 | let success = try resultA.get() 24 | return .success(success) 25 | } catch { 26 | return .failure(error as! Failure) // swiftlint:disable:this force_cast 27 | } 28 | } 29 | } 30 | 31 | /// Same as `Task.isCancelled` 32 | public var isCancelled: Bool { underlyingTask.isCancelled } 33 | 34 | /// Same as `Task.init` 35 | public init( 36 | priority: TaskPriority? = nil, 37 | @_implicitSelfCapture operation: sending @escaping @isolated(any) () async throws(Failure) -> Success 38 | ) { 39 | self.init(detached: false, priority: priority, operation: operation) 40 | } 41 | 42 | private init( 43 | detached: Bool, 44 | priority: TaskPriority? = nil, 45 | @_implicitSelfCapture operation: sending @escaping @isolated(any) () async throws(Failure) -> Success 46 | ) { 47 | if detached { 48 | underlyingTask = Task.detached(priority: priority, operation: operation) 49 | } else { 50 | underlyingTask = Task(priority: priority, operation: operation) 51 | } 52 | } 53 | 54 | /// Same as `Task.detached` 55 | public static func detached( 56 | priority: TaskPriority? = nil, 57 | operation: sending @escaping @isolated(any) () async throws(Failure) -> Success 58 | ) -> ETask { 59 | Self.init(detached: true, priority: priority, operation: operation) 60 | } 61 | 62 | /// Same as `Task.cancel` 63 | public func cancel() { 64 | underlyingTask.cancel() 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/NetworkHandler/Helpers/InputStream+Sendable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Callous conformance to `Sendable` for `InputStream`. While `InputStream` documentation 4 | /// doesn't state that it should be thread safe, there's really no reason it should be accessed by multiple 5 | /// threads anyway as that would inherently corrupt the stream. 6 | extension InputStream: @unchecked @retroactive Sendable {} 7 | -------------------------------------------------------------------------------- /Sources/NetworkHandler/Helpers/InputStreamImprovements.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A protocol that defines a stream which can be retried after failure. Conformance to this protocol enables 4 | /// retry mechanisms for streams, a functionality often precluded because standard `InputStream` implementations 5 | /// proceed from their last position. 6 | /// 7 | /// This is particularly useful in scenarios such as HTTP request bodies, where a stream's ability to restart 8 | /// from the beginning significantly facilitates automatic network error recovery. 9 | /// 10 | /// - Important: Ensure conforming types properly implement `copyWithRestart()` to return a stream starting 11 | /// fresh from its initial position of data. 12 | public protocol RetryableStream: InputStream { 13 | /// Creates a duplicate of the current stream that can start from the beginning. 14 | func copyWithRestart() throws(NetworkError) -> Self 15 | } 16 | 17 | /// A protocol that defines a stream with a known total length of bytes, important in scenarios where the content 18 | /// length of a stream needs to be explicitly stated, such as HTTP uploads. 19 | /// 20 | /// By conforming to this protocol, stream implementations can clearly communicate their total byte count, allowing 21 | /// for proper configuration of `Content-Length` headers and precise progress tracking during transfers. 22 | public protocol KnownLengthStream: InputStream { 23 | /// An optional `Int` representing the total size of the stream's data in bytes. Returns `nil` 24 | /// for indeterminate lengths. 25 | var totalStreamBytes: Int? { get } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/NetworkHandler/Helpers/NHActor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @globalActor 4 | public struct NHActor: GlobalActor { 5 | public actor ActorType {} 6 | 7 | nonisolated(unsafe) 8 | public static var shared = ActorType() 9 | } 10 | -------------------------------------------------------------------------------- /Sources/NetworkHandler/Helpers/Sendify.swift: -------------------------------------------------------------------------------- 1 | import SwiftPizzaSnips 2 | 3 | final package class Sendify: @unchecked Sendable { 4 | public var value: T { 5 | get { lock.withLock { _value } } 6 | set { lock.withLock { _value = newValue } } 7 | } 8 | 9 | private var _value: T 10 | 11 | private let lock = MutexLock() 12 | 13 | public init(_ value: T) { 14 | lock.lock() 15 | defer { lock.unlock() } 16 | self._value = value 17 | } 18 | } 19 | 20 | extension Sendify: Equatable where T: Equatable { 21 | package static func == (lhs: Sendify, rhs: Sendify) -> Bool { 22 | lhs.value == rhs.value 23 | } 24 | } 25 | extension Sendify: Hashable where T: Hashable { 26 | package func hash(into hasher: inout Hasher) { 27 | hasher.combine(value) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/NetworkHandler/NetworkCancellationToken.swift: -------------------------------------------------------------------------------- 1 | import SwiftPizzaSnips 2 | 3 | /// While, in theory, if you cancel the Task that's running the NetworkHandler operation, 4 | /// it should also cancel all its children. In practice, this *usually* happens. 5 | /// 6 | /// The most reliable way to cancel the transfer of network data is to instead create, hold a 7 | /// reference to, and pass a `NetworkCancellationToken` to the `NetworkHandler` 8 | /// method you're using. Once you determine you need to cancel your operation, simply 9 | /// call `.cancel()` on your reference and everything will fall in line! 10 | public class NetworkCancellationToken: @unchecked Sendable { 11 | let lock = MutexLock() 12 | 13 | private var _isCancelled = false 14 | /// Reflects whether the token has triggered a cancellation or not. 15 | public private(set) var isCancelled: Bool { 16 | get { lock.withLock { _isCancelled } } 17 | set { lock.withLock { _isCancelled = newValue } } 18 | } 19 | 20 | private var _onCancel: () -> Void = {} 21 | 22 | /// The block of code to run when cancellation is invoked. 23 | var onCancel: () -> Void { 24 | get { lock.withLock { _onCancel } } 25 | set { lock.withLock { _onCancel = newValue } } 26 | } 27 | 28 | public init() {} 29 | 30 | /// Invokes the cancellation block `onCancel` and marks the token as `isCancelled` 31 | public func cancel() { 32 | lock.withLock { 33 | _isCancelled = true 34 | _onCancel() 35 | } 36 | } 37 | 38 | /// Convenience that throws `CancellationError` in the event that `isCancelled` is true. 39 | public func checkIsCancelled() throws(CancellationError) { 40 | try lock.withLock { () throws(CancellationError) in 41 | guard _isCancelled == false else { throw CancellationError() } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/NetworkHandler/NetworkEngine.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftPizzaSnips 3 | import Logging 4 | 5 | /// Convenience type for forwarding the content of a response body. 6 | public typealias ResponseBodyStream = AsyncCancellableThrowingStream<[UInt8], Error> 7 | 8 | /// Convenience type for communicating upload progress. The yielded value should be the total number of 9 | /// bytes sent in this request. 10 | public typealias UploadProgressStream = AsyncCancellableThrowingStream 11 | 12 | /// The magic that makes everything work! 13 | /// 14 | /// Default implementations are provided for `URLSession` and `AsyncHTTPClient`. 15 | /// 16 | /// Conforming to this protocol with another engine automatically gets you simple streaming, automatic retry on 17 | /// errors, polling functionality (beta), tightly controlled caching functionality, conveniences for encoding and 18 | /// decoding data for sending and receiving from remote servers, and more! With a little additional elbow grease, 19 | /// you can also get simple token based cancellation and activity based timeouts. 20 | public protocol NetworkEngine: Sendable, Withable { 21 | 22 | /// Conforming to NetworkEngine primarily revolves around this method. The primary requirements are as follows: 23 | /// 24 | /// 1. Convert `request` to the native engine request type 25 | /// 2. Send the request to the server. If this is an upload, make sure to communicate 26 | /// upload progress with `uploadProgressContinuation` 27 | /// 3. Upon receiving the server response, convert it to `EngineResponseHeader` 28 | /// 4. Create a `ResponseBodyStream` and initiate forwarding the stream of data from the engine that will outlive 29 | /// the scope of this method until the incoming data is completed or the transfer is cancelled. 30 | /// 5. Return the tuple 31 | /// 32 | /// During these steps, if you encounter any errors or need to forward any from the engine, 33 | /// wrap them in `NetworkError.captureAndConvert()` 34 | /// 35 | /// Ensure you have robost cancellation support through your implementation. If activity based timeout isn't native 36 | /// to your engine, refer to the `AsyncHTTPClient` engine conformance for how to include that with your 37 | /// own engine with a simple debouncer on activity. 38 | /// 39 | /// Througout this process, log any relevant messages in `requestLogger` 40 | /// - Parameters: 41 | /// - request: The request 42 | /// - uploadProgressContinuation: Stream Continuation to foward upload progress updates to NetworkHandler. 43 | /// Required to use when performing an upload request. Nice to have when performing a general request. 44 | /// - requestLogger: logger to use 45 | func performNetworkTransfer( 46 | request: NetworkRequest, 47 | uploadProgressContinuation: UploadProgressStream.Continuation?, 48 | requestLogger: Logger? 49 | ) async throws(NetworkError) -> (responseHeader: EngineResponseHeader, responseBody: ResponseBodyStream) 50 | 51 | /// Since networking is fraught with potential errors, `NetworkHandler` tries to normalize them into 52 | /// `NetworkError` using `NetworkError.captureAndConvert()`. When `NetworkError.captureAndConvert` 53 | /// encounters an error it doesn't understand, it queries this method to see if it counts as a cancellation 54 | /// error. Consequently, you'll want to create an implementation that analyzes the error for known 55 | /// cancellation errors from your engine and return `true` when that's the case. 56 | /// - Parameter error: The error `NetworkError` is unsure about 57 | /// - Returns: Boolean indicating whether the error indicates a cancellation 58 | static func isCancellationError(_ error: any Error) -> Bool 59 | /// Since networking is fraught with potential errors, `NetworkHandler` tries to normalize them into 60 | /// `NetworkError` using `NetworkError.captureAndConvert()`. When `NetworkError.captureAndConvert` 61 | /// encounters an error it doesn't understand, it queries this method to see if it counts as a timeout 62 | /// error. Consequently, you'll want to create an implementation that analyzes the error for known 63 | /// timeout indication errors from your engine and return `true` when that's the case. 64 | /// - Parameter error: The error `NetworkError` is unsure about 65 | /// - Returns: Boolean indicating whether the error indicates a timeout 66 | static func isTimeoutError(_ error: any Error) -> Bool 67 | 68 | /// This method is called when `NetworkHandler` is being released from memory. This allows your engine to perform 69 | /// any cleanup and shutdown necessary to avoid leaking memory. If that's unecessary for your particular engine, an 70 | /// empty implementation is accepted. 71 | func shutdown() 72 | } 73 | -------------------------------------------------------------------------------- /Sources/NetworkHandler/NetworkHandler.RetryOptions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftPizzaSnips 3 | 4 | extension NetworkHandler { 5 | /// (previousRequest, failedAttempts, mostRecentError) 6 | /// Return whatever option you wish to proceed with. 7 | public typealias RetryOptionBlock = @NHActor (NetworkRequest, Int, NetworkError) -> RetryOption 8 | 9 | public struct RetryConfiguration: Hashable, Sendable, Withable { 10 | public static var simple: Self { RetryConfiguration(delay: 0) } 11 | 12 | public var delay: TimeInterval 13 | public var updatedRequest: NetworkRequest? 14 | 15 | public init( 16 | delay: TimeInterval, 17 | updatedRequest: NetworkRequest? = nil 18 | ) { 19 | self.delay = delay 20 | self.updatedRequest = updatedRequest 21 | } 22 | } 23 | 24 | public struct DefaultReturnValueConfiguration: Withable, Sendable { 25 | public var data: Data? 26 | public var response: ResponseOption 27 | 28 | public enum ResponseOption: Hashable, Sendable, Withable { 29 | case full(EngineResponseHeader) 30 | case code(Int) 31 | } 32 | } 33 | 34 | public enum RetryOption: Sendable, Withable { 35 | public static var retry: RetryOption { .retryWithConfiguration(config: .simple) } 36 | case retryWithConfiguration(config: RetryConfiguration) 37 | public static func retry( 38 | withDelay delay: TimeInterval = 0, 39 | updatedRequest: NetworkRequest? = nil 40 | ) -> RetryOption { 41 | let config = RetryConfiguration(delay: delay, updatedRequest: updatedRequest) 42 | return .retryWithConfiguration(config: config) 43 | } 44 | 45 | case `throw`(updatedError: Error?) 46 | public static var `throw`: RetryOption { .throw(updatedError: nil) } 47 | case defaultReturnValue(config: DefaultReturnValueConfiguration) 48 | 49 | public static func defaultReturnValue(data: Data?, statusCode: Int) -> RetryOption { 50 | let config = DefaultReturnValueConfiguration(data: data, response: .code(statusCode)) 51 | return .defaultReturnValue(config: config) 52 | } 53 | 54 | public static func defaultReturnValue(data: Data?, urlResponse: EngineResponseHeader) -> RetryOption { 55 | let config = DefaultReturnValueConfiguration(data: data, response: .full(urlResponse)) 56 | return .defaultReturnValue(config: config) 57 | } 58 | } 59 | } 60 | 61 | extension NetworkHandler.DefaultReturnValueConfiguration: Equatable {} 62 | extension NetworkHandler.DefaultReturnValueConfiguration: Hashable {} 63 | -------------------------------------------------------------------------------- /Sources/NetworkHandler/NetworkHandlerTaskDelegate.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @NHActor 4 | public protocol NetworkHandlerTaskDelegate: AnyObject, Sendable { 5 | /// Called when the engine modifies the request in some way. This can happen, for example, on 6 | /// an upload when the `ContentLength` header gets set. 7 | func requestModified(from oldVersion: NetworkRequest, to newVersion: NetworkRequest) 8 | /// Only called on uploads 9 | func transferDidStart(for request: NetworkRequest) 10 | /// Only called on uploads 11 | func sentData(for request: NetworkRequest, totalByteCountSent: Int, totalExpectedToSend: Int?) 12 | /// Only called on uploads 13 | func sendingDataDidFinish(for request: NetworkRequest) 14 | func responseHeaderRetrieved(for request: NetworkRequest, header: EngineResponseHeader) 15 | /// Only called on downloads 16 | func responseBodyReceived(for request: NetworkRequest, bytes: Data) 17 | /// Only called on downloads 18 | func responseBodyReceived(for request: NetworkRequest, byteCount: Int, totalExpectedToReceive: Int?) 19 | func requestFinished(withError error: Error?) 20 | } 21 | -------------------------------------------------------------------------------- /Sources/NetworkHandler/NetworkRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftPizzaSnips 3 | 4 | @dynamicMemberLookup 5 | public enum NetworkRequest: Sendable { 6 | case upload(UploadEngineRequest, payload: UploadFile) 7 | case general(GeneralEngineRequest) 8 | 9 | private var metadata: EngineRequestMetadata { 10 | get { 11 | switch self { 12 | case .upload(let uploadEngineRequest, _): 13 | uploadEngineRequest.metadata 14 | case .general(let generalEngineRequest): 15 | generalEngineRequest.metadata 16 | } 17 | } 18 | 19 | set { 20 | switch self { 21 | case .upload(var uploadEngineRequest, let payload): 22 | uploadEngineRequest.metadata = newValue 23 | self = .upload(uploadEngineRequest, payload: payload) 24 | case .general(var generalEngineRequest): 25 | generalEngineRequest.metadata = newValue 26 | self = .general(generalEngineRequest) 27 | } 28 | } 29 | } 30 | 31 | public subscript(dynamicMember member: WritableKeyPath) -> T { 32 | get { metadata[keyPath: member] } 33 | set { metadata[keyPath: member] = newValue } 34 | } 35 | 36 | public subscript(dynamicMember member: KeyPath) -> T { 37 | metadata[keyPath: member] 38 | } 39 | } 40 | 41 | extension NetworkRequest: Hashable, Withable {} 42 | -------------------------------------------------------------------------------- /Sources/NetworkHandler/URL+NetworkRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension URL { 4 | var generalRequest: GeneralEngineRequest { 5 | GeneralEngineRequest(url: self) 6 | } 7 | 8 | var uploadRequest: UploadEngineRequest { 9 | UploadEngineRequest(url: self) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/NetworkHandlerAHCEngine/EngineResponseHeader+HTTPClientResponse.swift: -------------------------------------------------------------------------------- 1 | import NetworkHandler 2 | import AsyncHTTPClient 3 | import Foundation 4 | import NIOHTTP1 5 | 6 | extension EngineResponseHeader { 7 | public init(from response: HTTPClientResponse, with url: URL) { 8 | let statusCode = Int(response.status.code) 9 | let headerList = response.headers.map { HTTPHeaders.Header(key: "\($0.name)", value: "\($0.value)") } 10 | let headers = NetworkHalpers.HTTPHeaders(headerList) 11 | 12 | self.init(status: statusCode, url: url, headers: headers) 13 | } 14 | } 15 | 16 | extension HTTPClientResponse { 17 | public init(from response: HTTPResponseHead) { 18 | self.init( 19 | version: response.version, 20 | status: response.status, 21 | headers: response.headers, 22 | body: .init()) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/NetworkHandlerAHCEngine/GeneralEngineRequest+HTTPClientRequest.swift: -------------------------------------------------------------------------------- 1 | import AsyncHTTPClient 2 | import NIOCore 3 | import NetworkHandler 4 | 5 | extension GeneralEngineRequest { 6 | var httpClientRequest: HTTPClientRequest { 7 | var request = HTTPClientRequest(url: self.url.absoluteURL.absoluteString) 8 | request.method = .init(rawValue: self.method.rawValue) 9 | request.headers = .init(self.headers.map { ($0.key.rawValue, $0.value.rawValue) }) 10 | 11 | if let payload { 12 | request.body = .bytes(ByteBuffer(bytes: payload)) 13 | } 14 | 15 | return request 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/NetworkHandlerAHCEngine/TimeoutDebouncer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftPizzaSnips 3 | 4 | class TimeoutDebouncer: @unchecked Sendable { 5 | let timeoutDuration: TimeInterval 6 | 7 | private(set) var onTimeoutReached: @Sendable () -> Void 8 | 9 | private let lock = MutexLock() 10 | 11 | private var timeoutTask: Task? 12 | private var isTimeoutReached = false 13 | private var isCancelled = false 14 | 15 | init(timeoutDuration: TimeInterval, onTimeoutReached: @escaping @Sendable () -> Void) { 16 | self.timeoutDuration = timeoutDuration 17 | self.onTimeoutReached = onTimeoutReached 18 | } 19 | 20 | func checkIn() { 21 | lock.withLock { 22 | timeoutTask?.cancel() 23 | timeoutTask = nil 24 | guard 25 | isCancelled == false, 26 | isTimeoutReached == false 27 | else { return } 28 | timeoutTask = Task { 29 | try await Task.sleep(for: .seconds(timeoutDuration)) 30 | try Task.checkCancellation() 31 | performTimeoutActions() 32 | } 33 | } 34 | } 35 | 36 | private func performTimeoutActions() { 37 | lock.withLock { 38 | guard 39 | isCancelled == false, 40 | isTimeoutReached == false 41 | else { return } 42 | onTimeoutReached() 43 | isTimeoutReached = true 44 | } 45 | } 46 | 47 | func updateTimeoutAction(_ block: @escaping @Sendable () -> Void) { 48 | lock.withLock { 49 | onTimeoutReached = block 50 | } 51 | } 52 | 53 | func cancelTimeout() { 54 | lock.withLock { 55 | timeoutTask?.cancel() 56 | timeoutTask = nil 57 | isCancelled = true 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/NetworkHandlerAHCEngine/UploadEngineRequest+HTTPClientRequest.swift: -------------------------------------------------------------------------------- 1 | import AsyncHTTPClient 2 | import NIOCore 3 | import NetworkHandler 4 | 5 | extension UploadEngineRequest { 6 | public var httpClientFutureRequest: HTTPClient.Request { 7 | get throws { 8 | try HTTPClient.Request( 9 | url: self.url, 10 | method: .init(rawValue: self.method.rawValue), 11 | headers: .init(self.headers.map { ($0.key.rawValue, $0.value.rawValue) }), 12 | body: nil) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/NetworkHandlerURLSessionEngine/EngineRequestMetadata+URLRequestProperties.swift: -------------------------------------------------------------------------------- 1 | import NetworkHandler 2 | import Foundation 3 | 4 | public extension EngineRequestMetadata { 5 | internal var derivedURLRequest: URLRequest { 6 | get { 7 | if let existing: URLRequest = extensionStorageRetrieve(valueForKey: #function) { 8 | existing 9 | } else { 10 | URLRequest(url: url) 11 | } 12 | } 13 | set { 14 | extensionStorage(store: newValue, with: #function) 15 | } 16 | } 17 | 18 | var cachePolicy: URLRequest.CachePolicy { 19 | get { derivedURLRequest.cachePolicy } 20 | set { derivedURLRequest.cachePolicy = newValue } 21 | } 22 | 23 | var mainDocumentURL: URL? { 24 | get { derivedURLRequest.mainDocumentURL } 25 | set { derivedURLRequest.mainDocumentURL = newValue } 26 | } 27 | 28 | var httpShouldHandleCookies: Bool { 29 | get { derivedURLRequest.httpShouldHandleCookies } 30 | set { derivedURLRequest.httpShouldHandleCookies = newValue } 31 | } 32 | 33 | var httpShouldUsePipelining: Bool { 34 | get { derivedURLRequest.httpShouldUsePipelining } 35 | set { derivedURLRequest.httpShouldUsePipelining = newValue } 36 | } 37 | 38 | var allowsCellularAccess: Bool { 39 | get { derivedURLRequest.allowsCellularAccess } 40 | set { derivedURLRequest.allowsCellularAccess = newValue } 41 | } 42 | 43 | var allowsConstrainedNetworkAccess: Bool { 44 | get { derivedURLRequest.allowsConstrainedNetworkAccess } 45 | set { derivedURLRequest.allowsConstrainedNetworkAccess = newValue } 46 | } 47 | 48 | var allowsExpensiveNetworkAccess: Bool { 49 | get { derivedURLRequest.allowsExpensiveNetworkAccess } 50 | set { derivedURLRequest.allowsExpensiveNetworkAccess = newValue } 51 | } 52 | 53 | var networkServiceType: URLRequest.NetworkServiceType { 54 | get { derivedURLRequest.networkServiceType } 55 | set { derivedURLRequest.networkServiceType = newValue } 56 | } 57 | 58 | var attribution: URLRequest.Attribution { 59 | get { derivedURLRequest.attribution } 60 | set { derivedURLRequest.attribution = newValue } 61 | } 62 | 63 | @available(macOS 15.0, iOS 18.0, tvOS 18.0, visionOS 1.0, watchOS 9.1, *) 64 | var allowsPersistentDNS: Bool { 65 | get { derivedURLRequest.allowsPersistentDNS } 66 | set { derivedURLRequest.allowsPersistentDNS = newValue } 67 | } 68 | 69 | var assumesHTTP3Capable: Bool { 70 | get { derivedURLRequest.assumesHTTP3Capable } 71 | set { derivedURLRequest.assumesHTTP3Capable = newValue } 72 | } 73 | 74 | @available(iOS 16.1, tvOS 16.1, watchOS 9.1, *) 75 | var requiresDNSSECValidation: Bool { 76 | get { derivedURLRequest.requiresDNSSECValidation } 77 | set { derivedURLRequest.requiresDNSSECValidation = newValue } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/NetworkHandlerURLSessionEngine/EngineResponseHeader+HTTPURLResponse.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NetworkHandler 3 | 4 | extension EngineResponseHeader { 5 | public init(from response: URLResponse) { 6 | let headers: HTTPHeaders 7 | let statusCode: Int 8 | if let httpResponse = response as? HTTPURLResponse { 9 | let headerList = httpResponse.allHeaderFields.map { HTTPHeaders.Header(key: "\($0.key)", value: "\($0.value)") } 10 | headers = HTTPHeaders(headerList) 11 | statusCode = httpResponse.statusCode 12 | } else { 13 | headers = HTTPHeaders(["ERROR": "Invalid response object - Not an HTTPURLResponse"]) 14 | statusCode = -1 15 | } 16 | 17 | self.init(status: statusCode, url: response.url, headers: headers) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/NetworkHandlerURLSessionEngine/GeneralEngineRequest+URLRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NetworkHandler 3 | 4 | extension GeneralEngineRequest { 5 | package var urlRequest: URLRequest { 6 | var new = URLRequest(url: self.url) 7 | for header in self.headers { 8 | new.addValue(header.value.rawValue, forHTTPHeaderField: header.key.rawValue) 9 | } 10 | new.httpMethod = self.method.rawValue 11 | 12 | new.httpBody = payload 13 | new.timeoutInterval = self.timeoutInterval 14 | 15 | let storedRequest = self.derivedURLRequest 16 | 17 | new.cachePolicy = storedRequest.cachePolicy 18 | new.mainDocumentURL = storedRequest.mainDocumentURL 19 | new.httpShouldHandleCookies = storedRequest.httpShouldHandleCookies 20 | new.httpShouldUsePipelining = storedRequest.httpShouldUsePipelining 21 | new.allowsCellularAccess = storedRequest.allowsCellularAccess 22 | new.allowsConstrainedNetworkAccess = storedRequest.allowsConstrainedNetworkAccess 23 | new.allowsExpensiveNetworkAccess = storedRequest.allowsExpensiveNetworkAccess 24 | new.networkServiceType = storedRequest.networkServiceType 25 | new.attribution = storedRequest.attribution 26 | if #available(macOS 15.0, iOS 18.0, *) { 27 | new.allowsPersistentDNS = storedRequest.allowsPersistentDNS 28 | } 29 | new.assumesHTTP3Capable = storedRequest.assumesHTTP3Capable 30 | if #available(iOS 16.1, tvOS 16.1, watchOS 9.1, *) { 31 | new.requiresDNSSECValidation = storedRequest.requiresDNSSECValidation 32 | } 33 | 34 | return new 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/NetworkHandlerURLSessionEngine/URLSession+UploadSessionDelegate.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftPizzaSnips 3 | import NetworkHandler 4 | 5 | extension URLSession { 6 | /// Used internally for upload tasks. Requires being set as the delegate on the URLSession. I can't remember if it 7 | /// mattered if it was the task delegate or not, but that's how the current implementation works, so I'd suggest 8 | /// being consistent with that. 9 | class UploadDellowFelegate: NSObject, URLSessionTaskDelegate, URLSessionDataDelegate, @unchecked Sendable { 10 | /// Tracks the state of a single task. Stored in a dictionary in the delegate. 11 | private struct State { 12 | /// Relays the total number of bytes sent for a given task in a stream. 13 | let progressContinuation: UploadProgressStream.Continuation? 14 | /// Relays the data body chunk blobs received from the response. 15 | let bodyContinuation: ResponseBodyStream.Continuation 16 | 17 | /// The actual network task 18 | let task: URLSessionTask 19 | 20 | /// Tracks the most recently progress continuation usage to keep from flooding the progress stream. 21 | var lastUpdate = Date.distantPast 22 | 23 | /// Source of the upload. 24 | let stream: InputStream? 25 | 26 | /// Reference back to the delegate 27 | unowned let parent: UploadDellowFelegate 28 | 29 | enum Completion { 30 | case inProgress 31 | case finished 32 | case error(Error) 33 | } 34 | 35 | @NHActor 36 | var dataSendCompletion: Completion = .inProgress 37 | 38 | init( 39 | progressContinuation: UploadProgressStream.Continuation?, 40 | bodyContinuation: ResponseBodyStream.Continuation, 41 | task: URLSessionTask, 42 | stream: InputStream?, 43 | parent: UploadDellowFelegate 44 | ) { 45 | self.progressContinuation = progressContinuation 46 | self.bodyContinuation = bodyContinuation 47 | self.task = task 48 | self.stream = stream 49 | self.parent = parent 50 | } 51 | } 52 | 53 | /// Tracks all the tasks with their current state. Accessed only with `lock`. For more info, see `lock` 54 | private var states: [URLSessionTask: State] = [:] 55 | /// Lock for keeping thread safety. However, it's probably not necessary. The delegate is set to be used on a 56 | /// single queue, so it's probably uneeded overhead... That said, I don't fully know the mechanism for the 57 | /// delegate thread, so until I can trust it fully, I'm going to keep using this. 58 | private let lock = MutexLock() 59 | 60 | /// Adds a task for tracking with the delegate. 61 | func addTask( 62 | _ task: URLSessionTask, 63 | withStream stream: InputStream?, 64 | progressContinuation: UploadProgressStream.Continuation?, 65 | bodyContinuation: ResponseBodyStream.Continuation 66 | ) { 67 | let state = State( 68 | progressContinuation: progressContinuation, 69 | bodyContinuation: bodyContinuation, 70 | task: task, 71 | stream: stream, 72 | parent: self) 73 | lock.withLock { 74 | states[task] = state 75 | } 76 | } 77 | 78 | func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { 79 | lock.lock() 80 | guard let state = states[dataTask] else { return lock.unlock() } 81 | lock.unlock() 82 | 83 | _ = try? state.bodyContinuation.yield(Array(data)) 84 | } 85 | 86 | func urlSession( 87 | _ session: URLSession, 88 | dataTask: URLSessionDataTask, 89 | didReceive response: URLResponse 90 | ) async -> URLSession.ResponseDisposition { 91 | guard 92 | let state = lock.withLock({ () -> State? in 93 | guard let state = states[dataTask] else { return nil } 94 | return state 95 | }) 96 | else { return .allow } 97 | 98 | Task { @NHActor in 99 | guard dataTask === state.task else { return } 100 | guard case .inProgress = state.dataSendCompletion else { 101 | fatalError("Multiple continuation completions") 102 | } 103 | 104 | lock.withLock { 105 | states[dataTask]?.dataSendCompletion = .finished 106 | } 107 | } 108 | 109 | return .allow 110 | } 111 | 112 | func urlSession( 113 | _ session: URLSession, 114 | task: URLSessionTask, 115 | needNewBodyStream completionHandler: @escaping (InputStream?) -> Void 116 | ) { 117 | var stream: InputStream? 118 | defer { completionHandler(stream) } 119 | lock.lock() 120 | defer { lock.unlock() } 121 | guard let state = states[task] else { return } 122 | stream = state.stream 123 | } 124 | 125 | func urlSession( 126 | _ session: URLSession, 127 | task: URLSessionTask, 128 | didSendBodyData bytesSent: Int64, 129 | totalBytesSent: Int64, 130 | totalBytesExpectedToSend: Int64 131 | ) { 132 | lock.lock() 133 | guard let state = states[task] else { return lock.unlock() } 134 | lock.unlock() 135 | 136 | let now = Date.now 137 | guard 138 | task === state.task, 139 | now.timeIntervalSince(state.lastUpdate) > 0.0333333 140 | else { return } 141 | defer { 142 | lock.withLock { 143 | states[task]?.lastUpdate = now 144 | } 145 | } 146 | _ = try? state.progressContinuation?.yield(totalBytesSent) 147 | 148 | guard 149 | totalBytesExpectedToSend != NSURLSessionTransferSizeUnknown, 150 | totalBytesSent == totalBytesExpectedToSend 151 | else { return } 152 | try? state.progressContinuation?.finish() 153 | } 154 | 155 | func urlSession( 156 | _ session: URLSession, 157 | task: URLSessionTask, 158 | didCompleteWithError error: (any Error)? 159 | ) { 160 | lock.lock() 161 | guard let state = states[task] else { 162 | lock.unlock() 163 | return 164 | } 165 | states[task] = nil 166 | lock.unlock() 167 | 168 | try? state.progressContinuation?.finish(throwing: error) 169 | try? state.bodyContinuation.finish(throwing: error) 170 | } 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /Sources/NetworkHandlerURLSessionEngine/URLSessionConfiguration+NHDefault.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension URLSessionConfiguration { 4 | /// Disables caching on the URLSession level as it would conflict with the caching provided via `NetworkHandler`. 5 | public static let networkHandlerDefault: URLSessionConfiguration = { 6 | let config = URLSessionConfiguration.default 7 | config.requestCachePolicy = .reloadIgnoringLocalCacheData 8 | config.urlCache = nil 9 | return config 10 | }() 11 | } 12 | -------------------------------------------------------------------------------- /Sources/NetworkHandlerURLSessionEngine/URLSessionConformance.swift: -------------------------------------------------------------------------------- 1 | @_exported import NetworkHandler 2 | import Foundation 3 | import SwiftPizzaSnips 4 | import Logging 5 | 6 | extension URLSession: NetworkEngine { 7 | /// To properly support progress and other features fully, a specific, custom `URLSessionDelegate` is needed. 8 | /// This method eliminates the need for the user of this code to manage that themselves. 9 | /// 10 | /// Additionally, during testing and troubleshooting, I had to constrain the URLSession delegate to a 11 | /// single operation queue for some reason. I believe I resolved the issue with correct thread safety in the 12 | /// delegate, but I don't want to remove the syncronous queue configuration until I know we are good to go. 13 | /// - Parameter configuration: URLSessionConfiguration - defaults to `.networkHandlerDefault` 14 | /// - Returns: a new `URLSession` 15 | public static func asEngine( 16 | withConfiguration configuration: URLSessionConfiguration = .networkHandlerDefault 17 | ) -> URLSession { 18 | let delegate = UploadDellowFelegate() 19 | let queue = OperationQueue() 20 | queue.maxConcurrentOperationCount = 1 21 | queue.name = "Dellow Felegate" 22 | return URLSession(configuration: configuration, delegate: delegate, delegateQueue: queue) 23 | } 24 | 25 | public func performNetworkTransfer( 26 | request: NetworkRequest, 27 | uploadProgressContinuation: UploadProgressStream.Continuation?, 28 | requestLogger: Logger? 29 | ) async throws(NetworkError) -> (responseHeader: EngineResponseHeader, responseBody: ResponseBodyStream) { 30 | guard 31 | let delegate = delegate as? UploadDellowFelegate 32 | else { 33 | throw .unspecifiedError(reason: """ 34 | URLSession delegate must be an instance of `UploadDellowFelegate`. Create your URLSession with \ 35 | `URLSession.asEngine()` to have this handled for you. 36 | """) 37 | } 38 | 39 | let (bodyStream, bodyContinuation) = ResponseBodyStream.makeStream(errorOnCancellation: NetworkError.requestCancelled) 40 | 41 | let (urlTask, payloadStream) = try getSessionTask(from: request) 42 | delegate.addTask( 43 | urlTask, 44 | withStream: payloadStream, 45 | progressContinuation: uploadProgressContinuation, 46 | bodyContinuation: bodyContinuation) 47 | if configuration.identifier == nil { 48 | urlTask.delegate = delegate 49 | } 50 | 51 | @Sendable 52 | func performCancellation() { 53 | urlTask.cancel() 54 | try? uploadProgressContinuation?.finish(throwing: CancellationError()) 55 | try? bodyContinuation.finish(throwing: CancellationError()) 56 | } 57 | 58 | let isUploadFinished = Sendify(false) 59 | uploadProgressContinuation?.onFinish { reason in 60 | isUploadFinished.value = true 61 | guard reason.finishedOrCancelledError != nil else { return } 62 | performCancellation() 63 | } 64 | 65 | bodyContinuation.onFinish { reason in 66 | guard reason.finishedOrCancelledError != nil else { return } 67 | performCancellation() 68 | } 69 | 70 | urlTask.resume() 71 | 72 | let response = try await NetworkError.captureAndConvert { 73 | while urlTask.response == nil { 74 | if let error = urlTask.error { 75 | throw error 76 | } 77 | let delayValue = isUploadFinished.value ? 20 : 100 78 | try await Task.sleep(for: .milliseconds(delayValue)) 79 | } 80 | guard let response = urlTask.response else { fatalError("I just saw it right there!") } 81 | return EngineResponseHeader(from: response) 82 | } 83 | 84 | return (response, bodyStream) 85 | } 86 | 87 | private func getSessionTask( 88 | from request: NetworkRequest 89 | ) throws(NetworkError) -> (task: URLSessionTask, inputStream: InputStream?) { 90 | switch request { 91 | case .upload(let uploadEngineRequest, let payload): 92 | let payloadStream: InputStream? 93 | switch payload { 94 | case .data(let data): 95 | payloadStream = InputStream(data: data) 96 | case .localFile(let localFile): 97 | guard 98 | let stream = InputStream(url: localFile) 99 | else { throw .unspecifiedError(reason: "Creating a stream from the referenced local file failed. \(localFile)") } 100 | payloadStream = stream 101 | case .inputStream(let stream): 102 | payloadStream = stream 103 | } 104 | let urlRequest = uploadEngineRequest.urlRequest 105 | 106 | let task = uploadTask(withStreamedRequest: urlRequest) 107 | return (task, payloadStream) 108 | case .general(let generalEngineRequest): 109 | let task = dataTask(with: generalEngineRequest.urlRequest) 110 | return (task, nil) 111 | } 112 | } 113 | 114 | public func shutdown() { 115 | finishTasksAndInvalidate() 116 | } 117 | 118 | // hard coded into NetworkError - no need to implement here 119 | public static func isCancellationError(_ error: any Error) -> Bool { false } 120 | 121 | // hard coded into NetworkError - no need to implement here 122 | public static func isTimeoutError(_ error: any Error) -> Bool { false } 123 | } 124 | -------------------------------------------------------------------------------- /Sources/NetworkHandlerURLSessionEngine/URLSessionTask.State+DebugDescription.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension URLSessionTask.State: @retroactive CustomDebugStringConvertible { 4 | public var debugDescription: String { 5 | var out = ["URLSessionTask", "State"] 6 | switch self { 7 | case .suspended: 8 | out.append("suspended") 9 | case .canceling: 10 | out.append("cancelling") 11 | case .completed: 12 | out.append("completed") 13 | case .running: 14 | out.append("running") 15 | @unknown default: 16 | out.append("unknown") 17 | } 18 | return out.joined(separator: ".") 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/NetworkHandlerURLSessionEngine/UploadEngineRequest+URLRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NetworkHandler 3 | 4 | extension UploadEngineRequest { 5 | package var urlRequest: URLRequest { 6 | var new = URLRequest(url: self.url) 7 | for header in self.headers { 8 | new.addValue(header.value.rawValue, forHTTPHeaderField: header.key.rawValue) 9 | } 10 | new.httpMethod = self.method.rawValue 11 | 12 | new.timeoutInterval = self.timeoutInterval 13 | 14 | let storedRequest = self.derivedURLRequest 15 | 16 | new.cachePolicy = storedRequest.cachePolicy 17 | new.mainDocumentURL = storedRequest.mainDocumentURL 18 | new.httpShouldHandleCookies = storedRequest.httpShouldHandleCookies 19 | new.httpShouldUsePipelining = storedRequest.httpShouldUsePipelining 20 | new.allowsCellularAccess = storedRequest.allowsCellularAccess 21 | new.allowsConstrainedNetworkAccess = storedRequest.allowsConstrainedNetworkAccess 22 | new.allowsExpensiveNetworkAccess = storedRequest.allowsExpensiveNetworkAccess 23 | new.networkServiceType = storedRequest.networkServiceType 24 | new.attribution = storedRequest.attribution 25 | if #available(macOS 15.0, iOS 18.0, *) { 26 | new.allowsPersistentDNS = storedRequest.allowsPersistentDNS 27 | } 28 | new.assumesHTTP3Capable = storedRequest.assumesHTTP3Capable 29 | if #available(iOS 16.1, tvOS 16.1, watchOS 9.1, *) { 30 | new.requiresDNSSECValidation = storedRequest.requiresDNSSECValidation 31 | } 32 | 33 | return new 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/TestSupport/AtomicValue.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final public class AtomicValue: @unchecked Sendable { 4 | private let lock = NSLock() 5 | 6 | private var _value: T 7 | public var value: T { 8 | get { 9 | lock.lock() 10 | defer { lock.unlock() } 11 | return _value 12 | } 13 | 14 | set { 15 | lock.lock() 16 | defer { lock.unlock() } 17 | _value = newValue 18 | } 19 | } 20 | 21 | public init(value: T) { 22 | lock.lock() 23 | defer { lock.unlock() } 24 | self._value = value 25 | } 26 | } 27 | 28 | extension AtomicValue: CustomStringConvertible, CustomDebugStringConvertible { 29 | public var description: String { 30 | "\(value)" 31 | } 32 | 33 | public var debugDescription: String { 34 | "AtomicValue: \(value)" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/TestSupport/DemoModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct DemoModel: Codable, Equatable, Sendable { 4 | public let id: UUID 5 | public var title: String 6 | public var subtitle: String 7 | public var imageURL: URL 8 | 9 | public init(id: UUID = UUID(), title: String, subtitle: String, imageURL: URL) { 10 | self.id = id 11 | self.title = title 12 | self.subtitle = subtitle 13 | self.imageURL = imageURL 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/TestSupport/DemoModelController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftPizzaSnips 3 | import NetworkHandler 4 | import NetworkHandlerAHCEngine 5 | import AsyncHTTPClient 6 | 7 | public class DemoModelController: @unchecked Sendable { 8 | private var _demoModels = [DemoModel]() 9 | 10 | private let lock = MutexLock() 11 | 12 | private(set) var demoModels: [DemoModel] { 13 | get { lock.withLock { _demoModels } } 14 | set { 15 | lock.withLock { _demoModels = newValue.sorted { $0.title < $1.title } } 16 | } 17 | } 18 | 19 | public init() {} 20 | 21 | @discardableResult 22 | public func create( 23 | modelWithTitle title: String, 24 | andSubtitle subtitle: String, 25 | imageURL: URL, 26 | completion: @escaping @Sendable (Error?) -> Void = { _ in } 27 | ) -> DemoModel { 28 | let model = DemoModel(title: title, subtitle: subtitle, imageURL: imageURL) 29 | demoModels.append(model) 30 | Task { 31 | do { 32 | _ = try await put(model: model) 33 | completion(nil) 34 | } catch { 35 | completion(error) 36 | } 37 | } 38 | return model 39 | } 40 | 41 | @discardableResult 42 | public func update( 43 | model: DemoModel, 44 | withTitle title: String, 45 | subtitle: String, 46 | imageURL: URL, 47 | completion: @escaping @Sendable (Error?) -> Void = { _ in }) -> DemoModel? { 48 | guard let index = demoModels.firstIndex(of: model) else { return nil } 49 | var updatedModel = demoModels[index] 50 | updatedModel.title = title 51 | updatedModel.subtitle = subtitle 52 | updatedModel.imageURL = imageURL 53 | demoModels[index] = updatedModel 54 | 55 | Task { [updatedModel] in 56 | do { 57 | _ = try await put(model: updatedModel) 58 | completion(nil) 59 | } catch { 60 | completion(error) 61 | } 62 | } 63 | return updatedModel 64 | } 65 | 66 | public func delete(model: DemoModel) async throws { 67 | guard let index = demoModels.firstIndex(of: model) else { return } 68 | demoModels.remove(at: index) 69 | try await deleteFromServer(model: model) 70 | } 71 | 72 | public func clearLocalModelCache() { 73 | demoModels.removeAll() 74 | } 75 | 76 | // MARK: - networking 77 | 78 | let baseURL = URL(string: "https://networkhandlertestbase.firebaseio.com/DemoAndTests")! 79 | 80 | public func fetchDemoModels() async throws { 81 | let getURL = baseURL.appendingPathExtension("json") 82 | 83 | let request = getURL.generalRequest 84 | let nh = NetworkHandler(name: "Default", engine: HTTPClient()) 85 | 86 | do { 87 | let stuff: [DemoModel] = try await nh.downloadMahCodableDatas(for: request).decoded 88 | self.demoModels = stuff 89 | } catch { 90 | throw error 91 | } 92 | } 93 | 94 | public func put(model: DemoModel) async throws -> DemoModel { 95 | let nh = NetworkHandler(name: "Default", engine: HTTPClient()) 96 | let putURL = baseURL 97 | .appendingPathComponent(model.id.uuidString) 98 | .appendingPathExtension("json") 99 | 100 | var request = putURL.generalRequest 101 | request.method = .put 102 | 103 | try request.encodeData(model) 104 | 105 | return try await nh.downloadMahCodableDatas(for: request).decoded 106 | } 107 | 108 | public func deleteFromServer(model: DemoModel) async throws { 109 | let deleteURL = baseURL 110 | .appendingPathComponent(model.id.uuidString) 111 | .appendingPathExtension("json") 112 | let nh = NetworkHandler(name: "Default", engine: HTTPClient()) 113 | 114 | var request = deleteURL.generalRequest 115 | request.method = .delete 116 | 117 | try await nh.transferMahDatas(for: .general(request)) 118 | } 119 | 120 | // MARK: - demo purposes 121 | 122 | public func generateDemoData() async throws { 123 | 124 | try await fetchDemoModels() 125 | 126 | let baseURL = URL(string: "https://placekitten.com/")! 127 | 128 | while self.demoModels.count < 100 { 129 | let dimensions = Int.random(in: 400...800) 130 | let kittenURL = baseURL 131 | .appendingPathComponent("\(dimensions)") 132 | .appendingPathComponent("\(dimensions)") 133 | 134 | create( 135 | modelWithTitle: DemoText.demoNames.randomElement()!, 136 | andSubtitle: DemoText.demoSubtitles.randomElement()!, 137 | imageURL: kittenURL) 138 | print(self.demoModels.count) 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /Sources/TestSupport/DemoText.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum DemoText { 4 | static let demoNames = [ 5 | "Annabell", 6 | "Purrincess", 7 | "Dragon", 8 | "Puff", 9 | "Zippy", 10 | "Mead", 11 | "QA", 12 | "Emily", 13 | "Izzy", 14 | "Kurumi (Walnut)", 15 | "Oreo", 16 | "Chanel", 17 | "Kinako", 18 | "Minù", 19 | "Maron (Chestnut)", 20 | "Minto (Mint)", 21 | "Casper", 22 | "Micio (Pussycat)", 23 | "Smudge", 24 | "Fuku (Lucky)", 25 | "Charly", 26 | "Lucy", 27 | "Sylvester", 28 | "Caramel", 29 | "Chobi", 30 | "Moka (Mocha)", 31 | "Susi", 32 | "Pacha", 33 | "Leo", 34 | "Alfie", 35 | "Mimi", 36 | "Momo (Peach)", 37 | "Molly", 38 | "Sakura (Cherry Blossom)", 39 | "Blackie", 40 | "Ti-Mine", 41 | "Misty", 42 | "Sam", 43 | "Whiskers", 44 | "Haru (Spring)", 45 | "Sophie", 46 | "Shadow", 47 | "Kotetsu (Small Iron)", 48 | "Trilly", 49 | "Poppy", 50 | "Grisou", 51 | "Sammy", 52 | "Simba", 53 | "Eve", 54 | "Tigger", 55 | "Oscar", 56 | "Smokey", 57 | "Patches", 58 | "Azuki (Sweet Red Beans)", 59 | "Hana (Flower)", 60 | "Cleo", 61 | "Lisa", 62 | "Tora (Tiger)", 63 | "Minka", 64 | "Puss", 65 | "Félix", 66 | "Koko", 67 | "Hime (Princess)", 68 | "Ginger", 69 | "Birba (Scoundrel)", 70 | "Muschi", 71 | "Sora (Sky)", 72 | "Felix", 73 | "Charlie", 74 | "Patch", 75 | "Kitty", 76 | "Fluffy", 77 | "Maggie", 78 | "Chibi (Tiny)", 79 | "Mikan (Mandarin Orange)", 80 | "Millie", 81 | "Chiro", 82 | "Romeo", 83 | "Pallina (Small ball)", 84 | "Missy", 85 | "Minette", 86 | "Lily", 87 | "Princess", 88 | "Oliver", 89 | "Bella", 90 | "Sassy", 91 | "Lucky", 92 | "Muffin", 93 | "Kuro (Black)", 94 | "Jack", 95 | "Daisy", 96 | "Simon", 97 | "Samantha", 98 | "Chicco (Grain)", 99 | "Briciola (Crumb)", 100 | "Sooty", 101 | "Luna (Moon)", 102 | "Max", 103 | "Rin", 104 | "Tama", 105 | "Tom", 106 | "Jiji", 107 | "Charlotte", 108 | "Baby", 109 | "Yuki (Snow)", 110 | "Minou", 111 | "Buddy", 112 | "Fraidy", 113 | "Scaredy", 114 | "Shiro (White)", 115 | "Miruku (Milk)", 116 | "Jasper", 117 | "Mei", 118 | "Moritz", 119 | "Tiger", 120 | "Angel", 121 | "Kai", 122 | "Blacky", 123 | "Maru", 124 | ] 125 | 126 | static let demoSubtitles = [ 127 | "Someone is getting through something hard right now because you've got their back.", 128 | "That color is perfect on you.", 129 | "Your kindness is a balm to all who encounter it.", 130 | "You help me feel more joy in life.", 131 | "You're a gift to those around you.", 132 | "Your quirks are so you -- and I love that.", 133 | "You're a great example to others.", 134 | "When I'm down you always say something encouraging to help me feel better.", 135 | "You're like a breath of fresh air.", 136 | "You're strong.", 137 | "Hanging out with you is always a blast.", 138 | "How do you keep being so funny and making everyone laugh?", 139 | "Your perspective is refreshing.", 140 | "You're always learning new things and trying to better yourself, which is awesome.", 141 | "Your name suits you to a T.", 142 | "Your voice is magnificent.", 143 | "You're more fun than bubble wrap.", 144 | "You have the courage of your convictions.", 145 | "You're like a ray of sunshine on a really dreary day.", 146 | "There's ordinary, and then there's you.", 147 | "When you say, \"I meant to do that,\" I totally believe you.", 148 | "You have impeccable manners.", 149 | "I'm grateful to know you.", 150 | "You deserve a hug right now.", 151 | "Your creative potential seems limitless.", 152 | "I appreciate you.", 153 | "You should be thanked more often. So thank you!!", 154 | "You are really kind to people around you.", 155 | "You have a good head on your shoulders.", 156 | "You're a candle in the darkness.", 157 | "Colors seem brighter when you're around.", 158 | "You have the best laugh.", 159 | "I bet you sweat glitter.", 160 | "You always know just what to say.", 161 | "Any team would be lucky to have you on it.", 162 | "How is it that you always look great, even in sweatpants?", 163 | "Everything would be better if more people were like you!", 164 | "When you make a mistake, you try to fix it.", 165 | "If you were a scented candle they'd call it Perfectly Imperfect (and it would smell like summer).", 166 | "You're an awesome friend.", 167 | "You are really courageous.", 168 | "You're really something special.", 169 | "You are awesome!", 170 | "You should be proud of yourself.", 171 | "Your ability to recall random factoids at just the right time is impressive.", 172 | "You're great at figuring stuff out.", 173 | "You make my insides jump around in the best way.", 174 | "If you were a box of crayons, you'd be the giant name-brand one with the built-in sharpener.", 175 | "I bet you do the crossword puzzle in ink.", 176 | "You've got an awesome sense of humor!", 177 | "You're better than a triple-scoop ice cream cone. With sprinkles.", 178 | "Thank you for being there for me.", 179 | "If someone based an Internet meme on you, it would have impeccable grammar.", 180 | "You could survive a Zombie apocalypse.", 181 | "You always find something special in the most ordinary things.", 182 | "Jokes are funnier when you tell them.", 183 | "You're one of a kind!", 184 | "That thing you don't like about yourself is what makes you so interesting.", 185 | "Babies and small animals probably love you.", 186 | "Being around you makes everything better!", 187 | "You seem to really know who you are.", 188 | "Our community is better because you're in it.", 189 | "You're a smart cookie.", 190 | "You bring out the best in other people.", 191 | "You are strong.", 192 | "You are enough.", 193 | "You may dance like no one's watching, but everyone's watching because you're an amazing dancer!", 194 | "Everyone gets knocked down sometimes, but you always get back up and keep going.", 195 | "I like your style.", 196 | "When you make up your mind about something, nothing stands in your way.", 197 | "You light up the room.", 198 | "I'm inspired by you.", 199 | "You were cool way before hipsters were cool.", 200 | "You always know -- and say -- exactly what I need to hear when I need to hear it.", 201 | "You have cute elbows. For reals!", 202 | "Being around you is like being on a happy little vacation.", 203 | "You're more fun than a ball pit filled with candy. (And seriously, what could be more fun than that?)", 204 | "You are the most perfect you there is.", 205 | "You help me be the best version of myself.", 206 | "Has anyone ever told you that you have great posture?", 207 | "When you say you will do something, I trust you.", 208 | "You have the best ideas.", 209 | "When you're not afraid to be yourself is when you're most incredible.", 210 | "Who raised you? They deserve a medal for a job well done.", 211 | "The way you treasure your loved ones is incredible.", 212 | "You're even more beautiful on the inside than you are on the outside.", 213 | "You're someone's reason to smile.", 214 | "You are making a difference.", 215 | "You're wonderful.", 216 | "You're a great listener.", 217 | "Somehow you make time stop and fly at the same time.", 218 | "You're more helpful than you realize.", 219 | "You're so thoughtful.", 220 | "You're even better than a unicorn, because you're real.", 221 | "In high school I bet you were voted \"most likely to keep being awesome.\"", 222 | "You have a great sense of humor.", 223 | "You're all that and a super-size bag of chips.", 224 | "The people you love are lucky to have you in their lives.", 225 | "Thank you for being you.", 226 | "On a scale from 1 to 10, you're an 11.", 227 | ] 228 | } 229 | -------------------------------------------------------------------------------- /Sources/TestSupport/DummyModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct DummyType: Codable, Equatable, Sendable { 4 | public let id: Int 5 | public let value: String 6 | public let other: Double 7 | 8 | public init(id: Int, value: String, other: Double) { 9 | self.id = id 10 | self.value = value 11 | self.other = other 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/TestSupport/HMAC Signing.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(CommonCrypto) 3 | import CommonCrypto 4 | 5 | public enum HMACAlgorithm { 6 | case md5, sha1, sha224, sha256, sha384, sha512 7 | 8 | public var hmacAlgValue: CCHmacAlgorithm { 9 | let value: Int 10 | switch self { 11 | case .md5: 12 | value = kCCHmacAlgMD5 13 | case .sha1: 14 | value = kCCHmacAlgSHA1 15 | case .sha224: 16 | value = kCCHmacAlgSHA224 17 | case .sha256: 18 | value = kCCHmacAlgSHA256 19 | case .sha384: 20 | value = kCCHmacAlgSHA384 21 | case .sha512: 22 | value = kCCHmacAlgSHA512 23 | } 24 | return CCHmacAlgorithm(value) 25 | } 26 | 27 | public var digestLength: Int { 28 | let result: Int32 29 | switch self { 30 | case .md5: 31 | result = CC_MD5_DIGEST_LENGTH 32 | case .sha1: 33 | result = CC_SHA1_DIGEST_LENGTH 34 | case .sha224: 35 | result = CC_SHA224_DIGEST_LENGTH 36 | case .sha256: 37 | result = CC_SHA256_DIGEST_LENGTH 38 | case .sha384: 39 | result = CC_SHA384_DIGEST_LENGTH 40 | case .sha512: 41 | result = CC_SHA512_DIGEST_LENGTH 42 | } 43 | return Int(result) 44 | } 45 | } 46 | 47 | public extension String { 48 | func hmac(algorithm: HMACAlgorithm, key: String) -> String { 49 | var result = [UInt8].init(repeating: 0, count: algorithm.digestLength) 50 | CCHmac(algorithm.hmacAlgValue, key, key.count, self, self.count, &result) 51 | 52 | let hmacData = Data(result) 53 | return hmacData.base64EncodedString(options: .lineLength76Characters) 54 | } 55 | } 56 | #endif 57 | -------------------------------------------------------------------------------- /Sources/TestSupport/NSImage+PNG.swift: -------------------------------------------------------------------------------- 1 | #if os(macOS) 2 | import AppKit 3 | 4 | extension NSImage { 5 | func pngData() -> Data? { 6 | guard let cgImage = cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil } 7 | let newRep = NSBitmapImageRep(cgImage: cgImage) 8 | newRep.size = size 9 | 10 | return newRep.representation(using: .png, properties: [:]) 11 | } 12 | } 13 | #endif 14 | -------------------------------------------------------------------------------- /Sources/TestSupport/NetworkHandlerBaseTest.swift: -------------------------------------------------------------------------------- 1 | @testable import NetworkHandler 2 | import NetworkHandlerMockingEngine 3 | import XCTest 4 | import Crypto 5 | import Foundation 6 | 7 | #if os(macOS) 8 | public typealias TestImage = NSImage 9 | #elseif os(iOS) 10 | public typealias TestImage = UIImage 11 | #else 12 | #endif 13 | 14 | open class NetworkHandlerBaseTest: XCTestCase { 15 | public func generateNetworkHandlerInstance(engine: Engine) -> NetworkHandler { 16 | let networkHandler = NetworkHandler(name: "Test Network Handler", engine: engine) 17 | addTeardownBlock { 18 | networkHandler.resetCache() 19 | } 20 | networkHandler.resetCache() 21 | return networkHandler 22 | } 23 | 24 | public func wait( 25 | forArbitraryCondition arbitraryCondition: @autoclosure () async throws -> Bool, 26 | timeout: TimeInterval = 10 27 | ) async throws { 28 | let start = Date() 29 | while try await arbitraryCondition() == false { 30 | let elapsed = Date().timeIntervalSince(start) 31 | if elapsed > timeout { 32 | throw TestError(message: "Timeout") 33 | } 34 | try await Task.sleep(nanoseconds: 1000) 35 | } 36 | } 37 | 38 | public struct TestError: Error, LocalizedError { 39 | let message: String 40 | 41 | public var failureReason: String? { message } 42 | public var errorDescription: String? { message } 43 | public var helpAnchor: String? { message } 44 | public var recoverySuggestion: String? { message } 45 | } 46 | 47 | public func generateRandomBytes(in file: URL, megabytes: UInt8) throws { 48 | let outputStream = OutputStream(url: file, append: false) 49 | outputStream?.open() 50 | let length = 1024 * 1024 51 | let buffer = UnsafeMutablePointer.allocate(capacity: length) 52 | let raw = UnsafeMutableRawPointer(buffer) 53 | let quicker = raw.bindMemory(to: UInt64.self, capacity: length / 8) 54 | 55 | (0.. SHA256Digest { 67 | guard let input = InputStream(url: url) else { throw NSError(domain: "Error loading file for hashing", code: -1) } 68 | 69 | return try streamHash(input) 70 | } 71 | 72 | public func streamHash(_ input: InputStream) throws -> SHA256Digest { 73 | var hasher = SHA256() 74 | 75 | let bufferSize = 1024 // KB 76 | * 1024 // MB 77 | * 10 // MB count 78 | let buffer = UnsafeMutableBufferPointer.allocate(capacity: bufferSize) 79 | guard let pointer = buffer.baseAddress else { throw NSError(domain: "Error allocating buffer", code: -2) } 80 | input.open() 81 | while input.hasBytesAvailable { 82 | let bytesRead = input.read(pointer, maxLength: bufferSize) 83 | let bufferrr = UnsafeRawBufferPointer(start: pointer, count: bytesRead) 84 | hasher.update(bufferPointer: bufferrr) 85 | } 86 | input.close() 87 | 88 | return hasher.finalize() 89 | } 90 | } 91 | 92 | extension Mirror { 93 | func firstChild(named name: String) -> T? { 94 | children.first(where: { 95 | guard $0.value is T else { return false } 96 | 97 | return $0.label == name ? true : false 98 | })?.value as? T 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Sources/TestSupport/Resources/lighthouse.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mredig/NetworkHandler/4b65e64dba3743b4911d0115f669f1d4a772f2e6/Sources/TestSupport/Resources/lighthouse.jpg -------------------------------------------------------------------------------- /Sources/TestSupport/SimpleTestError.swift: -------------------------------------------------------------------------------- 1 | public struct SimpleTestError: Error { 2 | public let message: String 3 | 4 | public init(message: String) { 5 | self.message = message 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/TestSupport/TestBundle.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Bundle { 4 | public static var testBundle: Bundle { module } 5 | } 6 | -------------------------------------------------------------------------------- /Sources/TestSupport/TestEnvironment.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @preconcurrency import SwiftlyDotEnv 3 | import Logging 4 | 5 | /// most easily populated by setting up env vars in xcode scheme. not sure how to do on linux... 6 | public enum TestEnvironment { 7 | private typealias SDEnv = SwiftlyDotEnv 8 | 9 | private static let logger = Logger(label: "Test Environment") 10 | 11 | private static func loadIfNeeded() { 12 | guard SwiftlyDotEnv.isLoaded == false else { return } 13 | do { 14 | try SwiftlyDotEnv.loadDotEnv( 15 | from: URL(fileURLWithPath: #filePath) 16 | .deletingLastPathComponent() 17 | .deletingLastPathComponent() 18 | .deletingLastPathComponent(), 19 | envName: "tests", 20 | requiringKeys: [ 21 | "S3KEY", 22 | "S3SECRET", 23 | ]) 24 | } catch { 25 | let message = """ 26 | Could not load env vars (you probably need a `.env.tests` file in the NetworkHandler root directory: \(error) 27 | """ 28 | logger.error("\(message)") 29 | fatalError(message) 30 | } 31 | } 32 | 33 | public static let s3AccessKey: String = { 34 | loadIfNeeded() 35 | return SwiftlyDotEnv[.s3AccessKeyKey]! 36 | }() 37 | public static let s3AccessSecret = { 38 | loadIfNeeded() 39 | return SwiftlyDotEnv[.s3AccessSecretKey]! 40 | }() 41 | } 42 | 43 | fileprivate extension String { 44 | static let s3AccessKeyKey = "S3KEY" 45 | static let s3AccessSecretKey = "S3SECRET" 46 | } 47 | -------------------------------------------------------------------------------- /Styleguide.md: -------------------------------------------------------------------------------- 1 | # Network Handler Style Guide 2 | 3 | This document is not comprehensive and will be updated as needed to address issues as they arise. 4 | 5 | ## Principles 6 | 7 | #### Optimize for the reader, not the writer 8 | 9 | Codebases often have extended lifetimes and more time is spent reading the code than writing it. We explicitly choose to optimize for the experience of our average software engineer reading, maintaining, and debugging code in our codebase rather than the ease of writing said code. For example, when something surprising or unusual is happening in a snippet of code, leaving textual hints for the reader is valuable. 10 | 11 | ## Naming 12 | 13 | Names should be as descriptive as possible, within reason. 14 | 15 | * avoid abbreviations 16 | * unless the name is excessively obvious, use 3 characters at minimum for names 17 | 18 | ## Indentation 19 | 20 | Tabs. You read that right. Tabs are modular. Do you prefer 2 space sized indentation? Feel free to view documents that way. Do you have old person eyes like me? Use 4 space sized tabs. 21 | 22 | Frequently navigate via keyboard? Press an arrow key once to get through a tab, not 2-4 or more times. 23 | 24 | Commenting out a block? Don't ruin the cleanliness of the columns by offsetting the commented code by 2 columns. 25 | 26 | But most importantly, the least selfish reason of mine... If a contributor relies on assistive devices, they typically (always?) require a single keystroke per whitespace character. Using spaces really inhibits their code navigation, even if your IDE of choice handles spaces like tabs transparently. Let's be as welcoming as possible to anyone who wants to bring something to the table. 27 | 28 | ## Whitespace 29 | 30 | Let's keep our documents as clean as possible. 31 | * eliminate trailing whitespace 32 | * leave a single, blank line of vertical whitespace to create visual separation between "strides" in the code. 33 | * if you are attempting to group a thematic section together, you may separate with two vertical whitespaces (use sparingly) 34 | * there should be no vertical whitespace between `// MARK` and the following line in the section it's marking 35 | 36 | ## Line Length 37 | 38 | This isn't intended to be a hard rule, but a guideline. There will be exceptions and they will be subjective. Try to keep your lines at 120 columns wide or less. We don't want to have to scroll horizontally to view an entire line (this is why 80 columns was so prevalent previously, but we have bigger monitors now). It's also statistically proven harder to read when you have to move your eyes too far horizontally. 39 | 40 | So how do we break up long lines? If it's a string, usually the best path forward is to make it a multi-line string and add escapes to avoid newlines. 41 | 42 | ```swift 43 | let sample = """ 44 | And THAT'S why you always leave a note. You might wanna lean away from that fire since you're soaked in alcohol. \ 45 | Oh…yeah…the guy in the…the $4,000 suit is holding the elevator for a guy who doesn't make that in three months. Come \ 46 | on! Hair up, glasses off. Stack the chafing dishes outside by the mailbox. I'm on the job. 47 | """ 48 | ``` 49 | 50 | What about function definitions that exceed the line length, you say? We need a format that is scalable with tabs, yet still easy to read and reason about. We also want something compatible with Xcode's auto indentation to help keep things easier. So here's the guideline (which should apply to both the implementation and call site): 51 | 52 | * function name up through the opening paren on the first line 53 | * each argument gets its own line 54 | * the closing paren with the return value (or omitted return value) and opening brace of the implementation gets its own line 55 | * If the return value is a tuple that pushes the line limit, that can be broken up similarly 56 | * Try not to leave dangling closing parens on their own line. 57 | * generally lean into the styles that Xcode provides via `ctrl-i` and `ctrl-m` - to get these results, there's little to no modification 58 | 59 | Here's an example, stright from this project (pro tip - this is usually what Xcode gives you when you place your cursor amongst the arguments and hit `ctrl-m`): 60 | ```swift 61 | func performNetworkTransfer( 62 | request: NetworkRequest, 63 | uploadProgressContinuation: UploadProgressStream.Continuation?, 64 | requestLogger: Logger? 65 | ) async throws(NetworkError) -> (responseHeader: EngineResponseHeader, responseBody: ResponseBodyStream) { 66 | // ... 67 | } 68 | ``` 69 | 70 | And here's another, imaginary modification for when the return ends up beyond the line limit: 71 | 72 | ```swift 73 | func performNetworkTransfer( 74 | request: NetworkRequest, 75 | uploadProgressContinuation: UploadProgressStream.Continuation?, 76 | requestLogger: Logger? 77 | ) async throws(NetworkError) -> ( 78 | responseHeader: EngineResponseHeader, 79 | responseBody: ResponseBodyStream, 80 | annoyinglyLongTupleComponentName: AnnoyinglyLongSymbolNameThatGetsVerySpecificAndOverlyVerboseButSimultaneouslyHasItsMerits 81 | ) { 82 | // ... 83 | } 84 | ``` 85 | 86 | Call site example: 87 | 88 | ```swift 89 | delegate.addTask( 90 | urlTask, 91 | withStream: payloadStream, 92 | progressContinuation: uploadProgressContinuation, 93 | bodyContinuation: bodyContinuation) 94 | ``` 95 | 96 | Tie breaker: 97 | 98 | If you have a definition that could get line splits in either the arguments or the return tuple, the arguments get split first: 99 | 100 | ```swift 101 | private func getSessionTask(from request: NetworkRequest) throws(NetworkError) -> (task: URLSessionTask, inputStream: InputStream?) { ... } 102 | 103 | // becomes 104 | 105 | private func getSessionTask( 106 | from request: NetworkRequest 107 | ) throws(NetworkError) -> (task: URLSessionTask, inputStream: InputStream?) { ... } 108 | ``` 109 | 110 | ## Trailing Closures 111 | 112 | Use of single trailing closures is fine, if not preferable. Use of multiple trailing closures is difficult to parse, so don't use them. 113 | 114 | ## Swiftlint 115 | 116 | This project uses [Swiftlint](https://github.com/realm/SwiftLint#using-homebrew). Be sure to fix any warnings brought about by swiftlint. If you need to disable or ignore a setting, be sure to justify your reasoning in your pull request. 117 | * if you don't have swiftlint installed, install it (best to use the homebrew method). 118 | 119 | 120 | Many parts of this document were pulled from Google's [ObjC](http://google.github.io/styleguide/objcguide.html) and [Swift](https://google.github.io/swift/#column-limit) style guides. 121 | -------------------------------------------------------------------------------- /Tests/NetworkHalpersTests/AWSv4AuthTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import NetworkHalpers 3 | import TestSupport 4 | import Crypto 5 | import PizzaMacros 6 | 7 | class AWSv4AuthTests: XCTestCase { 8 | 9 | static let awsKey = "LCNRBZKKF8QEWNT2DYGM" 10 | static let awsSecret = "F2XxYE7h6zim2nCgNaUqZp9hGWqYzy7kbNMazR8g" 11 | static let awsURL = #URL(""" 12 | https://s3.us-west-1.wasabisys.com/demoproject/?list-type=2&prefix=demo-subfolder%2FA%20Folder 13 | """) 14 | 15 | func testAWSSigning() { 16 | 17 | let formatter = ISO8601DateFormatter() 18 | let date = formatter.date(from: "2022-07-15T06:43:24Z")! 19 | 20 | let info = AWSV4Signature( 21 | requestMethod: .get, 22 | url: Self.awsURL, 23 | date: date, 24 | awsKey: Self.awsKey, 25 | awsSecret: Self.awsSecret, 26 | awsRegion: .usWest1, 27 | awsService: .s3, 28 | hexContentHash: .fromData(Data("".utf8)), 29 | additionalSignedHeaders: [:]) 30 | 31 | XCTAssertEqual( 32 | """ 33 | AWS4-HMAC-SHA256\n2022-07-15T06:43:24Z\n20220715/us-west-1/s3/aws4_request\ncc388e661c394a9b73dd2a71d1a20dd\ 34 | 890afb1433cb704549016be6a2af18cc5 35 | """, 36 | info.stringToSign) 37 | XCTAssertEqual("d3465b2bb220cf97a135c0746047bda485e70295f513048c192ca88ff50ecd18", info.signature) 38 | XCTAssertEqual( 39 | """ 40 | AWS4-HMAC-SHA256 Credential=LCNRBZKKF8QEWNT2DYGM/20220715/us-west-1/s3/aws4_request,SignedHeaders=host;\ 41 | x-amz-content-sha256,Signature=d3465b2bb220cf97a135c0746047bda485e70295f513048c192ca88ff50ecd18 42 | """, 43 | info.authorizationString) 44 | } 45 | 46 | func testApplyingAdditionalHeaders() throws { 47 | let request = Self.awsURL.generalRequest 48 | 49 | let headerKey: HTTPHeaders.Header.Key = "AdditionalHeaderKey" 50 | let headerValue: HTTPHeaders.Header.Value = "AdditionalHeaderValue" 51 | let awsSignature = AWSV4Signature( 52 | requestMethod: .get, 53 | url: Self.awsURL, 54 | awsKey: Self.awsKey, 55 | awsSecret: Self.awsSecret, 56 | awsRegion: .usWest1, 57 | awsService: .s3, 58 | hexContentHash: "\(SHA256.hash(data: Data("".utf8)).hex())", 59 | additionalSignedHeaders: [ 60 | headerKey: headerValue 61 | ]) 62 | 63 | XCTAssertNil(request.headers.value(for: headerKey)) 64 | let dlRequest = try awsSignature.processRequest(.general(request)) 65 | 66 | XCTAssertEqual(dlRequest.headers.value(for: headerKey), headerValue.rawValue) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Tests/NetworkHalpersTests/HTTPMethodTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import NetworkHalpers 3 | import TestSupport 4 | 5 | class HTTPMethodTests: XCTestCase { 6 | 7 | func testHTTPMethods() { 8 | var value: HTTPMethod = .get 9 | XCTAssertEqual(value.rawValue, "GET") 10 | 11 | value = .delete 12 | XCTAssertEqual(value.rawValue, "DELETE") 13 | 14 | value = .put 15 | XCTAssertEqual(value.rawValue, "PUT") 16 | 17 | value = .post 18 | XCTAssertEqual(value.rawValue, "POST") 19 | 20 | value = .head 21 | XCTAssertEqual(value.rawValue, "HEAD") 22 | 23 | value = .options 24 | XCTAssertEqual(value.rawValue, "OPTIONS") 25 | 26 | value = .patch 27 | XCTAssertEqual(value.rawValue, "PATCH") 28 | 29 | value = "CUSTOM" 30 | XCTAssertEqual(value.rawValue, "CUSTOM") 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/NetworkHandlerAHCTests/NetworkHandlerAHCTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | import Foundation 3 | import TestSupport 4 | import NetworkHandler 5 | import NetworkHandlerAHCEngine 6 | import Logging 7 | import SwiftPizzaSnips 8 | 9 | @Suite(.serialized) 10 | struct NetworkHandlerAHCTests: Sendable { 11 | let commonTests = NetworkHandlerCommonTests(logger: Logger(label: #fileID)) 12 | 13 | @Test func downloadAndCacheImages() async throws { 14 | let mockingEngine = generateEngine() 15 | 16 | let lighthouseURL = Bundle.testBundle.url(forResource: "lighthouse", withExtension: "jpg", subdirectory: "Resources")! 17 | let lighthouseData = try Data(contentsOf: lighthouseURL) 18 | 19 | try await commonTests.downloadAndCacheImages(engine: mockingEngine, imageExpectationData: lighthouseData) 20 | } 21 | 22 | @Test func downloadAndDecodeData() async throws { 23 | let mockingEngine = generateEngine() 24 | 25 | let modelURL = commonTests.demoModelURL 26 | 27 | let testModel = DemoModel( 28 | id: UUID(uuidString: "59747267-D47D-47CD-9E54-F79FA3C1F99B")!, 29 | title: "FooTitle", 30 | subtitle: "BarSub", 31 | imageURL: commonTests.imageURL) 32 | 33 | try await commonTests.downloadAndDecodeData(engine: mockingEngine, modelURL: modelURL, expectedModel: testModel) 34 | } 35 | 36 | @Test func handle404() async throws { 37 | let mockingEngine = generateEngine() 38 | 39 | let demo404URL = commonTests.demo404URL 40 | 41 | try await commonTests.handle404Error( 42 | engine: mockingEngine, 43 | expectedError: NetworkError.httpUnexpectedStatusCode( 44 | code: 404, 45 | originalRequest: .general(demo404URL.generalRequest), 46 | data: nil)) 47 | } 48 | 49 | @Test func expect200OnlyGet200() async throws { 50 | let mockingEngine = generateEngine() 51 | 52 | try await commonTests.expect200OnlyGet200(engine: mockingEngine) 53 | } 54 | 55 | @Test func expect201OnlyGet200() async throws { 56 | let mockingEngine = generateEngine() 57 | 58 | try await commonTests.expect201OnlyGet200(engine: mockingEngine) 59 | } 60 | 61 | @Test func uploadData() async throws { 62 | let mockingEngine = generateEngine() 63 | try await commonTests.uploadData(engine: mockingEngine) 64 | } 65 | 66 | @Test func uploadFileURL() async throws { 67 | let mockingEngine = generateEngine() 68 | 69 | try await commonTests.uploadFileURL(engine: mockingEngine) 70 | } 71 | 72 | @Test func uploadMultipartFile() async throws { 73 | let mockingEngine = generateEngine() 74 | 75 | try await commonTests.uploadMultipartFile(engine: mockingEngine) 76 | } 77 | 78 | @Test func uploadMultipartStream() async throws { 79 | let mockingEngine = generateEngine() 80 | try await commonTests.uploadMultipartStream(engine: mockingEngine) 81 | } 82 | 83 | @Test func badCodingData() async throws { 84 | let mockingEngine = generateEngine() 85 | 86 | try await commonTests.badCodableData(engine: mockingEngine) 87 | } 88 | 89 | @Test func cancellationViaToken() async throws { 90 | let mockingEngine = generateEngine() 91 | 92 | try await commonTests.cancellationViaToken(engine: mockingEngine) 93 | } 94 | 95 | @Test func cancellationViaStream() async throws { 96 | let mockingEngine = generateEngine() 97 | 98 | try await commonTests.cancellationViaStream(engine: mockingEngine) 99 | } 100 | 101 | @Test func uploadCancellationViaToken() async throws { 102 | let mockingEngine = generateEngine() 103 | try await commonTests.uploadCancellationViaToken(engine: mockingEngine) 104 | } 105 | 106 | @Test func timeoutTriggersRetry() async throws { 107 | let mockingEngine = generateEngine() 108 | try await commonTests.timeoutTriggersRetry(engine: mockingEngine) 109 | } 110 | 111 | @Test func downloadProgressTracking() async throws { 112 | let mockingEngine = generateEngine() 113 | try await commonTests.downloadProgressTracking(engine: mockingEngine) 114 | } 115 | 116 | @Test func uploadProgressTracking() async throws { 117 | let mockingEngine = generateEngine() 118 | 119 | try await commonTests.uploadProgressTracking(engine: mockingEngine) 120 | } 121 | 122 | @Test func polling() async throws { 123 | let mockingEngine = generateEngine() 124 | 125 | try await commonTests.polling(engine: mockingEngine) 126 | } 127 | 128 | @Test func downloadFile() async throws { 129 | let mockingEngine = generateEngine() 130 | 131 | try await commonTests.downloadFile(engine: mockingEngine) 132 | } 133 | 134 | private func generateEngine() -> HTTPClient { 135 | HTTPClient() 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /Tests/NetworkHandlerTests/EngineHeaderTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | import NetworkHandler 3 | import PizzaMacros 4 | import Foundation 5 | 6 | struct EngineHeaderTests { 7 | @Test func responseDescription() { 8 | let url = #URL("https://redeggproductions.com") 9 | var response = EngineResponseHeader( 10 | status: 200, 11 | url: url, 12 | headers: [ 13 | .contentLength: "\(1024)", 14 | .contentDisposition: "attachment; filename=\"asdf qwerty.jpg\"", 15 | .contentType: .json, 16 | ]) 17 | 18 | let description = "\(response)" 19 | 20 | print(description) 21 | 22 | #expect(description.contains("Status - 200")) 23 | #expect(description.contains("URL - https://redeggproductions.com")) 24 | #expect(description.contains("Expected length - 1024")) 25 | #expect(description.contains("MIME Type - application/json")) 26 | #expect(description.contains("Suggested Filename - asdf qwerty.jpg")) 27 | 28 | response = EngineResponseHeader( 29 | status: 200, 30 | url: url, 31 | headers: [ 32 | .contentLength: "\(1024)", 33 | .contentType: .json, 34 | ]) 35 | let description2 = "\(response)" 36 | #expect(description2.contains("Suggested Filename") == false) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Tests/NetworkHandlerTests/NetworkCacheTest.swift: -------------------------------------------------------------------------------- 1 | import Logging 2 | @testable import NetworkHandler 3 | import XCTest 4 | import TestSupport 5 | import NetworkHandlerMockingEngine 6 | 7 | class NetworkCacheTest: NetworkHandlerBaseTest { 8 | func waitForCacheToFinishActivity(_ cache: NetworkDiskCache, timeout: TimeInterval = 10) { 9 | let isActive = expectation( 10 | for: .init( 11 | block: { anyCache, _ in 12 | guard let cache = anyCache as? NetworkDiskCache else { return false } 13 | return !cache.isActive 14 | }), 15 | evaluatedWith: cache, 16 | handler: nil) 17 | 18 | wait(for: [isActive], timeout: timeout) 19 | } 20 | 21 | func generateDiskCache(named name: String? = nil) -> NetworkDiskCache { 22 | let logger = Logger(label: "Disk Test") 23 | let cache = NetworkDiskCache(cacheName: name, logger: logger) 24 | 25 | let reset = expectation( 26 | for: .init( 27 | block: { anyCache, _ in 28 | guard let cache = anyCache as? NetworkDiskCache else { return false } 29 | return !cache.isActive 30 | }), 31 | evaluatedWith: cache, 32 | handler: nil) 33 | 34 | wait(for: [reset], timeout: 10) 35 | 36 | cache.resetCache() 37 | return cache 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/NetworkHandlerTests/NetworkCacheTests.swift: -------------------------------------------------------------------------------- 1 | @testable import NetworkHandler 2 | import XCTest 3 | import PizzaMacros 4 | import NetworkHandlerMockingEngine 5 | 6 | class NetworkCacheTests: NetworkCacheTest { 7 | private let mockingEngine = MockingEngine() 8 | 9 | func testCacheCountLimit() { 10 | let cache = generateNetworkHandlerInstance(engine: mockingEngine).cache 11 | 12 | let initialLimit = cache.countLimit 13 | cache.countLimit = 5 14 | XCTAssertEqual(5, cache.countLimit) 15 | cache.countLimit = initialLimit 16 | XCTAssertEqual(initialLimit, cache.countLimit) 17 | } 18 | 19 | func testCacheTotalCostLimit() { 20 | let cache = generateNetworkHandlerInstance(engine: mockingEngine).cache 21 | 22 | let initialLimit = cache.totalCostLimit 23 | cache.totalCostLimit = 5 24 | XCTAssertEqual(5, cache.totalCostLimit) 25 | cache.totalCostLimit = initialLimit 26 | XCTAssertEqual(initialLimit, cache.totalCostLimit) 27 | } 28 | 29 | func testCacheName() { 30 | let cache = generateNetworkHandlerInstance(engine: mockingEngine).cache 31 | 32 | XCTAssertEqual("Test Network Handler-Cache", cache.name) 33 | } 34 | 35 | /// I've determined that NSCache's version of thread safety is that it doesn't block, so there are times that you 36 | /// might set a value, checking that it exists immediately afterwards only to find it's not there... But it will show 37 | /// up eventually. This, natually, messes with tests and causes this test to be unreliable. I'm working on 38 | /// finding a workaround to test, but in the meantime, this test failing isn't considered a real fail. 39 | /// 40 | /// see idea in NetworkCache class 41 | func testCacheAddRemove() { 42 | let data1 = Data([1, 2, 3, 4, 5]) 43 | let data2 = Data(data1.reversed()) 44 | 45 | let response1 = EngineResponseHeader( 46 | status: 200, 47 | url: #URL("https://redeggproductions.com"), 48 | headers: [ 49 | .contentLength: "\(1024)" 50 | ]) 51 | let response2 = EngineResponseHeader( 52 | status: 200, 53 | url: #URL("https://github.com"), 54 | headers: [ 55 | .contentLength: "\(2048)" 56 | ]) 57 | 58 | let cachedItem1 = NetworkCacheItem(response: response1, data: data1) 59 | let cachedItem2 = NetworkCacheItem(response: response2, data: data2) 60 | 61 | let networkHandler = generateNetworkHandlerInstance(engine: mockingEngine) 62 | let cache = networkHandler.cache 63 | let diskCache = cache.diskCache 64 | 65 | let key1 = URL(fileURLWithPath: "/").absoluteString 66 | let key2 = URL(fileURLWithPath: "/etc").absoluteString 67 | let key3 = URL(fileURLWithPath: "/usr").absoluteString 68 | 69 | cache[key1] = cachedItem1 70 | XCTAssertEqual(cachedItem1.data, cache[key1]?.data) 71 | cache[key1] = cachedItem2 72 | XCTAssertEqual(cachedItem2.data, cache[key1]?.data) 73 | 74 | cache[key2] = cachedItem1 75 | XCTAssertEqual(cachedItem1.data, cache[key2]?.data) 76 | XCTAssertEqual(cachedItem2.data, cache[key1]?.data) 77 | 78 | cache[key3] = cachedItem1 79 | XCTAssertEqual(cachedItem1.data, cache[key3]?.data) 80 | waitForCacheToFinishActivity(diskCache) 81 | cache[key3] = nil 82 | XCTAssertNil(cache[key3]) 83 | XCTAssertEqual(cachedItem1.data, cache[key2]?.data) 84 | XCTAssertEqual(cachedItem2.data, cache[key1]?.data) 85 | 86 | cache[key3] = cachedItem1 87 | XCTAssertEqual(cachedItem1.data, cache[key3]?.data) 88 | waitForCacheToFinishActivity(diskCache) 89 | let removed = cache.remove(objectFor: key3) 90 | XCTAssertNil(cache[key3]) 91 | XCTAssertEqual(cachedItem1.data, removed?.data) 92 | 93 | cache.reset() 94 | XCTAssertNil(cache[key1]) 95 | XCTAssertNil(cache[key2]) 96 | XCTAssertNil(cache[key3]) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Tests/NetworkHandlerTests/NetworkDiskCacheTests.swift: -------------------------------------------------------------------------------- 1 | @testable import NetworkHandler 2 | import XCTest 3 | import Logging 4 | import NetworkHandlerMockingEngine 5 | 6 | class NetworkDiskCacheTests: NetworkCacheTest { 7 | 8 | nonisolated(unsafe) 9 | static var dummy1KFile = Data(repeating: 0, count: 1024) 10 | nonisolated(unsafe) 11 | static var dummy2KFile = Data(repeating: 0, count: 1024 * 2) 12 | nonisolated(unsafe) 13 | static var dummy5KFile = Data(repeating: 0, count: 1024 * 5) 14 | 15 | // swiftlint:disable:next large_tuple 16 | static func fileAssortment() -> ( 17 | file1: (key: String, data: Data), 18 | file2: (key: String, data: Data), 19 | file3: (key: String, data: Data), 20 | file4: (key: String, data: Data), 21 | file5: (key: String, data: Data) 22 | ) { 23 | let file1 = (key: "file1", data: Self.dummy1KFile) 24 | let file2 = (key: "file2", data: Self.dummy2KFile) 25 | let file3 = (key: "file3", data: Self.dummy5KFile) 26 | let file4 = (key: "file4", data: Self.dummy1KFile) 27 | let file5 = (key: "file5", data: Self.dummy1KFile) 28 | return (file1, file2, file3, file4, file5) 29 | } 30 | 31 | override func tearDown() { 32 | let cache = generateDiskCache() 33 | cache.resetCache() 34 | } 35 | 36 | func testCacheAddRemove() { 37 | let logger = Logger(label: #function) 38 | let cache = NetworkDiskCache(logger: logger) 39 | cache.resetCache() 40 | 41 | let (file1, file2, file3, file4, file5) = Self.fileAssortment() 42 | 43 | cache.setData(file1.data, key: file1.key, sync: false) 44 | cache.setData(file2.data, key: file2.key, sync: false) 45 | cache.setData(file3.data, key: file3.key, sync: false) 46 | cache.setData(file4.data, key: file4.key, sync: false) 47 | 48 | let save = expectation( 49 | for: .init( 50 | block: { anyCache, _ in 51 | guard let cache = anyCache as? NetworkDiskCache else { return false } 52 | return !cache.isActive 53 | }), 54 | evaluatedWith: cache, 55 | handler: nil) 56 | 57 | wait(for: [save], timeout: 10) 58 | 59 | XCTAssertEqual(cache.getData(for: file1.key), file1.data) 60 | XCTAssertEqual(cache.getData(for: file2.key), file2.data) 61 | XCTAssertEqual(cache.getData(for: file3.key), file3.data) 62 | XCTAssertEqual(cache.getData(for: file4.key), file4.data) 63 | XCTAssertNil(cache.getData(for: file5.key)) 64 | 65 | cache.deleteData(for: file1.key) 66 | XCTAssertNil(cache.getData(for: file1.key)) 67 | XCTAssertEqual(cache.getData(for: file2.key), file2.data) 68 | XCTAssertEqual(cache.getData(for: file3.key), file3.data) 69 | XCTAssertEqual(cache.getData(for: file4.key), file4.data) 70 | XCTAssertNil(cache.getData(for: file5.key)) 71 | 72 | cache.deleteData(for: file3.key) 73 | XCTAssertNil(cache.getData(for: file1.key)) 74 | XCTAssertEqual(cache.getData(for: file2.key), file2.data) 75 | XCTAssertNil(cache.getData(for: file3.key)) 76 | XCTAssertEqual(cache.getData(for: file4.key), file4.data) 77 | XCTAssertNil(cache.getData(for: file5.key)) 78 | } 79 | 80 | func testReset() { 81 | let cache = generateDiskCache() 82 | 83 | let file1 = Self.fileAssortment().file1 84 | 85 | cache.setData(file1.data, key: file1.key) 86 | 87 | waitForCacheToFinishActivity(cache) 88 | 89 | XCTAssertEqual(1024, cache.size) 90 | XCTAssertEqual(1, cache.count) 91 | 92 | cache.resetCache() 93 | 94 | XCTAssertEqual(0, cache.size) 95 | XCTAssertEqual(0, cache.count) 96 | } 97 | 98 | func testCacheCapacity() { 99 | let cache = generateDiskCache() 100 | cache.capacity = 1024 * 2 101 | 102 | let (file1, file2, file3, file4, file5) = Self.fileAssortment() 103 | 104 | cache.setData(file1.data, key: file1.key, sync: true) 105 | XCTAssertEqual(file1.data, cache.getData(for: file1.key)) 106 | 107 | cache.setData(file2.data, key: file2.key, sync: true) 108 | XCTAssertNil(cache.getData(for: file1.key)) 109 | XCTAssertEqual(file2.data, cache.getData(for: file2.key)) 110 | 111 | cache.setData(file3.data, key: file3.key, sync: true) 112 | XCTAssertNil(cache.getData(for: file1.key)) 113 | XCTAssertNil(cache.getData(for: file2.key)) 114 | XCTAssertNil(cache.getData(for: file3.key)) 115 | 116 | cache.setData(file4.data, key: file4.key, sync: true) 117 | XCTAssertNil(cache.getData(for: file1.key)) 118 | XCTAssertNil(cache.getData(for: file2.key)) 119 | XCTAssertNil(cache.getData(for: file3.key)) 120 | XCTAssertEqual(file4.data, cache.getData(for: file4.key)) 121 | 122 | cache.setData(file5.data, key: file5.key, sync: true) 123 | XCTAssertNil(cache.getData(for: file1.key)) 124 | XCTAssertNil(cache.getData(for: file2.key)) 125 | XCTAssertNil(cache.getData(for: file3.key)) 126 | XCTAssertEqual(file4.data, cache.getData(for: file4.key)) 127 | XCTAssertEqual(file5.data, cache.getData(for: file5.key)) 128 | 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /Tests/NetworkHandlerTests/NetworkErrorTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import NetworkHandler 3 | @testable import TestSupport 4 | import PizzaMacros 5 | 6 | /// Obviously dependent on network conditions 7 | class NetworkErrorTests: XCTestCase { 8 | static let simpleURL = #URL("http://he@ho.hum") 9 | 10 | // MARK: - Template/Prototype Objects 11 | /// Tests Equatability on NetworkError cases 12 | func testErrorEquatable() { 13 | let allErrors = NetworkError.allErrorCases() 14 | let dupErrors = NetworkError.allErrorCases() 15 | var rotErrors = NetworkError.allErrorCases() 16 | let rot1 = rotErrors.remove(at: 0) 17 | rotErrors.append(rot1) 18 | 19 | for (index, error) in allErrors.enumerated() { 20 | XCTAssertEqual(error, dupErrors[index]) 21 | XCTAssertNotEqual(error, rotErrors[index]) 22 | } 23 | } 24 | 25 | @available(iOS 11.0, macOS 13.0, *) 26 | func testErrorOutput() { 27 | let testDummy = DummyType(id: 23, value: "Woop woop woop!", other: 25.3) 28 | let encoder = JSONEncoder() 29 | encoder.outputFormatting = [.sortedKeys] 30 | let testData = try? encoder.encode(testDummy) 31 | 32 | var error = NetworkError.unspecifiedError(reason: "Foo bar") 33 | let testString = String(data: testData!, encoding: .utf8)! 34 | let error1Str = "NetworkError: Unspecified Error: Foo bar" 35 | 36 | XCTAssertEqual(error1Str, error.debugDescription) 37 | 38 | error = .httpUnexpectedStatusCode( 39 | code: 401, 40 | originalRequest: .general(Self.simpleURL.generalRequest).with { $0.requestID = nil }, 41 | data: testData) 42 | let error2Str = "NetworkError: Bad Response Code (401) for request: (GET): http://he@ho.hum with data: \(testString)" 43 | XCTAssertEqual(error2Str, error.debugDescription) 44 | 45 | error = NetworkError.unspecifiedError(reason: nil) 46 | let error3Str = "NetworkError: Unspecified Error: nil value" 47 | XCTAssertEqual(error3Str, error.debugDescription) 48 | } 49 | } 50 | 51 | extension NetworkError { 52 | /// Creates a collection of Network errors covering most of the spectrum 53 | static func allErrorCases() -> [NetworkError] { 54 | let dummyError = NSError(domain: "com.redeggproductions.NetworkHandler", code: -1, userInfo: nil) 55 | let originalRequest = NetworkRequest.general(NetworkErrorTests.simpleURL.generalRequest).with { 56 | $0.requestID = nil 57 | } 58 | let allErrorCases: [NetworkError] = [ 59 | .dataCodingError(specifically: dummyError, sourceData: nil), 60 | .httpUnexpectedStatusCode(code: 404, originalRequest: originalRequest, data: nil), 61 | .unspecifiedError(reason: "Who knows what the error might be?!"), 62 | .unspecifiedError(reason: nil), 63 | .requestTimedOut, 64 | .otherError(error: dummyError), 65 | .requestCancelled, 66 | .noData, 67 | ] 68 | return allErrorCases 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Tests/NetworkHandlerTests/NetworkRequestTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | import NetworkHandler 3 | import TestSupport 4 | import PizzaMacros 5 | import Foundation 6 | 7 | struct NetworkRequestTests { 8 | @Test func genericEncoding() async throws { 9 | let testDummy = DummyType(id: 23, value: "Woop woop woop!", other: 25.3) 10 | 11 | let dummyURL = #URL("https://redeggproductions.com") 12 | let request = try dummyURL.generalRequest.with { 13 | try $0.encodeData(testDummy) 14 | } 15 | 16 | let data = try #require(request.payload) 17 | 18 | let decoded = try GeneralEngineRequest.defaultDecoder.decode(DummyType.self, from: data) 19 | #expect(decoded == testDummy) 20 | } 21 | 22 | /// Tests adding, setting, and getting header values 23 | @Test func requestHeaders() { 24 | let dummyURL = #URL("https://redeggproductions.com") 25 | let origRequest = dummyURL.generalRequest.with { 26 | $0.requestID = nil 27 | } 28 | var request = NetworkRequest.general(origRequest) 29 | 30 | request.headers.addValue(.json, forKey: .contentType) 31 | #expect("application/json" == request.headers[.contentType]) 32 | request.headers.setValue(.xml, forKey: .contentType) 33 | #expect("application/xml" == request.headers[.contentType]) 34 | request.headers.setValue("Bearer: 12345", forKey: .authorization) 35 | #expect(["Content-Type": "application/xml", "Authorization": "Bearer: 12345"] == request.headers) 36 | 37 | request.headers.setValue(nil, forKey: .authorization) 38 | #expect(["Content-Type": "application/xml"] == request.headers) 39 | #expect(request.headers[.authorization] == nil) 40 | 41 | request.headers.setValue("Arbitrary Value", forKey: "Arbitrary Key") 42 | #expect(["Content-Type": "application/xml", "arbitrary key": "Arbitrary Value"] == request.headers) 43 | 44 | let allFields: HTTPHeaders = [ 45 | "Content-Type": "application/xml", 46 | "Authorization": "Bearer: 12345", 47 | "Arbitrary Key": "Arbitrary Value", 48 | ] 49 | request.headers = allFields 50 | #expect(allFields == request.headers) 51 | 52 | var request2 = dummyURL.generalRequest.with { 53 | $0.requestID = nil 54 | } 55 | request2.headers.setValue(.audioMp4, forKey: .contentType) 56 | #expect("audio/mp4" == request2.headers.value(for: .contentType)) 57 | 58 | request2.headers.setContentType(.bmp) 59 | #expect("image/bmp" == request2.headers.value(for: .contentType)) 60 | 61 | request2.headers.setAuthorization("Bearer asdlkqf") 62 | #expect("Bearer asdlkqf" == request2.headers.value(for: .authorization)) 63 | } 64 | 65 | @Test func requestHeadersWithDuplicates() async throws { 66 | let dummyURL = #URL("https://redeggproductions.com") 67 | var requestWithNoDup = dummyURL.generalRequest.with { 68 | $0.requestID = nil 69 | } 70 | requestWithNoDup.headers.addValue("sessionId=abc123", forKey: .cookie) 71 | 72 | var requestWithDup = requestWithNoDup 73 | requestWithDup.headers.addValue("foo=bar", forKey: .cookie) 74 | 75 | #expect(requestWithDup != requestWithNoDup) 76 | #expect(requestWithDup.headers.count == 2) 77 | #expect(requestWithNoDup.headers.count == 1) 78 | } 79 | 80 | @Test func requestHeadersWithDuplicatesAddedInDifferentOrder() async throws { 81 | let dummyURL = #URL("https://redeggproductions.com") 82 | var request1 = dummyURL.generalRequest.with { 83 | $0.requestID = nil 84 | } 85 | var request2 = request1 86 | 87 | request1.headers.addValue("sessionId=abc123", forKey: .cookie) 88 | request1.headers.addValue("foo=bar", forKey: .cookie) 89 | request2.headers.addValue("foo=bar", forKey: .cookie) 90 | request2.headers.addValue("sessionId=abc123", forKey: .cookie) 91 | 92 | #expect(request1 == request2) 93 | #expect(request1.headers.count == 2) 94 | #expect(request2.headers.count == 2) 95 | } 96 | 97 | @Test func headerKeysAndValuesEquatableWithString() { 98 | let contentKey = HTTPHeaders.Header.Key.contentType 99 | 100 | let nilString: String? = nil 101 | 102 | #expect("Content-Type" == contentKey) 103 | #expect(contentKey == "Content-Type") 104 | #expect("Content-Typo" != contentKey) 105 | #expect(contentKey != "Content-Typo") 106 | #expect(contentKey != nilString) 107 | 108 | let gif = HTTPHeaders.Header.Value.gif 109 | 110 | #expect("image/gif" == gif) 111 | #expect(gif == "image/gif") 112 | #expect("image/jif" != gif) 113 | #expect(gif != "image/jif") 114 | #expect(gif != nilString) 115 | } 116 | 117 | @Test func requestID() throws { 118 | let dummyURL = #URL("https://redeggproductions.com") 119 | 120 | let downRequest = dummyURL.generalRequest 121 | #expect(downRequest.requestID != nil) 122 | 123 | let upRequest = dummyURL.uploadRequest 124 | #expect(upRequest.requestID != nil) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Tests/NetworkHandlerURLSessionTests/EngineRequestMetadata+URLRequestPropertiesTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | import Foundation 3 | import NetworkHandler 4 | import NetworkHandlerURLSessionEngine 5 | import TestSupport 6 | import PizzaMacros 7 | 8 | struct EngineRequestMetadata_URLRequestProperties { 9 | let testURL = #URL("https://s3.wasabisys.com/network-handler-tests/images/lighthouse.jpg") 10 | 11 | @Test func cachePolicy() async throws { 12 | let url = testURL 13 | var request = url.generalRequest 14 | 15 | let plainURLRequest = URLRequest(url: url) 16 | #expect(request.cachePolicy == plainURLRequest.cachePolicy) 17 | 18 | request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData 19 | #expect(request.cachePolicy == .reloadIgnoringLocalAndRemoteCacheData) 20 | #expect(request.urlRequest.cachePolicy == .reloadIgnoringLocalAndRemoteCacheData) 21 | #expect(request.cachePolicy != plainURLRequest.cachePolicy) 22 | } 23 | 24 | @Test func mainDocumentURL() { 25 | let url = testURL 26 | var request = url.generalRequest 27 | 28 | let plainURLRequest = URLRequest(url: url) 29 | #expect(request.mainDocumentURL == plainURLRequest.mainDocumentURL) 30 | 31 | let fooURL = testURL.appending(component: "floooblarrr") 32 | request.mainDocumentURL = fooURL 33 | #expect(request.mainDocumentURL == fooURL) 34 | #expect(request.mainDocumentURL != plainURLRequest.mainDocumentURL) 35 | #expect(request.urlRequest.mainDocumentURL == fooURL) 36 | } 37 | 38 | @Test func httpShouldHandleCookies() { 39 | let url = testURL 40 | var request = url.generalRequest 41 | 42 | let plainURLRequest = URLRequest(url: url) 43 | #expect(request.httpShouldHandleCookies == plainURLRequest.httpShouldHandleCookies) 44 | 45 | request.httpShouldHandleCookies = false 46 | #expect(request.httpShouldHandleCookies == false) 47 | #expect(request.urlRequest.httpShouldHandleCookies == false) 48 | #expect(request.httpShouldHandleCookies != plainURLRequest.httpShouldHandleCookies) 49 | } 50 | 51 | @Test func httpShouldUsePipelining() { 52 | let url = testURL 53 | var request = url.generalRequest 54 | 55 | let plainURLRequest = URLRequest(url: url) 56 | #expect(request.httpShouldUsePipelining == plainURLRequest.httpShouldUsePipelining) 57 | 58 | request.httpShouldUsePipelining = true 59 | #expect(request.httpShouldUsePipelining == true) 60 | #expect(request.urlRequest.httpShouldUsePipelining == true) 61 | #expect(request.httpShouldUsePipelining != plainURLRequest.httpShouldUsePipelining) 62 | } 63 | 64 | @Test func allowsCellularAccess() { 65 | let url = testURL 66 | var request = url.generalRequest 67 | 68 | let plainURLRequest = URLRequest(url: url) 69 | #expect(request.allowsCellularAccess == plainURLRequest.allowsCellularAccess) 70 | 71 | request.allowsCellularAccess = false 72 | #expect(request.allowsCellularAccess == false) 73 | #expect(request.urlRequest.allowsCellularAccess == false) 74 | #expect(request.allowsCellularAccess != plainURLRequest.allowsCellularAccess) 75 | } 76 | 77 | @Test func allowsConstrainedNetworkAccess() { 78 | let url = testURL 79 | var request = url.generalRequest 80 | 81 | let plainURLRequest = URLRequest(url: url) 82 | #expect(request.allowsConstrainedNetworkAccess == plainURLRequest.allowsConstrainedNetworkAccess) 83 | 84 | request.allowsConstrainedNetworkAccess = false 85 | #expect(request.allowsConstrainedNetworkAccess == false) 86 | #expect(request.urlRequest.allowsConstrainedNetworkAccess == false) 87 | #expect(request.allowsConstrainedNetworkAccess != plainURLRequest.allowsConstrainedNetworkAccess) 88 | } 89 | 90 | @Test func allowsExpensiveNetworkAccess() { 91 | let url = testURL 92 | var request = url.generalRequest 93 | 94 | let plainURLRequest = URLRequest(url: url) 95 | #expect(request.allowsExpensiveNetworkAccess == plainURLRequest.allowsExpensiveNetworkAccess) 96 | 97 | request.allowsExpensiveNetworkAccess = false 98 | #expect(request.allowsExpensiveNetworkAccess == false) 99 | #expect(request.urlRequest.allowsExpensiveNetworkAccess == false) 100 | #expect(request.allowsExpensiveNetworkAccess != plainURLRequest.allowsExpensiveNetworkAccess) 101 | } 102 | 103 | @Test func networkServiceType() { 104 | let url = testURL 105 | var request = url.generalRequest 106 | 107 | let plainURLRequest = URLRequest(url: url) 108 | #expect(request.networkServiceType == plainURLRequest.networkServiceType) 109 | 110 | request.networkServiceType = .video 111 | #expect(request.networkServiceType == .video) 112 | #expect(request.urlRequest.networkServiceType == .video) 113 | #expect(request.networkServiceType != plainURLRequest.networkServiceType) 114 | } 115 | 116 | @Test func attribution() { 117 | let url = testURL 118 | var request = url.generalRequest 119 | 120 | let plainURLRequest = URLRequest(url: url) 121 | #expect(request.attribution == plainURLRequest.attribution) 122 | 123 | request.attribution = .user 124 | #expect(request.attribution == .user) 125 | #expect(request.urlRequest.attribution == .user) 126 | #expect(request.attribution != plainURLRequest.attribution) 127 | } 128 | 129 | @available(macOS 15.0, *) 130 | @Test func allowsPersistentDNS() { 131 | let url = testURL 132 | var request = url.generalRequest 133 | 134 | let plainURLRequest = URLRequest(url: url) 135 | #expect(request.allowsPersistentDNS == plainURLRequest.allowsPersistentDNS) 136 | 137 | request.allowsPersistentDNS = true 138 | #expect(request.allowsPersistentDNS == true) 139 | #expect(request.urlRequest.allowsPersistentDNS == true) 140 | #expect(request.allowsPersistentDNS != plainURLRequest.allowsPersistentDNS) 141 | } 142 | 143 | @Test func assumesHTTP3Capable() { 144 | let url = testURL 145 | var request = url.generalRequest 146 | 147 | let plainURLRequest = URLRequest(url: url) 148 | #expect(request.assumesHTTP3Capable == plainURLRequest.assumesHTTP3Capable) 149 | 150 | request.assumesHTTP3Capable = true 151 | #expect(request.assumesHTTP3Capable == true) 152 | #expect(request.urlRequest.assumesHTTP3Capable == true) 153 | #expect(request.assumesHTTP3Capable != plainURLRequest.assumesHTTP3Capable) 154 | } 155 | 156 | @Test func requiresDNSSECValidation() { 157 | let url = testURL 158 | var request = url.generalRequest 159 | 160 | let plainURLRequest = URLRequest(url: url) 161 | #expect(request.requiresDNSSECValidation == plainURLRequest.requiresDNSSECValidation) 162 | 163 | request.requiresDNSSECValidation = true 164 | #expect(request.requiresDNSSECValidation == true) 165 | #expect(request.urlRequest.requiresDNSSECValidation == true) 166 | #expect(request.requiresDNSSECValidation != plainURLRequest.requiresDNSSECValidation) 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /Tests/NetworkHandlerURLSessionTests/NetworkHandlerURLSessionTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | import Foundation 3 | import TestSupport 4 | import NetworkHandler 5 | import NetworkHandlerURLSessionEngine 6 | import Logging 7 | import SwiftPizzaSnips 8 | 9 | @Suite(.serialized) 10 | struct NetworkHandlerURLSessionTests: Sendable { 11 | let commonTests = NetworkHandlerCommonTests(logger: Logger(label: #fileID)) 12 | 13 | @Test func downloadAndCacheImages() async throws { 14 | let mockingEngine = generateEngine() 15 | 16 | let lighthouseURL = Bundle.testBundle.url(forResource: "lighthouse", withExtension: "jpg", subdirectory: "Resources")! 17 | let lighthouseData = try Data(contentsOf: lighthouseURL) 18 | 19 | try await commonTests.downloadAndCacheImages(engine: mockingEngine, imageExpectationData: lighthouseData) 20 | } 21 | 22 | @Test func downloadAndDecodeData() async throws { 23 | let mockingEngine = generateEngine() 24 | 25 | let modelURL = commonTests.demoModelURL 26 | 27 | let testModel = DemoModel( 28 | id: UUID(uuidString: "59747267-D47D-47CD-9E54-F79FA3C1F99B")!, 29 | title: "FooTitle", 30 | subtitle: "BarSub", 31 | imageURL: commonTests.imageURL) 32 | 33 | try await commonTests.downloadAndDecodeData(engine: mockingEngine, modelURL: modelURL, expectedModel: testModel) 34 | } 35 | 36 | @Test func handle404() async throws { 37 | let mockingEngine = generateEngine() 38 | 39 | let demo404URL = commonTests.demo404URL 40 | 41 | try await commonTests.handle404Error( 42 | engine: mockingEngine, 43 | expectedError: NetworkError.httpUnexpectedStatusCode( 44 | code: 404, 45 | originalRequest: .general(demo404URL.generalRequest), 46 | data: nil)) 47 | } 48 | 49 | @Test func expect200OnlyGet200() async throws { 50 | let mockingEngine = generateEngine() 51 | 52 | try await commonTests.expect200OnlyGet200(engine: mockingEngine) 53 | } 54 | 55 | @Test func expect201OnlyGet200() async throws { 56 | let mockingEngine = generateEngine() 57 | 58 | try await commonTests.expect201OnlyGet200(engine: mockingEngine) 59 | } 60 | 61 | @Test func backgroundSessionUpload() async throws { 62 | let config = URLSessionConfiguration.background(withIdentifier: "backgroundID").with { 63 | $0.requestCachePolicy = .reloadIgnoringLocalCacheData 64 | $0.urlCache = nil 65 | } 66 | 67 | let engine = URLSession.asEngine(withConfiguration: config) 68 | 69 | try await commonTests.uploadFileURL(engine: engine) 70 | } 71 | 72 | @Test func uploadData() async throws { 73 | let mockingEngine = generateEngine() 74 | try await commonTests.uploadData(engine: mockingEngine) 75 | } 76 | 77 | @Test func uploadFileURL() async throws { 78 | let mockingEngine = generateEngine() 79 | 80 | try await commonTests.uploadFileURL(engine: mockingEngine) 81 | } 82 | 83 | @Test func uploadMultipartFile() async throws { 84 | let mockingEngine = generateEngine() 85 | 86 | try await commonTests.uploadMultipartFile(engine: mockingEngine) 87 | } 88 | 89 | @Test func uploadMultipartStream() async throws { 90 | let mockingEngine = generateEngine() 91 | try await commonTests.uploadMultipartStream(engine: mockingEngine) 92 | } 93 | 94 | @Test func badCodingData() async throws { 95 | let mockingEngine = generateEngine() 96 | 97 | try await commonTests.badCodableData(engine: mockingEngine) 98 | } 99 | 100 | @Test func cancellationViaToken() async throws { 101 | let mockingEngine = generateEngine() 102 | 103 | try await commonTests.cancellationViaToken(engine: mockingEngine) 104 | } 105 | 106 | @Test func cancellationViaStream() async throws { 107 | let mockingEngine = generateEngine() 108 | 109 | try await commonTests.cancellationViaStream(engine: mockingEngine) 110 | } 111 | 112 | @Test func uploadCancellationViaToken() async throws { 113 | let mockingEngine = generateEngine() 114 | try await commonTests.uploadCancellationViaToken(engine: mockingEngine) 115 | } 116 | 117 | @Test func timeoutTriggersRetry() async throws { 118 | let mockingEngine = generateEngine() 119 | try await commonTests.timeoutTriggersRetry(engine: mockingEngine) 120 | } 121 | 122 | @Test func downloadProgressTracking() async throws { 123 | let mockingEngine = generateEngine() 124 | try await commonTests.downloadProgressTracking(engine: mockingEngine) 125 | } 126 | 127 | @Test func uploadProgressTracking() async throws { 128 | let mockingEngine = generateEngine() 129 | 130 | try await commonTests.uploadProgressTracking(engine: mockingEngine) 131 | } 132 | 133 | @Test func polling() async throws { 134 | let mockingEngine = generateEngine() 135 | 136 | try await commonTests.polling(engine: mockingEngine) 137 | } 138 | 139 | @Test func downloadFile() async throws { 140 | let mockingEngine = generateEngine() 141 | 142 | try await commonTests.downloadFile(engine: mockingEngine) 143 | } 144 | 145 | private func generateEngine() -> URLSession { 146 | URLSession.asEngine(withConfiguration: .networkHandlerDefault) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Docker Compose file for Vapor 2 | # 3 | # Install Docker on your system to run and test 4 | # your Vapor app in a production-like environment. 5 | # 6 | # Note: This file is intended for testing and does not 7 | # implement best practices for a production deployment. 8 | # 9 | # Learn more: https://docs.docker.com/compose/reference/ 10 | # 11 | # Build images: docker-compose build 12 | # Start app: docker-compose up app 13 | # Stop all: docker-compose down 14 | # 15 | version: '3.7' 16 | 17 | x-shared_environment: &shared_environment 18 | LOG_LEVEL: ${LOG_LEVEL:-debug} 19 | 20 | services: 21 | app: 22 | image: networkhandlertests:latest 23 | build: 24 | context: . 25 | args: 26 | CONFIG: ${CONFIG} 27 | environment: 28 | <<: *shared_environment 29 | -------------------------------------------------------------------------------- /env.tests.sample: -------------------------------------------------------------------------------- 1 | S3KEY= 2 | S3SECRET= 3 | 4 | // delete this line and rename the file to `.env.tests` --------------------------------------------------------------------------------