├── .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://swiftpackageindex.com/mredig/NetworkHandler)
9 |
10 | [](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`
--------------------------------------------------------------------------------