├── .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 | [![Build Status](https://travis-ci.org/3sidedcube/ThunderRequest.svg)](https://travis-ci.org/3sidedcube/ThunderRequest) [![Carthage Compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage) [![Swift 5.5](http://img.shields.io/badge/swift-5.5-brightgreen.svg)](https://swift.org/blog/swift-5-5-released/) [![Apache 2](https://img.shields.io/badge/license-Apache%202-brightgreen.svg)](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 | --------------------------------------------------------------------------------