├── .gitignore
├── .travis.yml
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── ISSUE_TEMPLATE.md
├── LICENSE.md
├── PULL_REQUEST_TEMPLATE.md
├── README.md
├── ThunderRequest.xcodeproj
├── project.pbxproj
├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
└── xcshareddata
│ └── xcschemes
│ ├── ThunderRequest-iOS.xcscheme
│ ├── ThunderRequest-macOS.xcscheme
│ ├── ThunderRequest-tvOS.xcscheme
│ └── ThunderRequest-watchOS.xcscheme
├── ThunderRequest
├── ApplicationLoadingIndicatorManager.swift
├── Authenticator.swift
├── BackgroundSessionController.swift
├── CredentialStore.swift
├── CustomisableRecoverableError.swift
├── Data+ContentType.swift
├── Data+MultipartFormElement.swift
├── Data+Mutate.swift
├── Data+RequestBody.swift
├── Dictionary+URLEncodedString.swift
├── EncodableRequestBody.swift
├── ErrorRecoveryOption.swift
├── FormURLEncodedRequestBody.swift
├── HTTP+Error.swift
├── HTTP.swift
├── ImageRequestBody.swift
├── Info.plist
├── JSONRequestBody.swift
├── Logger.swift
├── MultipartFormFile.swift
├── MultipartFormRequestBody.swift
├── NSImage+MultipartFormElement.swift
├── PropertyListRequestBody.swift
├── Request.swift
├── RequestController+Auth.swift
├── RequestController+Callbacks.swift
├── RequestController+SessionDelegate.swift
├── RequestController.swift
├── RequestCredential.swift
├── RequestResponse+Codable.swift
├── RequestResponse.swift
├── String+MD5.swift
├── String+MultipartFormElement.swift
├── ThunderRequest.h
├── UIAlertController+ErrorRecovery.swift
├── UIImage+MultipartFormElement.swift
├── URLRequest+Backgroundable.swift
├── URLRequest+TaskIdentifier.swift
├── URLSession+Synchronous.swift
├── URLSessionDelegate.swift
└── URLSessionTask+Tag.swift
├── ThunderRequestMac
├── Info.plist
└── ThunderRequestMac.h
├── ThunderRequestMacTests
├── Info.plist
└── ThunderRequestMacTests.m
├── ThunderRequestTV
├── Info.plist
└── ThunderRequestTV.h
├── ThunderRequestTests-tvOS
└── Info.plist
├── ThunderRequestTests
├── 350x150.png
├── AuthTests.swift
├── DownloadTests.swift
├── Info.plist
├── KeychainTests.swift
├── MD5Tests.swift
├── MultipartFormTests.swift
├── RequestBodyTests.swift
├── RequestConstructionTests.swift
├── ResponseTests.swift
└── ThunderRequestTests.m
└── ThunderRequestWatch
├── Info.plist
└── ThunderRequestWatch.h
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | build/
4 | *.pbxuser
5 | !default.pbxuser
6 | *.mode1v3
7 | !default.mode1v3
8 | *.mode2v3
9 | !default.mode2v3
10 | *.perspectivev3
11 | !default.perspectivev3
12 | xcuserdata
13 | *.xccheckout
14 | *.moved-aside
15 | DerivedData
16 | *.hmap
17 | *.ipa
18 | *.xcuserstate
19 |
20 | # CocoaPods
21 | #
22 | # We recommend against adding the Pods directory to your .gitignore. However
23 | # you should judge for yourself, the pros and cons are mentioned at:
24 | # http://guides.cocoapods.org/using/using-cocoapods.html#should-i-ignore-the-pods-directory-in-source-control
25 | #
26 | # Pods/
27 |
28 | # Carthage
29 | #
30 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
31 | # Carthage/Checkouts
32 |
33 | Carthage/Build
34 | ThunderRequest.framework.zip
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: swift
2 | xcode_project: ThunderRequest.xcodeproj # path to your xcodeproj folder
3 | osx_image: xcode13
4 | env:
5 | global:
6 | - LC_CTYPE=en_US.UTF-8
7 | - LANG=en_US.UTF-8
8 | matrix:
9 | include:
10 | - xcode_scheme: ThunderRequest-iOS
11 | xcode_destination: platform=iOS Simulator,OS=15.0,name=iPhone 13
12 | - xcode_scheme: ThunderRequest-macOS
13 | xcode_destination: platform=macOS
14 | - xcode_scheme: ThunderRequest-tvOS
15 | xcode_destination: platform=tvOS Simulator,OS=15.0,name=Apple TV 4K
16 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behaviour that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behaviour by participants include:
24 |
25 | * The use of sexualised language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behaviour and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behaviour.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviours that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies within all project spaces, and it also applies when
49 | an individual is representing the project or its community in public spaces.
50 | Examples of representing a project or community include using an official
51 | project e-mail address, posting via an official social media account, or acting
52 | as an appointed representative at an online or offline event. Representation of
53 | a project may be further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behaviour may be
58 | reported by contacting the project team at [ios@3sidedcube.com](ios@3sidedcube.com). All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72 |
73 | [homepage]: https://www.contributor-covenant.org
74 |
75 | For answers to common questions about this code of conduct, see
76 | https://www.contributor-covenant.org/faq
77 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # _"I want to contribute to ThunderRequest!"_
2 |
3 | 👋🏻 Hi there, we're super excited that you want to contribute to ThunderRequest! ⛈
4 |
5 | ThunderRequest is a project used within [3 SIDED CUBE](3sidedcube.com), built over many years, to power our award-winning iOS apps. It is to be used alongside [ThunderCloud](https://github.com/3sidedcube/ThunderCloud), [ThunderTable](https://github.com/3sidedcube/ThunderTable), [ThunderCollection](https://github.com/3sidedcube/ThunderCollection), and [ThunderBasics](https://github.com/3sidedcube/ThunderBasics) to provide a foundation for apps to be built against.
6 |
7 | ## _"How can I contribute?"_
8 |
9 | Before you get started, please take a look at [code of conduct](CODE_OF_CONDUCT.md), and make sure to adhere to it ☮️.
10 |
11 | Then:
12 | - Clone the project down via your preferred means.
13 | - Run the project! ✨
14 |
15 | ### _"How can I report bugs?"_
16 |
17 | - Ensure the bug hasn't already been reported in GitHub under [Issues](https://github.com/3sidedcube/ThunderRequest/issues)
18 | - If you can't find a bug which seems to match yours, or you're unsure please create a [new one](https://github.com/3sidedcube/ThunderRequest/issues)
19 |
20 | ### _"How can I fix bugs?"_
21 |
22 | - If you find a bug, please create a PR _and also_ [create an issue](https://github.com/3sidedcube/ThunderRequest/issues) so it can be tagged against a specific release.
23 |
24 | ### _"What about fixing whitespacing/formatting or making a cosmetic patch?"_
25 |
26 | - Please go ahead and fix whitespacing (we indent using spaces, please also do this... even if you don't agree!)
27 |
28 | ### _"What about adding functionality / features?"_
29 |
30 | Please go ahead and add new functionality and features, however it may be wise to [create an issue](https://github.com/3sidedcube/ThunderRequest/issues) (or check the open [issues](https://github.com/3sidedcube/ThunderRequest/issues)) first to make sure it isn't already being worked on, and it remains in scope of the ThunderCloud framework.
31 |
--------------------------------------------------------------------------------
/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | ## Expected Behaviour
4 |
5 |
6 |
7 | ## Current Behaviour
8 |
9 |
10 |
11 | ## Possible Solution
12 |
13 |
14 |
15 | ## Steps to Reproduce (for bugs)
16 |
17 |
18 | 1.
19 | 2.
20 | 3.
21 | 4.
22 |
23 | ## Context
24 |
25 |
26 |
27 | ## Your Environment
28 |
29 | * ThunderRequest version:
30 | * iOS / macOS version:
31 | * Link to your project, if public:
32 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright 2014 Three Sided Cube Design LTD
2 |
3 | Licensed under the Apache License, Version 2.0 (the "License");
4 | you may not use this file except in compliance with the License.
5 | You may obtain a copy of the License at
6 |
7 | http://www.apache.org/licenses/LICENSE-2.0
8 |
9 | Unless required by applicable law or agreed to in writing, software
10 | distributed under the License is distributed on an "AS IS" BASIS,
11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | See the License for the specific language governing permissions and
13 | limitations under the License.
--------------------------------------------------------------------------------
/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | ## Description
4 |
5 |
6 | ## Related Issue
7 |
8 |
9 |
10 |
11 |
12 | ## Motivation and Context
13 |
14 |
15 | ## How Has This Been Tested?
16 |
17 |
18 |
19 |
20 | ## Screenshots (if appropriate):
21 |
22 | ## Types of changes
23 |
24 | - [ ] Bug fix (non-breaking change which fixes an issue)
25 | - [ ] New feature (non-breaking change which adds functionality)
26 | - [ ] Breaking change (fix or feature that would cause existing functionality to change)
27 |
28 | ## Checklist:
29 |
30 |
31 | - [ ] My code follows the code style of this project.
32 | - [ ] My change requires a change to the documentation.
33 | - [ ] I have updated the documentation accordingly.
34 | - [ ] I have read the **CONTRIBUTING** document.
35 | - [ ] I have added tests to cover my changes.
36 | - [ ] All new and existing tests passed.
37 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Thunder Request
2 |
3 | [](https://travis-ci.org/3sidedcube/ThunderRequest) [](https://github.com/Carthage/Carthage) [](https://swift.org/blog/swift-5-5-released/) [](LICENSE.md)
4 |
5 | Thunder Request is a Framework used to simplify making http and https web requests.
6 |
7 | # Installation
8 |
9 | Setting up your app to use ThunderBasics is a simple and quick process. You can choose between a manual installation, or use Carthage.
10 |
11 | ## Carthage
12 |
13 | - Add `github "3sidedcube/ThunderRequest" == 3.0.0` to your Cartfile.
14 | - Run `carthage update --platform ios --use-xcframeworks` to fetch the framework.
15 | - Drag `ThunderRequest` into your project's _Frameworks and Libraries_ section from the `Carthage/Build` folder (Embed).
16 | - Add the Build Phases script step as defined [here](https://github.com/Carthage/Carthage#if-youre-building-for-ios-tvos-or-watchos).
17 |
18 | ## Manual
19 |
20 | - Clone as a submodule, or download this repo
21 | - Import ThunderRequest.xcproject into your project
22 | - Add ThunderRequest.framework to your Embedded Binaries.
23 | - Wherever you want to use ThunderBasics use `import ThunderRequest` if you're using swift.
24 |
25 | # Authentication Support
26 | Support for authentication protocols such as OAuth2 is available via the `Authenticator` protocol which when set on `RequestController` will have it's delegate methods called to refresh the user's token when it either expires or a 403 is sent by the server.
27 |
28 | When `authenticator` is set on `RequestController` any current credentials will be pulled from the user's keychain by the service identifier provided by `authIdentifier` on the protocol object.
29 |
30 | To register a credential for the first time to the user's keychain, use the method `set(sharedRequestCredentials:savingToKeychain:)` after having set the delegate. This will store the credential to the keychain for later use by the request controller and also set the `sharedRequestCredential` property on the request controller.
31 |
32 | If the request controller detects that the `RequestCredential` object is expired, or receives a 403 on a request it will call the method `reAuthenticate(credential:completion:)` to re-authenticate the user before then continuing to make the request (Or re-making) the request.
33 |
34 | # Examples
35 |
36 | All of the examples shown below are shown with all optional parameters excluded, for example the `request`, `download` and `upload` functions have multiple parameters (For things such as header overrides and base url overrides) as outlined in the generated docs.
37 |
38 | ### Initialization
39 |
40 | ```
41 | guard let baseURL = URL(string: "https://httpbin.org/") else {
42 | fatalError("Unexpectedly failed to create URL")
43 | }
44 | let requestController = RequestController(baseURL: baseURL)
45 | ```
46 |
47 | ### GET request
48 | ```
49 | requestController.request("get", method: .GET) { (response, error) in
50 | // Do something with response
51 | }
52 | ```
53 |
54 | ### POST request
55 | ```
56 | let body = [
57 | "name": "Thunder Request",
58 | "isAwesome": true
59 | ]
60 | requestController.request("post", method: .POST, body: JSONRequestBody(body)) { (response, error) in
61 | // Do something with response
62 | }
63 | ```
64 |
65 | ### Request bodies
66 | The body sent to the `request` function must conform to the `RequestBody` protocol. There are multiple extensions and structs built into the project that conform to this protocol for ease of use.
67 |
68 | #### JSONRequestBody
69 | Formats the request as JSON, and sets the request's `Content-Type` header to `application/json`.
70 |
71 | ```
72 | let bodyJSON = [
73 | "name": "Thunder Request",
74 | "isAwesome": true
75 | ]
76 | let body = JSONRequestBody(bodyJSON)
77 | ```
78 |
79 | #### PropertyListRequestBody
80 | Similar to `JSONRequestBody` but uses the `"text/x-xml-plist"` or `"application/x-plist"` `Content-Type`.
81 |
82 | ```
83 | let bodyPlist = [
84 | "name": "Thunder Request",
85 | "isAwesome": true
86 | ]
87 | let body = PropertyListRequestBody(bodyPlist, format: .xml)
88 | ```
89 |
90 | #### MultipartFormRequestBody
91 | Formats a dictionary of objects conforming to `MultipartFormElement` to the data required for the `multipart/form-data; boundary=` `Content-Type`.
92 |
93 | ```
94 | let multipartElements = [
95 | "name": "Thunder Request",
96 | "avatar": MultipartFormFile(
97 | image: image,
98 | format: .png,
99 | fileName: "image.png",
100 | name: "image"
101 | )!
102 | ]
103 | let body = MultipartFormRequestBody(
104 | parts: multipartElements,
105 | boundary: "----SomeBoundary"
106 | )
107 | ```
108 |
109 | #### FormURLEncodedRequestBody
110 | Similar to `JSONRequestBody` except uses the `"application/x-www-form-urlencoded"` `Content-Type` and formats the payload to be correct for this type of request.
111 |
112 | ```
113 | let bodyJSON = [
114 | "name": "Thunder Request",
115 | "isAwesome": true
116 | ]
117 | let body = FormURLEncodedRequestBody(bodyJSON)
118 | ```
119 |
120 | #### ImageRequestBody
121 | Converts a `UIImage` to a request payload data and `Content-Type` based on the provided format.
122 |
123 | ```
124 | let imageBody = ImageRequestBody(image: image, format: .png)
125 | ```
126 |
127 | #### EncodableRequestBody
128 | Converts an object which conforms to the `Encodable` (Or `Codable`) protocol to either `JSON` or `Plist` based on the format provided upon initialisation (Defaults to `JSON`).
129 |
130 | ```
131 | let someEncodableObject: CodableStruct = CodableStruct(
132 | name: "Thunder Request",
133 | isAwesome: true
134 | )
135 | let body = EncodableRequestBody(someEncodableObject)
136 | ```
137 |
138 | ### Request Response
139 | The request response callback sends both an `Error?` object and a `RequestResponse?` object. `RequestResponse` has helper methods for converting the response to various `Swift` types:
140 |
141 | #### Decodable
142 | If your object conforms to the `Decodable` (Or `Codable`) is can be decoded directly for you:
143 |
144 | ```
145 | let codableArray: [CodableStruct]? = response.decoded()
146 | let codableObject: CodableStruct? = response.decoded()
147 | ```
148 |
149 | #### Dictionary
150 | ```
151 | let dictionaryResponse = response.dictionary
152 | ```
153 |
154 | #### Array
155 | ```
156 | let arrayResponse = response.array
157 | ```
158 |
159 | #### String
160 | ```
161 | let stringResponse = response.string
162 | let utf16Response = response.string(encoding: .utf16)
163 | ```
164 |
165 | The `RequestResponse` object also includes the HTTP `status` as an enum, the raw `Data` from the request response, the original response (For when a request was re-directed), and the request headers (`headers`)
166 |
167 | ### Downloading
168 | Downloading from a url is as simple as making any a request using any other HTTP method
169 |
170 | ```
171 | let requestBaseURL = URL(string: "https://via.placeholder.com/")!
172 | let requestController = RequestController(baseURL: requestBaseURL)
173 | requestController.download("500", progress: nil) { (response, url, error) in
174 | // Do something with the filePath that the file was downloaded to
175 | }
176 | ```
177 |
178 | ### Uploading
179 | Uploading is just as simple, and can be done using any of the `RequestBody` types listed above, as well as via a raw `Data` instance or from a file `URL`
180 |
181 | ```
182 | requestController.uploadFile(fileURL, to: "post", progress: { (progress, totalBytes, uploadedBytes) in
183 | // Do something with progress
184 | }) { (response, _, error) in
185 | // Do something with response/error
186 | }
187 | ```
188 |
189 | # Code level documentation
190 | Documentation is available for the entire library in AppleDoc format. This is available in the framework itself or in the [Hosted Version](http://3sidedcube.github.io/iOS-ThunderRequest/)
191 |
192 | # License
193 | See [LICENSE.md](LICENSE.md)
194 |
--------------------------------------------------------------------------------
/ThunderRequest.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/ThunderRequest.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/ThunderRequest.xcodeproj/xcshareddata/xcschemes/ThunderRequest-iOS.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
38 |
39 |
40 |
41 |
43 |
49 |
50 |
51 |
52 |
53 |
63 |
64 |
70 |
71 |
72 |
73 |
79 |
80 |
86 |
87 |
88 |
89 |
91 |
92 |
95 |
96 |
97 |
--------------------------------------------------------------------------------
/ThunderRequest.xcodeproj/xcshareddata/xcschemes/ThunderRequest-macOS.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
37 |
38 |
39 |
40 |
42 |
48 |
49 |
50 |
51 |
52 |
62 |
63 |
69 |
70 |
71 |
72 |
78 |
79 |
85 |
86 |
87 |
88 |
90 |
91 |
94 |
95 |
96 |
--------------------------------------------------------------------------------
/ThunderRequest.xcodeproj/xcshareddata/xcschemes/ThunderRequest-tvOS.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
37 |
38 |
39 |
40 |
42 |
48 |
49 |
50 |
51 |
52 |
62 |
63 |
69 |
70 |
71 |
72 |
78 |
79 |
85 |
86 |
87 |
88 |
90 |
91 |
94 |
95 |
96 |
--------------------------------------------------------------------------------
/ThunderRequest.xcodeproj/xcshareddata/xcschemes/ThunderRequest-watchOS.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
32 |
33 |
43 |
44 |
50 |
51 |
52 |
53 |
59 |
60 |
66 |
67 |
68 |
69 |
71 |
72 |
75 |
76 |
77 |
--------------------------------------------------------------------------------
/ThunderRequest/ApplicationLoadingIndicatorManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ApplicationLoadingIndicatorManager.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 13/04/2016.
6 | // Copyright © 2016 threesidedcube. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | open class ApplicationLoadingIndicatorManager: NSObject {
12 |
13 | @objc(sharedManager)
14 | public static let shared = ApplicationLoadingIndicatorManager()
15 | fileprivate var activityCount = 0
16 |
17 | @objc open func showActivityIndicator() {
18 |
19 | objc_sync_enter(self)
20 | if activityCount == 0 {
21 |
22 | OperationQueue.main.addOperation({
23 | UIApplication.shared.isNetworkActivityIndicatorVisible = true
24 | })
25 | }
26 | activityCount += 1
27 | objc_sync_exit(self)
28 | }
29 |
30 | @objc open func hideActivityIndicator() {
31 |
32 | objc_sync_enter(self)
33 | activityCount -= 1
34 | if activityCount <= 0 {
35 |
36 | OperationQueue.main.addOperation({
37 | UIApplication.shared.isNetworkActivityIndicatorVisible = false
38 | })
39 | }
40 | objc_sync_exit(self)
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/ThunderRequest/Authenticator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Authenticator.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 14/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// Authenticator is a protocol which is used by `RequestController` to generate and re-authenticate credential objects
12 | ///
13 | /// To perform the initial authentication call your own `authenticateWithCompletion` implementation
14 | /// and then save the credential object which you would otherwise return
15 | /// in the completion block to the keychain using `RequestCredential.store(credential:withIdentifier)`.
16 | /// Once you have stored the credential in the keychain all requests will check that it hasn't expired
17 | /// before making the request.
18 | ///
19 | /// Setting the `RequestController`'s `sharedRequestCredential` using `set(sharedRequestCredentials:savingToKeychain:)`
20 | /// with savingToKeychain as true will also achieve the same affect.
21 | public protocol Authenticator {
22 |
23 | /// This method will be called if a request is made without a `RequestCredential` object having
24 | /// been saved to the keychain under the provided value from `self.authIdentifier`
25 | ///
26 | /// - Parameter completion: The closure which must be called when the user has been authenticated
27 | func authenticate(completion: (_ credential: RequestCredential?, _ error: Error?, _ saveToKeychain: Bool) -> Void)
28 |
29 | /// This defines the service identifier for the auth flow, which the credentials object will
30 | /// be saved under in the user's keychain
31 | var authIdentifier: String { get }
32 |
33 | /// The accessibility level of the credential when stored in the user's keychain
34 | var keychainAccessibility: CredentialStore.Accessibility { get }
35 |
36 | /// This method will be called if a request is made with an expired token, or if we receive a 403 challenge from a particular request
37 | ///
38 | /// - Parameters:
39 | /// - credential: The credential which should be used in the refresh process
40 | /// - completion: The completion block which should be called when the user's credential has been refreshed
41 | func reAuthenticate(credential: RequestCredential?, completion: (_ credential: RequestCredential?, _ error: Error?, _ saveToKeychain: Bool) -> Void)
42 | }
43 |
--------------------------------------------------------------------------------
/ThunderRequest/BackgroundSessionController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BackgroundSessionController.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 11/04/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// BackgroundRequestController is a helper class for setting up a URLSession object returned from a background task via a session identifier.
12 | ///
13 | /// The helper wraps NSURLConnectionDownloadDelegate and calls a provided handler with the results of any requests as `TSCRequestResponse` objects
14 | public class BackgroundRequestController: NSObject, URLSessionDelegate, URLSessionDownloadDelegate {
15 |
16 | public typealias ResponseHandler = (_ task: URLSessionTask, _ response: RequestResponse?, _ error: Error?) -> Void
17 |
18 | public typealias FinishHandler = (_ session: URLSession) -> Void
19 |
20 | /// A closure called for each request that occured.
21 | public var responseHandler: ResponseHandler?
22 |
23 | /// A closure called when all requests have been sent to the responseHandler.
24 | public var finishedHandler: FinishHandler?
25 |
26 | private let sessionConfiguration: URLSessionConfiguration
27 |
28 | private var urlSession: URLSession?
29 |
30 | private let readData: Bool
31 |
32 | /// Creates a new request controller with a background session configuration identifier passed by the OS.
33 | ///
34 | /// - Parameters:
35 | /// - identifier: The identifier to re-create the `URLSessionConfiguration` using.
36 | /// - responseHandler: A closure called with the response to each background request.
37 | /// - finishedHandler: A closure called when all background events have finished.
38 | /// - queue: The operation queue to call back on.
39 | /// - readDataAutomatically: Setting this to false allows you to stop the downloaded file's data being read from disk.
40 | /// Reading from disk can cause problems with large background downloads as the background Daemon has a ~40mb memory limit
41 | /// before being killed by the OS!
42 | public init(identifier: String, responseHandler: ResponseHandler?, finishedHandler: FinishHandler?, readDataAutomatically: Bool = true, queue: OperationQueue? = nil) {
43 |
44 | readData = readDataAutomatically
45 | self.responseHandler = responseHandler
46 | self.finishedHandler = finishedHandler
47 | sessionConfiguration = URLSessionConfiguration.background(withIdentifier: identifier)
48 |
49 | super.init()
50 |
51 | urlSession = URLSession(configuration: sessionConfiguration, delegate: self, delegateQueue: queue)
52 | }
53 |
54 | #if os(iOS) || os(tvOS) || os(watchOS)
55 | public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
56 | finishedHandler?(session)
57 | }
58 | #endif
59 |
60 | public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
61 | guard let taskResponse = downloadTask.response else {
62 | responseHandler?(downloadTask, nil, nil)
63 | return
64 | }
65 | var data: Data?
66 | if readData {
67 | data = try? Data(contentsOf: location)
68 | }
69 | let response = RequestResponse(response: taskResponse, data: data, fileURL: location)
70 | responseHandler?(downloadTask, response, nil)
71 | }
72 |
73 | public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
74 | responseHandler?(task, nil, error)
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/ThunderRequest/CredentialStore.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CredentialStore.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 18/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// A protocol for the underlying store used in `CredentialStore`
12 | public protocol DataStore {
13 |
14 | /// A function which can be called to add data under a spectific identifier to the store
15 | ///
16 | /// - Parameters:
17 | /// - data: The data to save to the store
18 | /// - identifier: The identifier to save the data under
19 | /// - accessibility: The accessibility level the data should be saved under
20 | /// - Returns: Whether the data was saved sucessfully
21 | func add(data: Data, identifier: String, accessibility: CredentialStore.Accessibility) -> Bool
22 |
23 | /// A function which can be called to update the data in the store under a particular identifier
24 | ///
25 | /// - Parameters:
26 | /// - data: The data to update in the store
27 | /// - identifier: The identifier to update the data for
28 | /// - accessibility: The accessibility level the data should be saved under
29 | /// - Returns: Whether the data was saved sucessfully
30 | func update(data: Data, identifier: String, accessibility: CredentialStore.Accessibility) -> Bool
31 |
32 | /// Fetches the data from the store under a particular identifier
33 | ///
34 | /// - Parameter identifier: The identifier to fetchdata from
35 | /// - Returns: The data if any was present
36 | func retrieveDataFor(identifier: String) -> Data?
37 |
38 | /// Deletes the data in the store under a particular identifier
39 | ///
40 | /// - Parameter identifier: The identifier to delete the data for
41 | /// - Returns: Whether deletion was sucessful
42 | func removeDataFor(identifier: String) -> Bool
43 | }
44 |
45 | /// An implementation of the `DataStore` protocol which uses the device's keychain as it's internal store
46 | public struct KeychainStore: DataStore {
47 |
48 | /// The store's service identifier, set during init
49 | public let serviceIdentifier: String
50 |
51 | /// Creates a new keychain store with a particular service name
52 | ///
53 | /// - Parameter serviceName: The service name to use with the keychain
54 | public init(serviceName: String) {
55 | self.serviceIdentifier = serviceName
56 | }
57 |
58 | private func keychainQueryWith(identifier: String, accessibility: CredentialStore.Accessibility? = nil) -> [AnyHashable : Any] {
59 |
60 | var dictionary: [CFString : Any] = [
61 | kSecClass: kSecClassGenericPassword,
62 | kSecAttrService: kTSCAuthServiceName,
63 | kSecAttrAccount: identifier,
64 | ]
65 | if let accessibility = accessibility {
66 | dictionary[kSecAttrAccessible] = accessibility.cfString
67 | }
68 | return dictionary
69 | }
70 |
71 | public func add(data: Data, identifier: String, accessibility: CredentialStore.Accessibility) -> Bool {
72 |
73 | var query = keychainQueryWith(identifier: identifier, accessibility: accessibility)
74 | query[kSecValueData] = data
75 | let status = SecItemAdd(query as CFDictionary, nil)
76 | return status == errSecSuccess
77 | }
78 |
79 | public func update(data: Data, identifier: String, accessibility: CredentialStore.Accessibility) -> Bool {
80 |
81 | // Send nil here because if we send accessibility we get an -25300 status code (errSecItemNotFound)
82 | let query = keychainQueryWith(identifier: identifier, accessibility: nil)
83 |
84 | let updateDictionary: [CFString : Any] = [
85 | kSecValueData: data,
86 | kSecAttrAccessible: accessibility.cfString
87 | ]
88 |
89 | let status = SecItemUpdate(query as CFDictionary, updateDictionary as CFDictionary)
90 |
91 | return status == errSecSuccess
92 | }
93 |
94 | public func retrieveDataFor(identifier: String) -> Data? {
95 |
96 | var query = keychainQueryWith(identifier: identifier)
97 | query[kSecReturnData] = kCFBooleanTrue
98 | query[kSecMatchLimit] = kSecMatchLimitOne
99 |
100 | var result: CFTypeRef?
101 | let status = SecItemCopyMatching(query as CFDictionary, &result)
102 |
103 | guard status == errSecSuccess else {
104 | return nil
105 | }
106 |
107 | return result as? Data
108 | }
109 |
110 | public func removeDataFor(identifier: String) -> Bool {
111 | let result = SecItemDelete(keychainQueryWith(identifier: identifier) as CFDictionary)
112 | return result == errSecSuccess
113 | }
114 | }
115 |
116 | /// A generic store of network credentials
117 | public struct CredentialStore {
118 |
119 | /// An enum representation of CFString constants for the accessibility of keychain items
120 | ///
121 | /// - afterFirstUnlock: After the first unlock, the data remains accessible until the next restart. This is recommended for items that need to be accessed by background applications. Migrates to new devices.
122 | /// - always: The data in the keychain item can always be accessed regardless of whether the device is locked. Migrates to new devices.
123 | /// - whenUnlocked: The data in the keychain item can be accessed only while the device is unlocked by the user. Migrates to new devices.
124 | /// - whenPasscodeSetThisDeviceOnly: The data in the keychain can only be accessed when the device is unlocked. Only available if a passcode is set on the device. Does not migrate to new devices.
125 | /// - whenUnlockedThisDeviceOnly: The data in the keychain item can be accessed only while the device is unlocked by the user. Does not migrate to new devices.
126 | /// - afterFirstUnlockThisDeviceOnly: The data in the keychain item cannot be accessed after a restart until the device has been unlocked once by the user. Does not migrate to new devices.
127 | /// - alwaysThisDeviceOnly: The data in the keychain item can always be accessed regardless of whether the device is locked. Does not migrate to new devices.
128 | public enum Accessibility {
129 | case afterFirstUnlock
130 | case whenUnlocked
131 | case whenPasscodeSetThisDeviceOnly
132 | case whenUnlockedThisDeviceOnly
133 | case afterFirstUnlockThisDeviceOnly
134 |
135 | var cfString: CFString {
136 | switch self {
137 | case .afterFirstUnlock:
138 | return kSecAttrAccessibleAfterFirstUnlock
139 | case .whenUnlocked:
140 | return kSecAttrAccessibleWhenUnlocked
141 | case .afterFirstUnlockThisDeviceOnly:
142 | return kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
143 | case .whenPasscodeSetThisDeviceOnly:
144 | return kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
145 | case .whenUnlockedThisDeviceOnly:
146 | return kSecAttrAccessibleWhenUnlockedThisDeviceOnly
147 | }
148 | }
149 | }
150 |
151 | /// Stores the credential in the keychain under a certian identifier
152 | ///
153 | /// - Parameters:
154 | /// - credential: The credentials object to store in the keychain
155 | /// - identifier: The identifier to store the credential object under
156 | /// - accessibility: The access rule for the credential
157 | /// - Returns: Whether the item was sucessfully stored
158 | /// - Important: Passing a nil credential here will delete it from the store
159 | @discardableResult public static func store(credential: RequestCredential?, identifier: String, accessibility: Accessibility = .afterFirstUnlock, in store: DataStore = KeychainStore(serviceName: kTSCAuthServiceName)) -> Bool {
160 |
161 | guard let credential = credential else {
162 | return delete(withIdentifier: identifier)
163 | }
164 |
165 | let existingCredential = retrieve(withIdentifier: identifier)
166 | let exists = existingCredential != nil
167 |
168 | guard let data = try? credential.keychainData() else { return false }
169 |
170 | if exists {
171 | return store.update(data: data, identifier: identifier, accessibility: accessibility)
172 | } else {
173 | return store.add(data: data, identifier: identifier, accessibility: accessibility)
174 | }
175 | }
176 |
177 | /// Retrieves an entry for a certain identifier from the keychain
178 | ///
179 | /// - Parameter withIdentifier: The identifier to retrieve the credential object for
180 | /// - Returns: The retrieved credential
181 | public static func retrieve(withIdentifier identifier: String, from store: DataStore = KeychainStore(serviceName: kTSCAuthServiceName)) -> RequestCredential? {
182 |
183 | guard let data = store.retrieveDataFor(identifier: identifier) else {
184 | return nil
185 | }
186 |
187 | return try? RequestCredential(keychainData: data)
188 | }
189 |
190 | /// Deletes an entry for a certain identifier from the keychain
191 | ///
192 | /// - Parameter withIdentifier: The identifier to delete the credential object for
193 | /// - Returns: Whether the item was sucessfully deleted
194 | @discardableResult public static func delete(withIdentifier identifier: String, in store: DataStore = KeychainStore(serviceName: kTSCAuthServiceName)) -> Bool {
195 | return store.removeDataFor(identifier: identifier)
196 | }
197 | }
198 |
--------------------------------------------------------------------------------
/ThunderRequest/CustomisableRecoverableError.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ErrorRecoveryAttempter.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 14/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// A protocol that inherits from `RecoverableError`
12 | /// This can be used to allow the user to attempt to recover from an error.
13 | public protocol CustomisableRecoverableError: RecoverableError {
14 |
15 | /// The localized description for the error
16 | var description: String? { get }
17 |
18 | /// The reason the failure occured.
19 | var failureReason: String? { get }
20 |
21 | /// The suggested method of recovery.
22 | var recoverySuggestion: String? { get }
23 |
24 | /// An array of recovery options for the user.
25 | var options: [ErrorRecoveryOption] { get set }
26 |
27 | /// The code for the error.
28 | var code: Int { get }
29 |
30 | /// The domain of the error.
31 | var domain: String? { get }
32 | }
33 |
34 | extension CustomisableRecoverableError {
35 |
36 | public var recoveryOptions: [String] {
37 | return options.map({ $0.title })
38 | }
39 |
40 | public func attemptRecovery(optionIndex recoveryOptionIndex: Int, resultHandler handler: @escaping (Bool) -> Void) {
41 | guard recoveryOptionIndex < options.count else {
42 | handler(false)
43 | return
44 | }
45 | let option = options[recoveryOptionIndex]
46 | option.handler?(option, handler)
47 | }
48 |
49 | public func attemptRecovery(optionIndex recoveryOptionIndex: Int) -> Bool {
50 | guard recoveryOptionIndex < options.count else {
51 | return false
52 | }
53 | let option = options[recoveryOptionIndex]
54 | option.handler?(option, nil)
55 | return true
56 | }
57 |
58 | public mutating func add(option: ErrorRecoveryOption) {
59 | options.append(option)
60 | }
61 | }
62 |
63 | public struct ErrorOverrides {
64 |
65 | //MARK: - Overrides -
66 |
67 | /// Registers an override for a system error message. For example
68 | /// CLGeocoder has very poor error messaging. Registering a description through
69 | /// this method will ensure that this is used instead of the system one when
70 | /// using `UIAlertController(error:)` or `UIAlertController.present(error:in:)`
71 | ///
72 | /// - Parameters:
73 | /// - overrideDescription: The description you want to display instead of the system one.
74 | /// - recoverySuggestion: Advice to the user on how to recover from the error.
75 | /// - forDomain: The error domain for the error. Use constants where possible.
76 | /// - code: The error code to override. Use constants where possible.
77 | public static func register(overrideDescription description: String?, recoverySuggestion: String?, forDomain domain: String, code: Int) {
78 |
79 | let errorDescriptionKey = "\(domain)\(code)Description"
80 | let errorRecoveryKey = "\(domain)\(code)Recovery"
81 |
82 | var errorDictionary = UserDefaults.standard.dictionary(forKey: "TSCErrorRecoveryOverrides") ?? [:]
83 | errorDictionary[errorDescriptionKey] = description
84 | errorDictionary[errorRecoveryKey] = recoverySuggestion
85 |
86 | UserDefaults.standard.set(errorDictionary, forKey: "TSCErrorRecoveryOverrides")
87 | }
88 |
89 | /// Returns the overrides for a given error if there is one.
90 | ///
91 | /// - Parameters:
92 | /// - domain: The error domain for the error. Use constants where possible.
93 | /// - code: The error code to override. Use constants where possible.
94 | /// - Returns: The override information if it has been provided
95 | public static func overrideFor(domain: String, code: Int) -> (description: String?, recoverySuggestion: String?) {
96 |
97 | let errorDescriptionKey = "\(domain)\(code)Description"
98 | let errorRecoveryKey = "\(domain)\(code)Recovery"
99 |
100 | guard let dictionary = UserDefaults.standard.dictionary(forKey: "TSCErrorRecoveryOverrides") else {
101 | return (nil, nil)
102 | }
103 |
104 | return (dictionary[errorDescriptionKey] as? String, dictionary[errorRecoveryKey] as? String)
105 | }
106 | }
107 |
108 | /// A struct which attempts to convert any `Error` into a customisable representation
109 | public struct AnyCustomisableRecoverableError: CustomisableRecoverableError, CustomNSError, LocalizedError {
110 |
111 | public var errorDescription: String? {
112 | return originalError.localizedDescription
113 | }
114 |
115 | public var localizedDescription: String {
116 | return originalError.localizedDescription
117 | }
118 |
119 | public var description: String?
120 |
121 | public var failureReason: String?
122 |
123 | public var recoverySuggestion: String?
124 |
125 | public var options: [ErrorRecoveryOption] = []
126 |
127 | public var code: Int
128 |
129 | public var domain: String?
130 |
131 | public var errorCode: Int {
132 | return code
133 | }
134 |
135 | public var errorDomain: String {
136 | return domain ?? "Unknown"
137 | }
138 |
139 | private var originalError: Error
140 |
141 | init(_ error: Error) {
142 |
143 | originalError = error
144 | description = error.localizedDescription
145 | failureReason = (error as NSError).localizedFailureReason
146 | recoverySuggestion = (error as NSError).localizedRecoverySuggestion
147 | code = (error as NSError).code
148 | domain = (error as NSError).domain
149 | }
150 | }
151 |
--------------------------------------------------------------------------------
/ThunderRequest/Data+ContentType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ContentType+Data.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 11/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension Data {
12 |
13 | /// Returns the estimated mime type of the data based on it's first byte
14 | var mimeType: String {
15 |
16 | guard count > 0 else { return "application/octet-stream" }
17 | let firstByte = self[0]
18 | switch firstByte {
19 | case 0xFF:
20 | return "image/jpeg"
21 | case 0x89:
22 | return "image/png"
23 | case 0x47:
24 | return "image/gif"
25 | case 0x49, 0x4D:
26 | return "image/tiff"
27 | case 0x00:
28 | return "video/quicktime"
29 | case 0x44:
30 | return "text/plain"
31 | default:
32 | return "application/octet-stream"
33 | }
34 | }
35 |
36 | /// Returns the appropriate file extension for the data based on it's mimeType
37 | var fileExtension: String? {
38 |
39 | switch mimeType {
40 | case "image/jpeg":
41 | return "jpg"
42 | case "image/png":
43 | return "png"
44 | case "image/gif":
45 | return "gif"
46 | case "image/tiff":
47 | return "tiff"
48 | case "text/plain":
49 | return "txt"
50 | case "video/quicktime":
51 | return "mov"
52 | default:
53 | return nil
54 | }
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/ThunderRequest/Data+MultipartFormElement.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Data+MultipartFormElement.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 11/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | extension Data: MultipartFormElement {
12 |
13 | public func multipartDataWith(boundary: String, key: String) -> Data? {
14 | return multipartDataWith(boundary: boundary, key: key, contentType: mimeType, fileExtension: fileExtension)
15 | }
16 |
17 | func multipartDataWith(boundary: String, key: String, contentType: String, fileExtension: String?) -> Data? {
18 |
19 | var elementString = "--\(boundary)\r\nContent-Disposition: form-data; name=\"\(key)\"; filename=\"filename\(fileExtension != nil ? ".\(fileExtension!)" : "")\"\r\n"
20 | elementString.append("Content-Type: \(contentType)\r\n")
21 | elementString.append("Content-Transfer-Encoding: binary\r\n\r\n")
22 |
23 | var data = elementString.data(using: .utf8)
24 | data?.append(self)
25 | data?.append("\r\n")
26 | data?.append("--\(boundary)")
27 |
28 | return data
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/ThunderRequest/Data+Mutate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Data+Mutate.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 11/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | extension Data {
12 | mutating func append(_ string: String, using encoding: String.Encoding = .utf8) {
13 | guard let newData = string.data(using: encoding) else { return }
14 | self.append(newData)
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/ThunderRequest/Data+RequestBody.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Data+RequestBody.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 14/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | extension Data: RequestBody {
12 |
13 | public var contentType: String? {
14 | return mimeType
15 | }
16 |
17 | public func payload() -> Data? {
18 | return self
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/ThunderRequest/Dictionary+URLEncodedString.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Dictionary+URLEncodedString.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 11/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | extension Dictionary where Key == AnyHashable, Value == Any {
12 |
13 | init(urlEncodedString: String) {
14 |
15 | self.init()
16 |
17 | let string = URL(string: urlEncodedString)?.query ?? urlEncodedString
18 | let parameters = string.components(separatedBy: "&")
19 |
20 | parameters.forEach { (parameter) in
21 | let parts = parameter.components(separatedBy: "=")
22 | guard parts.count > 1 else { return }
23 | guard let key = parts[0].removingPercentEncoding else { return }
24 | guard let value = parts[1].removingPercentEncoding else { return }
25 | self[key] = value
26 | }
27 | }
28 | }
29 |
30 | extension Dictionary {
31 |
32 | /// Converts the dictionary to a query parameter string
33 | var queryParameterString: String? {
34 |
35 | guard !keys.isEmpty else {
36 | return nil
37 | }
38 |
39 | let parts: [String] = self.map { (keyValue) -> String in
40 |
41 | let keyString = String(describing: keyValue.key)
42 | let valueString = String(describing: keyValue.value)
43 |
44 | let part = "\(keyString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? keyString)=\(valueString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? valueString)"
45 | return part
46 | }
47 |
48 | return parts.joined(separator: "&")
49 | }
50 |
51 | /// Converts the dictionary to it's query parameter data with utf8 encoding
52 | var queryParameterData: Data? {
53 | return queryParameterString?.data(using: .utf8)
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/ThunderRequest/EncodableRequestBody.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Codable+RequestBody.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 14/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// A request body struct which can be used to represent the payload of
12 | /// anything that conforms to Encodable!
13 | public struct EncodableRequestBody: RequestBody {
14 |
15 | /// Enum representation of the encoding that should be used to prep data for the request
16 | ///
17 | /// - json: Encode to json
18 | /// - plist: Encode to plist
19 | public enum Encoding {
20 | case json
21 | case plist
22 |
23 | var contentType: String {
24 | switch self {
25 | case .json:
26 | return "application/json"
27 | case .plist:
28 | return "text/x-xml-plist"
29 | }
30 | }
31 | }
32 |
33 | /// The encoding type that should be used when converting to data for use with `URLSession`
34 | let encoding: Encoding
35 |
36 | /// The json object that should be sent with the request
37 | let encodableObject: T
38 |
39 | /// Creates a new Encodable upload request body
40 | ///
41 | /// - Parameters:
42 | /// - jsonObject: The JSON to send
43 | public init(_ encodableObject: T, encoding: Encoding = .json) {
44 | self.encodableObject = encodableObject
45 | self.encoding = encoding
46 | }
47 |
48 | public var contentType: String? {
49 | return encoding.contentType
50 | }
51 |
52 | public func payload() -> Data? {
53 | switch encoding {
54 | case .json:
55 | return try? JSONEncoder().encode(encodableObject)
56 | case .plist:
57 | return try? PropertyListEncoder().encode(encodableObject)
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/ThunderRequest/ErrorRecoveryOption.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ErrorRecoveryOption.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 14/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 |
12 | /// An option to be added to an `ErrorRecoveryAttempter`.
13 | /// when the attempter presents the alert on screen to the user,
14 | /// each one of the options will be displayed as a selectable button
15 | public struct ErrorRecoveryOption {
16 |
17 | /// The styles available for the action
18 | ///
19 | /// - custom: A custom option for recovering from the error
20 | /// - retry: Displays a retry button and repeats the request where possible
21 | /// - cancel: Cancels the recovery
22 | public enum Style {
23 | case custom
24 | case retry
25 | case cancel
26 | }
27 |
28 | /// A typealias for a callback when an error recovery option is chosen
29 | public typealias Handler = (_ option: ErrorRecoveryOption, _ callback: ((Bool) -> Void)?) -> Void
30 |
31 | /// The title to be used on the recovery option's button
32 | public let title: String
33 |
34 | /// A closure to be called when the user selects the recovery option.
35 | /// If none is supplied then the alert dialog will simply dismiss
36 | /// when this option is selected.
37 | public let handler: Handler?
38 |
39 | /// The type/style that is applied to the recovery option
40 | public let style: Style
41 |
42 | /// Creates a new option
43 | ///
44 | /// - Parameters:
45 | /// - title: The title to display in the alert
46 | /// - style: The style to display the button as in the alert
47 | /// - handler: A closure called when the option is selected
48 | public init(title: String, style: Style, handler: Handler? = nil) {
49 | self.title = title
50 | self.style = style
51 | self.handler = handler
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/ThunderRequest/FormURLEncodedRequestBody.swift:
--------------------------------------------------------------------------------
1 | //
2 | // JSONRequestBody.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 11/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// A request body struct which can be used to represent the payload of a form url encoded request
12 | public struct FormURLEncodedRequestBody: RequestBody {
13 |
14 | /// The payload object that should be sent with the request
15 | public let payloadObject: [AnyHashable : Any]
16 |
17 | /// Creates a new form url encoded upload request body
18 | ///
19 | /// - Parameters:
20 | /// - propertyList: The Plist to send
21 | public init(_ payload: [AnyHashable: Any]) {
22 | self.payloadObject = payload
23 | }
24 |
25 | public var contentType: String? {
26 | return "application/x-www-form-urlencoded"
27 | }
28 |
29 | public func payload() -> Data? {
30 | return payloadObject.queryParameterData
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/ThunderRequest/HTTP+Error.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HTTP+Error.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 18/04/2019.
6 | // Copyright © 2019 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public extension HTTP {
12 |
13 | /// Structural representation of a HTTP error
14 | struct Error: CustomisableRecoverableError {
15 |
16 | public var description: String?
17 |
18 | public var code: Int
19 |
20 | public var domain: String?
21 |
22 | public var failureReason: String?
23 |
24 | public var recoverySuggestion: String?
25 |
26 | public var options: [ErrorRecoveryOption] = []
27 |
28 | init(statusCode: HTTP.StatusCode, domain: String) {
29 | failureReason = statusCode.localizedDescription
30 | self.code = statusCode.rawValue
31 | self.domain = domain
32 | }
33 | }
34 | }
35 |
36 | extension HTTP.Error: CustomNSError {
37 |
38 | public var errorCode: Int {
39 | return code
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/ThunderRequest/HTTP.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HTTP.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 11/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// A protocol which can be conformed to in order to send any object as a HTTP request
12 | public protocol RequestBody {
13 |
14 | /// Returns the content type that should be used in the headers for a request of this type
15 | var contentType: String? { get }
16 |
17 | /// Returns the data payload that should be sent with the `URLRequest`
18 | ///
19 | /// - Returns: Data to be sent with the request. If nil, the request will error!
20 | func payload() -> Data?
21 |
22 | /// A function to allow this request body to mutate the url that is being sent with the request
23 | ///
24 | /// - Parameter url: The url which can be mutated
25 | func mutate(url: inout URL)
26 | }
27 |
28 | public extension RequestBody {
29 |
30 | func mutate(url: inout URL) {
31 |
32 | }
33 | }
34 |
35 | public struct HTTP {
36 | /// Enum representing HTTP Methods
37 | ///
38 | /// - CONNECT: The CONNECT method establishes a tunnel to the server identified by the target resource.
39 | /// - DELETE: The DELETE method deletes the specified resource.
40 | /// - GET: The GET method requests a representation of the specified resource. Requests using GET should only retrieve data.
41 | /// - HEAD: The HEAD method asks for a response identical to that of a GET request, but without the response body.
42 | /// - OPTIONS: The OPTIONS method is used to describe the communication options for the target resource.
43 | /// - PATCH: The PATCH method is used to apply partial modifications to a resource.
44 | /// - POST: The POST method is used to submit an entity to the specified resource, often causing a change in state or side effects on the server.
45 | /// - PUT: The PUT method replaces all current representations of the target resource with the request payload.
46 | /// - TRACE: The TRACE method performs a message loop-back test along the path to the target resource.
47 | public enum Method: String {
48 | case CONNECT
49 | case DELETE
50 | case GET
51 | case HEAD
52 | case OPTIONS
53 | case PATCH
54 | case POST
55 | case PUT
56 | case TRACE
57 | }
58 |
59 | /// HTTP status codes as defined by the IETF RFCs and other commonly used codes in popular server implementations.
60 | ///
61 | /// - `continue`: The server has received the request headers and the client should proceed to send the request body (in the case of a request for which a body needs to be sent; for example, a POST request).
62 | /// - switchingProtocols: The requester has asked the server to switch protocols and the server has agreed to do so.
63 | /// - processing: A WebDAV request may contain many sub-requests involving file operations, requiring a long time to complete the request. This code indicates that the server has received and is processing the request, but no response is available yet. This prevents the client from timing out and assuming the request was lost.
64 | /// - earlyHints: Used to return some response headers before final HTTP message.
65 | /// - ok: Standard response for successful HTTP requests. The actual response will depend on the request method used. In a GET request, the response will contain an entity corresponding to the requested resource. In a POST request, the response will contain an entity describing or containing the result of the action.
66 | /// - created: The request has been fulfilled, resulting in the creation of a new resource.
67 | /// - accepted: The request has been accepted for processing, but the processing has not been completed. The request might or might not be eventually acted upon, and may be disallowed when processing occurs.
68 | /// - nonAuthoritativeInfo: The server is a transforming proxy (e.g. a Web accelerator) that received a 200 OK from its origin, but is returning a modified version of the origin's response.
69 | /// - noContent: The server successfully processed the request and is not returning any content.
70 | /// - resetContent: The server successfully processed the request, but is not returning any content. Unlike a 204 response, this response requires that the requester reset the document view.
71 | /// - partialContent: The server is delivering only part of the resource (byte serving) due to a range header sent by the client. The range header is used by HTTP clients to enable resuming of interrupted downloads, or split a download into multiple simultaneous streams.
72 | /// - multiStatus: The message body that follows is by default an XML message and can contain a number of separate response codes, depending on how many sub-requests were made.
73 | /// - alreadyReported: The members of a DAV binding have already been enumerated in a preceding part of the (multistatus) response, and are not being included again.
74 | /// - thisIsFine: (Apache Web Server) Used as a catch-all error condition for allowing response bodies to flow through Apache when ProxyErrorOverride is enabled. When ProxyErrorOverride is enabled in Apache, response bodies that contain a status code of 4xx or 5xx are automatically discarded by Apache in favor of a generic response or a custom response specified by the ErrorDocument directive.
75 | /// - pageExpired: (Laravel Framework) Used by the Laravel Framework when a CSRF Token is missing or expired.
76 | /// - methodFailure: (Spring Framework) A deprecated response used by the Spring Framework when a method has failed.
77 | /// - imUsed: The server has fulfilled a request for the resource, and the response is a representation of the result of one or more instance-manipulations applied to the current instance.
78 | /// - multipleChoices: Indicates multiple options for the resource from which the client may choose (via agent-driven content negotiation). For example, this code could be used to present multiple video format options, to list files with different filename extensions, or to suggest word-sense disambiguation.
79 | /// - movedPermanently: This and all future requests should be directed to the given URI.
80 | /// - found: Tells the client to look at (browse to) another url.
81 | /// - seeOther: The response to the request can be found under another URI using the GET method. When received in response to a POST (or PUT/DELETE), the client should presume that the server has received the data and should issue a new GET request to the given URI.
82 | /// - notModified: Indicates that the resource has not been modified since the version specified by the request headers If-Modified-Since or If-None-Match. In such case, there is no need to retransmit the resource since the client still has a previously-downloaded copy.
83 | /// - useProxy: The requested resource is available only through a proxy, the address for which is provided in the response. Many HTTP clients (such as Mozilla[27] and Internet Explorer) do not correctly handle responses with this status code, primarily for security reasons.
84 | /// - switchProxy: No longer used. Originally meant "Subsequent requests should use the specified proxy."
85 | /// - temporaryRedirect: In this case, the request should be repeated with another URI; however, future requests should still use the original URI. In contrast to how 302 was historically implemented, the request method is not allowed to be changed when reissuing the original request. For example, a POST request should be repeated using another POST request.
86 | /// - permanentRedirect: The request and all future requests should be repeated using another URI. 307 and 308 parallel the behaviors of 302 and 301, but do not allow the HTTP method to change. So, for example, submitting a form to a permanently redirected resource may continue smoothly.
87 | /// - badRequest: The server cannot or will not process the request due to an apparent client error (e.g., malformed request syntax, size too large, invalid request message framing, or deceptive request routing).
88 | /// - unauthorized: Similar to 403 Forbidden, but specifically for use when authentication is required and has failed or has not yet been provided. The response must include a WWW-Authenticate header field containing a challenge applicable to the requested resource. See Basic access authentication and Digest access authentication.[34] 401 semantically means "unauthenticated",[35] i.e. the user does not have the necessary credentials.
89 | /// Note: Some sites incorrectly issue HTTP 401 when an IP address is banned from the website (usually the website domain) and that specific address is refused permission to access a website.
90 | /// - paymentRequired: Reserved for future use. The original intention was that this code might be used as part of some form of digital cash or micropayment scheme, as proposed for example by GNU Taler, but that has not yet happened, and this code is not usually used. Google Developers API uses this status if a particular developer has exceeded the daily limit on requests. Sipgate uses this code if an account does not have sufficient funds to start a call.[38] Shopify uses this code when the store has not paid their fees and is temporarily disabled.
91 | /// - forbidden: The request was valid, but the server is refusing action. The user might not have the necessary permissions for a resource, or may need an account of some sort.
92 | /// - notFound: The requested resource could not be found but may be available in the future. Subsequent requests by the client are permissible.
93 | /// - methodNotAllowed: A request method is not supported for the requested resource; for example, a GET request on a form that requires data to be presented via POST, or a PUT request on a read-only resource.
94 | /// - notAcceptable: The requested resource is capable of generating only content not acceptable according to the Accept headers sent in the request. See Content negotiation.
95 | /// - proxyAuthenticationRequired: The client must first authenticate itself with the proxy.
96 | /// - requestTimeout: The server timed out waiting for the request. According to HTTP specifications: "The client did not produce a request within the time that the server was prepared to wait. The client MAY repeat the request without modifications at any later time."
97 | /// - conflict: Indicates that the request could not be processed because of conflict in the current state of the resource, such as an edit conflict between multiple simultaneous updates.
98 | /// - gone: Indicates that the resource requested is no longer available and will not be available again. This should be used when a resource has been intentionally removed and the resource should be purged. Upon receiving a 410 status code, the client should not request the resource in the future. Clients such as search engines should remove the resource from their indices. Most use cases do not require clients and search engines to purge the resource, and a "404 Not Found" may be used instead.
99 | /// - lengthRequired: The request did not specify the length of its content, which is required by the requested resource.
100 | /// - preconditionFailed: The server does not meet one of the preconditions that the requester put on the request.
101 | /// - payloadTooLarge: The request is larger than the server is willing or able to process. Previously called "Request Entity Too Large".
102 | /// - uriTooLong: The URI provided was too long for the server to process. Often the result of too much data being encoded as a query-string of a GET request, in which case it should be converted to a POST request. Called "Request-URI Too Long" previously.
103 | /// - unsupportedMediaType: The request entity has a media type which the server or resource does not support. For example, the client uploads an image as image/svg+xml, but the server requires that images use a different format.
104 | /// - rangeNotSatisfiable: The client has asked for a portion of the file (byte serving), but the server cannot supply that portion. For example, if the client asked for a part of the file that lies beyond the end of the file. Called "Requested Range Not Satisfiable" previously.
105 | /// - expectationFailed: The server cannot meet the requirements of the Expect request-header field.
106 | /// - imATeapot: This code was defined in 1998 as one of the traditional IETF April Fools' jokes, in RFC 2324, Hyper Text Coffee Pot Control Protocol, and is not expected to be implemented by actual HTTP servers. The RFC specifies this code should be returned by teapots requested to brew coffee. This HTTP status is used as an Easter egg in some websites, including Google.com.
107 | /// - misdirectedRequest: The request was directed at a server that is not able to produce a response (for example because of connection reuse).
108 | /// - unprocessableIdentity: The request was well-formed but was unable to be followed due to semantic errors.
109 | /// - locked: The resource that is being accessed is locked.
110 | /// - failedDependency: The request failed because it depended on another request and that request failed (e.g., a PROPPATCH).
111 | /// - upgradeRequired: The client should switch to a different protocol such as TLS/1.0, given in the Upgrade header field.
112 | /// - preconditionRequired: The origin server requires the request to be conditional. Intended to prevent the 'lost update' problem, where a client GETs a resource's state, modifies it, and PUTs it back to the server, when meanwhile a third party has modified the state on the server, leading to a conflict."
113 | /// - tooManyRequests: The user has sent too many requests in a given amount of time. Intended for use with rate-limiting schemes.
114 | /// - requestHeaderFieldsTooLarge: The server is unwilling to process the request because either an individual header field, or all the header fields collectively, are too large.
115 | /// - loginTimeout: (Internet Information Services) The client's session has expired and must log in again.
116 | /// - noResponse: (nginx) Used internally to instruct the server to return no information to the client and close the connection immediately.
117 | /// - retryWith: (Internet Information Services) The server cannot honour the request because the user has not provided the required information.
118 | /// - blockedByWindowsParentalControls: (Microsoft) The Microsoft extension code indicated when Windows Parental Controls are turned on and are blocking access to the requested webpage.
119 | /// - unavailableForLegalReasons: A server operator has received a legal demand to deny access to a resource or to a set of resources that includes the requested resource. The code 451 was chosen as a reference to the novel Fahrenheit 451 (see the Acknowledgements in the RFC).
120 | /// - requestHeaderTooLarge: (nginx) Client sent too large request or too long header line.
121 | /// - sslCertificateError: (nginx) An expansion of the 400 Bad Request response code, used when the client has provided an invalid client certificate.
122 | /// - sslCertificateRequired: (nginx) An expansion of the 400 Bad Request response code, used when a client certificate is required but not provided.
123 | /// - httpRequestSentToHttpsPort: (nginx) An expansion of the 400 Bad Request response code, used when the client has made a HTTP request to a port listening for HTTPS requests.
124 | /// - invalidToken: (Esri) Returned by ArcGIS for Server. Code 498 indicates an expired or otherwise invalid token.
125 | /// - tokenRequired: (Esri) Returned by ArcGIS for Server. Code 499 indicates that a token is required but was not submitted.
126 | /// - internalServerError: A generic error message, given when an unexpected condition was encountered and no more specific message is suitable.
127 | /// - notImplemented: The server either does not recognize the request method, or it lacks the ability to fulfil the request. Usually this implies future availability (e.g., a new feature of a web-service API).
128 | /// - badGateway: The server was acting as a gateway or proxy and received an invalid response from the upstream server.
129 | /// - serviceUnavailable: The server is currently unavailable (because it is overloaded or down for maintenance). Generally, this is a temporary state.
130 | /// - gatewayTimeout: The server was acting as a gateway or proxy and did not receive a timely response from the upstream server.
131 | /// - httpVersionNotSupported: The server does not support the HTTP protocol version used in the request.
132 | /// - variantAlsoNegotiates: Transparent content negotiation for the request results in a circular reference.
133 | /// - insufficientStorage: The server is unable to store the representation needed to complete the request.
134 | /// - loopDetected: The server detected an infinite loop while processing the request (sent in lieu of 208 Already Reported).
135 | /// - bandwidthLimitExceeded: (Apache Web Server / cPanel) The server has exceeded the bandwidth specified by the server administrator; this is often used by shared hosting providers to limit the bandwidth of customers.
136 | /// - notExtended: Further extensions to the request are required for the server to fulfil it.
137 | /// - networkAuthenticationRequired: The client needs to authenticate to gain network access. Intended for use by intercepting proxies used to control access to the network (e.g., "captive portals" used to require agreement to Terms of Service before granting full Internet access via a Wi-Fi hotspot).
138 | /// - unknownError: (Cloudflate) The 520 error is used as a "catch-all response for when the origin server returns something unexpected", listing connection resets, large headers, and empty or invalid responses as common triggers.
139 | /// - webServerIsDown: (Cloudflare) The origin server has refused the connection from Cloudflare.
140 | /// - connectionTimedOut: (Cloudflare) Cloudflare could not negotiate a TCP handshake with the origin server.
141 | /// - originIsUnreachable: (Cloudflare) Cloudflare could not reach the origin server; for example, if the DNS records for the origin server are incorrect.
142 | /// - timeoutOccured: (Cloudflare) Cloudflare was able to complete a TCP connection to the origin server, but did not receive a timely HTTP response.
143 | /// - sslHandshakeFailed: (Cloudflare) Cloudflare could not negotiate a SSL/TLS handshake with the origin server.
144 | /// - invalidSSLCertificate: (Cloudflare) Cloudflare could not validate the SSL certificate on the origin web server.
145 | /// - railgunError: (Cloudflare) Error 527 indicates that the request timed out or failed after the WAN connection had been established.
146 | /// - originDNSError: (Cloudflare) Error 530 indicates that the requested host name could not be resolved on the Cloudflare network to an origin server.
147 | /// - networkReadTimeoutError: Used by some HTTP proxies to signal a network read timeout behind the proxy to a client in front of the proxy.
148 | public enum StatusCode: Int {
149 | case `continue` = 100
150 | case switchingProtocols
151 | case processing
152 | case earlyHints
153 | case okay = 200
154 | case created
155 | case accepted
156 | case nonAuthoritativeInfo
157 | case noContent
158 | case resetContent
159 | case partialContent
160 | case multiStatus
161 | case alreadyReported
162 | case thisIsFine = 218
163 | case pageExpired
164 | case methodFailure
165 | case imUsed = 226
166 | case multipleChoices = 300
167 | case movedPermanently
168 | case found
169 | case seeOther
170 | case notModified
171 | case useProxy
172 | case switchProxy
173 | case temporaryRedirect
174 | case permanentRedirect
175 | case badRequest = 400
176 | case unauthorized
177 | case paymentRequired
178 | case forbidden
179 | case notFound
180 | case methodNotAllowed
181 | case notAcceptable
182 | case proxyAuthenticationRequired
183 | case requestTimeout
184 | case conflict
185 | case gone
186 | case lengthRequired
187 | case preconditionFailed
188 | case payloadTooLarge
189 | case uriTooLong
190 | case unsupportedMediaType
191 | case rangeNotSatisfiable
192 | case expectationFailed
193 | case imATeapot
194 | case misdirectedRequest = 421
195 | case unprocessableIdentity
196 | case locked
197 | case failedDependency
198 | case upgradeRequired = 426
199 | case preconditionRequired = 428
200 | case tooManyRequests
201 | case requestHeaderFieldsTooLarge = 431
202 | case loginTimeout = 440
203 | case noResponse = 444
204 | case retryWith = 449
205 | case blockedByWindowsParentalControls = 450
206 | case unavailableForLegalReasons
207 | case requestHeaderTooLarge = 494
208 | case sslCertificateError
209 | case sslCertificateRequired
210 | case httpRequestSentToHttpsPort
211 | case invalidToken
212 | case tokenRequired
213 | case internalServerError
214 | case notImplemented
215 | case badGateway
216 | case serviceUnavailable
217 | case gatewayTimeout
218 | case httpVersionNotSupported
219 | case variantAlsoNegotiates
220 | case insufficientStorage
221 | case loopDetected
222 | case bandwidthLimitExceeded
223 | case notExtended
224 | case networkAuthenticationRequired
225 | case unknownError = 520
226 | case webServerIsDown
227 | case connectionTimedOut
228 | case originIsUnreachable
229 | case timeoutOccured
230 | case sslHandshakeFailed
231 | case invalidSSLCertificate
232 | case railgunError
233 | case originDNSError = 530
234 | case networkReadTimeoutError = 598
235 |
236 | public var isConsideredError: Bool {
237 | return rawValue >= 400 && rawValue < 600
238 | }
239 |
240 | public var localizedDescription: String {
241 | return HTTPURLResponse.localizedString(forStatusCode: rawValue)
242 | }
243 | }
244 | }
245 |
--------------------------------------------------------------------------------
/ThunderRequest/ImageRequestBody.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ImageRequestBody.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 11/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | #if os(macOS)
10 | import AppKit
11 | public typealias Image = NSImage
12 | #else
13 | import UIKit
14 | public typealias Image = UIImage
15 | #endif
16 |
17 | public extension Image {
18 |
19 | /// Image format (jpeg/png/gif e.t.c)
20 | enum Format {
21 | case jpeg
22 | case png
23 | #if os(macOS)
24 | case jpeg2000
25 | case gif
26 | case bmp
27 | case tiff
28 | #endif
29 | var contentType: String {
30 | switch self {
31 | case .jpeg:
32 | return "image/jpeg"
33 | case .png:
34 | return "image/png"
35 | #if os(macOS)
36 | case .jpeg2000:
37 | return "image/jpeg"
38 | case .gif:
39 | return "image/gif"
40 | case .bmp:
41 | return "image/bmp"
42 | case .tiff:
43 | return "image/tiff"
44 | #endif
45 | }
46 | }
47 |
48 | #if os(macOS)
49 | var fileType: NSBitmapImageRep.FileType {
50 | switch self {
51 | case .jpeg:
52 | return .jpeg
53 | case .jpeg2000:
54 | return .jpeg2000
55 | case .png:
56 | return .png
57 | case .gif:
58 | return .gif
59 | case .bmp:
60 | return .bmp
61 | case .tiff:
62 | return .tiff
63 | }
64 | }
65 | #endif
66 | }
67 |
68 | func dataFor(format: Format) -> Data? {
69 |
70 | #if os(macOS)
71 |
72 | guard let bitmapRepresentation = representations.first(where: { $0 is NSBitmapImageRep }) as? NSBitmapImageRep else {
73 | return nil
74 | }
75 |
76 | return bitmapRepresentation.representation(using: format.fileType, properties: [:])
77 | #else
78 | switch format {
79 | case .jpeg:
80 | return jpegData(compressionQuality: 1.0)
81 | default:
82 | return pngData()
83 | }
84 | #endif
85 | }
86 | }
87 |
88 | /// A request body struct which can be used to represent the payload of an
89 | /// image upload
90 | public struct ImageRequestBody: RequestBody {
91 |
92 | /// The image that should be uploaded
93 | public let image: Image
94 |
95 | /// The image format of the image
96 | public let format: Image.Format
97 |
98 | /// Creates a new image upload request body
99 | ///
100 | /// - Parameters:
101 | /// - image: The image to upload
102 | /// - format: The format to apply to the image
103 | public init(image: Image, format: Image.Format) {
104 | self.image = image
105 | self.format = format
106 | }
107 |
108 | public var contentType: String? {
109 | return format.contentType
110 | }
111 |
112 | public func payload() -> Data? {
113 | return image.dataFor(format: format)
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/ThunderRequest/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | FMWK
17 | CFBundleShortVersionString
18 | $(MARKETING_VERSION)
19 | CFBundleSignature
20 | ????
21 | CFBundleVersion
22 | $(CURRENT_PROJECT_VERSION)
23 | NSPrincipalClass
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/ThunderRequest/JSONRequestBody.swift:
--------------------------------------------------------------------------------
1 | //
2 | // JSONRequestBody.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 11/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// A request body struct which can be used to represent the payload of a
12 | /// JSON object
13 | public struct JSONRequestBody: RequestBody {
14 |
15 | /// The json object that should be sent with the request
16 | let jsonObject: Any
17 |
18 | /// Creates a new JSON upload request body
19 | ///
20 | /// - Parameters:
21 | /// - jsonObject: The JSON to send
22 | public init(_ jsonObject: Any) {
23 | self.jsonObject = jsonObject
24 | }
25 |
26 | public var contentType: String? {
27 | return "application/json"
28 | }
29 |
30 | public func payload() -> Data? {
31 | guard JSONSerialization.isValidJSONObject(jsonObject) else {
32 | return nil
33 | }
34 | return try? JSONSerialization.data(withJSONObject: jsonObject, options: [])
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/ThunderRequest/Logger.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Logger.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 23/01/2019.
6 | // Copyright © 2019 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// An enum representing the logging level of a log
12 | ///
13 | /// - `default`: Use this level to capture information about things that might result a failure.
14 | /// - info: Use this level to capture information that may be helpful, but isn’t essential, for troubleshooting errors.
15 | /// - debug: Debug logging is intended for use in a development environment and not in shipping software.
16 | /// - error: Error-level messages are intended for reporting process-level errors.
17 | /// - fault: Fault-level messages are intended for capturing system-level or multi-process errors only.
18 | public enum LogLevel {
19 | case `default`
20 | case info
21 | case debug
22 | case error
23 | case fault
24 | }
25 |
26 | /// Protocol allowing logging to be achieved
27 | public protocol LogReceiver {
28 |
29 | func log(_ message: String, category: String, level: LogLevel)
30 | }
31 |
--------------------------------------------------------------------------------
/ThunderRequest/MultipartFormFile.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MultipartFormFile.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 11/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | #if os(iOS) || os(watchOS) || os(tvOS)
12 | import UIKit
13 | #endif
14 |
15 | #if os(macOS)
16 | import AppKit
17 | public typealias UIImage = NSImage
18 | #endif
19 |
20 | public struct MultipartFormFile: MultipartFormElement {
21 |
22 | public let fileData: Data
23 |
24 | public let contentType: String
25 |
26 | public let fileName: String
27 |
28 | public let disposition: String?
29 |
30 | public let name: String?
31 |
32 | public let transferEncoding: String?
33 |
34 | public init?(image: UIImage, format: Image.Format = .jpeg, fileName: String, name: String? = nil) {
35 |
36 | guard let data = image.dataFor(format: format) else {
37 | return nil
38 | }
39 |
40 | self.init(
41 | fileData: data,
42 | contentType: format.contentType,
43 | fileName: fileName,
44 | disposition: nil,
45 | name: name,
46 | transferEncoding: nil
47 | )
48 | }
49 |
50 | public init(fileData: Data, contentType: String, fileName: String, disposition: String? = nil, name: String? = nil, transferEncoding: String? = nil) {
51 |
52 | self.fileData = fileData
53 | self.contentType = contentType
54 | self.fileName = fileName
55 | self.disposition = disposition
56 | self.name = name
57 | self.transferEncoding = transferEncoding
58 | }
59 |
60 | public func multipartDataWith(boundary: String, key: String) -> Data? {
61 |
62 | var dataString = "--\(boundary)\r\nContent-Disposition: \(disposition ?? "form-data");"
63 | dataString.append(" name=\"\(name ?? key)\";")
64 | dataString.append(" filename=\"\(fileName)\"\r\n")
65 | dataString.append("Content-Type: \(contentType)\r\n")
66 | dataString.append("Content-Transfer-Encoding: \(transferEncoding ?? "binary")\r\n\r\n")
67 |
68 | var returnData = dataString.data(using: .utf8)
69 | returnData?.append(fileData)
70 | returnData?.append("\r\n")
71 | returnData?.append("--\(boundary)")
72 |
73 | return returnData
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/ThunderRequest/MultipartFormRequestBody.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MultipartFormBody.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 11/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// A protocol which can be conformed by anything to represent a part of a
12 | /// multi-part form request payload
13 | public protocol MultipartFormElement {
14 | /// Return the data to be appended to the request as a whole for this
15 | /// particular element
16 | ///
17 | /// - Parameters:
18 | /// - boundary: The boundary which separates this element from the next. This should normally be appended and pre-pended to the returned data.
19 | /// - key: The key for this part of the multi-part form data. This would normally be added as the "name" part of the returned data.
20 | /// - Returns: The data to append to the payload
21 | func multipartDataWith(boundary: String, key: String) -> Data?
22 | }
23 |
24 | /// A request body struct which can be used to represent the payload of a
25 | /// multi-part form data request
26 | public struct MultipartFormRequestBody: RequestBody {
27 |
28 | /// A dictionary comprising of the multi-part elements to be sent with the request
29 | let parts: [String : MultipartFormElement]
30 |
31 | /// The boundary for the request
32 | let boundary: String
33 |
34 | /// Creates a new multi-part form body with the given elements and boundary
35 | ///
36 | /// - Parameters:
37 | /// - parts: A dictionary comprising the multi-part elements to be sent with the request
38 | /// - boundary: (Optional) the boundary to use to separate elements in `object`
39 | public init(parts: [String : MultipartFormElement], boundary: String? = nil) {
40 | self.parts = parts
41 | self.boundary = boundary ?? "----TSCRequestController" + (String(describing: parts).md5Hex ?? "")
42 | }
43 |
44 | public var contentType: String? {
45 | return "multipart/form-data; boundary=\(boundary)"
46 | }
47 |
48 | public func payload() -> Data? {
49 | var returnData = Data()
50 | parts.forEach { (keyValue) in
51 | guard let partData = keyValue.value.multipartDataWith(boundary: boundary, key: keyValue.key) else {
52 | return
53 | }
54 | returnData.append(partData)
55 | }
56 | return returnData
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/ThunderRequest/NSImage+MultipartFormElement.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSImage+MultipartFormElement.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 11/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import AppKit
10 |
11 | extension NSImage: MultipartFormElement {
12 | public func multipartDataWith(boundary: String, key: String) -> Data? {
13 |
14 | guard let bitmapRepresentation = representations.first(where: { $0 is NSBitmapImageRep }) as? NSBitmapImageRep else {
15 | return nil
16 | }
17 | guard let jpegData = bitmapRepresentation.representation(using: .jpeg, properties: [:]) else {
18 | return nil
19 | }
20 | return jpegData.multipartDataWith(boundary: boundary, key: key, contentType: "image/jpeg", fileExtension: "jpg")
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/ThunderRequest/PropertyListRequestBody.swift:
--------------------------------------------------------------------------------
1 | //
2 | // JSONRequestBody.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 11/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// A request body struct which can be used to represent the payload of a
12 | /// Plist object
13 | public struct PropertyListRequestBody: RequestBody {
14 |
15 | /// The xml plist object that should be sent with the request
16 | let propertyList: Any
17 |
18 | /// The format to be used to write the plist data
19 | ///
20 | /// - xml: As XML
21 | /// - binary: As a binary file
22 | public enum Format {
23 | case xml
24 | case binary
25 |
26 | var plistFormat: PropertyListSerialization.PropertyListFormat {
27 | switch self {
28 | case .binary:
29 | return .binary
30 | case .xml:
31 | return .xml
32 | }
33 | }
34 |
35 | var contentType: String {
36 | switch self {
37 | case .xml:
38 | return "text/x-xml-plist"
39 | case .binary:
40 | return "application/x-plist"
41 | }
42 | }
43 | }
44 |
45 | /// The format to send the plist as
46 | let format: Format
47 |
48 | /// Creates a new Plist upload request body
49 | ///
50 | /// - Parameters:
51 | /// - propertyList: The Plist to send
52 | /// - format: (optional) How to format the plist
53 | public init(_ propertyList: Any, format: Format = .xml) {
54 | self.propertyList = propertyList
55 | self.format = format
56 | }
57 |
58 | public var contentType: String? {
59 | return format.contentType
60 | }
61 |
62 | public func payload() -> Data? {
63 | guard PropertyListSerialization.propertyList(propertyList, isValidFor: format.plistFormat) else {
64 | return nil
65 | }
66 | return try? PropertyListSerialization.data(fromPropertyList: propertyList, format: format.plistFormat, options: 0)
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/ThunderRequest/Request.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Request.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 11/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import os.log
11 |
12 | /// A `Request` object represents a URL load request to be made by an instance of `RequestController`
13 | ///
14 | /// Generally `Request` objects are created automatically by `RequestController`, but you may with to manually construct one in certain cases
15 | public class Request {
16 |
17 | /// The base URL for the request e.g. "https://api.mywebsite.com"
18 | public var baseURL: URL
19 |
20 | /// The path to be appended to the `baseURL`.
21 | ///
22 | /// This should exclude the first "/" as this is appended automatically.
23 | /// e.g: "users/list.php"
24 | public var path: String?
25 |
26 | /// The HTTP method for the request.
27 | public var method: HTTP.Method
28 |
29 | /// An object to be used as the body of the request
30 | public var body: RequestBody?
31 |
32 | /// URL query items to be sent with the request
33 | public var urlQueryItems: [URLQueryItem]?
34 |
35 | /// A dictionary to be used as the headers for the request
36 | public var headers: [String : String?] = [:]
37 |
38 | /// The content type override for the request, such as "application/json"
39 | public var contentType: String?
40 |
41 | /// The tag for the request
42 | /// This can be used to cancel multiple requests with the same tag.
43 | public var tag: Int?
44 |
45 | private var _log: Any? = nil
46 | @available(macOS 10.12, watchOSApplicationExtension 3.0, *)
47 | fileprivate var log: OSLog {
48 | if _log == nil {
49 | _log = OSLog(subsystem: "com.threesidedcube.ThunderRequest", category: "Request")
50 | }
51 | return _log as! OSLog
52 | }
53 |
54 | public init(baseURL: URL, path: String?, method: HTTP.Method, queryItems: [URLQueryItem]?) {
55 |
56 | self.path = path
57 | self.method = method
58 | self.baseURL = baseURL
59 | urlQueryItems = queryItems
60 | }
61 |
62 | /// Configures and returns an `NSMutableRequest` which can be used with an `NSURLSession`
63 | ///
64 | /// - Returns: Returns a valid request object
65 | func construct() throws -> URLRequest {
66 |
67 | guard var urlComponents = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
68 | throw RequestError.invalidBaseURL
69 | }
70 |
71 | var allQueryItems = urlQueryItems
72 |
73 | // Make sure if base url is our full URL (incl. query items) we don't throw away it's query items!
74 | if let baseURLQueryItems = urlComponents.queryItems, !baseURLQueryItems.isEmpty {
75 | if allQueryItems == nil {
76 | allQueryItems = []
77 | }
78 | allQueryItems?.append(contentsOf: baseURLQueryItems)
79 | }
80 |
81 | if let path = path, let pathComponents = URLComponents(string: path) {
82 |
83 | // First let's construct urlComponents from the passed in path, if we just append the path itself,
84 | // and it already contains urlComponents, these are url encoded (i.e. ? => %3F) which breaks requests
85 | urlComponents.path = urlComponents.path.appending(pathComponents.path)
86 | if let pathQueryItems = pathComponents.queryItems, !pathQueryItems.isEmpty {
87 | if allQueryItems == nil {
88 | allQueryItems = []
89 | }
90 | allQueryItems?.append(contentsOf: pathQueryItems)
91 | }
92 |
93 | } else if let path = path { // If we can't construct url components, just try
94 | urlComponents.path = urlComponents.path.appending(path)
95 | }
96 |
97 | // Set the query items, if this is an empty array `?` will be appended, if it's nil, nothing will
98 | urlComponents.queryItems = allQueryItems
99 |
100 | guard let url = urlComponents.url else {
101 | throw RequestError.invalidURL
102 | }
103 |
104 | var request = URLRequest(url: url)
105 | request.httpMethod = method.rawValue
106 |
107 | if let body = body {
108 | request.httpBody = body.payload()
109 | }
110 |
111 | // Don't set the content-type header for GET requests, as they shouldn't be sending data
112 | // Some APIs will error if you provide a content-type with no data!
113 | if method != .GET && request.httpBody != nil {
114 | let contentTypeString = contentType ?? body?.contentType
115 | request.setValue(contentTypeString, forHTTPHeaderField: "Content-Type")
116 | headers["Content-Type"] = contentTypeString
117 | }
118 |
119 | if method == .GET && request.httpBody != nil {
120 | if #available(OSX 10.12, watchOSApplicationExtension 3.0, *) {
121 | os_log("Invalid request to: %{public}@. Should not be sending a GET request with a non-nil body", log: log, type: .error, url.absoluteString)
122 | }
123 | }
124 |
125 | headers.forEach { (keyValue) in
126 | request.setValue(keyValue.value, forHTTPHeaderField: keyValue.key)
127 | }
128 |
129 | return request
130 | }
131 | }
132 |
133 | public enum RequestError: Error {
134 | case invalidBaseURL
135 | case invalidURL
136 | case invalidBody
137 | }
138 |
--------------------------------------------------------------------------------
/ThunderRequest/RequestController+Auth.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RequestController+OAuth.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 12/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | typealias AuthCheckCompletion = (_ authenticated: Bool, _ error: Error?, _ needsQueueing: Bool) -> Void
12 |
13 | extension RequestController {
14 |
15 | /// Checks the authentication status for a given request
16 | ///
17 | /// - Parameters:
18 | /// - request: The request to check authentication for
19 | /// - completion: Closure callback with result
20 | func checkAuthStatusFor(request: Request, completion: @escaping AuthCheckCompletion) {
21 |
22 | guard let authenticator = authenticator else {
23 | completion(true, nil, false)
24 | return
25 | }
26 |
27 | // If we don't already have request credentials, then fetch them
28 | if sharedRequestCredentials == nil {
29 | sharedRequestCredentials = CredentialStore.retrieve(withIdentifier: authenticator.authIdentifier, from: dataStore)
30 | }
31 |
32 | // Make sure we have shared credentials
33 | guard let credentials = sharedRequestCredentials else {
34 | completion(true, nil, false)
35 | return
36 | }
37 |
38 | guard !credentials.hasExpired, !self.reAuthenticating else {
39 | // If we are re-authenticating then the token has expired, but this is not the
40 | // request that will refresh it, then this request can be queued by the user
41 | completion(!self.reAuthenticating, nil, self.reAuthenticating)
42 | return
43 | }
44 |
45 | // Important so if the re-authenticating call uses this request controller
46 | // to make the authentication request, we don't end up in an infinite loop!
47 | self.reAuthenticating = true
48 |
49 | let _dataStore = self.dataStore
50 |
51 | authenticator.reAuthenticate(credential: credentials) { [weak self] (newCredential, error, saveToKeychain) in
52 |
53 | // If we don't have an error, then save the credentials to the keychain
54 | if let newCredentials = newCredential, error == nil {
55 | if saveToKeychain {
56 | CredentialStore.store(credential: newCredentials, identifier: authenticator.authIdentifier, accessibility: authenticator.keychainAccessibility, in: _dataStore)
57 | }
58 | self?.sharedRequestCredentials = credentials
59 | }
60 |
61 | // Call back to the initial OAuth check
62 | completion(error == nil, error, false)
63 |
64 | guard let this = self else { return }
65 |
66 | // Re-schedule any requests that were queued whilst we were refreshing the OAuth token
67 | this.requestsQueuedForAuthentication.forEach({ (request, completion) in
68 | this.schedule(request: request, completion: completion)
69 | })
70 |
71 | this.requestsQueuedForAuthentication = []
72 | this.reAuthenticating = false
73 | }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/ThunderRequest/RequestController+Callbacks.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RequestController+Callbacks.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 12/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import os.log
11 |
12 | public struct RequestNotificationKey {
13 | public static let request = "TSCRequestNotificationRequestKey"
14 | public static let response = "TSCRequestNotificationResponseKey"
15 | public static let error = "TSCRequestNotificationErrorKey"
16 | }
17 |
18 | extension RequestController {
19 |
20 | public static let ErrorDomain = "com.threesidedcube.ThunderRequest"
21 |
22 | /// Name of `Notification` posted when the `Error` from a URL task is a `URLError`
23 | public static let RequestDidURLErrorNotificationName =
24 | Notification.Name(rawValue: "TSCRequestDidURLError")
25 |
26 | public static let DidReceiveResponseNotificationName = Notification.Name(rawValue: "TSCRequestDidReceiveResponse")
27 |
28 | public static let DidErrorNotificationName = Notification.Name("TSCRequestServerError")
29 |
30 | func add(completionHandler: TransferCompletion?, progressHandler: ProgressHandler?, forTaskId taskId: Int) {
31 |
32 | if transferCompletionHandlers[taskId] != nil {
33 | if #available(OSX 10.12, watchOSApplicationExtension 3.0, *) {
34 | os_log("Error: Got multiple handlers for a single task identifier. This should not happen.", log: requestLog, type: .error)
35 | }
36 | }
37 |
38 | transferCompletionHandlers[taskId] = completionHandler
39 |
40 | if progressHandlers[taskId] != nil {
41 | if #available(OSX 10.12, watchOSApplicationExtension 3.0, *) {
42 | os_log("Error: Got multiple progress handlers for a single task identifier. This should not happen.", log: requestLog, type: .error)
43 | }
44 | }
45 |
46 | progressHandlers[taskId] = progressHandler
47 | }
48 |
49 | func callProgressHandlerFor(taskIdentifier: Int, progress: Double, totalBytes: Int64, progressBytes: Int64) {
50 | progressHandlers[taskIdentifier]?(progress, totalBytes, progressBytes)
51 | }
52 |
53 | func callTransferCompletionHandlersFor(taskIdentifier: Int, downloadedFileURL fileURL: URL?, error: Error?, response: URLResponse?) {
54 |
55 | var requestResponse: RequestResponse?
56 | if let urlResponse = response {
57 | requestResponse = RequestResponse(response: urlResponse, data: nil, fileURL: fileURL)
58 | }
59 |
60 | transferCompletionHandlers[taskIdentifier]?(requestResponse, fileURL, error)
61 | transferCompletionHandlers[taskIdentifier] = nil
62 | progressHandlers[taskIdentifier] = nil
63 | }
64 |
65 | func callCompletionHandlersFor(request: Request, urlRequest: URLRequest, data: Data?, response urlResponse: URLResponse?, error: Error?, completion: RequestCompletion?) {
66 |
67 | var response: RequestResponse?
68 | if let urlResponse = urlResponse {
69 | response = RequestResponse(response: urlResponse, data: data)
70 | }
71 |
72 | if let redirectResponse = redirectResponses[urlRequest.taskIdentifier] {
73 | response?.originalResponse = redirectResponse
74 | }
75 |
76 | var requestInfo: [AnyHashable : Any] = [:]
77 | requestInfo[RequestNotificationKey.request] = request
78 | requestInfo[RequestNotificationKey.response] = response
79 |
80 | NotificationCenter.default.post(name: RequestController.DidReceiveResponseNotificationName, object: nil, userInfo: requestInfo)
81 |
82 | if let urlError = error as? URLError {
83 | var userInfo = requestInfo
84 | userInfo[RequestNotificationKey.error] = urlError
85 | NotificationCenter.default.post(name: RequestController.RequestDidURLErrorNotificationName, object: nil, userInfo: userInfo)
86 | }
87 |
88 | if response?.status.isConsideredError == true {
89 | NotificationCenter.default.post(name: RequestController.DidErrorNotificationName, object: nil, userInfo: requestInfo)
90 | }
91 |
92 | defer {
93 | logResponse(error, request: request, urlRequest: urlRequest, response: response)
94 | }
95 |
96 | guard error != nil || response?.status.isConsideredError == true else {
97 | (callbackQueue ?? DispatchQueue.main).async {
98 | completion?(response, error)
99 | }
100 | return
101 | }
102 |
103 | var recoverableError: CustomisableRecoverableError
104 | if let error = error {
105 | recoverableError = AnyCustomisableRecoverableError(error)
106 | } else {
107 | recoverableError = HTTP.Error(statusCode: response?.status ?? .unknownError, domain: RequestController.ErrorDomain)
108 | }
109 |
110 | recoverableError.add(option: ErrorRecoveryOption(title: "Retry", style: .retry, handler: { (_, _) in
111 | self.schedule(request: request, completion: completion)
112 | }))
113 |
114 | recoverableError.add(option: ErrorRecoveryOption(title: "Cancel", style: .cancel))
115 |
116 | (callbackQueue ?? DispatchQueue.main).async {
117 | completion?(response, recoverableError)
118 | }
119 | }
120 |
121 | private func logResponse(_ error: Error?, request: Request, urlRequest: URLRequest, response: RequestResponse?) {
122 |
123 | if let error = error {
124 | if #available(OSX 10.12, watchOSApplicationExtension 3.0, *) {
125 | os_log("Request: %@", log: requestLog, type: .debug, urlRequest.debugDescription)
126 | os_log("""
127 |
128 | URL: %@
129 | Method:%@
130 | Request Headers:%@
131 | Body: %@
132 |
133 | Response Status: FAILURE
134 | Error Description: %@
135 | """,
136 | log: requestLog,
137 | type: .error,
138 | urlRequest.url?.description ?? request.baseURL.description,
139 | request.method.rawValue,
140 | urlRequest.allHTTPHeaderFields ?? "",
141 | urlRequest.httpBody != nil ? String(data: urlRequest.httpBody!, encoding: .utf8) ?? "" : "",
142 | error.localizedDescription
143 | )
144 |
145 | log("""
146 |
147 | URL: \(urlRequest.url?.description ?? request.baseURL.description)
148 | Method: \(request.method.rawValue)
149 | Request Headers: \(urlRequest.allHTTPHeaderFields ?? [:])
150 | Body: \(urlRequest.httpBody != nil ? String(data: urlRequest.httpBody!, encoding: .utf8) ?? "" : "")
151 |
152 | Response Status: FAILURE
153 | Error Description: \(error.localizedDescription)
154 |
155 | """,
156 | level: .error
157 | )
158 | }
159 |
160 | } else {
161 |
162 | if #available(OSX 10.12, watchOSApplicationExtension 3.0, *) {
163 | log("Request: \(urlRequest.debugDescription)", level: .debug)
164 | log("""
165 |
166 | URL: \(urlRequest.url?.description ?? request.baseURL.description)
167 | Method: \(request.method.rawValue)
168 | Request Headers: \(urlRequest.allHTTPHeaderFields ?? [:])
169 | Body: \(urlRequest.httpBody != nil ? String(data: urlRequest.httpBody!, encoding: .utf8) ?? "" : "")
170 |
171 | Response Status: \(response?.status.rawValue ?? 999)
172 | Response Body: \(response?.string ?? "")
173 |
174 | """,
175 | level: .error
176 | )
177 | os_log("Request: %@", log: requestLog, type: .debug, urlRequest.debugDescription)
178 | os_log("""
179 |
180 | URL: %@
181 | Method: %@
182 | Request Headers: %@
183 | Body: %@
184 |
185 | Response Status: %li
186 | Response Body: %@
187 |
188 | """,
189 | log: requestLog,
190 | type: .error,
191 | urlRequest.url?.description ?? request.baseURL.description,
192 | request.method.rawValue,
193 | urlRequest.allHTTPHeaderFields ?? "",
194 | urlRequest.httpBody != nil ? String(data: urlRequest.httpBody!, encoding: .utf8) ?? "" : "",
195 | response?.status.rawValue ?? 999,
196 | response?.string ?? ""
197 | )
198 | }
199 | }
200 | }
201 | }
202 |
--------------------------------------------------------------------------------
/ThunderRequest/RequestController+SessionDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RequestController+SessionDelegate.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 12/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | extension RequestController: SessionDelegate {
12 |
13 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
14 | callTransferCompletionHandlersFor(taskIdentifier: downloadTask.taskIdentifier, downloadedFileURL: location, error: nil, response: downloadTask.response)
15 | }
16 |
17 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
18 | let progress = Double(totalBytesWritten)/Double(totalBytesExpectedToWrite)
19 | callProgressHandlerFor(taskIdentifier: downloadTask.taskIdentifier, progress: progress, totalBytes: totalBytesExpectedToWrite, progressBytes: totalBytesWritten)
20 | }
21 |
22 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64) {
23 |
24 | }
25 |
26 | func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
27 | let progress = Double(totalBytesSent)/Double(totalBytesExpectedToSend)
28 | callProgressHandlerFor(taskIdentifier: task.taskIdentifier, progress: progress, totalBytes: totalBytesExpectedToSend, progressBytes: totalBytesSent)
29 | }
30 |
31 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
32 | callTransferCompletionHandlersFor(taskIdentifier: task.taskIdentifier, downloadedFileURL: nil, error: error, response: task.response)
33 | }
34 |
35 | func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
36 |
37 | redirectResponses[task.taskIdentifier] = response
38 | completionHandler(request)
39 | }
40 |
41 | func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
42 |
43 | guard challenge.previousFailureCount == 0 else {
44 | completionHandler(.performDefaultHandling, nil)
45 | return
46 | }
47 |
48 | completionHandler(.useCredential, sharedRequestCredentials?.credential)
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/ThunderRequest/RequestCredential.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RequestCredential.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 14/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public struct Authentication {
12 |
13 | /// Representation of the token type for use in `Authorization` header!
14 | public struct TokenType {
15 |
16 | static let bearer = "Bearer"
17 | }
18 | }
19 |
20 | public let kTSCAuthServiceName = "TSCAuthCredential"
21 |
22 | /// A class used to store authentication information and return the `URLCredential` object when required
23 | @objc(TSCRequestCredential)
24 | public final class RequestCredential: NSObject, NSSecureCoding {
25 |
26 | /// Returns the url credential which can be used to authenticate a request
27 | public var credential: URLCredential? {
28 | guard let username = username else { return nil }
29 | guard let password = password else { return nil }
30 | return URLCredential(user: username, password: password, persistence: .none)
31 | }
32 |
33 | /// The username to auth the user with
34 | public var username: String?
35 |
36 | /// The password to auth the user with
37 | public var password: String?
38 |
39 | /// The auth token to auth the user with
40 | public var authorizationToken: String?
41 |
42 | /// The type of the token
43 | public var tokenType: String = Authentication.TokenType.bearer
44 |
45 | /// The date on which the authorization token expires
46 | public var expirationDate: Date?
47 |
48 | /// The refresh token to be sent back to the authenticating endpoint for certain auth methods
49 | public var refreshToken: String?
50 |
51 | /// Init method for re-constructing from data stored in the user's keychain
52 | ///
53 | /// - Parameter keychainData: The data which was retrieved from the keychain
54 | init(keychainData: Data) throws {
55 | // Root object RequestCredential and other encoded types
56 | let requestCredential = try NSKeyedUnarchiver.unarchivedObject(
57 | ofClasses: [RequestCredential.self, NSString.self, NSDate.self],
58 | from: keychainData
59 | )
60 |
61 | guard let credential = requestCredential as? RequestCredential else {
62 | throw RequestCredentialError.invalidType
63 | }
64 |
65 | self.authorizationToken = credential.authorizationToken
66 | self.username = credential.username
67 | self.password = credential.password
68 | self.tokenType = credential.tokenType
69 | }
70 |
71 | /// Whether the credential has expired. Where expiryDate is missing this will return as false, as it is
72 | /// assumed the credential doesn't have an expiry date in this case
73 | public var hasExpired: Bool {
74 | guard let expiry = expirationDate else {
75 | return false
76 | }
77 | return Date() > expiry
78 | }
79 |
80 | /// The data to store in the keychain
81 | public func keychainData() throws -> Data {
82 | return try NSKeyedArchiver.archivedData(
83 | withRootObject: self,
84 | requiringSecureCoding: false
85 | )
86 | }
87 |
88 | /// Creates a new username/password based credential
89 | ///
90 | /// - Parameters:
91 | /// - username: The username of the authorization object
92 | /// - password: The password of the authorization object
93 | public init(username: String, password: String) {
94 | super.init()
95 | self.username = username
96 | self.password = password
97 | }
98 |
99 | /// Initialises a new OAuth2 credential with given parameters
100 | ///
101 | /// - Parameters:
102 | /// - authorizationToken: The authorizationToken to be sent by `RequestController` for authentication requests.
103 | /// - refreshToken: The refresh token to be sent back to the authenticating endpoint for certain authentification methods.
104 | /// - expiryDate: The date upon which the credential will expire for the user.
105 | /// - tokenType: The token type of the credential (Defaults to Bearer)
106 | public init(authorizationToken: String, refreshToken: String?, expiryDate: Date, tokenType: String = "Bearer") {
107 |
108 | self.refreshToken = refreshToken
109 | self.expirationDate = expiryDate
110 | self.authorizationToken = authorizationToken
111 | self.tokenType = tokenType
112 | super.init()
113 | }
114 |
115 | /// Creates a new auth token based credential
116 | ///
117 | /// - Parameter authorizationToken: The authorization token to use
118 | init(authorizationToken: String) {
119 | super.init()
120 | self.authorizationToken = authorizationToken
121 | }
122 |
123 | private enum CodingKeys: String {
124 | case username
125 | case password
126 | case authToken = "authtoken"
127 | case tokenType = "tokentype"
128 | case expiration
129 | case refreshToken = "refreshtoken"
130 | }
131 |
132 | public func encode(with aCoder: NSCoder) {
133 | aCoder.encode(username, forKey: CodingKeys.username.rawValue)
134 | aCoder.encode(password, forKey: CodingKeys.password.rawValue)
135 | aCoder.encode(authorizationToken, forKey: CodingKeys.authToken.rawValue)
136 | aCoder.encode(tokenType, forKey: CodingKeys.tokenType.rawValue)
137 | aCoder.encode(expirationDate, forKey: CodingKeys.expiration.rawValue)
138 | aCoder.encode(refreshToken, forKey: CodingKeys.refreshToken.rawValue)
139 | }
140 |
141 | required public init?(coder aDecoder: NSCoder) {
142 | super.init()
143 | username = aDecoder.decodeObject(forKey: CodingKeys.username.rawValue) as? String
144 | password = aDecoder.decodeObject(forKey: CodingKeys.password.rawValue) as? String
145 | authorizationToken = aDecoder.decodeObject(forKey: CodingKeys.authToken.rawValue) as? String
146 | tokenType = aDecoder.decodeObject(forKey: CodingKeys.tokenType.rawValue) as? String ?? Authentication.TokenType.bearer
147 | refreshToken = aDecoder.decodeObject(forKey: CodingKeys.refreshToken.rawValue) as? String
148 | expirationDate = aDecoder.decodeObject(forKey: CodingKeys.expiration.rawValue) as? Date
149 | }
150 |
151 | // MARK: - NSSecureCoding
152 |
153 | public static var supportsSecureCoding: Bool {
154 | return true
155 | }
156 | }
157 |
158 | enum RequestCredentialError: Error {
159 | case invalidType
160 | }
161 |
--------------------------------------------------------------------------------
/ThunderRequest/RequestResponse+Codable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RequestResponse+Codable.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 12/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | extension RequestResponse {
12 |
13 | /// Attempts to decode the response data as a given type
14 | ///
15 | /// First attempts using JSONDecoder, then falls back to PropertyListDecoder
16 | ///
17 | /// - Returns: The decoded object, if parsing was sucessful
18 | public func decoded() -> T? {
19 |
20 | guard let data = data else { return nil }
21 |
22 | let jsonDecoder = JSONDecoder()
23 | if let jsonResult = try? jsonDecoder.decode(T.self, from: data) {
24 | return jsonResult
25 | }
26 |
27 | let plistDecoder = PropertyListDecoder()
28 | return try? plistDecoder.decode(T.self, from: data)
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/ThunderRequest/RequestResponse.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RequestResponse.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 11/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// A more useful representation of a `URLResponse` object.
12 | ///
13 | /// This class contains useful properties to help access response data from a HTTP request with ease
14 | public class RequestResponse {
15 |
16 | /// A dictionary representation of the headers the server responded with
17 | public var headers: [AnyHashable : Any]? {
18 | return httpResponse?.allHeaderFields
19 | }
20 |
21 | /// File url the response's data was saved to. This is only present for file downloads and is useful
22 | /// to get around the `40mb` memory limit which the Apple background service imposes on background download daemon
23 | public let fileURL: URL?
24 |
25 | /// Raw data returned from the server
26 | public let data: Data?
27 |
28 | /// The `HTTPURLResponse` object returned from the request. Contains info such as the response code.
29 | public let httpResponse: HTTPURLResponse?
30 |
31 | /// The original response of the request
32 | /// If the request was redirected, this represents the URLResponse for the original request
33 | public var originalResponse: HTTPURLResponse?
34 |
35 | /// Initialises a new request response from a given `URLResponse` and `Data`
36 | /// - Parameter response: The response to populate properties with
37 | /// - Parameter data: The data that was returned with the response
38 | /// - Parameter fileURL: The file url that the responses download was saved to (Only present for download tasks!)
39 | public init(response: URLResponse, data: Data?, fileURL: URL? = nil) {
40 | httpResponse = response as? HTTPURLResponse
41 | self.data = data
42 | self.fileURL = fileURL
43 | }
44 |
45 | /// The status of the HTTP request as an enum
46 | public var status: HTTP.StatusCode {
47 | guard let httpResponse = httpResponse else {
48 | return .unknownError
49 | }
50 | return HTTP.StatusCode(rawValue: httpResponse.statusCode) ?? .unknownError
51 | }
52 |
53 | /// The status of the HTTP request as a raw integer value
54 | public var statusCode: Int {
55 | return httpResponse?.statusCode ?? -1
56 | }
57 |
58 | /// Attempts to parse the response data to `Any`
59 | public var object: Any? {
60 | guard let data = data else { return nil }
61 | if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) {
62 | return jsonObject
63 | }
64 | return try? PropertyListSerialization.propertyList(from: data, options: .mutableContainersAndLeaves, format: nil)
65 | }
66 |
67 | /// Returns the response data as a dictionary if available
68 | public var dictionary: [AnyHashable : Any]? {
69 | return object as? [AnyHashable : Any]
70 | }
71 |
72 | /// Returns the response data as an array if available
73 | public var array: [Any]? {
74 | return object as? [Any]
75 | }
76 |
77 | /// Returns the response data as a string if available
78 | public var string: String? {
79 | guard let data = data else { return nil }
80 | return String(data: data, encoding: .utf8)
81 | }
82 |
83 | /// Returns the response data as a string using the given encoding
84 | ///
85 | /// - Parameter encoding: The encoding to try and decode the data using
86 | /// - Returns: The string response encoded using the given method
87 | public func string(encoding: String.Encoding = .utf8) -> String? {
88 | guard let data = data else { return nil }
89 | return String(data: data, encoding: encoding)
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/ThunderRequest/String+MD5.swift:
--------------------------------------------------------------------------------
1 | //
2 | // String+MD5.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 11/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import CommonCrypto
11 |
12 | public extension String {
13 |
14 | /// Returns the md5 data for the string
15 | var md5: Data? {
16 |
17 | guard let messageData = data(using:.utf8) else {
18 | return nil
19 | }
20 | var digestData = Data(count: Int(CC_MD5_DIGEST_LENGTH))
21 |
22 | digestData.withUnsafeMutableBytes { (digestBody: UnsafeMutableRawBufferPointer) in
23 |
24 | guard let baseAddress = digestBody.baseAddress, digestBody.count > 0 else {
25 | return
26 | }
27 |
28 | let digestBytes = baseAddress.assumingMemoryBound(to: UInt8.self)
29 |
30 | messageData.withUnsafeBytes { (messageBody: UnsafeRawBufferPointer) in
31 |
32 | guard let messageBaseAddress = messageBody.baseAddress, messageBody.count > 0 else {
33 | return
34 | }
35 |
36 | let messageBytes = messageBaseAddress.assumingMemoryBound(to: UInt8.self)
37 |
38 | CC_MD5(messageBytes, CC_LONG(messageData.count), digestBytes)
39 | }
40 | }
41 |
42 | return digestData
43 | }
44 |
45 | /// Returns HEX md5 string of self
46 | var md5Hex: String? {
47 | return md5?.map { String(format: "%02hhx", $0) }.joined()
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/ThunderRequest/String+MultipartFormElement.swift:
--------------------------------------------------------------------------------
1 | //
2 | // String+MultipartFormElement.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 11/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | extension String: MultipartFormElement {
12 |
13 | public func multipartDataWith(boundary: String, key: String) -> Data? {
14 | return "--\(boundary)\r\nContent-Disposition: form- ;name=\"\(key)\"\r\n\(self)\r\n".data(using: .utf8)
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/ThunderRequest/ThunderRequest.h:
--------------------------------------------------------------------------------
1 | #import
2 |
3 | //! Project version number for ThunderRequest.
4 | FOUNDATION_EXPORT double ThunderRequestVersionNumber;
5 |
6 | //! Project version string for ThunderRequest.
7 | FOUNDATION_EXPORT const unsigned char ThunderRequestVersionString[];
8 |
9 | // In this header, you should import all the public headers of your framework using statements like #import "PublicHeader.h"
10 |
11 |
12 |
--------------------------------------------------------------------------------
/ThunderRequest/UIAlertController+ErrorRecovery.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIAlertController+ErrorRecovery.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 14/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | extension ErrorRecoveryOption.Style {
12 | var alertActionStyle: UIAlertAction.Style {
13 | switch self {
14 | case .cancel:
15 | return .cancel
16 | default:
17 | return .default
18 | }
19 | }
20 | }
21 |
22 | public extension ErrorOverrides {
23 |
24 | /// Returns a summarised message body to display to the user combining
25 | /// failure reasons and suggested recovery options if supplied
26 | ///
27 | /// - Parameter error: The error to format for
28 | /// - Returns: An optional message
29 | static func recoveryMessageFor(error: Error) -> String? {
30 |
31 | let recoverableError = error as? CustomisableRecoverableError
32 | let override = ErrorOverrides.overrideFor(domain: recoverableError?.domain ?? (error as NSError).domain, code: recoverableError?.code ?? (error as NSError).code)
33 |
34 | var message: String = ""
35 | if let failureReason = recoverableError?.failureReason ?? (error as NSError).localizedFailureReason {
36 | message.append(failureReason)
37 | }
38 | if let recoverySuggestion = override.recoverySuggestion ?? recoverableError?.recoverySuggestion ?? (error as NSError).localizedRecoverySuggestion {
39 | if !message.isEmpty {
40 | message.append("\n")
41 | }
42 | message.append(recoverySuggestion)
43 | }
44 |
45 | return message.isEmpty ? nil : message
46 | }
47 | }
48 |
49 | extension UIAlertController {
50 |
51 | /// Presents an error as a recoverable error from the given view controller
52 | ///
53 | /// - Parameters:
54 | /// - error: The error to present
55 | /// - viewController: The view controller to present it in
56 | public static func present(error: Error, in viewController: UIViewController) {
57 |
58 | let alertController = UIAlertController(error: error)
59 | OperationQueue.main.addOperation { [weak viewController] in
60 | viewController?.present(alertController, animated: true, completion: nil)
61 | }
62 | }
63 |
64 | /// Initialises a UIAlertController from a given error
65 | ///
66 | /// This will add recovery options if the error conforms to `CustomisableRecoverableError` or `RecoverableError`
67 | ///
68 | /// - Parameter error: The error to show
69 | public convenience init(error: Error) {
70 |
71 | let errorOverride = ErrorOverrides.overrideFor(domain: (error as NSError).domain, code: (error as NSError).code)
72 |
73 | self.init(
74 | title: errorOverride.description ?? (error as? CustomisableRecoverableError)?.description ?? error.localizedDescription,
75 | message: ErrorOverrides.recoveryMessageFor(error: error),
76 | preferredStyle: .alert
77 | )
78 |
79 | switch error {
80 | case var anyRecoverableError as CustomisableRecoverableError:
81 |
82 | if anyRecoverableError.options.isEmpty {
83 | anyRecoverableError.add(option: ErrorRecoveryOption(title: "Dismiss", style: .cancel))
84 | }
85 |
86 | anyRecoverableError.options.enumerated().forEach { (index, option) in
87 | addAction(UIAlertAction(title: option.title, style: option.style.alertActionStyle, handler: { (action) in
88 | _ = anyRecoverableError.attemptRecovery(optionIndex: index)
89 | }))
90 | }
91 |
92 | case let recoverableError as RecoverableError:
93 |
94 | guard !recoverableError.recoveryOptions.isEmpty else {
95 | addAction(UIAlertAction(title: "Dismiss", style: .default, handler: nil))
96 | return
97 | }
98 |
99 | recoverableError.recoveryOptions.enumerated().forEach { (index, option) in
100 | addAction(UIAlertAction(title: option, style: .default, handler: { (action) in
101 | _ = recoverableError.attemptRecovery(optionIndex: index)
102 | }))
103 | }
104 |
105 | default:
106 | addAction(UIAlertAction(title: "Dismiss", style: .default, handler: nil))
107 | }
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/ThunderRequest/UIImage+MultipartFormElement.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIImage+MultipartFormElement.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 11/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | extension UIImage: MultipartFormElement {
12 |
13 | public func multipartDataWith(boundary: String, key: String) -> Data? {
14 | let jpegData = self.jpegData(compressionQuality: 1.0)
15 | return jpegData?.multipartDataWith(boundary: boundary, key: key, contentType: "image/jpeg", fileExtension: "jpg")
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/ThunderRequest/URLRequest+Backgroundable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // URLRequest+Backgroundable.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 13/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | extension URLRequest {
12 |
13 | var backgroundable: URLRequest? {
14 |
15 | guard let url = url else { return nil }
16 |
17 | var backgroundableRequest = URLRequest(url: url)
18 | backgroundableRequest.httpMethod = httpMethod
19 | backgroundableRequest.httpBody = httpBody
20 | allHTTPHeaderFields?.forEach({ (keyValue) in
21 | backgroundableRequest.setValue(keyValue.value, forHTTPHeaderField: keyValue.key)
22 | })
23 | return backgroundableRequest
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/ThunderRequest/URLRequest+TaskIdentifier.swift:
--------------------------------------------------------------------------------
1 | //
2 | // URLRequest+TaskIdentifier.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 12/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | private var identifierKey: UInt8 = 0
12 |
13 | extension URLRequest {
14 |
15 | /// This is an additional property which is used internally to hookup a `URLRequest` object
16 | /// with it's session task in order to call completion blocks upon it finishing!
17 | var taskIdentifier: Int? {
18 | get {
19 | return (objc_getAssociatedObject(self, &identifierKey) as? NSNumber)?.intValue
20 | }
21 | set {
22 | if let newValue = newValue {
23 | objc_setAssociatedObject(self, &identifierKey, NSNumber(integerLiteral: newValue), .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
24 | } else {
25 | objc_setAssociatedObject(self, &identifierKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
26 | }
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/ThunderRequest/URLSession+Synchronous.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSURLSession+Synchronous.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 12/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | extension URLSession {
12 |
13 | //MARK: - Data Tasks -
14 |
15 | func sendSynchronousDataTaskWith(request: inout URLRequest) -> (data: Data?, response: URLResponse?, error: Error?) {
16 |
17 | let taskSemaphore = DispatchSemaphore(value: 0)
18 | var data: Data?
19 | var error: Error?
20 | var response: URLResponse?
21 |
22 | let task = dataTask(with: request) { (responseData, returnResponse, taskError) in
23 |
24 | data = responseData
25 | response = returnResponse
26 | error = taskError
27 |
28 | taskSemaphore.signal()
29 | }
30 |
31 | request.taskIdentifier = task.taskIdentifier
32 | task.resume()
33 |
34 | _ = taskSemaphore.wait(timeout: .distantFuture)
35 |
36 | return (data, response, error)
37 | }
38 |
39 | func sendSynchronousDataTaskWith(url: URL) -> (data: Data?, response: URLResponse?, error: Error?) {
40 |
41 | var urlRequest = URLRequest(url: url)
42 | return sendSynchronousDataTaskWith(request: &urlRequest)
43 | }
44 |
45 | //MARK: - Upload Tasks -
46 |
47 | func sendSynchronousUploadTaskWith(request: inout URLRequest, uploadData: Data) -> (data: Data?, response: URLResponse?, error: Error?) {
48 |
49 | let taskSemaphore = DispatchSemaphore(value: 0)
50 | var data: Data?
51 | var error: Error?
52 | var response: URLResponse?
53 |
54 | let task = uploadTask(with: request, from: uploadData) { (responseData, returnResponse, taskError) in
55 |
56 | data = responseData
57 | response = returnResponse
58 | error = taskError
59 |
60 | taskSemaphore.signal()
61 | }
62 |
63 | request.taskIdentifier = task.taskIdentifier
64 | task.resume()
65 |
66 | _ = taskSemaphore.wait(timeout: .distantFuture)
67 |
68 | return (data, response, error)
69 | }
70 |
71 | func sendSynchronousUploadTaskWith(request: inout URLRequest, fileURL: URL) -> (data: Data?, response: URLResponse?, error: Error?) {
72 |
73 | let taskSemaphore = DispatchSemaphore(value: 0)
74 | var data: Data?
75 | var error: Error?
76 | var response: URLResponse?
77 |
78 | let task = uploadTask(with: request, fromFile: fileURL) { (responseData, returnResponse, taskError) in
79 |
80 | data = responseData
81 | response = returnResponse
82 | error = taskError
83 |
84 | taskSemaphore.signal()
85 | }
86 |
87 | request.taskIdentifier = task.taskIdentifier
88 | task.resume()
89 |
90 | _ = taskSemaphore.wait(timeout: .distantFuture)
91 |
92 | return (data, response, error)
93 | }
94 |
95 | //MARK: - Download Tasks -
96 |
97 | func sendSynchronousDownloadTaskWith(request: inout URLRequest) -> (downloadURL: URL?, response: URLResponse?, error: Error?) {
98 |
99 | let taskSemaphore = DispatchSemaphore(value: 0)
100 | var location: URL?
101 | var error: Error?
102 | var response: URLResponse?
103 |
104 | let task = downloadTask(with: request) { (responseLocation, returnResponse, taskError) in
105 |
106 | location = responseLocation
107 | response = returnResponse
108 | error = taskError
109 |
110 | taskSemaphore.signal()
111 | }
112 |
113 | request.taskIdentifier = task.taskIdentifier
114 | task.resume()
115 |
116 | _ = taskSemaphore.wait(timeout: .distantFuture)
117 |
118 | return (location, response, error)
119 | }
120 |
121 | func sendSynchronousDownloadTaskWith(url: URL) -> (downloadURL: URL?, response: URLResponse?, error: Error?) {
122 |
123 | var urlRequest = URLRequest(url: url)
124 | return sendSynchronousDownloadTaskWith(request: &urlRequest)
125 | }
126 |
127 | func sendSynchronousDownloadTaskWith(resumeData: Data) -> (downloadURL: URL?, response: URLResponse?, error: Error?) {
128 |
129 | let taskSemaphore = DispatchSemaphore(value: 0)
130 | var location: URL?
131 | var error: Error?
132 | var response: URLResponse?
133 |
134 | let task = downloadTask(withResumeData: resumeData) { (responseLocation, returnResponse, taskError) in
135 |
136 | location = responseLocation
137 | response = returnResponse
138 | error = taskError
139 |
140 | taskSemaphore.signal()
141 | }
142 |
143 | task.resume()
144 |
145 | _ = taskSemaphore.wait(timeout: .distantFuture)
146 |
147 | return (location, response, error)
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/ThunderRequest/URLSessionDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // URLSessionDelegate.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 12/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | protocol SessionDelegate {
12 |
13 | //MARK: - Download Delegate -
14 |
15 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL)
16 |
17 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64)
18 |
19 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64)
20 |
21 | //MARK: - Upload Delegate -
22 |
23 | func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64)
24 |
25 | //MARK: - URL Session Delegate -
26 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?)
27 |
28 | func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void)
29 |
30 | func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)
31 | }
32 |
33 | class SessionDelegateProxy: NSObject, URLSessionDownloadDelegate, URLSessionTaskDelegate {
34 |
35 | let delegate: SessionDelegate
36 |
37 | init(delegate: SessionDelegate) {
38 | self.delegate = delegate
39 | }
40 |
41 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
42 | delegate.urlSession(session, downloadTask: downloadTask, didFinishDownloadingTo: location)
43 | }
44 |
45 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64) {
46 | delegate.urlSession(session, downloadTask: downloadTask, didResumeAtOffset: fileOffset, expectedTotalBytes: expectedTotalBytes)
47 | }
48 |
49 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
50 | delegate.urlSession(session, downloadTask: downloadTask, didWriteData: bytesWritten, totalBytesWritten: totalBytesWritten, totalBytesExpectedToWrite: totalBytesExpectedToWrite)
51 | }
52 |
53 | func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
54 | delegate.urlSession(session, task: task, didSendBodyData: bytesSent, totalBytesSent: totalBytesSent, totalBytesExpectedToSend: totalBytesExpectedToSend)
55 | }
56 |
57 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
58 | delegate.urlSession(session, task: task, didCompleteWithError: error)
59 | }
60 |
61 | func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
62 | delegate.urlSession(session, task: task, willPerformHTTPRedirection: response, newRequest: request, completionHandler: completionHandler)
63 | }
64 |
65 | func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
66 | delegate.urlSession(session, task: task, didReceive: challenge, completionHandler: completionHandler)
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/ThunderRequest/URLSessionTask+Tag.swift:
--------------------------------------------------------------------------------
1 | //
2 | // URLSessionTask+Identifier.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 12/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | private var tagKey: UInt8 = 0
12 |
13 | extension URLSessionTask {
14 |
15 | /// This is an additional property on `URLSessionTask` which can be used to tag tasks.
16 | /// This allows for the cancellation of particular requests using tagging.
17 | var tag: Int? {
18 | get {
19 | return (objc_getAssociatedObject(self, &tagKey) as? NSNumber)?.intValue
20 | }
21 | set {
22 | if let newValue = newValue {
23 | objc_setAssociatedObject(self, &tagKey, NSNumber(integerLiteral: newValue), .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
24 | } else {
25 | objc_setAssociatedObject(self, &tagKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
26 | }
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/ThunderRequestMac/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | FMWK
17 | CFBundleShortVersionString
18 | $(MARKETING_VERSION)
19 | CFBundleSignature
20 | ????
21 | CFBundleVersion
22 | $(CURRENT_PROJECT_VERSION)
23 | NSHumanReadableCopyright
24 | Copyright © 2015 threesidedcube. All rights reserved.
25 | NSPrincipalClass
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/ThunderRequestMac/ThunderRequestMac.h:
--------------------------------------------------------------------------------
1 | //
2 | // ThunderRequestMac.h
3 | // ThunderRequestMac
4 | //
5 | // Created by Simon Mitchell on 29/09/2015.
6 | // Copyright (c) 2015 threesidedcube. All rights reserved.
7 | //
8 |
9 | #import
10 |
11 | //! Project version number for ThunderRequestMac.
12 | FOUNDATION_EXPORT double ThunderRequestMacVersionNumber;
13 |
14 | //! Project version string for ThunderRequestMac.
15 | FOUNDATION_EXPORT const unsigned char ThunderRequestMacVersionString[];
16 |
17 | // In this header, you should import all the public headers of your framework using statements like #import
18 |
19 |
--------------------------------------------------------------------------------
/ThunderRequestMacTests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | BNDL
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleSignature
20 | ????
21 | CFBundleVersion
22 | 1
23 |
24 |
25 |
--------------------------------------------------------------------------------
/ThunderRequestMacTests/ThunderRequestMacTests.m:
--------------------------------------------------------------------------------
1 | //
2 | // ThunderRequestMacTests.m
3 | // ThunderRequestMacTests
4 | //
5 | // Created by Simon Mitchell on 29/09/2015.
6 | // Copyright (c) 2015 threesidedcube. All rights reserved.
7 | //
8 |
9 | #import
10 | #import
11 |
12 | @interface ThunderRequestMacTests : XCTestCase
13 |
14 | @end
15 |
16 | @implementation ThunderRequestMacTests
17 |
18 | - (void)setUp {
19 | [super setUp];
20 | // Put setup code here. This method is called before the invocation of each test method in the class.
21 | }
22 |
23 | - (void)tearDown {
24 | // Put teardown code here. This method is called after the invocation of each test method in the class.
25 | [super tearDown];
26 | }
27 |
28 | - (void)testExample {
29 | // This is an example of a functional test case.
30 | XCTAssert(YES, @"Pass");
31 | }
32 |
33 | - (void)testPerformanceExample {
34 | // This is an example of a performance test case.
35 | [self measureBlock:^{
36 | // Put the code you want to measure the time of here.
37 | }];
38 | }
39 |
40 | @end
41 |
--------------------------------------------------------------------------------
/ThunderRequestTV/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | FMWK
17 | CFBundleShortVersionString
18 | $(MARKETING_VERSION)
19 | CFBundleSignature
20 | ????
21 | CFBundleVersion
22 | $(CURRENT_PROJECT_VERSION)
23 | NSPrincipalClass
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/ThunderRequestTV/ThunderRequestTV.h:
--------------------------------------------------------------------------------
1 | //
2 | // ThunderRequestTV.h
3 | // ThunderRequestTV
4 | //
5 | // Created by Matthew Cheetham on 16/10/2015.
6 | // Copyright © 2015 threesidedcube. All rights reserved.
7 | //
8 |
9 | #import
10 |
11 | //! Project version number for ThunderRequestTV.
12 | FOUNDATION_EXPORT double ThunderRequestTVVersionNumber;
13 |
14 | //! Project version string for ThunderRequestTV.
15 | FOUNDATION_EXPORT const unsigned char ThunderRequestTVVersionString[];
16 |
17 | // In this header, you should import all the public headers of your framework using statements like #import
18 |
19 |
--------------------------------------------------------------------------------
/ThunderRequestTests-tvOS/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 | BNDL
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 |
22 |
23 |
--------------------------------------------------------------------------------
/ThunderRequestTests/350x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/3sidedcube/ThunderRequest/db575a3527106f2309bb529bbe85ae34d1f390e6/ThunderRequestTests/350x150.png
--------------------------------------------------------------------------------
/ThunderRequestTests/AuthTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AuthTests.swift
3 | // ThunderRequestTests
4 | //
5 | // Created by Simon Mitchell on 18/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import ThunderRequest
11 |
12 | class DummyAuthenticator: Authenticator {
13 |
14 | func authenticate(completion: (RequestCredential?, Error?, Bool) -> Void) {
15 |
16 | }
17 |
18 | var keychainAccessibility: CredentialStore.Accessibility {
19 | return .afterFirstUnlock
20 | }
21 |
22 | func reAuthenticate(credential: RequestCredential?, completion: (RequestCredential?, Error?, Bool) -> Void) {
23 |
24 | }
25 |
26 | var authIdentifier: String = "dummyauthenticator"
27 |
28 | init() {
29 |
30 | }
31 | }
32 |
33 | class KeychainMockStore: DataStore {
34 |
35 | var internalStore: [String : Data] = [:]
36 |
37 | init() {
38 |
39 | }
40 |
41 | func add(data: Data, identifier: String, accessibility: CredentialStore.Accessibility) -> Bool {
42 | internalStore[identifier] = data
43 | return true
44 | }
45 |
46 | func update(data: Data, identifier: String, accessibility: CredentialStore.Accessibility) -> Bool {
47 | internalStore[identifier] = data
48 | return true
49 | }
50 |
51 | func retrieveDataFor(identifier: String) -> Data? {
52 | return internalStore[identifier]
53 | }
54 |
55 | func removeDataFor(identifier: String) -> Bool {
56 | guard internalStore[identifier] != nil else {
57 | return false
58 | }
59 | internalStore[identifier] = nil
60 | return true
61 | }
62 | }
63 |
64 | class AuthTests: XCTestCase {
65 |
66 | let requestBaseURL = URL(string: "https://httpbin.org/")!
67 |
68 | func testFetchesAuthWhenAuthenticatorSet() {
69 |
70 | let store = KeychainMockStore()
71 |
72 | let requestController = RequestController(baseURL: requestBaseURL, dataStore: store)
73 |
74 | let credential = RequestCredential(authorizationToken: "token", refreshToken: "refresh", expiryDate: Date(timeIntervalSinceNow: 1600))
75 |
76 |
77 | CredentialStore.store(
78 | credential: credential,
79 | identifier: "dummyauthenticator",
80 | accessibility: .afterFirstUnlock,
81 | in: store
82 | )
83 |
84 | let authenticator = DummyAuthenticator()
85 | requestController.authenticator = authenticator
86 |
87 | XCTAssertNotNil(requestController.sharedRequestCredentials)
88 | XCTAssertEqual(requestController.sharedRequestCredentials?.authorizationToken, "token")
89 | XCTAssertEqual(requestController.sharedRequestCredentials?.hasExpired, false)
90 | }
91 |
92 | func testFetchesAuthOnInit() {
93 |
94 | let credential = RequestCredential(authorizationToken: "ABCDE")
95 |
96 | let store = KeychainMockStore()
97 |
98 | CredentialStore.store(
99 | credential: credential,
100 | identifier: "thundertable.com.threesidedcube-https://httpbin.org/",
101 | accessibility: .afterFirstUnlock,
102 | in: store
103 | )
104 |
105 | let requestController = RequestController(baseURL: requestBaseURL, dataStore: store)
106 |
107 | XCTAssertNotNil(requestController.sharedRequestCredentials)
108 | XCTAssertEqual(requestController.sharedRequestCredentials?.authorizationToken, "ABCDE")
109 | XCTAssertEqual(requestController.sharedRequestCredentials?.hasExpired, false)
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/ThunderRequestTests/DownloadTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DownloadTests.swift
3 | // ThunderRequestTests
4 | //
5 | // Created by Simon Mitchell on 14/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | #if os(iOS) || os(tvOS)
10 | import UIKit
11 | #endif
12 | import XCTest
13 | @testable import ThunderRequest
14 |
15 | class DownloadTests: XCTestCase {
16 |
17 | let requestBaseURL = URL(string: "https://via.placeholder.com/")!
18 |
19 | func testDownloadSavesToDisk() {
20 |
21 | let requestController = RequestController(baseURL: requestBaseURL)
22 | let finishExpectation = expectation(description: "App should correctly download body from server")
23 |
24 | requestController.download("500", progress: nil) { (response, url, error) in
25 |
26 | XCTAssertNotNil(url)
27 | XCTAssertNotNil(response)
28 | XCTAssertEqual(response?.status, .okay)
29 |
30 | XCTAssertTrue(FileManager.default.fileExists(atPath: url!.path))
31 |
32 | let data = try? Data(contentsOf: url!)
33 | XCTAssertNotNil(data)
34 | XCTAssertNotNil(UIImage(data: data!))
35 |
36 | finishExpectation.fulfill()
37 | }
38 |
39 | waitForExpectations(timeout: 35) { (error) in
40 | XCTAssertNil(error, "The download timed out")
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/ThunderRequestTests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | BNDL
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleSignature
20 | ????
21 | CFBundleVersion
22 | 1
23 |
24 |
25 |
--------------------------------------------------------------------------------
/ThunderRequestTests/KeychainTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ThunderRequest-KeychainTests.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 14/09/2015.
6 | // Copyright © 2015 threesidedcube. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import ThunderRequest
11 |
12 | class ThunderRequest_KeychainTests: XCTestCase {
13 |
14 | override func setUp() {
15 | super.setUp()
16 | // Put setup code here. This method is called before the invocation of each test method in the class.
17 | }
18 |
19 | override func tearDown() {
20 | // Put teardown code here. This method is called after the invocation of each test method in the class.
21 | super.tearDown()
22 | }
23 |
24 | func testInitialiseUsernamePasswordCredential() {
25 |
26 | let credential = RequestCredential(username: "test", password: "123")
27 |
28 | XCTAssertNotNil(credential.username, "Username is nil")
29 | XCTAssertNotNil(credential.password, "Password is nil")
30 | XCTAssertNotNil(credential.credential, "Credential is nil")
31 | }
32 |
33 | func testInitialiseAuthTokenCredential() {
34 |
35 | let credential = RequestCredential(authorizationToken: "SHADSJMAS")
36 |
37 | XCTAssertNotNil(credential.authorizationToken, "Authorization Token is nil")
38 | }
39 |
40 | func testInitialiseOAuth2Credential() {
41 |
42 | let credential = RequestCredential(authorizationToken: "saDHSAHF", refreshToken: "DSAHJDSA", expiryDate: Date(timeIntervalSinceNow: 24))
43 |
44 | XCTAssertNotNil(credential.authorizationToken, "Authorization Token is nil")
45 | XCTAssertNotNil(credential.refreshToken, "Refresh Token is nil")
46 | XCTAssertNotNil(credential.expirationDate, "Expiry Date is nil")
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/ThunderRequestTests/MD5Tests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MD5Tests.swift
3 | // ThunderRequestTests
4 | //
5 | // Created by Simon Mitchell on 30/03/2019.
6 | // Copyright © 2019 threesidedcube. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import ThunderRequest
11 |
12 | class MD5Tests: XCTestCase {
13 |
14 | func testHelloWorldHashesCorrectly() {
15 |
16 | let helloWorld = "Hello World"
17 | let md5Hash = helloWorld.md5Hex
18 |
19 | XCTAssertEqual(md5Hash, "b10a8db164e0754105b7a99be72e3fe5")
20 | print("md5 hash", md5Hash!)
21 | }
22 |
23 | func testGoogleHashesCorrectly() {
24 |
25 | let google = "https://www.google.com"
26 | let md5Hash = google.md5Hex
27 |
28 | XCTAssertEqual(md5Hash, "8ffdefbdec956b595d257f0aaeefd623")
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/ThunderRequestTests/MultipartFormTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MultipartFormTests.swift
3 | // ThunderRequestTests
4 | //
5 | // Created by Simon Mitchell on 17/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | import Foundation
11 | @testable import ThunderRequest
12 |
13 | #if os(iOS) || os(tvOS)
14 | let expectedImageSize = CGSize(width: 350, height: 150)
15 | #elseif os(macOS)
16 | let expectedImageSize = CGSize(width: 262.5, height: 112.5)
17 | #endif
18 |
19 |
20 | class MultipartFormTests: XCTestCase {
21 |
22 | override func setUp() {
23 | // Put setup code here. This method is called before the invocation of each test method in the class.
24 | }
25 |
26 | override func tearDown() {
27 | // Put teardown code here. This method is called after the invocation of each test method in the class.
28 | }
29 |
30 | func testStringElementFormatsCorrectly() {
31 |
32 | let stringElement = "Hello World"
33 | let multipartData = stringElement.multipartDataWith(boundary: "123456", key: "sentence")
34 | XCTAssertEqual(multipartData?.count, 70)
35 | XCTAssertNotNil(multipartData)
36 | guard let data = multipartData else {
37 | return
38 | }
39 | XCTAssertEqual(String(data: data, encoding: .utf8), "--123456\r\nContent-Disposition: form- ;name=\"sentence\"\r\nHello World\r\n")
40 | }
41 |
42 | func testImageFormatsCorrectly() {
43 |
44 | guard let imageURL = Bundle(for: MultipartFormTests.self).url(forResource: "350x150", withExtension: "png") else {
45 | fatalError("Couldn't find test image file")
46 | }
47 | guard let image = UIImage(contentsOfFile: imageURL.path) else {
48 | fatalError("Couldn't create image from test image file")
49 | }
50 |
51 | #if os(iOS) || os(tvOS)
52 | let endStringRange = 8193...8203
53 | let imageRange = 145...8192
54 | let dataLength = 8204
55 | #elseif os(macOS)
56 | let endStringRange = 4655...4665
57 | let imageRange = 145...4644
58 | let dataLength = 4666
59 | #endif
60 |
61 | let imageMultiPartData = image.multipartDataWith(boundary: "ABCDEFG", key: "image")
62 |
63 | XCTAssertNotNil(imageMultiPartData)
64 | XCTAssertEqual(imageMultiPartData?.count, dataLength)
65 |
66 | guard let data = imageMultiPartData else { return }
67 | XCTAssertEqual(String(data: data[0...144], encoding: .utf8), "--ABCDEFG\r\nContent-Disposition: form-data; name=\"image\"; filename=\"filename.jpg\"\r\nContent-Type: image/jpeg\r\nContent-Transfer-Encoding: binary\r\n\r\n")
68 |
69 | XCTAssertEqual(String(data: data[endStringRange], encoding: .utf8), "\r\n--ABCDEFG")
70 |
71 | let dataImage = UIImage(data: data[imageRange])
72 | XCTAssertNotNil(dataImage)
73 | XCTAssertEqual(dataImage?.size, expectedImageSize)
74 | }
75 |
76 | func testFileElementFormatsCorrectly() {
77 |
78 | guard let imageURL = Bundle(for: MultipartFormTests.self).url(forResource: "350x150", withExtension: "png") else {
79 | fatalError("Couldn't find test image file")
80 | }
81 | guard let fileData = try? Data(contentsOf: imageURL) else {
82 | fatalError("Couldn't create image from test image file")
83 | }
84 |
85 | let imagePart = MultipartFormFile(
86 | fileData: fileData,
87 | contentType: "image/png",
88 | fileName: "fileface.png",
89 | disposition: "form-data",
90 | name: "hello",
91 | transferEncoding: "bubbles"
92 | )
93 | let imageMultiPartData = imagePart.multipartDataWith(boundary: "ABCDEFG", key: "image")
94 |
95 | XCTAssertNotNil(imageMultiPartData)
96 | XCTAssertEqual(imageMultiPartData?.count, 1409)
97 |
98 | guard let data = imageMultiPartData else { return }
99 | XCTAssertEqual(String(data: data[0...144], encoding: .utf8), "--ABCDEFG\r\nContent-Disposition: form-data; name=\"hello\"; filename=\"fileface.png\"\r\nContent-Type: image/png\r\nContent-Transfer-Encoding: bubbles\r\n\r\n")
100 | XCTAssertEqual(String(data: data[1398...1408], encoding: .utf8), "\r\n--ABCDEFG")
101 |
102 | let dataImage = UIImage(data: data[145...1408])
103 | XCTAssertNotNil(dataImage)
104 | XCTAssertEqual(dataImage?.size, expectedImageSize)
105 | }
106 |
107 | func testFileElementWithDefaultsFormatsCorrectly() {
108 |
109 | guard let imageURL = Bundle(for: MultipartFormTests.self).url(forResource: "350x150", withExtension: "png") else {
110 | fatalError("Couldn't find test image file")
111 | }
112 | guard let fileData = try? Data(contentsOf: imageURL) else {
113 | fatalError("Couldn't create image from test image file")
114 | }
115 |
116 | let imagePart = MultipartFormFile(
117 | fileData: fileData,
118 | contentType: "image/png",
119 | fileName: "fileface.png"
120 | )
121 | let imageMultiPartData = imagePart.multipartDataWith(boundary: "ABCDEFG", key: "image")
122 |
123 | XCTAssertNotNil(imageMultiPartData)
124 | XCTAssertEqual(imageMultiPartData?.count, 1408)
125 |
126 | guard let data = imageMultiPartData else { return }
127 | XCTAssertEqual(String(data: data[0...143], encoding: .utf8), "--ABCDEFG\r\nContent-Disposition: form-data; name=\"image\"; filename=\"fileface.png\"\r\nContent-Type: image/png\r\nContent-Transfer-Encoding: binary\r\n\r\n")
128 | XCTAssertEqual(String(data: data[1397...1407], encoding: .utf8), "\r\n--ABCDEFG")
129 |
130 | let dataImage = UIImage(data: data[144...1407])
131 | XCTAssertNotNil(dataImage)
132 | XCTAssertEqual(dataImage?.size, expectedImageSize)
133 | }
134 |
135 | func testJpegFileFormatsCorrectly() {
136 |
137 | guard let imageURL = Bundle(for: MultipartFormTests.self).url(forResource: "350x150", withExtension: "png") else {
138 | fatalError("Couldn't find test image file")
139 | }
140 | guard let image = UIImage(contentsOfFile: imageURL.path) else {
141 | fatalError("Couldn't create image from test image file")
142 | }
143 |
144 | #if os(iOS) || os(tvOS)
145 | let endStringRange = 8190...8200
146 | let imageRange = 142...8189
147 | let dataLength = 8201
148 | #elseif os(macOS)
149 | let endStringRange = 4652...4662
150 | let imageRange = 142...4641
151 | let dataLength = 4663
152 | #endif
153 |
154 | let imageFile = MultipartFormFile(image: image, format: .jpeg, fileName: "image.jpg", name: "image")
155 | XCTAssertNotNil(imageFile)
156 |
157 | let imageMultiPartData = imageFile?.multipartDataWith(boundary: "ABCDEFG", key: "image")
158 |
159 | XCTAssertNotNil(imageMultiPartData)
160 | XCTAssertEqual(imageMultiPartData?.count, dataLength)
161 |
162 | guard let data = imageMultiPartData else { return }
163 | XCTAssertEqual(String(data: data[0...141], encoding: .utf8), "--ABCDEFG\r\nContent-Disposition: form-data; name=\"image\"; filename=\"image.jpg\"\r\nContent-Type: image/jpeg\r\nContent-Transfer-Encoding: binary\r\n\r\n")
164 | XCTAssertEqual(String(data: data[endStringRange], encoding: .utf8), "\r\n--ABCDEFG")
165 |
166 | let dataImage = UIImage(data: data[imageRange])
167 | XCTAssertNotNil(dataImage)
168 | XCTAssertEqual(dataImage?.size, expectedImageSize)
169 | }
170 |
171 | func testPNGFileFormatsCorrectly() {
172 |
173 | guard let imageURL = Bundle(for: MultipartFormTests.self).url(forResource: "350x150", withExtension: "png") else {
174 | fatalError("Couldn't find test image file")
175 | }
176 | guard let image = UIImage(contentsOfFile: imageURL.path) else {
177 | fatalError("Couldn't create image from test image file")
178 | }
179 |
180 | let imageFile = MultipartFormFile(image: image, format: .png, fileName: "image.png", name: "image")
181 | XCTAssertNotNil(imageFile)
182 |
183 | let imageMultiPartData = imageFile?.multipartDataWith(boundary: "ABCDEFG", key: "image")
184 |
185 | #if os(iOS) || os(tvOS)
186 | let endStringRange: ClosedRange
187 | let imageRange: ClosedRange
188 | let dataLength: Int
189 | if #available(iOS 13.0, *) {
190 | dataLength = 2022
191 | imageRange = 141...2012
192 | endStringRange = 2011...2021
193 | } else {
194 | dataLength = 1942
195 | imageRange = 141...1932
196 | endStringRange = 1931...1941
197 | }
198 | #elseif os(macOS)
199 | let endStringRange = 1952...1962
200 | let imageRange = 141...1951
201 | let dataLength = 1963
202 | #endif
203 |
204 | XCTAssertNotNil(imageMultiPartData)
205 | XCTAssertEqual(imageMultiPartData?.count, dataLength)
206 |
207 | guard let data = imageMultiPartData else { return }
208 | XCTAssertEqual(String(data: data[0...140], encoding: .utf8), "--ABCDEFG\r\nContent-Disposition: form-data; name=\"image\"; filename=\"image.png\"\r\nContent-Type: image/png\r\nContent-Transfer-Encoding: binary\r\n\r\n")
209 | XCTAssertEqual(String(data: data[endStringRange], encoding: .utf8), "\r\n--ABCDEFG")
210 |
211 | let dataImage = UIImage(data: data[imageRange])
212 | XCTAssertNotNil(dataImage)
213 | XCTAssertEqual(dataImage?.size, expectedImageSize)
214 | }
215 |
216 | func testWholeFormFormatsCorrectly() {
217 |
218 | guard let imageURL = Bundle(for: MultipartFormTests.self).url(forResource: "350x150", withExtension: "png") else {
219 | fatalError("Couldn't find test image file")
220 | }
221 | guard let image = UIImage(contentsOfFile: imageURL.path) else {
222 | fatalError("Couldn't create image from test image file")
223 | }
224 |
225 | let pngFile = MultipartFormFile(image: image, format: .png, fileName: "image.png", name: "image")!
226 | let jpegFile = MultipartFormFile(image: image, format: .jpeg, fileName: "image.jpeg", name: "jpeg")!
227 |
228 | let formBody = MultipartFormRequestBody(
229 | parts: [
230 | "png": pngFile,
231 | "jpeg": jpegFile
232 | ],
233 | boundary: "ABCDEFG"
234 | )
235 |
236 | let payload = formBody.payload()
237 |
238 | #if os(iOS) || os(tvOS)
239 | let dataLength: Int
240 | if #available(iOS 13.0, *) {
241 | dataLength = 10223
242 | } else {
243 | dataLength = 10143
244 | }
245 | #elseif os(macOS)
246 | let dataLength = 6626
247 | #endif
248 |
249 | XCTAssertNotNil(payload)
250 | XCTAssertEqual(payload?.count, dataLength)
251 | XCTAssertEqual(formBody.contentType, "multipart/form-data; boundary=ABCDEFG")
252 | }
253 | }
254 |
--------------------------------------------------------------------------------
/ThunderRequestTests/RequestBodyTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BodyTests.swift
3 | // ThunderRequestTests
4 | //
5 | // Created by Simon Mitchell on 15/12/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | import Foundation
11 | @testable import ThunderRequest
12 |
13 | struct TestStruct {
14 | let body: String
15 | }
16 |
17 | struct CodableStruct: Codable {
18 | var integer: Int
19 | var double: Double
20 | var bool: Bool
21 | var date: Date
22 | var string: String
23 | var nullable: String?
24 | var stringArray: [String]
25 | var url: URL
26 | var dictionary: [String: String]
27 | }
28 |
29 | class RequestBodyTests: XCTestCase {
30 |
31 | func testCodableBodyCreatesDataCorrectly() {
32 |
33 | let codable = CodableStruct(
34 | integer: 123,
35 | double: 23.12,
36 | bool: true,
37 | date: Date(timeIntervalSince1970: 0),
38 | string: "Hello",
39 | nullable: nil,
40 | stringArray: ["Hello", "World"],
41 | url: URL(string: "https://www.google.co.uk")!,
42 | dictionary: [
43 | "Hello": "World"
44 | ]
45 | )
46 |
47 | let codableBody = EncodableRequestBody(codable, encoding: .json)
48 | let codableData = codableBody.payload()
49 |
50 | XCTAssertNotNil(codableData)
51 | XCTAssertEqual(codableData?.count, 188)
52 | XCTAssertEqual(codableBody.contentType, "application/json")
53 | XCTAssertEqual(String(data: codableData!, encoding: .utf8), "{\"double\":23.120000000000001,\"string\":\"Hello\",\"integer\":123,\"stringArray\":[\"Hello\",\"World\"],\"dictionary\":{\"Hello\":\"World\"},\"date\":-978307200,\"bool\":true,\"url\":\"https:\\/\\/www.google.co.uk\"}")
54 | }
55 |
56 | func testJSONBodyCreatesDataCorrectly() {
57 |
58 | let json = ["Hello": "World"]
59 |
60 | let jsonBody = JSONRequestBody(json)
61 | let jsonData = jsonBody.payload()
62 |
63 | XCTAssertNotNil(jsonData)
64 | XCTAssertEqual(jsonData?.count, 17)
65 | XCTAssertEqual(jsonBody.contentType, "application/json")
66 | XCTAssertEqual(String(data: jsonData!, encoding: .utf8), "{\"Hello\":\"World\"}")
67 | }
68 |
69 | func testJSONBodyFailsWithNonJSONParameters() {
70 |
71 | let json = ["Hello": TestStruct(body: "World")]
72 |
73 | let jsonBody = JSONRequestBody(json)
74 | let jsonData = jsonBody.payload()
75 |
76 | XCTAssertNil(jsonData)
77 | XCTAssertEqual(jsonBody.contentType, "application/json")
78 | }
79 |
80 | func testPlistBodyCreatesDataCorrectly() {
81 |
82 | let payload = ["Hello": "World"]
83 |
84 | let plistBody = PropertyListRequestBody(payload)
85 | let plistData = plistBody.payload()
86 |
87 | XCTAssertNotNil(plistData)
88 | XCTAssertEqual(plistData?.count, 230)
89 | XCTAssertEqual(plistBody.contentType, "text/x-xml-plist")
90 | XCTAssertEqual(String(data: plistData!, encoding: .utf8), "\n\n\n\n\tHello\n\tWorld\n\n\n")
91 | }
92 |
93 | func testBinaryPlistBodyCreatesDataCorrectly() {
94 |
95 | let payload = ["Hello": "World"]
96 |
97 | let plistBody = PropertyListRequestBody(payload, format: .binary)
98 | let plistData = plistBody.payload()
99 |
100 | XCTAssertNotNil(plistData)
101 | XCTAssertEqual(plistData?.count, 58)
102 | XCTAssertEqual(plistBody.contentType, "application/x-plist")
103 | XCTAssertEqual(Data(plistData![0...4]), Data([
104 | 98,
105 | 112,
106 | 108,
107 | 105,
108 | 115
109 | ]))
110 | }
111 |
112 | func testPlistBodyFailsWithNonPlistParameters() {
113 |
114 | let payload = ["Hello": TestStruct(body: "World")]
115 |
116 | let plistBody = PropertyListRequestBody(payload)
117 | let plistData = plistBody.payload()
118 |
119 | XCTAssertNil(plistData)
120 | XCTAssertEqual(plistBody.contentType, "text/x-xml-plist")
121 | }
122 |
123 | func testFormURLEncodedRequestBodyCreatesDataCorrectly() {
124 |
125 | let formURLEncodedBody = FormURLEncodedRequestBody(["hello":"world", "bool": true])
126 | XCTAssertEqual(formURLEncodedBody.contentType, "application/x-www-form-urlencoded")
127 |
128 | let urlEncodedData = formURLEncodedBody.payload()
129 | XCTAssertEqual(urlEncodedData?.count, 21)
130 | XCTAssertNotNil(urlEncodedData)
131 | XCTAssertTrue(["bool=true&hello=world", "hello=world&bool=true"].contains(String(data: urlEncodedData!, encoding: .utf8)!))
132 | }
133 |
134 | func testDataBodyCreatesDataCorrectly() {
135 |
136 | guard let fileURL = Bundle(for: RequestBodyTests.self).url(forResource: "350x150", withExtension: "png") else {
137 | fatalError("Failed to get url for test png")
138 | }
139 | guard let data = try? Data(contentsOf: fileURL) else {
140 | fatalError("Failed to get data from test png file")
141 | }
142 |
143 | let payload = data.payload()
144 | XCTAssertNotNil(payload)
145 | XCTAssertEqual(data.contentType, "image/png")
146 | XCTAssertEqual(data, payload)
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/ThunderRequestTests/RequestConstructionTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RequestConstructionTests.swift
3 | // ThunderRequest
4 | //
5 | // Created by Simon Mitchell on 24/07/2019.
6 | // Copyright © 2019 threesidedcube. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import ThunderRequest
11 |
12 | class RequestConstructionTests: XCTestCase {
13 |
14 | static let testURL = URL(string: "https://www.google.co.uk/")!
15 |
16 | func testNilQueryItemsDoesntAppendQuestionMark() {
17 |
18 | let request = Request(baseURL: RequestConstructionTests.testURL, path: "home", method: .GET, queryItems: nil)
19 | do {
20 | let urlRequest = try request.construct()
21 | XCTAssertEqual(urlRequest.url?.absoluteString, "https://www.google.co.uk/home")
22 | } catch let error {
23 | XCTFail("Failed to construct request object \(error)")
24 | }
25 | }
26 |
27 | func testNilQueryItemsAndSpaceInPathDoesntAppendQuestionMark() {
28 | let request = Request(baseURL: RequestConstructionTests.testURL, path: " ", method: .GET, queryItems: nil)
29 | do {
30 | let urlRequest = try request.construct()
31 | XCTAssertEqual(urlRequest.url?.absoluteString, "https://www.google.co.uk/%20")
32 | } catch let error {
33 | XCTFail("Failed to construct request object \(error)")
34 | }
35 | }
36 |
37 | func testEmptyQueryItemsAppendsQuestionMark() {
38 |
39 | let request = Request(baseURL: RequestConstructionTests.testURL, path: "home", method: .GET, queryItems: [])
40 | do {
41 | let urlRequest = try request.construct()
42 | XCTAssertEqual(urlRequest.url?.absoluteString, "https://www.google.co.uk/home?")
43 | } catch let error {
44 | XCTFail("Failed to construct request object \(error)")
45 | }
46 | }
47 |
48 | func testUrlParametersArePulledFromBaseURLWhenNilQueryItemsProvided() {
49 |
50 | let url = URL(string: "https://www.google.co.uk/search?term=pie")!
51 | let request = Request(baseURL: url, path: "", method: .GET, queryItems: nil)
52 | do {
53 | let urlRequest = try request.construct()
54 | XCTAssertEqual(urlRequest.url?.absoluteString, "https://www.google.co.uk/search?term=pie")
55 | } catch let error {
56 | XCTFail("Failed to construct request object \(error)")
57 | }
58 | }
59 |
60 | func testUrlParametersArePulledFromBaseURLWhenEmptyQueryItemsProvided() {
61 |
62 | let url = URL(string: "https://www.google.co.uk/search?term=pie")!
63 | let request = Request(baseURL: url, path: "", method: .GET, queryItems: [])
64 | do {
65 | let urlRequest = try request.construct()
66 | XCTAssertEqual(urlRequest.url?.absoluteString, "https://www.google.co.uk/search?term=pie")
67 | } catch let error {
68 | XCTFail("Failed to construct request object \(error)")
69 | }
70 | }
71 |
72 | func testUrlParametersArePulledFromPathWhenNilQueryItemsProvided() {
73 |
74 | let url = URL(string: "https://www.google.co.uk/")!
75 | let request = Request(baseURL: url, path: "search?term=pie", method: .GET, queryItems: nil)
76 | do {
77 | let urlRequest = try request.construct()
78 | XCTAssertEqual(urlRequest.url?.absoluteString, "https://www.google.co.uk/search?term=pie")
79 | } catch let error {
80 | XCTFail("Failed to construct request object \(error)")
81 | }
82 | }
83 |
84 | func testUrlParametersArePulledFromPathWhenEmptyQueryItemsProvided() {
85 |
86 | let url = URL(string: "https://www.google.co.uk/")!
87 | let request = Request(baseURL: url, path: "search?term=pie", method: .GET, queryItems: [])
88 | do {
89 | let urlRequest = try request.construct()
90 | XCTAssertEqual(urlRequest.url?.absoluteString, "https://www.google.co.uk/search?term=pie")
91 | } catch let error {
92 | XCTFail("Failed to construct request object \(error)")
93 | }
94 | }
95 |
96 | func testUrlParametersArePulledFromQueryItemsWhenBaseURLProvided() {
97 |
98 | let url = URL(string: "https://www.google.co.uk/search")!
99 | let request = Request(baseURL: url, path: "", method: .GET, queryItems: [URLQueryItem(name: "term", value: "pie")])
100 | do {
101 | let urlRequest = try request.construct()
102 | XCTAssertEqual(urlRequest.url?.absoluteString, "https://www.google.co.uk/search?term=pie")
103 | } catch let error {
104 | XCTFail("Failed to construct request object \(error)")
105 | }
106 | }
107 |
108 | func testUrlParametersArePulledFromQueryItemsWhenPathProvided() {
109 |
110 | let url = URL(string: "https://www.google.co.uk/")!
111 | let request = Request(baseURL: url, path: "search", method: .GET, queryItems: [URLQueryItem(name: "term", value: "pie")])
112 | do {
113 | let urlRequest = try request.construct()
114 | XCTAssertEqual(urlRequest.url?.absoluteString, "https://www.google.co.uk/search?term=pie")
115 | } catch let error {
116 | XCTFail("Failed to construct request object \(error)")
117 | }
118 | }
119 |
120 | func testUrlParametersArePulledFromMultipleQueryItemsWhenPathProvided() {
121 |
122 | let url = URL(string: "https://www.google.co.uk/")!
123 | let request = Request(baseURL: url, path: "search", method: .GET, queryItems: [
124 | URLQueryItem(name: "term", value: "pie"),
125 | URLQueryItem(name: "test", value: "2")
126 | ])
127 | do {
128 | let urlRequest = try request.construct()
129 | XCTAssertEqual(urlRequest.url?.absoluteString, "https://www.google.co.uk/search?term=pie&test=2")
130 | } catch let error {
131 | XCTFail("Failed to construct request object \(error)")
132 | }
133 | }
134 |
135 | func testUrlParametersAreSupplementary() {
136 |
137 | let url = URL(string: "https://www.google.co.uk/")!
138 | let request = Request(baseURL: url, path: "search?term=pie", method: .GET, queryItems: [URLQueryItem(name: "test", value: "2")])
139 | do {
140 | let urlRequest = try request.construct()
141 | XCTAssertEqual(urlRequest.url?.absoluteString, "https://www.google.co.uk/search?test=2&term=pie")
142 | } catch let error {
143 | XCTFail("Failed to construct request object \(error)")
144 | }
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/ThunderRequestTests/ResponseTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ThunderRequestTests.swift
3 | // ThunderRequestTests
4 | //
5 | // Created by Simon Mitchell on 16/09/2014.
6 | // Copyright (c) 2014 threesidedcube. All rights reserved.
7 | //
8 |
9 | #if os(iOS) || os(tvOS)
10 | import UIKit
11 | #elseif os(macOS)
12 | import AppKit
13 | typealias UIImage = NSImage
14 | #endif
15 | import XCTest
16 | @testable import ThunderRequest
17 |
18 | struct Response: Codable {
19 | var args: [String : String]
20 | var data: String
21 | var files: [String : String]
22 | var form: [String : String]
23 | var headers: [String : String]
24 | var json: CodableStruct
25 | var origin: String
26 | var url: URL
27 | }
28 |
29 | class ResponseTests: XCTestCase {
30 |
31 | let requestBaseURL = URL(string: "https://httpbin.org/")!
32 |
33 | override func setUp() {
34 | super.setUp()
35 | // Put setup code here. This method is called before the invocation of each test method in the class.
36 | }
37 |
38 | override func tearDown() {
39 | // Put teardown code here. This method is called after the invocation of each test method in the class.
40 | super.tearDown()
41 | }
42 |
43 | func testCreateControllerWithURL() {
44 |
45 | let requestController = RequestController(baseURL: requestBaseURL)
46 |
47 | XCTAssertNotNil(requestController, "A request Controller failed to be initialised with a URL")
48 | }
49 |
50 | func testRequestInvokesSuccessCompletionBlockWithResponseObject() {
51 |
52 | let requestController = RequestController(baseURL: requestBaseURL)
53 |
54 | let finishExpectation = expectation(description: "GET Request")
55 |
56 | requestController.request("get", method: .GET) { (response, error) in
57 |
58 | XCTAssertNil(error, "Request controller returned error for GET request")
59 | XCTAssertNotNil(response, "Request Controller did not return a response object")
60 | finishExpectation.fulfill()
61 | }
62 |
63 | waitForExpectations(timeout: 35) { (error) -> Void in
64 | XCTAssertNil(error, "The GET request timed out")
65 | }
66 | }
67 |
68 | func testOperationInvokesFailureCompletionBlockWithErrorOn404() {
69 |
70 | let requestController = RequestController(baseURL: requestBaseURL)
71 |
72 | let finishExpectation = expectation(description: "404 Request should return with response and error")
73 |
74 | requestController.request("status/404", method: .GET) { (response, error) in
75 | XCTAssertNotNil(error, "Request controller did not return an error object")
76 | XCTAssertNotNil(response, "Request controller did not return a response object")
77 | XCTAssertEqual(response?.status, .notFound, "Request controller did not return 404")
78 | finishExpectation.fulfill()
79 | }
80 |
81 | waitForExpectations(timeout: 35, handler: { (error) -> Void in
82 | XCTAssertNil(error, "The 404 request timed out")
83 | })
84 | }
85 |
86 | func testOperationInvokesFailureCompletionBlockWithErrorOn500() {
87 |
88 | let requestController = RequestController(baseURL: requestBaseURL)
89 |
90 | let finishExpectation = expectation(description: "500 Response should return with response and error")
91 |
92 | requestController.request("status/500", method: .GET) { (response, error) in
93 | XCTAssertNotNil(error, "Request controller did not return an error object")
94 | XCTAssertNotNil(response, "Request controller did not return a response object")
95 | XCTAssertEqual(response!.status, .internalServerError, "Request controller did not return 500")
96 | finishExpectation.fulfill()
97 | }
98 |
99 | waitForExpectations(timeout: 35, handler: { (error) -> Void in
100 | XCTAssertNil(error, "The 404 request timed out")
101 | })
102 | }
103 |
104 | func testAppIsNotifiedAboutServerErrors() {
105 |
106 | let requestController = RequestController(baseURL: requestBaseURL)
107 |
108 | let finishExpectation = expectation(description: "App should be notified about server errors")
109 |
110 | var notificationFound = false
111 |
112 | let observer = NotificationCenter.default.addObserver(forName: RequestController.DidErrorNotificationName, object: nil, queue: nil) { (notification) -> Void in
113 | notificationFound = true
114 | }
115 |
116 | requestController.request("status/500", method: .GET) { (response, error) in
117 | if notificationFound == true {
118 | finishExpectation.fulfill()
119 | }
120 | }
121 |
122 | waitForExpectations(timeout: 35, handler: { (error) -> Void in
123 |
124 | XCTAssertNil(error, "The notification test timed out")
125 | NotificationCenter.default.removeObserver(observer)
126 | })
127 | }
128 |
129 | func testAppIsNotifiedAboutServerResponse() {
130 |
131 | let requestController = RequestController(baseURL: requestBaseURL)
132 |
133 | let finishExpectation = expectation(description: "App should be notified about server responses")
134 |
135 | var notificationFound = false
136 |
137 | let observer = NotificationCenter.default.addObserver(forName: RequestController.DidReceiveResponseNotificationName, object: nil, queue: nil) { (notification) -> Void in
138 |
139 | notificationFound = true
140 |
141 | }
142 |
143 | requestController.request("status/500", method: .GET) { (response, error) in
144 | if notificationFound == true {
145 | finishExpectation.fulfill()
146 | }
147 | }
148 |
149 | waitForExpectations(timeout: 35, handler: { (error) -> Void in
150 |
151 | XCTAssertNil(error, "The server response notification test timed out")
152 |
153 | NotificationCenter.default.removeObserver(observer)
154 |
155 | })
156 | }
157 |
158 | func testPostRequest() {
159 |
160 | let requestController = RequestController(baseURL: requestBaseURL)
161 |
162 | let finishExpectation = expectation(description: "App should correctly send POST data to server")
163 |
164 | requestController.request("post", method: .POST, body: JSONRequestBody(["RequestTest": "Success"])) { (response, error) in
165 |
166 | let responseJson = response?.dictionary?["json"] as! Dictionary
167 | let successString = responseJson["RequestTest"]
168 | XCTAssertTrue(successString == "Success", "Server did not return POST body sent by request kit")
169 | finishExpectation.fulfill()
170 | }
171 |
172 | waitForExpectations(timeout: 35, handler: { (error) -> Void in
173 | XCTAssertNil(error, "The POST request timed out")
174 | })
175 | }
176 |
177 | func testCancelRequestWithTagReturnsCancelledError() {
178 |
179 | let requestController = RequestController(baseURL: requestBaseURL)
180 |
181 | let finishExpectation1 = expectation(description: "Request should be cancelled")
182 | let finishExpectation2 = expectation(description: "Request should be cancelled")
183 | let finishExpectation3 = expectation(description: "Request should be cancelled")
184 | let finishExpectation4 = expectation(description: "Request should succeed")
185 |
186 | requestController.request("get", method: .GET, tag: 123) { (_, error) in
187 | XCTAssertNotNil(error, "Error unexpectedly nil")
188 | defer {
189 | finishExpectation1.fulfill()
190 | }
191 | guard let error = error else { return }
192 | XCTAssertEqual((error as NSError).code, URLError.cancelled.rawValue, "Request controller returned invalid error")
193 | }
194 |
195 | requestController.request("get", method: .GET, tag: 123) { (_, error) in
196 | XCTAssertNotNil(error, "Error unexpectedly nil")
197 | defer {
198 | finishExpectation2.fulfill()
199 | }
200 | guard let error = error else { return }
201 | XCTAssertEqual((error as NSError).code, URLError.cancelled.rawValue, "Request controller returned invalid error")
202 | }
203 |
204 | requestController.request("get", method: .GET, tag: 123) { (_, error) in
205 | XCTAssertNotNil(error, "Error unexpectedly nil")
206 | defer {
207 | finishExpectation3.fulfill()
208 | }
209 | guard let error = error else { return }
210 | XCTAssertEqual((error as NSError).code, URLError.cancelled.rawValue, "Request controller returned invalid error")
211 | }
212 |
213 | requestController.request("get", method: .GET, tag: 201) { (response, error) in
214 | XCTAssertNil(error, "Request controller returned error for GET request")
215 | XCTAssertNotNil(response, "Request Controller did not return a response object")
216 | XCTAssertEqual(response?.status, .okay)
217 | finishExpectation4.fulfill()
218 | }
219 |
220 | requestController.cancelRequestsWith(tag: 123)
221 |
222 | waitForExpectations(timeout: 35, handler: { (error) -> Void in
223 | XCTAssertNil(error, "The GET request timed out")
224 | })
225 | }
226 |
227 | func testCancelAllRequestsReturnsCancelledError() {
228 |
229 | let requestController = RequestController(baseURL: requestBaseURL)
230 |
231 | let finishExpectation1 = expectation(description: "Request should be cancelled")
232 | let finishExpectation2 = expectation(description: "Request should be cancelled")
233 | let finishExpectation3 = expectation(description: "Request should be cancelled")
234 | let finishExpectation4 = expectation(description: "Request should succeed")
235 |
236 | requestController.request("get", method: .GET, tag: 123) { (_, error) in
237 | XCTAssertNotNil(error, "Error unexpectedly nil")
238 | defer {
239 | finishExpectation1.fulfill()
240 | }
241 | guard let error = error else { return }
242 | XCTAssertEqual((error as NSError).code, URLError.cancelled.rawValue, "Request controller returned invalid error")
243 | }
244 |
245 | requestController.request("get", method: .GET, tag: 123) { (_, error) in
246 | XCTAssertNotNil(error, "Error unexpectedly nil")
247 | defer {
248 | finishExpectation2.fulfill()
249 | }
250 | guard let error = error else { return }
251 | XCTAssertEqual((error as NSError).code, URLError.cancelled.rawValue, "Request controller returned invalid error")
252 | }
253 |
254 | requestController.request("get", method: .GET, tag: 123) { (_, error) in
255 | XCTAssertNotNil(error, "Error unexpectedly nil")
256 | defer {
257 | finishExpectation3.fulfill()
258 | }
259 | guard let error = error else { return }
260 | XCTAssertEqual((error as NSError).code, URLError.cancelled.rawValue, "Request controller returned invalid error")
261 | }
262 |
263 | requestController.request("get", method: .GET, tag: 201) { (response, error) in
264 | XCTAssertNotNil(error, "Error unexpectedly nil")
265 | defer {
266 | finishExpectation4.fulfill()
267 | }
268 | guard let error = error else { return }
269 | XCTAssertEqual((error as NSError).code, URLError.cancelled.rawValue, "Request controller returned invalid error")
270 | }
271 |
272 | requestController.cancelAllRequests()
273 |
274 | waitForExpectations(timeout: 35, handler: { (error) -> Void in
275 | XCTAssertNil(error, "The GET request timed out")
276 | })
277 | }
278 |
279 | func testResponseEncodesCorrectly() {
280 |
281 | let codable = CodableStruct(
282 | integer: 123,
283 | double: 23.12,
284 | bool: true,
285 | date: Date(timeIntervalSince1970: 0),
286 | string: "Hello",
287 | nullable: nil,
288 | stringArray: ["Hello", "World"],
289 | url: URL(string: "https://www.google.co.uk")!,
290 | dictionary: [
291 | "Hello": "World"
292 | ]
293 | )
294 |
295 | let codableBody = EncodableRequestBody(codable, encoding: .json)
296 |
297 | let requestController = RequestController(baseURL: requestBaseURL)
298 |
299 | let finishExpectation = expectation(description: "App should correctly send POST data to server")
300 |
301 | requestController.request("post", method: .POST, body: codableBody) { (response, error) in
302 |
303 | let codableResponse: Response? = response?.decoded()
304 | XCTAssertNotNil(codableResponse)
305 | XCTAssertNotNil(codableResponse?.json)
306 | XCTAssertEqual(codableResponse?.json.bool, true)
307 | XCTAssertEqual(codableResponse!.json.double, 23.12, accuracy: 0.0001)
308 | XCTAssertEqual(codableResponse?.json.string, "Hello")
309 | XCTAssertNil(codableResponse?.json.nullable)
310 | XCTAssertEqual(codableResponse?.json.stringArray, ["Hello", "World"])
311 | XCTAssertEqual(codableResponse?.json.url, URL(string: "https://www.google.co.uk"))
312 | XCTAssertEqual(codableResponse?.json.dictionary, ["Hello":"World"])
313 | finishExpectation.fulfill()
314 | }
315 |
316 | waitForExpectations(timeout: 35, handler: { (error) -> Void in
317 | XCTAssertNil(error, "The POST request timed out")
318 | })
319 | }
320 |
321 | func testMultipartRequestReturnsCorrectResponse() {
322 |
323 | guard let imageURL = Bundle(for: MultipartFormTests.self).url(forResource: "350x150", withExtension: "png") else {
324 | fatalError("Couldn't find test image file")
325 | }
326 | guard let image = UIImage(contentsOfFile: imageURL.path) else {
327 | fatalError("Couldn't create image from test image file")
328 | }
329 |
330 | let pngFile = MultipartFormFile(image: image, format: .png, fileName: "image.png", name: "image")!
331 |
332 | let formBody = MultipartFormRequestBody(
333 | parts: [
334 | "png": pngFile
335 | ],
336 | boundary: "------ABCDEFG"
337 | )
338 |
339 | let finishExpectation = expectation(description: "App should correctly send POST data to server")
340 |
341 | let requestController = RequestController(baseURL: requestBaseURL)
342 |
343 | requestController.request("post", method: .POST, body: formBody) { (response, error) in
344 | XCTAssertNil(error)
345 | XCTAssertNotNil(response?.dictionary)
346 | XCTAssertEqual(response?.status, .okay)
347 | finishExpectation.fulfill()
348 | }
349 |
350 | waitForExpectations(timeout: 35, handler: { (error) -> Void in
351 | XCTAssertNil(error, "The POST request timed out")
352 | })
353 | }
354 | }
355 |
--------------------------------------------------------------------------------
/ThunderRequestTests/ThunderRequestTests.m:
--------------------------------------------------------------------------------
1 | //
2 | // ThunderRequestTests.m
3 | // ThunderRequestTests
4 | //
5 | // Created by Matt Cheetham on 15/09/2014.
6 | // Copyright (c) 2014 3 SIDED CUBE Design Ltd. All rights reserved.
7 | //
8 |
9 | #import
10 | #import
11 |
12 | @interface ThunderRequestTests : XCTestCase
13 |
14 | @end
15 |
16 | @implementation ThunderRequestTests
17 |
18 | - (void)setUp {
19 | [super setUp];
20 | // Put setup code here. This method is called before the invocation of each test method in the class.
21 | }
22 |
23 | - (void)tearDown {
24 | // Put teardown code here. This method is called after the invocation of each test method in the class.
25 | [super tearDown];
26 | }
27 |
28 | - (void)testExample {
29 | // This is an example of a functional test case.
30 | XCTAssert(YES, @"Pass");
31 | }
32 |
33 | - (void)testPerformanceExample {
34 | // This is an example of a performance test case.
35 | [self measureBlock:^{
36 | // Put the code you want to measure the time of here.
37 | }];
38 | }
39 |
40 | @end
41 |
--------------------------------------------------------------------------------
/ThunderRequestWatch/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 | FMWK
17 | CFBundleShortVersionString
18 | $(MARKETING_VERSION)
19 | CFBundleVersion
20 | $(CURRENT_PROJECT_VERSION)
21 | NSPrincipalClass
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/ThunderRequestWatch/ThunderRequestWatch.h:
--------------------------------------------------------------------------------
1 | //
2 | // ThunderRequestWatch.h
3 | // ThunderRequestWatch
4 | //
5 | // Created by Simon Mitchell on 09/04/2018.
6 | // Copyright © 2018 threesidedcube. All rights reserved.
7 | //
8 |
9 | #import
10 |
11 | //! Project version number for ThunderRequestWatch.
12 | FOUNDATION_EXPORT double ThunderRequestWatchVersionNumber;
13 |
14 | //! Project version string for ThunderRequestWatch.
15 | FOUNDATION_EXPORT const unsigned char ThunderRequestWatchVersionString[];
16 |
17 | // In this header, you should import all the public headers of your framework using statements like #import
18 |
--------------------------------------------------------------------------------