├── CHANGELOG.md ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── SwiftlySalesforce │ ├── Authorizer.swift │ ├── Connection+API.swift │ ├── Connection.swift │ ├── Credential.swift │ ├── CredentialStore.swift │ ├── DataService.swift │ ├── DataTransformer.swift │ ├── DefaultAuthorizer.swift │ ├── DefaultCredentialStore.swift │ ├── Errors │ ├── KeychainError.swift │ ├── OAuthError.swift │ ├── RequestError.swift │ ├── ResponseError.swift │ └── StateError.swift │ ├── Extensions │ ├── Array+URLQueryItem.swift │ ├── Collection+URLQueryItem.swift │ ├── DateFormatter+Salesforce.swift │ ├── Error+Authentication.swift │ ├── JSONDecoder+Salesforce.swift │ ├── String+Helpers.swift │ ├── URL+OAuth.swift │ ├── URLComponents+Query.swift │ ├── URLRequest+Credential.swift │ ├── URLRequest+OAuth.swift │ └── UserDefaults+IdentityURL.swift │ ├── HTTP.swift │ ├── Keychain.swift │ ├── Models │ ├── Address.swift │ ├── FieldDescription.swift │ ├── Identity.swift │ ├── Limit.swift │ ├── ObjectDescription.swift │ ├── PicklistItem.swift │ ├── QueryResult.swift │ └── Record.swift │ ├── OAuthFlow.swift │ ├── RequestCreator.swift │ ├── Resource.swift │ ├── ResponseValidator.swift │ ├── Salesforce.swift │ ├── Services │ ├── ApexService.swift │ ├── IdentityService.swift │ ├── Resource+Limits.swift │ ├── Resource+Query.swift │ ├── Resource+SObjects.swift │ └── Resource+Search.swift │ ├── UserIdentifier.swift │ └── WebAuthenticationSession.swift └── Tests ├── LinuxMain.swift └── SwiftlySalesforceTests ├── AddressTests.swift ├── ApexServiceTests.swift ├── ConnectionTests.swift ├── CredentialTests.swift ├── DataServiceTests.swift ├── DefaultAuthorizerTests.swift ├── Extensions ├── URLSession+Mock.swift └── XCTestCase+Helpers.swift ├── IdentityServiceTests.swift ├── IdentityTests.swift ├── MockAccount.json ├── MockAccountMetadata.json ├── MockAccountMissingURLAttribute.json ├── MockAggregateQueryResult.json ├── MockConfig.json ├── MockIdentity.json ├── MockLimits.json ├── MockSearchResults.json ├── MockURLProtocol.swift ├── RecordTests.swift ├── Resource_LimitsTests.swift ├── Resource_QueryTests.swift ├── Resource_SObjectsTests.swift ├── Resource_SearchTests.swift └── SalesforceTests.swift /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## Version 10.0.1 (March 29, 2022) 4 | Minor update. 5 | * Added inline code documentation. 6 | * Updated README.md. 7 | * Made `ResponseError` public. 8 | 9 | ## Version 10.0.0 (March 28, 2022) 10 | Major 'breaking' release. Highlights: 11 | * Replaces Swift Combine publishers with Swift Concurrency (async/await) model 12 | * Requires iOS 15+, Swift 5.5+ and Xcode 13+ 13 | * Improves performance of OAuth2 authorization flows 14 | * Adds [SOSL search convenience method](https://github.com/mike4aday/SwiftlySalesforce/blob/27f79a1b6ba8b0be5695c02d6c0282dbf57394a7/Sources/SwiftlySalesforce/Connection%2BAPI.swift#L27) 15 | * Resolves issue #135 16 | * Resolves issue #138 17 | * Updates default Salesforce API version to 54.0 (Spring '22) 18 | * Removes capability to disable automatic user authentication when required. (It's complicated with the new Swift Concurrency and the improved performance mentioned above. If you require this feature, please let me know by creating a [new GitHub issue](https://github.com/mike4aday/SwiftlySalesforce/issues/new).) 19 | 20 | ## Version 9.0.5 (July 19, 2021) 21 | Updated default API version to 52.0 (Summer '21). 22 | 23 | ## Version 9.0.4 (June 25, 2021) 24 | Minor release. Updated README.md. 25 | 26 | ## Version 9.0.3 (June 7, 2021) 27 | Patch release. Deleted no-longer-used support files. 28 | 29 | ## Version 9.0.2 (June 7, 2021) 30 | Patch release. Added documentation to [`myRecords`](https://github.com/mike4aday/SwiftlySalesforce/blob/e17c02b9b9837415290fef67f2098edf4226fd58/Sources/SwiftlySalesforce/ConnectedApp%2BQuery.swift#L55) method re. `OwnerId` field on target `type`, and fixed broken link in README.md. 31 | 32 | ## Version 9.0.1 (June 6, 2021) 33 | Patch release. Updated README.md, and increased access level of `Identity` properties and `Limit.used` to `public`. 34 | 35 | ## Version 9.0.0 (June 4, 2021) 36 | Major 'breaking' release. Requires iOS 14, Swift 5.3, Xcode 12. 37 | 38 | ## Version 8.0.2 (Feb. 16, 2021) 39 | - Updated default Salesforce API version to 49.0 (Summer '20). 40 | - Minor updates to README file. 41 | 42 | ## Version 8.0.1 (July 10, 2020) 43 | - Made RequestConfig constructor public. 44 | - Updated default Salesforce API version to 48.0 (Spring '20). 45 | 46 | ## Version 8.0.0 (Nov. 29, 2019) 47 | Major release. Requires iO3 13, Swift 5.1, Xcode 11. 48 | - Swift Combine framework. 49 | - Swift Package Manager (SPM). 50 | - Removed last dependency (PromiseKit). 51 | 52 | ## Version 7.1.6 (Jan. 22, 2019) 53 | Minor release. Updated default Salesforce API version to 44.0 (Winter '19). 54 | 55 | ## Version 7.1.5 (Oct. 26, 2018) 56 | Removed dependency on PromiseKit's Foundation extension. 57 | 58 | ## Version 7.1.4 (Oct. 25, 2018) 59 | Added Cartfile entry for PromiseKit's Foundation extensions. 60 | 61 | ## Version 7.1.3 (Oct. 24, 2018) 62 | - Updated Cartfile.resolved; was out of date. 63 | - Fixed documentation [issue #96](https://github.com/mike4aday/SwiftlySalesforce/issues/96). Thanks to [@pbrondum](https://github.com/pbrondum). 64 | 65 | ## Version 7.1.2 (Oct. 11, 2018) 66 | Support aggregate query result. Resolves issue [issue #88](https://github.com/mike4aday/SwiftlySalesforce/issues/88). 67 | 68 | ## Version 7.1.1 (Oct. 11, 2018) 69 | Adapt to Winter '19 date format change in `identity` JSON response. See [release notes](https://releasenotes.docs.salesforce.com/en-us/winter19/release-notes/rn_security_auth_json_value_endpoints.htm). 70 | Resolves issue [issue #91](https://github.com/mike4aday/SwiftlySalesforce/issues/91). 71 | 72 | ## Version 7.1.0 (Oct. 10, 2018) 73 | Support OpenID Connect 'ID token.' Resolves issue [issue #92](https://github.com/mike4aday/SwiftlySalesforce/issues/92). 74 | 75 | ## Version 7.0.4 (Oct. 5, 2018) 76 | Updated podspec 'source_files' attribute to avoid Xcode 10 build errors. 77 | 78 | ## Version 7.0.3 (Aug. 28, 2018) 79 | Updated connected app's consumer key in example app. 80 | 81 | ## Version 7.0.2 (Aug. 10, 2018) 82 | Added SOSL search example to README, and some inline documentation. 83 | 84 | ## Version 7.0.1 (Aug. 6, 2018) 85 | Fixed access to `Organization` properties. Was `internal`, now `public`. Thanks to [@joaoamaral](https://github.com/joaoamaral) for [pull request](https://github.com/mike4aday/SwiftlySalesforce/pull/84). 86 | 87 | ## Version 7.0.0 (July 12, 2018) 88 | - Deferred login (set `options` in function argument to `[.dontAuthenticate]`) 89 | - Simplifies app configuration (no more need to set URL scheme in plist file) 90 | - Simplifies calling custom endpoints (call `Salesforce.dataTask` with your own `URLRequest` and `Decodable` model object) 91 | - Simplifies & improves user login experience with iOS 11's new [`SFAuthenticationSession`](https://developer.apple.com/documentation/safariservices/sfauthenticationsession) (Apple has already deprecated `SFAuthenticationSession` in the upcoming iOS 12 in favor of the very similar [`ASWebAuthenticationSession`](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession) - Swiftly Salesforce will be updated after iOS 12 is released) 92 | - New functions to call Salesforce's [UI API](https://developer.salesforce.com/blogs/2018/01/introduction-salesforce-ui-api.html) 93 | - Supports SOSL searching 94 | - Enables full control over authorization URL (useful for Community or custom login flows) 95 | - Works with latest PromiseKit version 96 | - Resolves issue [issue #73](https://github.com/mike4aday/SwiftlySalesforce/issues/73) 97 | - Resolves issue [issue #68](https://github.com/mike4aday/SwiftlySalesforce/issues/68) 98 | - Resolves issue [issue #63](https://github.com/mike4aday/SwiftlySalesforce/issues/63) 99 | - Resolves issue [issue #32](https://github.com/mike4aday/SwiftlySalesforce/issues/32) 100 | 101 | ## Version 6.0.7 (May 21, 2018) 102 | Resolves [issue #69](https://github.com/mike4aday/SwiftlySalesforce/issues/69). Thanks to [@hmuronaka](https://github.com/hmuronaka) for [pull request](https://github.com/mike4aday/SwiftlySalesforce/pull/72). 103 | 104 | ## Version 6.0.6 (May 8, 2018) 105 | Resolves [issue #70](https://github.com/mike4aday/SwiftlySalesforce/issues/70). Thanks to [@hmuronaka](https://github.com/hmuronaka) for [pull request](https://github.com/mike4aday/SwiftlySalesforce/pull/71). 106 | 107 | ## Version 6.0.5 (Apr. 12, 2018) 108 | Fixed error in identity method's response handler. (Resolves [issue #60](https://github.com/mike4aday/SwiftlySalesforce/issues/60).) 109 | 110 | ## Version 6.0.4 (Mar. 1, 2018) 111 | Change access to `ConnectedApp.revoke` method from `internal` to `public`. 112 | 113 | ## Version 6.0.3 (Jan. 23, 2018) 114 | Replace '+' with '%2B' in URLRequest.queryParameters. (Resolves [issue #58](https://github.com/mike4aday/SwiftlySalesforce/issues/58).) Thanks to [@hmuronaka](https://github.com/hmuronaka) for [pull request](https://github.com/mike4aday/SwiftlySalesforce/pull/59). 115 | 116 | ## Version 6.0.2 (Jan. 11, 2018) 117 | - Clear token from secure storage after revocation ([issue #57](https://github.com/mike4aday/SwiftlySalesforce/issues/57)). 118 | - Fixed error in revocation endpoint URL. (Thanks to [@daichi1021](https://github.com/daichi1021) for [pull request](https://github.com/mike4aday/SwiftlySalesforce/pull/56).) 119 | 120 | ## Version 6.0.1 (Dec. 7, 2017) 121 | Fixed bug in `Salesorce.apex` and `Salesforce.custom` methods so that callers can now set the HTTP body data. 122 | 123 | ## Version 6.0.0 (Nov. 5, 2017) 124 | This release contains breaking changes. See [README](https://github.com/mike4aday/SwiftlySalesforce/blob/master/README.md) and [documentation](http://cocoadocs.org/docsets/SwiftlySalesforce). 125 | Highlights of changes and improvements: 126 | - Incorporated Swift 4's new `Codable` protocol (i.e. `Decodable` and `Encodable` throughout. This simplifies both Swiftly Salesforce's code and the creation of your own models that represent Salesforce objects. 127 | - Simpler and faster to incorporate Swiftly Salesforce in your apps. 128 | - New `Record` type to represent generic Salesforce object records, replaces `SObject` from version 5.0.0. If you prefer, you could create your own model objects and use those instead, via the magic of Swift generics and the new `Codable` protocol. See the [README](https://github.com/mike4aday/SwiftlySalesforce/blob/master/README.md) and example app for samples. 129 | - New `Organization` type holds information about the Salesforce "org." Call `salesforce.org( )` to retrieve org information. 130 | - References to `redirectURL` replaced with `callbackURL` to be consistent with Salesforce [Connected App](https://help.salesforce.com/articleView?id=connected_app_overview.htm&type=0) terminology. 131 | - More and better test coverage. 132 | 133 | ## Version 5.0.0 (Oct. 15, 2017) 134 | This release contains breaking changes. See [README](https://github.com/mike4aday/SwiftlySalesforce/blob/master/README.md) and [documentation](http://cocoadocs.org/docsets/SwiftlySalesforce). 135 | Highlights of changes and improvements: 136 | - Incorporated Swift 4's new `Decodable` protocol throughout. This simplifies both Swiftly Salesforce's code and the creation of your own models that represent Salesforce objects. 137 | - New `SObject` type to represent a generic Salesforce objects. If you prefer, you can create your own model objects and use those instead, via the magic of Swift generics and the new `Decodable` protocol. See the [README](https://github.com/mike4aday/SwiftlySalesforce/blob/master/README.md) and example app for samples. 138 | - Revamped Error types. 139 | - More and better test coverage. 140 | 141 | ## Version 4.0.6 (Sep. 28, 2017) 142 | - Removed Alamofire dependency 143 | - Increased test coverage 144 | 145 | ## Version 4.0.5 (Sep. 10, 2017) 146 | - Added Keychain wrapper class 147 | - Removed Locksmith dependency 148 | 149 | ## Version 4.0.4 (Sep. 5, 2017) 150 | Support Swift 4 151 | 152 | ## Version 4.0.3 (Aug. 5, 2017) 153 | Changed access level of `Address` members to explicitly `public` (were implicitly `internal`) 154 | 155 | ## Version 4.0.2 (July 30, 2017) 156 | Fixed misspelling in enum `Address.GeocodeAccuracy` ([issue #44](https://github.com/mike4aday/SwiftlySalesforce/issues/44)) 157 | 158 | ## Version 4.0.1 (July 17, 2017) 159 | Documentation updates 160 | 161 | ## Version 4.0.0 (July 14, 2017) 162 | This release contains breaking changes. See [README](https://github.com/mike4aday/SwiftlySalesforce/blob/master/README.md) and [documentation](http://cocoadocs.org/docsets/SwiftlySalesforce). 163 | Highlights of changes and improvements: 164 | - Removed the `salesforce` singleton (you could still instantiate your own global `salesforce` variable, if you like; see [example](https://github.com/mike4aday/SwiftlySalesforce/blob/master/Example/SwiftlySalesforce/AppDelegate.swift)). 165 | - `Salesforce` now instantiated with new `ConnectedApp` class. See [README](./README.md#example-configure-your-app-delegate). 166 | - Supports switching among multiple users and securely storing their access & refresh tokens. 167 | - `Salesforce.apexREST` method renamed `Salesforce.apex`, and now returns `Promise` (instead of `Promise`). 168 | - New `Salesforce.fetchImage` methods to get relatively-small images, such as user thumbnails or Contact photos ([issue #33](https://github.com/mike4aday/SwiftlySalesforce/issues/33) and [issue #35](https://github.com/mike4aday/SwiftlySalesforce/issues/35)). 169 | - New `Address` struct to hold standard, compound address fields, including longitude and latitude ([issue #38](https://github.com/mike4aday/SwiftlySalesforce/issues/38) and [issue #39](https://github.com/mike4aday/SwiftlySalesforce/issues/39)). 170 | 171 | ## Version 3.6.0 (Jun. 17, 2017) 172 | Updated the default Salesforce API version to 40.0 (Summer '17) 173 | 174 | ## Version 3.5.1 (Jun. 7, 2017) 175 | Fixes [issue #29](https://github.com/mike4aday/SwiftlySalesforce/issues/29). 176 | 177 | ## Version 3.5.0 (Apr. 26, 2017) 178 | - Updated the default Salesforce API version to 39.0 (Spring '17). 179 | - Added method `Salesforce.describeAll()` to retrieve metadata about all objects defined in the org ([issue #28](https://github.com/mike4aday/SwiftlySalesforce/issues/28)). 180 | - Bug fix; `ObjectDescription.keyPrefix` now returns an empty string if the retrieved object metadata value is null. (In the next major release `keyPrefix` will become an optional string.) ([issue #36](https://github.com/mike4aday/SwiftlySalesforce/issues/36)) 181 | - `ObjectDescription.fields` now returns an empty dictionary if the retrieved metadata has no field-level information, as is the case with `Salesforce.describeAll()`. (In the next major release, `fields` will become an optional dictionary.) 182 | - Bug fix; `Salesforce.limits()` broke with the Salesforce Spring '17 release, which changed the JSON payload returned by the REST API's `limits` resource ([issue #37](https://github.com/mike4aday/SwiftlySalesforce/issues/37)). 183 | - `Model.UserInfo` and `Model.QueryResult` now have public initializers ([issue #34](https://github.com/mike4aday/SwiftlySalesforce/issues/34)). 184 | 185 | ## Version 3.4.0 (Feb. 26, 2017) 186 | Support for registering with Salesforce notification services. 187 | (Thanks to [@quintonwall](https://github.com/quintonwall) for [pull request](https://github.com/mike4aday/SwiftlySalesforce/pull/24).) 188 | 189 | ## Version 3.3.2 (Feb. 7, 2017) 190 | Support Carthage dependency manager 191 | 192 | ## Version 3.3.1 (Jan. 10, 2017) 193 | Updated README 194 | 195 | ## Version 3.3.0 (Jan. 6, 2017) 196 | - Added overloaded `Salesforce.retrieve` method to retrieve multiple records in parallel 197 | - Added overloaded `Salesforce.query` method to execute multiple SOQL queries in parallel 198 | - Added overloaded `Salesforce.describe` method to retrieve metadata about multiple Salesforce objects in parallel 199 | - Additional documentation, tests and test coverage 200 | 201 | ## Version 3.2.0 (Dec. 14, 2016) 202 | - Added Salesforce.describe( ) method, and corresponding [model](https://github.com/mike4aday/SwiftlySalesforce/blob/master/Pod/Classes/Model.swift) structs (ObjectDescription, FieldDescription, PicklistValue) for retrieving [object metadata](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_sobject_describe.htm). (Closes [issue #13](https://github.com/mike4aday/SwiftlySalesforce/issues/13).) 203 | - Additional tests and test coverage 204 | 205 | ## Version 3.1.1 (Nov. 23, 2016) 206 | Fixed issue #15; removed unneeded `id` parameter in `Salesforce.insert( )` method 207 | 208 | ## Version 3.1.0 (Nov. 16, 2016) 209 | - Updated `LoginDelegate` to accommodate custom login view controllers and flows. Note: the default [OAuth2 user-agent flow](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/intro_understanding_user_agent_oauth_flow.htm) with the Salesforce-hosted webform is the recommended way to authenticate users; your app shouldn't handle their credentials. Adapted from [@hmuronaka's pull request](https://github.com/mike4aday/SwiftlySalesforce/pull/14). 210 | - Deprecated `LoginDelegate` extension method `handleRedirectURL(redirectURL: URL)` in favor of `handleRedirectURL(url: URL)`. 211 | 212 | ## Version 3.0.1 (Oct. 27, 2016) 213 | Added file "OAuth2.plist" which is used for testing the framework. If you run the tests, edit the file and insert your own values for the Salesforce access token, refresh token, etc. 214 | 215 | ## Version 3.0.0 (Oct. 25, 2016) 216 | (This is a ‘breaking’ change that is not compatible with prior versions) 217 | - Upgrade for Swift 3 218 | - Lots of enhancements to make building native iOS apps on Salesforce even easier - see the [README](./README.md) 219 | 220 | ## Version 2.2.0 (Oct. 24, 2016) 221 | - Support for custom login view controllers - [thanks to @humoronaka](https://github.com/mike4aday/SwiftlySalesforce/pull/10). (Note: this feature will not be carried over into version 3.0.0, which is nearly complete as of Oct. 24, 2016, but will be incorporated into a subsequent release.) 222 | 223 | ## Version 2.1.0 (Oct. 1, 2016) 224 | - Updated code for Swift 2.3 225 | - Updated Podfile for Xcode 8 226 | 227 | ## Version 2.0.1 (Aug. 4, 2016) 228 | - Updated README 229 | - Updated PromiseKit dependency to version 3.2.1+ 230 | - Updated SalesforceAPI.DefaultVersion to 37.0 231 | - Replaced deprecated selector string syntax with Swift #selector 232 | - Fixed issue #1 (irrelevant comments) 233 | 234 | ## Version 2.0.0 (Mar. 5, 2016) 235 | - Incorporated PromiseKit for asynchronous interaction with Salesforce REST API and OAuth2 endpoints 236 | - Updated SalesforceAPI.DefaultVersion to 36.0 237 | - Added ApexRest to SalesforceAPI enum 238 | - Simplified Alamofire extension 239 | 240 | ## Version 1.0.3 (Jan. 14, 2016) 241 | Updated README 242 | 243 | ## Version 1.0.2 (Jan. 11, 2016) 244 | Updated README 245 | 246 | ## Version 1.0.1 (Jan. 8, 2016) 247 | Updated example files 248 | 249 | ## Version 1.0.0 (Jan. 7, 2016) 250 | Initial release 251 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016-2022 Michael Epstein (Twitter: @mike4aday) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "SwiftlySalesforce", 7 | platforms: [ 8 | .iOS(.v15), 9 | ], 10 | products: [ 11 | .library( 12 | name: "SwiftlySalesforce", 13 | targets: ["SwiftlySalesforce"]), 14 | ], 15 | dependencies: [], 16 | targets: [ 17 | .target( 18 | name: "SwiftlySalesforce", 19 | dependencies: []), 20 | .testTarget( 21 | name: "SwiftlySalesforceTests", 22 | dependencies: ["SwiftlySalesforce"], 23 | resources: [ 24 | .copy("MockAccount.json"), 25 | .copy("MockAccountMetadata.json"), 26 | .copy("MockAccountMissingURLAttribute.json"), 27 | .copy("MockAggregateQueryResult.json"), 28 | .copy("MockConfig.json"), 29 | .copy("MockIdentity.json"), 30 | .copy("MockLimits.json"), 31 | .copy("MockSearchResults.json") 32 | ] 33 | ), 34 | ] 35 | ) 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Swiftly Salesforce 4 | 5 | "The Swift-est way to build native mobile apps that connect to [Salesforce](https://www.salesforce.com/products/platform/overview/)." 6 | 7 |    8 | 9 | * Written entirely in [Swift](https://developer.apple.com/swift/). 10 | * Very easy to install and update with [Swift Package Manager](https://developer.apple.com/documentation/swift_packages/adding_package_dependencies_to_your_app). 11 | * Built with Apple's new [Swift concurrency](https://developer.apple.com/news/?id=2o3euotz) model to simplify complex, asynchronous calls to the [Salesforce REST API](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/). 12 | * Designed for [SwiftUI](https://developer.apple.com/documentation/swiftui/), the modern, declarative way to build iOS apps. 13 | * Manages the Salesforce [user authorization flows](https://help.salesforce.com/articleView?id=sf.remoteaccess_oauth_flows.htm&type=5) automatically. 14 | * Pair with [Core Data](https://developer.apple.com/documentation/coredata) for a complete offline mobile solution. 15 | * Simpler and lighter alternative to the Salesforce [Mobile SDK for iOS](https://github.com/forcedotcom/SalesforceMobileSDK-iOS). 16 | * See [what's new](./CHANGELOG.md) in this release. 17 | 18 | ## Minimum Requirements 19 | * iOS 15.0 20 | * Swift 5.5 21 | * Xcode 13 22 | 23 | ## Quick Start 24 | Get up and running in less than 5 minutes! 25 | 26 | 1. **Get a free Salesforce Developer Edition:** You can sign up for a free developer environment (also called an "organization" or "org") [here](https://developer.salesforce.com/signup). It will never expire as long as you log in at least once every 6 months. 27 | 28 | 2. **Create a Salesforce Connected App:** Create a new [Connected App](https://help.salesforce.com/articleView?id=sf.connected_app_create.htm&type=5) in your developer environment. [This screenshot](https://mike4aday.github.io/SwiftlySalesforce/images/ConnectedAppDefinition.png) shows an example; you can copy the settings that I've entered. Be sure that "Require Secret for Refresh Token Flow" is *not* checked. 29 | 30 | 3. **Add Swiftly Salesforce to your project:** Add the Swiftly Salesforce package to your Xcode project ([instructions](https://developer.apple.com/documentation/xcode/adding_package_dependencies_to_your_app)) with the URL https://github.com/mike4aday/SwiftlySalesforce.git. 31 | 32 | 4. **Create a configuration file:** In your Xcode project, create an empty file named [`Salesforce.json`](https://github.com/mike4aday/MySalesforceAccounts/blob/51cda01bc5c867643a9ef5085ede05e91151dfda/MySalesforceAccounts/Salesforce.json) and add the following JSON text, replacing the placeholder text with the actual values for your Connected App's consumer key and callback URL: 33 | ```json 34 | { 35 | "consumerKey" : "", 36 | "callbackURL" : "" 37 | } 38 | ``` 39 | 40 | 5. **Connect to Salesforce:** Call [`Salesforce.connect()`](https://github.com/mike4aday/SwiftlySalesforce/blob/fa9b051a9c857b09ae17b091a5db7210fa1dedd4/Sources/SwiftlySalesforce/Salesforce.swift#L5) and you're ready to go! If you're using SwiftUI, you could call the following from your main application file and store the Salesforce connection in the environment. Swiftly Salesforce will automatically handle all the OAuth flows, authenticating users on their first use of your app and then silently refreshing their access tokens when required. 41 | 42 | ```swift 43 | // MyApp.swift 44 | import SwiftUI 45 | import SwiftlySalesforce 46 | 47 | @main 48 | struct MyApp: App { 49 | var body: some Scene { 50 | WindowGroup { 51 | ContentView().environmentObject(try! Salesforce.connect()) 52 | } 53 | } 54 | } 55 | ``` 56 | 57 | I expect that you'll find most of the methods you'll need in the file [`Connection+API.swift`](https://github.com/mike4aday/SwiftlySalesforce/blob/fc9a5cfd659537cdde34059df35e6b5a1f8f229d/Sources/SwiftlySalesforce/Connection+API.swift) but if you require more, you could create your own implementation of [`DataService`](https://github.com/mike4aday/SwiftlySalesforce/blob/fc9a5cfd659537cdde34059df35e6b5a1f8f229d/Sources/SwiftlySalesforce/DataService.swift) and override just the relevant methods. See the source files in the `Sources/SwiftlySalesforce/Services` folder for examples of [`DataService`](https://github.com/mike4aday/SwiftlySalesforce/blob/fc9a5cfd659537cdde34059df35e6b5a1f8f229d/Sources/SwiftlySalesforce/DataService.swift) implementations that I created. 58 | 59 | Here are some examples of using the `Connection` class' convenience methods: 60 | 61 | ```swift 62 | // ContentView.swift 63 | import SwiftUI 64 | import SwiftlySalesforce 65 | 66 | //... 67 | @EnvironmentObject var salesforce: Connection 68 | 69 | //... 70 | // Query the current user's accounts 71 | let queryResults: QueryResult = try await salesforce.myRecords(type: "Account") 72 | 73 | // Search for a string in Salesforce records 74 | let searchResults: [Record] = try await salesforce.search(sosl: "FIND {Joe Smith}") 75 | 76 | // Get info about the current user 77 | let userInfo: Identity = try await salesforce.identity() 78 | 79 | // Retrieve all fields of an Account record 80 | let account: Record = try await salesforce.read(type: "Account", id: "0011Y00003HVMu4QAH") 81 | 82 | // Retrieve all fields of an Account record and decode them into your own, custom Decodable instance 83 | let account2: CustomAccount = try await salesforce.read(type: "Account", id: "0011Y00003HVMu4QAH") 84 | 85 | // Insert a new record 86 | let recordID: String = try await salesforce.create(type: "Account", fields: ["Name": "Acme Corp."] 87 | 88 | // Update a record 89 | try await salesforce.update(type: "Account", id: "0011Y00003HVMu4QAH", fields: ["BillingCity": "Austin"]) 90 | 91 | // Get metadata about any Salesforce object, including custom fields, labels, validation rules, etc. 92 | let accountMetadata = try await salesforce.describe("Account") 93 | ``` 94 | 95 | ## User Authorization 96 | Swiftly Salesforce will automatically manage all required Salesforce [authorization flows](https://help.salesforce.com/articleView?id=sf.remoteaccess_oauth_flows.htm&type=5). If Swiftly Salesforce already has a valid access token in its secure store, it will include that token in the header of every API request. If the token has expired and Salesforce rejects the request, then Swiftly Salesforce will attempt to refresh the access token without bothering the user to re-enter the username and password. If Swiftly Salesforce doesn't have a valid access token, or is unable to refresh it, then Swiftly Salesforce will direct the user to the Salesforce-hosted login form. 97 | 98 | ## Sample App 99 | Check out [MySalesforceAccounts](https://github.com/mike4aday/MySalesforceAccounts) for a complete, working app that uses [SwiftUI](https://developer.apple.com/documentation/swiftui/), [Swift concurrency](https://developer.apple.com/news/?id=2o3euotz) and Swiftly Salesforce to display the user's Salesforce account records. Though it's a relatively-trival app, it illustrates how to configure an app and quickly connect it to Salesforce. 100 | 101 | Before you run the sample app, edit [Salesforce.json](https://github.com/mike4aday/MySalesforceAccounts/blob/51cda01bc5c867643a9ef5085ede05e91151dfda/MySalesforceAccounts/Salesforce.json) and replace the temporary values for the consumer key and callback URL with those of your own Connected App. 102 | 103 | ## Questions, Suggestions & Bug Reports 104 | * Open a [GitHub issue](https://github.com/mike4aday/SwiftlySalesforce/issues/new) 105 | * Send me a direct message on Twitter [@mike4aday](https://twitter.com/mike4aday) 106 | * Send me a message on LinkedIn [in/mike4aday](https://www.linkedin.com/in/mike4aday) 107 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Authorizer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol Authorizer { 4 | func grantCredential(refreshing: Credential?) async throws -> Credential 5 | func revoke(credential: Credential) async throws -> () 6 | } 7 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Connection+API.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension Connection { 4 | 5 | /// Retrieves information about limits in your org. 6 | /// For each limit, this method returns the maximum allocation and the remaining allocation based on usage. 7 | /// 8 | /// [Limits](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_limits.htm) 9 | /// 10 | /// - Returns: A dictionary of ``Limit`` instances. 11 | /// 12 | func limits() async throws -> [String: Limit] { 13 | return try await request(service: Resource.Limits()) 14 | } 15 | 16 | /// Performs a query. 17 | /// 18 | /// - [Query](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_query.htm) 19 | /// - [SOQL and SOSL Reference](https://developer.salesforce.com/docs/atlas.en-us.232.0.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_sosl_intro.htm) 20 | /// 21 | /// - Parameters: 22 | /// - soql: A SOQL query string. 23 | /// - batchSize: A numeric value that specifies the number of records returned for a query request. 24 | /// 25 | /// - Returns: ``QueryResult`` 26 | /// 27 | func query(soql: String, batchSize: Int? = nil) async throws -> QueryResult { 28 | return try await request(service: Resource.Query.Run(soql: soql, batchSize: batchSize)) 29 | } 30 | 31 | func query(soql: String, batchSize: Int? = nil) async throws -> QueryResult { 32 | return try await request(service: Resource.Query.Run(soql: soql, batchSize: batchSize)) 33 | } 34 | 35 | func myRecords(type: String, fields: [String]? = nil, limit: Int? = nil, batchSize: Int? = nil) async throws -> QueryResult { 36 | return try await request(service: Resource.Query.MyRecords(type: type, fields: fields, limit: limit, batchSize: batchSize)) 37 | } 38 | 39 | func myRecords(type: String, fields: [String]? = nil, limit: Int? = nil, batchSize: Int? = nil) async throws -> QueryResult { 40 | return try await request(service: Resource.Query.MyRecords(type: type, fields: fields, limit: limit, batchSize: batchSize)) 41 | } 42 | 43 | /// Searches for a string in Salesforce record fields 44 | /// 45 | /// [SOQL and SOSL Reference](https://developer.salesforce.com/docs/atlas.en-us.232.0.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_sosl_intro.htm) 46 | /// 47 | /// - Parameters: 48 | /// - sosl: A SOSL query string. 49 | /// 50 | /// - Returns: 51 | /// - An array of records that match the search criteria. 52 | /// 53 | func search(sosl: String) async throws -> [Record] { 54 | return try await request(service: Resource.Search(sosl: sosl)) 55 | } 56 | 57 | /// Inserts a Salesforce record 58 | /// 59 | /// - Parameters: 60 | /// - type: Type of record (e.g. `Account`, `Contact` or `MyCustomObject__c`). 61 | /// - fields: Dictionary of fields names and values to insert. 62 | /// 63 | /// - Returns: Publisher that emits the ID of the successfully-inserted record, or an error. 64 | /// 65 | func insert(type: String, fields: [String: T]) async throws -> String { 66 | return try await request(service: Resource.SObjects.Create(type: type, fields: fields)) 67 | } 68 | 69 | /// Retrieves a Salesforce record. 70 | /// 71 | /// [Working with Records](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/using_resources_working_with_records.htm) 72 | /// 73 | /// - Parameters: 74 | /// - type: Type of record (e.g. "Account", "Contact" or "MyCustomObject__c"). 75 | /// - id: Unique ID of the record; 15 or 18 characters. 76 | /// - fields: Fields to retrieve. If nil, then all fields will be retrieved. 77 | /// 78 | /// - Returns: A Salesforce record. 79 | /// 80 | /// - Throws: ``ResponseError`` if the record can't be found or if the request can't be completed. 81 | /// 82 | func read(type: String, id: String, fields: [String]? = nil) async throws -> T { 83 | return try await request(service: Resource.SObjects.Read(type: type, id: id, fields: fields)) 84 | } 85 | 86 | func read(type: String, id: String, fields: [String]? = nil) async throws -> Record { 87 | return try await request(service: Resource.SObjects.Read(type: type, id: id, fields: fields)) 88 | } 89 | 90 | /// Updates a Salesforce record 91 | /// 92 | /// - Parameters: 93 | /// - type: Type of record (e.g. `Account`, `Contact` or `MyCustomObject__c`). 94 | /// - id: Unique ID of the record; 15 or 18 characters. 95 | /// - fields: Dictionary of fields names and values to update. 96 | /// 97 | /// - Returns: Void; no return value. 98 | /// 99 | func update(type: String, id: String, fields: [String: T]) async throws -> Void { 100 | return try await request(service: Resource.SObjects.Update(type: type, id: id, fields: fields)) 101 | } 102 | 103 | /// Deletes a Salesforce record 104 | /// 105 | /// - Parameters: 106 | /// - type: Type of record (e.g. `Account`, `Contact` or `MyCustomObject__c`). 107 | /// - id: Unique ID of the record; 15 or 18 characters. 108 | /// 109 | /// - Returns: Void; no return value. 110 | /// 111 | func delete(type: String, id: String) async throws -> Void { 112 | return try await request(service: Resource.SObjects.Delete(type: type, id: id)) 113 | } 114 | 115 | /// Retrieves metadata about a Salesforce object. 116 | /// 117 | /// [sObject Describe](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_describe.htm) 118 | /// 119 | /// - Parameters: 120 | /// - type: Type of record (e.g. `Account`, `Contact` or `MyCustomObject__c`). 121 | /// 122 | /// - Returns: An ``ObjectDescription`` instance. 123 | /// 124 | func describe(type: String) async throws -> ObjectDescription { 125 | return try await request(service: Resource.SObjects.Describe(type: type)) 126 | } 127 | 128 | /// Describes all accessible objects in the user's Salesforce org. 129 | /// 130 | /// [sObject Describe](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_describe.htm) 131 | /// 132 | /// - Returns: An array of ``ObjectDescription`` instances. 133 | /// 134 | func describeGlobal() async throws -> [ObjectDescription] { 135 | return try await request(service: Resource.SObjects.DescribeGlobal()) 136 | } 137 | 138 | /// Gets information about the current user 139 | /// 140 | /// - Returns: An ``Identity`` instance. 141 | /// 142 | func identity() async throws -> Identity { 143 | return try await request(service: IdentityService()) 144 | } 145 | 146 | /// Calls an Apex class exposed as a REST service. 147 | /// 148 | /// [Introduction to Apex REST](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_rest_intro.htm) 149 | /// 150 | /// - Parameters: 151 | /// - method: Optional, HTTP method to use; if `nil` then GET will be used in the request. 152 | /// - path: Path to the Apex REST service, as defined in the `urlMapping` of the `@RestResource` annotation on the target class. 153 | /// - queryItems: Optional query items to include in the request. 154 | /// - headers: Optional `HTTP` headers to include in the request. 155 | /// - body: Request body for a `POST` , `PATCH` or `PUT` request. 156 | /// - timeoutInterval: request timeout interval, in seconds. 157 | /// 158 | /// - Returns: The `Decodable` return value from the Apex class. 159 | /// 160 | func apex( 161 | method: String? = nil, 162 | path: String, 163 | queryItems: [String: String]? = nil, 164 | headers: [String: String]? = nil, 165 | body: Data? = nil, 166 | timeoutInterval: TimeInterval = URLRequest.defaultTimeoutInterval 167 | ) async throws -> T { 168 | 169 | let service = ApexService(path: path, method: method, queryItems: queryItems, headers: headers, body: body, timeoutInterval: timeoutInterval) 170 | return try await request(service: service) 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Connection.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | /// Handles the interaction with the Salesforce REST API. 5 | /// Your app should only have a single `Connection` instance. 6 | public class Connection: ObservableObject { 7 | 8 | internal let authorizer: Authorizer 9 | internal let credentialStore: CredentialStore 10 | internal let defaults: UserDefaults 11 | internal let session: URLSession 12 | 13 | /// Unique identifier for current Salesforce user. 14 | public var userIdentifier: UserIdentifier? { 15 | get { 16 | return defaults.userIdentifier 17 | } 18 | set { 19 | DispatchQueue.main.async { 20 | self.defaults.userIdentifier = newValue 21 | self.objectWillChange.send() 22 | } 23 | } 24 | } 25 | 26 | public init(authorizer: Authorizer, credentialStore: CredentialStore, defaults: UserDefaults, session: URLSession = .shared) { 27 | self.authorizer = authorizer 28 | self.credentialStore = credentialStore 29 | self.defaults = defaults 30 | self.session = session 31 | } 32 | 33 | /// Makes an asynchronous request for data from the Salesforce REST API. 34 | /// - Parameters: 35 | /// - service: The `DataService` instance from which you're requesting data 36 | /// - Returns: Output from the service endpoint 37 | public func request(service: T) async throws -> T.Output { 38 | let credential = try await getCredential() 39 | do { 40 | return try await service.request(with: credential, using: session) 41 | } 42 | catch let error where error.isAuthenticationRequired { 43 | let newCredential = try await getCredential(refreshing: credential) 44 | return try await service.request(with: newCredential, using: session) 45 | } 46 | } 47 | 48 | public func getCredential(refreshing: Credential? = nil) async throws -> Credential { 49 | if let oldCredential = refreshing { 50 | return try await authenticate(refreshing: oldCredential) 51 | } 52 | else if let storedCredential = try await retrieveStoredCredential() { 53 | return storedCredential 54 | } 55 | else { 56 | return try await authenticate() 57 | } 58 | } 59 | 60 | public func retrieveStoredCredential() async throws -> Credential? { 61 | guard let id = userIdentifier, let cred = try await credentialStore.retrieve(for: id) else { 62 | return nil 63 | } 64 | return cred 65 | } 66 | 67 | public func authenticate(refreshing: Credential? = nil) async throws -> Credential { 68 | let cred = try await authorizer.grantCredential(refreshing: refreshing) 69 | try await credentialStore.save(credential: cred) 70 | self.userIdentifier = cred.identityURL 71 | return cred 72 | } 73 | 74 | public func logOut() async throws { 75 | defer { self.userIdentifier = nil } 76 | if let id = self.userIdentifier, let cred = try await credentialStore.retrieve(for: id) { 77 | Task { try? await authorizer.revoke(credential: cred) } 78 | Task { try? await credentialStore.delete(for: id) } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Credential.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Credential: Codable, Equatable { 4 | 5 | public let accessToken: String 6 | public let instanceURL: URL 7 | public let identityURL: URL 8 | public let timestamp: Date 9 | public let refreshToken: String? 10 | public let siteURL: URL? 11 | public let siteID: String? 12 | 13 | internal init( 14 | accessToken: String, 15 | instanceURL: URL, 16 | identityURL: URL, 17 | timestamp: Date, 18 | refreshToken: String? = nil, 19 | siteURL: URL? = nil, 20 | siteID: String? = nil 21 | ) { 22 | self.accessToken = accessToken 23 | self.instanceURL = instanceURL 24 | self.identityURL = identityURL 25 | self.timestamp = timestamp 26 | self.refreshToken = refreshToken 27 | self.siteURL = siteURL 28 | self.siteID = siteID 29 | } 30 | 31 | /// The ID of the Salesforce User record associated with this credential. 32 | var userID: String { 33 | return identityURL.lastPathComponent 34 | } 35 | 36 | /// The ID of the Salesforce Organization record associated with this credential. 37 | var orgID: String { 38 | return identityURL.deletingLastPathComponent().lastPathComponent 39 | } 40 | } 41 | 42 | internal extension Credential { 43 | 44 | init?(fromPercentEncoded: String, andRefreshToken: String? = nil) { 45 | 46 | var comps = URLComponents() 47 | comps.percentEncodedQuery = fromPercentEncoded 48 | 49 | // Non-nillable properties 50 | guard let queryItems: [URLQueryItem] = comps.queryItems, 51 | let accessToken: String = queryItems["access_token"], 52 | let instanceURL: URL = queryItems["instance_url"].flatMap({ URL(string: $0) }), 53 | let identityURL: URL = queryItems["id"].flatMap({ URL(string: $0) }), 54 | let timestamp: Date = queryItems["issued_at"].flatMap({ Double($0) }).map({ Date(timeIntervalSince1970: $0/1000) }) else { 55 | return nil 56 | } 57 | 58 | // Nillable properties 59 | let refreshToken: String? = queryItems["refresh_token"] ?? andRefreshToken 60 | let siteURL: URL? = queryItems["sfdc_site_url"].flatMap({ URL(string: $0) }) 61 | let siteID: String? = queryItems["sfdc_site_id"] 62 | 63 | self.init( 64 | accessToken: accessToken, 65 | instanceURL: instanceURL, 66 | identityURL: identityURL, 67 | timestamp: timestamp, 68 | refreshToken: refreshToken, 69 | siteURL: siteURL, 70 | siteID: siteID 71 | ) 72 | } 73 | } 74 | 75 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/CredentialStore.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol CredentialStore { 4 | func save(credential: Credential) async throws -> () 5 | func retrieve(for userIdentifier: URL) async throws -> Credential? 6 | func delete(for userIdentifier: URL) async throws -> () 7 | } 8 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/DataService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol DataService: RequestCreator & ResponseValidator & DataTransformer where Body == Data { 4 | 5 | func request(with: Credential, using: URLSession) async throws -> Output 6 | } 7 | 8 | public extension DataService { 9 | 10 | func request(with credential: Credential, using session: URLSession = .shared) async throws -> Output { 11 | let request = try createRequest(with: credential) 12 | guard let response = try await session.data(for: request) as? Response else { 13 | throw URLError(.badServerResponse) 14 | } 15 | try validate(response: response) 16 | return try transform(data: response.0) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/DataTransformer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol DataTransformer { 4 | 5 | associatedtype Output 6 | func transform(data: Data) throws -> Output 7 | } 8 | 9 | public extension DataTransformer { 10 | 11 | func transform(data: Data) throws -> Output where Output: Decodable { 12 | try JSONDecoder.salesforce.decode(Output.self, from: data) 13 | } 14 | 15 | func transform(data: Data) throws -> Output where Output == Data { 16 | return data 17 | } 18 | 19 | func transform(data: Data) throws -> Output where Output == String { 20 | return String(data: data) ?? "" 21 | } 22 | 23 | func transform(data: Data) throws -> Output where Output == Void { 24 | return 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/DefaultAuthorizer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | actor DefaultAuthorizer { 4 | 5 | let consumerKey: String 6 | let callbackURL: URL 7 | let defaultHost: String 8 | 9 | private var authenticatingTask: Task? 10 | private var revokingTask: Task? 11 | 12 | init(consumerKey: String, callbackURL: URL, session: URLSession? = nil, defaultHost: String? = nil) { 13 | self.consumerKey = consumerKey 14 | self.callbackURL = callbackURL 15 | self.defaultHost = defaultHost ?? "login.salesforce.com" 16 | } 17 | } 18 | 19 | //MARK: - Authenticator conformance - 20 | extension DefaultAuthorizer: Authorizer { 21 | 22 | func grantCredential(refreshing: Credential? = nil) async throws -> Credential { 23 | if let task = authenticatingTask { 24 | return try await task.value 25 | } 26 | let task: Task = Task { 27 | defer { self.authenticatingTask = nil } 28 | let host = refreshing?.siteURL?.host ?? refreshing?.instanceURL.host ?? defaultHost 29 | guard let credential = refreshing, let refreshToken = credential.refreshToken else { 30 | return try await OAuthFlow.userAgent(consumerKey: consumerKey, host: host, callbackURL: callbackURL) 31 | } 32 | do { 33 | return try await OAuthFlow.refreshToken(consumerKey: consumerKey, host: host, refreshToken: refreshToken) 34 | } 35 | catch { 36 | return try await OAuthFlow.userAgent(consumerKey: consumerKey, host: host, callbackURL: callbackURL) 37 | } 38 | } 39 | self.authenticatingTask = task 40 | return try await task.value 41 | } 42 | 43 | func revoke(credential: Credential) async throws { 44 | if let task = revokingTask { 45 | return try await task.value 46 | } 47 | let task: Task = Task { 48 | defer { self.revokingTask = nil } 49 | let host = credential.siteURL?.host ?? credential.instanceURL.host ?? defaultHost 50 | let token = credential.refreshToken ?? credential.accessToken 51 | return try await OAuthFlow.revokeToken(host: host, token: token) 52 | } 53 | self.revokingTask = task 54 | return try await task.value 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/DefaultCredentialStore.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Secure storage of Salesforce access and refresh tokens 4 | struct DefaultCredentialStore { 5 | let consumerKey: String 6 | let encoder = JSONEncoder() 7 | let decoder = JSONDecoder() 8 | } 9 | 10 | extension DefaultCredentialStore: CredentialStore { 11 | 12 | func save(credential: Credential) throws { 13 | let data = try encoder.encode(credential) 14 | try Keychain.write(data: data, service: consumerKey, account: credential.identityURL.absoluteString) 15 | } 16 | 17 | func retrieve(for userIdentifier: URL) throws -> Credential? { 18 | do { 19 | let data = try Keychain.read(service: consumerKey, account: userIdentifier.absoluteString) 20 | return try decoder.decode(Credential.self, from: data) 21 | } 22 | catch { 23 | if case KeychainError.itemNotFound = error { 24 | return nil 25 | } 26 | else { 27 | throw error 28 | } 29 | } 30 | } 31 | 32 | func delete(for userIdentifier: URL) throws -> () { 33 | do { 34 | try Keychain.delete(service: consumerKey, account: userIdentifier.absoluteString) 35 | } 36 | catch { 37 | if case KeychainError.itemNotFound = error { 38 | return 39 | } 40 | else { 41 | throw error 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Errors/KeychainError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum KeychainError: Error, Equatable { 4 | case readFailure(status: OSStatus) 5 | case writeFailure(status: OSStatus) 6 | case deleteFailure(status: OSStatus) 7 | case itemNotFound 8 | } 9 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Errors/OAuthError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct OAuthError: Error, Decodable { 4 | 5 | let code: String 6 | let message: String? 7 | 8 | enum CodingKeys: String, CodingKey { 9 | case code = "error" 10 | case message = "error_description" 11 | } 12 | 13 | init(code: String, message: String? = nil) { 14 | self.code = code 15 | self.message = message 16 | } 17 | 18 | init?(fromPercentEncodedString: String) { 19 | let comps = URLComponents(percentEncodedQuery: fromPercentEncodedString) 20 | guard let code = comps.queryItems?[CodingKeys.code.rawValue] else { 21 | return nil 22 | } 23 | self.code = code 24 | self.message = comps.queryItems?[CodingKeys.message.rawValue] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Errors/RequestError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct RequestError: Error, CustomDebugStringConvertible { 4 | 5 | let debugDescription: String 6 | 7 | init(_ debugDescription: String) { 8 | self.debugDescription = debugDescription 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Errors/ResponseError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct ResponseError: Error { 4 | 5 | public var code: String? = nil 6 | public var message: String? = nil 7 | public var fields: [String]? = nil 8 | public let metadata: HTTPURLResponse 9 | 10 | private let na = "N/A" // 'Not Applicable' or 'Not Available' 11 | } 12 | 13 | extension ResponseError: LocalizedError { 14 | 15 | public var errorDescription: String? { 16 | return NSLocalizedString(message ?? na, comment: code ?? na) 17 | } 18 | } 19 | 20 | extension ResponseError: CustomDebugStringConvertible { 21 | 22 | public var debugDescription: String { 23 | let na = "N/A" // 'Not Applicable' or 'Not Available' 24 | let fieldStr = fields?.joined(separator: ", ") ?? na 25 | return "Salesforce response error. Code: \(code ?? na). Message: \(message ?? na). Fields: \(fieldStr). HTTP Status Code: \(metadata.statusCode))" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Errors/StateError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct StateError: Error, CustomDebugStringConvertible { 4 | 5 | let debugDescription: String 6 | 7 | init(_ debugDescription: String) { 8 | self.debugDescription = debugDescription 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Extensions/Array+URLQueryItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension Array where Element == URLQueryItem { 4 | 5 | // Borrowed from https://www.avanderlee.com/swift/url-components/ 6 | init(_ dictionary: [String: T]) { 7 | self = dictionary.map({ (key, value) -> Element in 8 | URLQueryItem(name: key, value: String(value)) 9 | }) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Extensions/Collection+URLQueryItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension Collection where Element == URLQueryItem { 4 | 5 | // Borrowed from https://www.avanderlee.com/swift/url-components/ 6 | subscript(_ name: String) -> String? { 7 | first(where: { $0.name == name })?.value 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Extensions/DateFormatter+Salesforce.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension DateFormatter { 4 | 5 | enum Length: String { 6 | case short = "yyyy-MM-dd" 7 | case long = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZ" 8 | } 9 | 10 | static func salesforce(_ length: Length = .long) -> DateFormatter { 11 | let formatter = DateFormatter() 12 | formatter.locale = Locale(identifier: "en_US_POSIX") 13 | formatter.timeZone = TimeZone(secondsFromGMT: 0) 14 | formatter.dateFormat = length.rawValue 15 | return formatter 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Extensions/Error+Authentication.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Error { 4 | 5 | var isAuthenticationRequired: Bool { 6 | return (self as? URLError).map { $0.code == URLError.Code.userAuthenticationRequired } ?? false 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Extensions/JSONDecoder+Salesforce.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension JSONDecoder { 4 | 5 | static let salesforce = JSONDecoder(dateFormatter: .salesforce(.long)) 6 | 7 | convenience init(dateDecodingStrategy: DateDecodingStrategy) { 8 | self.init() 9 | self.dateDecodingStrategy = dateDecodingStrategy 10 | } 11 | 12 | convenience init(dateFormatter: DateFormatter) { 13 | self.init(dateDecodingStrategy: .formatted(dateFormatter)) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Extensions/String+Helpers.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension String { 4 | 5 | init?(byPercentEncoding params: Dictionary){ 6 | var comps = URLComponents() 7 | comps.queryItems = .init(params) 8 | if let s = comps.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B") { 9 | self = s 10 | } 11 | else { 12 | return nil 13 | } 14 | } 15 | 16 | init?(data: Data) { 17 | self.init(data: data, encoding: .utf8) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Extensions/URL+OAuth.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension URL { 4 | 5 | static func userAgentFlow( 6 | host: String, 7 | clientID: String, 8 | callbackURL: URL, 9 | state: String? = nil, 10 | loginHint: String? = nil, 11 | display: String = "touch", 12 | prompt: String = "login consent") throws -> Self { 13 | 14 | let path = "/services/oauth2/authorize" 15 | var parameters = [ 16 | "response_type" : "token", 17 | "client_id" : clientID, 18 | "redirect_uri" : callbackURL.absoluteString, 19 | "prompt" : prompt, 20 | "display" : display 21 | ] 22 | state.map { parameters["state"] = $0 } 23 | loginHint.map { parameters["login_hint"] = $0 } 24 | 25 | guard let url = URLComponents(host: host, path: path, queryParameters: parameters).url else { 26 | throw URLError(.badURL) 27 | } 28 | return url 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Extensions/URLComponents+Query.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension URLComponents { 4 | 5 | init(scheme: String = "https", host: String, path: String, queryParameters: Dictionary? = nil) { 6 | self.init() 7 | self.scheme = scheme 8 | self.host = host 9 | self.path = path 10 | queryParameters.map { self.queryItems = .init($0) } 11 | } 12 | 13 | init(percentEncodedQuery: String) { 14 | self.init() 15 | self.percentEncodedQuery = percentEncodedQuery 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Extensions/URLRequest+Credential.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension URLRequest { 4 | 5 | static let defaultTimeoutInterval: TimeInterval = 60.0 6 | 7 | init( 8 | credential: Credential, 9 | method: String? = nil, 10 | path: String, 11 | queryItems: [String: String]? = nil, 12 | headers: [String: String]? = nil, 13 | body: Data? = nil, 14 | cachePolicy: CachePolicy = .useProtocolCachePolicy, 15 | timeoutInterval: TimeInterval = URLRequest.defaultTimeoutInterval 16 | ) throws { 17 | 18 | // URL 19 | var comps = URLComponents() 20 | comps.scheme = "https" 21 | comps.host = credential.siteURL?.host ?? credential.instanceURL.host 22 | comps.path = path.starts(with: "/") ? path : "/\(path)" 23 | comps.percentEncodedQuery = queryItems.flatMap { String(byPercentEncoding: $0) } 24 | guard let url = comps.url else { 25 | throw URLError(.badURL) 26 | } 27 | 28 | // URLRequest 29 | self = URLRequest(url: url, cachePolicy: cachePolicy, timeoutInterval: timeoutInterval) 30 | self.httpMethod = method 31 | self.httpBody = body 32 | 33 | // Headers 34 | let contentType: String = { 35 | switch self.httpMethod?.uppercased() { 36 | case nil, HTTP.Method.get.uppercased(), HTTP.Method.delete.uppercased(): 37 | return HTTP.MIMEType.formUrlEncoded 38 | default: 39 | return HTTP.MIMEType.json 40 | } 41 | }() 42 | let defaultHeaders: [String:String] = [ 43 | HTTP.Header.accept : HTTP.MIMEType.json, 44 | HTTP.Header.contentType : contentType 45 | ].reduce(into: [:]) { $0[$1.0] = $1.1 } 46 | self.allHTTPHeaderFields = defaultHeaders.merging(headers ?? [:]) { (_, new) in new } 47 | self.setAuthorizationHeader(with: credential.accessToken) 48 | } 49 | 50 | static func identity(with credential: Credential) -> Self { 51 | var req = URLRequest(url: credential.identityURL) 52 | req.setValue(HTTP.MIMEType.json, forHTTPHeaderField: HTTP.Header.accept) 53 | req.setAuthorizationHeader(with: credential.accessToken) 54 | return req 55 | } 56 | 57 | mutating func setAuthorizationHeader(with accessToken: String) { 58 | setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Extensions/URLRequest+OAuth.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension URLRequest { 4 | 5 | static func refreshTokenFlow(host: String, clientID: String, refreshToken: String) throws -> Self { 6 | 7 | guard let url = URL(string: "https://\(host)/services/oauth2/token") else { 8 | throw URLError(.badURL) 9 | } 10 | 11 | var req = URLRequest(url: url, cachePolicy: .reloadIgnoringCacheData) 12 | req.httpMethod = HTTP.Method.post 13 | let params: [String: String] = [ 14 | "format" : "urlencoded", 15 | "grant_type": "refresh_token", 16 | "client_id": clientID, 17 | "refresh_token": refreshToken] 18 | guard let body = String(byPercentEncoding: params)?.data(using: .utf8) else { 19 | throw RequestError("Failed to create refresh token flow request") 20 | } 21 | req.httpBody = body 22 | req.setValue(HTTP.MIMEType.formUrlEncoded, forHTTPHeaderField: HTTP.Header.contentType) 23 | 24 | return req 25 | } 26 | 27 | static func revokeTokenFlow(host: String, token: String) throws -> Self { 28 | 29 | guard let url = URL(string: "https://\(host)/services/oauth2/revoke") else { 30 | throw URLError(.badURL) 31 | } 32 | 33 | var req = URLRequest(url: url, cachePolicy: .reloadIgnoringCacheData) 34 | req.httpMethod = HTTP.Method.post 35 | let params: [String: String] = ["token" : token] 36 | guard let body = String(byPercentEncoding: params)?.data(using: .utf8) else { 37 | throw RequestError("Failed to create revoke token flow request") 38 | } 39 | req.httpBody = body 40 | req.setValue(HTTP.MIMEType.formUrlEncoded, forHTTPHeaderField: HTTP.Header.contentType) 41 | 42 | return req 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Extensions/UserDefaults+IdentityURL.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | internal extension UserDefaults { 4 | 5 | var userIdentifier: UserIdentifier? { 6 | get { 7 | return url(forKey: #function) 8 | } 9 | set { 10 | guard let user = newValue else { 11 | return removeObject(forKey: #function) 12 | } 13 | set(user, forKey: #function) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/HTTP.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct HTTP { 4 | 5 | struct Method { 6 | static let get = "GET" 7 | static let delete = "DELETE" 8 | static let post = "POST" 9 | static let patch = "PATCH" 10 | static let head = "HEAD" 11 | static let put = "PUT" 12 | } 13 | 14 | struct MIMEType { 15 | static let json = "application/json;charset=UTF-8" 16 | static let formUrlEncoded = "application/x-www-form-urlencoded;charset=utf-8" 17 | } 18 | 19 | struct Header { 20 | static let accept = "Accept" 21 | static let contentType = "Content-Type" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Keychain.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Adapted from https://forums.developer.apple.com/thread/86961 4 | struct Keychain { 5 | 6 | /// Returns the value of a generic password keychain item. 7 | /// - Parameters: 8 | /// - service: The service name for the item. 9 | /// - account: The account for the item. 10 | /// - Returns: The value of the item 11 | /// - Throws: Any error returned by the Security framework. 12 | static func read(service: String, account: String) throws -> Data { 13 | var copyResult: CFTypeRef? = nil 14 | let err = SecItemCopyMatching([ 15 | kSecClass: kSecClassGenericPassword, 16 | kSecAttrService: service, 17 | kSecAttrAccount: account, 18 | kSecReturnData: true 19 | ] as NSDictionary, ©Result) 20 | switch err { 21 | case errSecSuccess: 22 | return copyResult as? Data ?? Data() 23 | case errSecItemNotFound: 24 | throw KeychainError.itemNotFound 25 | default: 26 | throw KeychainError.readFailure(status: err) 27 | } 28 | } 29 | 30 | /// Stores a value to a generic password keychain item. 31 | /// This method delegates the work to two helper routines depending on whether the item already 32 | /// exists in the keychain or not. 33 | /// - Parameters: 34 | /// - service: The service name for the item. 35 | /// - account: The account for the item. 36 | /// - data: The desired data. 37 | /// - Throws: Any error returned by the Security framework. 38 | static func write(data password: Data, service: String, account: String) throws { 39 | var copyResult: CFTypeRef? = nil 40 | let err = SecItemCopyMatching([ 41 | kSecClass: kSecClassGenericPassword, 42 | kSecAttrService: service, 43 | kSecAttrAccount: account, 44 | kSecReturnData: true 45 | ] as NSDictionary, ©Result) 46 | switch err { 47 | case errSecSuccess: 48 | let oldPassword = copyResult as? Data ?? Data() 49 | if oldPassword != password { 50 | try self.storeByUpdating(service: service, account: account, password: password) 51 | } 52 | case errSecItemNotFound: 53 | try self.storeByAdding(service: service, account: account, password: password) 54 | default: 55 | throw KeychainError.writeFailure(status: err) 56 | } 57 | } 58 | 59 | /// Deletes an item from the keychain. 60 | /// - Parameters: 61 | /// - service: The service name for the item. 62 | /// - account: The account for the item. 63 | static func delete(service: String, account: String) throws { 64 | 65 | // Delete the existing item from the keychain. 66 | let err = SecItemDelete([ 67 | kSecClass: kSecClassGenericPassword, 68 | kSecAttrService: service, 69 | kSecAttrAccount: account, 70 | ] as NSDictionary) 71 | 72 | // Throw an error if an unexpected status was returned 73 | switch err { 74 | case errSecSuccess: 75 | return 76 | case errSecItemNotFound: 77 | throw KeychainError.itemNotFound 78 | default: 79 | throw KeychainError.deleteFailure(status: err) 80 | } 81 | } 82 | 83 | /// Stores a value to a generic password keychain item. 84 | /// This private routine is called to update an existing keychain item. 85 | /// - Parameters: 86 | /// - service: The service name for the item. 87 | /// - account: The account for the item. 88 | /// - password: The desired password. 89 | /// - Throws: Any error returned by the Security framework. 90 | private static func storeByUpdating(service: String, account: String, password: Data) throws { 91 | let err = SecItemUpdate([ 92 | kSecClass: kSecClassGenericPassword, 93 | kSecAttrService: service, 94 | kSecAttrAccount: account, 95 | ] as NSDictionary, [ 96 | kSecValueData: password 97 | ] as NSDictionary) 98 | guard err == errSecSuccess else { 99 | throw KeychainError.writeFailure(status: err) 100 | } 101 | } 102 | 103 | /// Stores a value to a generic password keychain item. 104 | /// This private routine is called to add the keychain item. 105 | /// - Parameters: 106 | /// - service: The service name for the item. 107 | /// - account: The account for the item. 108 | /// - password: The desired password. 109 | /// - Throws: Any error returned by the Security framework. 110 | private static func storeByAdding(service: String, account: String, password: Data) throws { 111 | let err = SecItemAdd([ 112 | kSecClass: kSecClassGenericPassword, 113 | kSecAttrService: service, 114 | kSecAttrAccount: account, 115 | kSecValueData: password, 116 | ] as NSDictionary, nil) 117 | guard err == errSecSuccess else { 118 | throw KeychainError.writeFailure(status: err) 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Models/Address.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /** 4 | Address data for a standard Salesforce object. 5 | 6 | # Reference 7 | [Address Compound Fields](https://developer.salesforce.com/docs/atlas.en-us.api.meta/api/compound_fields_address.htm) 8 | */ 9 | public struct Address: Decodable { 10 | 11 | public enum GeocodeAccuracy: String, Decodable { 12 | case address = "Address" 13 | case nearAddress = "NearAddress" 14 | case block = "Block" 15 | case street = "Street" 16 | case extendedZip = "ExtendedZip" 17 | case zip = "Zip" 18 | case city = "Neighborhood" 19 | case county = "County" 20 | case state = "State" 21 | case unknown = "Unknown" 22 | } 23 | 24 | public let city: String? 25 | public let country: String? 26 | public let countryCode: String? 27 | public let geocodeAccuracy: GeocodeAccuracy? 28 | public let latitude: Double? 29 | public let longitude: Double? 30 | public let postalCode: String? 31 | public let state: String? 32 | public let stateCode: String? 33 | public let street: String? 34 | } 35 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Models/FieldDescription.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Salesforce field metadata. 4 | /// See [SObject Describe](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_describe.htm). 5 | public struct FieldDescription { 6 | 7 | @DefaultValueEncoded 8 | public var defaultValue: Any? 9 | 10 | public let defaultValueFormula: String? 11 | public let inlineHelpText: String? 12 | public let isCreateable: Bool 13 | public let isCustom: Bool 14 | public let isEncrypted: Bool 15 | public let isNillable: Bool 16 | public let isSortable: Bool 17 | public let isUpdateable: Bool 18 | public let label: String 19 | public let length: UInt? 20 | public let name: String 21 | public let picklistValues: [PicklistItem] 22 | public let relatedTypes: [String] 23 | public let relationshipName: String? 24 | public let type: String 25 | 26 | public var helpText: String? { 27 | return inlineHelpText 28 | } 29 | 30 | public var referenceTo: [String]? { 31 | return relatedTypes 32 | } 33 | } 34 | 35 | extension FieldDescription: Decodable { 36 | 37 | enum CodingKeys: String, CodingKey { 38 | case defaultValue 39 | case defaultValueFormula 40 | case inlineHelpText 41 | case isCreateable = "createable" 42 | case isCustom = "custom" 43 | case isEncrypted = "encrypted" 44 | case isNillable = "nillable" 45 | case isSortable = "sortable" 46 | case isUpdateable = "updateable" 47 | case label 48 | case length 49 | case name 50 | case picklistValues 51 | case relatedTypes = "referenceTo" 52 | case relationshipName 53 | case type 54 | } 55 | } 56 | 57 | @propertyWrapper 58 | public struct DefaultValueEncoded: Decodable { 59 | 60 | public var wrappedValue: Any? = nil 61 | 62 | public init(from decoder: Decoder) throws { 63 | // 'defaultValue' can be either String (for Picklist-type fields) or Boolean (for Checkbox-type fields). 64 | // All other field types store their default values in 'defaultValueFormula'... 65 | let container = try decoder.singleValueContainer() 66 | if let f = try? container.decode(Bool.self) { 67 | self.wrappedValue = f 68 | } 69 | else if let s = try? container.decode(String.self) { 70 | self.wrappedValue = s 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Models/Identity.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Identity { 4 | 5 | public var userID: String 6 | public var orgID: String 7 | public var username: String 8 | public var displayName: String 9 | public var email: String 10 | public var firstName: String 11 | public var lastName: String 12 | public var timezone: String 13 | public var photos: Photos 14 | public var street: String? 15 | public var city: String? 16 | public var country: String? 17 | public var state: String? 18 | public var zip: String? 19 | public var mobilePhone: String? 20 | public var isActive: Bool 21 | public var userType: String 22 | public var language: String 23 | public var locale: String 24 | public var utcOffset: Int 25 | public var lastModifiedDate: Date 26 | 27 | public struct Photos: Codable { 28 | public var picture: URL 29 | public var thumbnail: URL 30 | } 31 | } 32 | 33 | extension Identity: Decodable { 34 | 35 | enum CodingKeys: String, CodingKey { 36 | case userID = "user_id" 37 | case orgID = "organization_id" 38 | case username 39 | case displayName = "display_name" 40 | case email 41 | case firstName = "first_name" 42 | case lastName = "last_name" 43 | case timezone 44 | case photos 45 | case street = "addr_street" 46 | case city = "addr_city" 47 | case state = "addr_state" 48 | case country = "addr_country" 49 | case zip = "addr_zip" 50 | case mobilePhone = "mobile_phone" 51 | case isActive = "active" 52 | case userType = "user_type" 53 | case language 54 | case locale 55 | case utcOffset 56 | case lastModifiedDate = "last_modified_date" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Models/Limit.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Represents a limited Salesforce resource. 4 | /// See: [Limits](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_limits.htm). 5 | public struct Limit { 6 | 7 | public let maximum: Int 8 | public let remaining: Int 9 | } 10 | 11 | public extension Limit { 12 | 13 | var used: Int { 14 | return maximum - remaining 15 | } 16 | } 17 | 18 | extension Limit: Codable { 19 | 20 | enum CodingKeys: String, CodingKey { 21 | case maximum = "Max" 22 | case remaining = "Remaining" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Models/ObjectDescription.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Salesforce object metadata. 4 | /// See [SObject Describe](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_describe.htm). 5 | public struct ObjectDescription: Decodable { 6 | 7 | public let fields: [FieldDescription]? 8 | public let isCreateable: Bool 9 | public let isCustom: Bool 10 | public let isCustomSetting: Bool 11 | public let isDeletable: Bool 12 | public let isFeedEnabled: Bool 13 | public let isQueryable: Bool 14 | public let isSearchable: Bool 15 | public let isTriggerable: Bool 16 | public let isUndeletable: Bool 17 | public let isUpdateable: Bool 18 | public let keyPrefix: String? 19 | public let label: String 20 | public let labelPlural: String 21 | public let name: String 22 | 23 | public var idPrefix: String? { 24 | return keyPrefix 25 | } 26 | 27 | public var pluralLabel: String { 28 | return labelPlural 29 | } 30 | 31 | enum CodingKeys: String, CodingKey { 32 | case fields 33 | case isCreateable = "createable" 34 | case isCustom = "custom" 35 | case isCustomSetting = "customSetting" 36 | case isDeletable = "deletable" 37 | case isFeedEnabled = "feedEnabled" 38 | case isQueryable = "queryable" 39 | case isSearchable = "searchable" 40 | case isTriggerable = "triggerable" 41 | case isUndeletable = "undeletable" 42 | case isUpdateable = "updateable" 43 | case keyPrefix 44 | case label 45 | case labelPlural 46 | case name 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Models/PicklistItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Represents an option in a Salesforce Picklist-type field (i.e. drop-down list); used with ObjectDescription 4 | public struct PicklistItem: Decodable { 5 | 6 | public let isActive: Bool 7 | public let isDefault: Bool 8 | public let label: String 9 | public let value: String 10 | 11 | enum CodingKeys: String, CodingKey { 12 | case isActive = "active" 13 | case isDefault = "defaultValue" 14 | case label 15 | case value 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Models/QueryResult.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Holds the result of a SOQL query. 4 | /// See [Execute a SOQL Query](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_query.htm). 5 | public struct QueryResult: Decodable { 6 | 7 | public let totalSize: Int 8 | public let isDone: Bool 9 | public let records: [T] 10 | public let nextRecordsPath: String? 11 | 12 | enum CodingKeys: String, CodingKey { 13 | case totalSize 14 | case isDone = "done" 15 | case records 16 | case nextRecordsPath = "nextRecordsUrl" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Models/Record.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public typealias SObject = Record 4 | public typealias SalesforceRecord = Record 5 | 6 | public struct Record: Identifiable, Decodable { 7 | 8 | public let type: String 9 | public let id: String 10 | private let container: KeyedDecodingContainer 11 | 12 | public init(from decoder: Decoder) throws { 13 | 14 | self.container = try decoder.container(keyedBy: RecordCodingKey.self) 15 | 16 | struct Attributes: Decodable { 17 | var type: String 18 | var url: String? 19 | } 20 | 21 | let key = RecordCodingKey(stringValue: "attributes")! 22 | let attrs = try container.decode(Attributes.self, forKey: key) 23 | 24 | // SObject type 25 | self.type = attrs.type 26 | 27 | // Parse record ID 28 | if self.type.caseInsensitiveCompare("AggregateResult") == ComparisonResult.orderedSame { 29 | self.id = "" 30 | } 31 | else { 32 | guard let recordID = attrs.url?.components(separatedBy: "/").last, recordID.count == 15 || recordID.count == 18 else { 33 | throw DecodingError.dataCorruptedError(forKey: key, in: container, debugDescription: "Failed to decode record ID from url attribute.") 34 | } 35 | self.id = recordID 36 | } 37 | } 38 | 39 | public func hasField(named field: String) -> Bool { 40 | if let key = RecordCodingKey(stringValue: field) { 41 | return container.contains(key) 42 | } 43 | return false 44 | } 45 | 46 | public func value(forField field: String) throws -> Value? { 47 | let key = RecordCodingKey(stringValue: field)! 48 | return try container.decodeIfPresent(Value.self, forKey: key) 49 | } 50 | 51 | public subscript(field: String) -> Value? { 52 | return try? value(forField: field) 53 | } 54 | 55 | /// Returns the value for the given field as a String 56 | public func string(forField field: String) -> String? { 57 | return self[field] 58 | } 59 | 60 | /// Returns the value for the given field as a Date 61 | public func date(forField field: String) -> Date? { 62 | return self[field] 63 | } 64 | 65 | /// Returns the value for the given field as a URL 66 | public func url(forField field: String) -> URL? { 67 | return self[field] 68 | } 69 | 70 | /// Returns the value for the given field as an Int 71 | public func int(forField field: String) -> Int? { 72 | return self[field] 73 | } 74 | 75 | /// Returns the value for the given field as a Float 76 | public func float(forField field: String) -> Float? { 77 | return self[field] 78 | } 79 | 80 | /// Returns the value for the given field as a Double 81 | public func double(forField field: String) -> Double? { 82 | return self[field] 83 | } 84 | 85 | /// Returns the value for the given field as a Bool 86 | public func bool(forField field: String) -> Bool? { 87 | return self[field] 88 | } 89 | 90 | public func address(forField field: String) -> Address? { 91 | return self[field] 92 | } 93 | 94 | public func subqueryResult(forField field: String) -> QueryResult? { 95 | return self[field] 96 | } 97 | } 98 | 99 | extension Record { 100 | 101 | struct RecordCodingKey: CodingKey { 102 | var stringValue: String 103 | var intValue: Int? = nil 104 | init?(stringValue: String) { self.stringValue = stringValue } 105 | init?(intValue: Int) { return nil } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/OAuthFlow.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct OAuthFlow { 4 | 5 | static func userAgent( 6 | consumerKey: String, 7 | host: String, 8 | callbackURL: URL 9 | ) async throws -> Credential { 10 | 11 | let authURL = try URL.userAgentFlow(host: host, clientID: consumerKey, callbackURL: callbackURL) 12 | guard let scheme = callbackURL.scheme else { 13 | throw URLError(.badURL, userInfo: [NSURLErrorFailingURLStringErrorKey: callbackURL]) 14 | } 15 | let redirectURL = try await WebAuthenticationSession.shared.start(url: authURL, callbackURLScheme: scheme) 16 | return try parse(encodedString: redirectURL.fragment) { 17 | Credential(fromPercentEncoded: $0) 18 | } 19 | } 20 | 21 | static func refreshToken( 22 | consumerKey: String, 23 | host: String, 24 | refreshToken: String, 25 | session: URLSession = URLSession(configuration: .ephemeral) 26 | ) async throws -> Credential { 27 | 28 | let req = try URLRequest.refreshTokenFlow(host: host, clientID: consumerKey, refreshToken: refreshToken) 29 | let (response, _) = try await session.data(for: req) 30 | return try parse(encodedString: String(data: response)) { 31 | Credential(fromPercentEncoded: $0, andRefreshToken: refreshToken) 32 | } 33 | } 34 | 35 | static func revokeToken( 36 | host: String, 37 | token: String, 38 | session: URLSession = URLSession(configuration: .ephemeral) 39 | ) async throws -> Void { 40 | 41 | let req = try URLRequest.revokeTokenFlow(host: host, token: token) 42 | let (response, _) = try await session.data(for: req) 43 | return try parse(encodedString: String(data: response)) { 44 | $0 == "" ? Void() : nil 45 | } 46 | } 47 | } 48 | 49 | private extension OAuthFlow { 50 | 51 | static func parse(encodedString: String?, with parser: (String) -> T?) throws -> T { 52 | if let t = encodedString.flatMap({ parser($0) }) { 53 | return t 54 | } 55 | if let err = encodedString.flatMap({ OAuthError(fromPercentEncodedString: $0) }) { 56 | throw err 57 | } 58 | throw URLError(.badServerResponse) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/RequestCreator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol RequestCreator { 4 | 5 | func createRequest(with: Credential) throws -> URLRequest 6 | } 7 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Resource.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum Resource { 4 | 5 | static let defaultVersion = "54.0" // Spring '22 6 | 7 | static func path(for leaf: String, version: String = defaultVersion) -> String { 8 | "/services/data/v\(version)/\(leaf)" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/ResponseValidator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol ResponseValidator { 4 | 5 | typealias Response = (body: Body, metadata: HTTPURLResponse) 6 | 7 | associatedtype Body = Data 8 | func validate(response: Response) throws 9 | func checkAuthenticationRequired(response: Response) throws 10 | func checkError(response: Response) throws 11 | } 12 | 13 | public extension ResponseValidator { 14 | 15 | func validate(response: Response) throws { 16 | try checkAuthenticationRequired(response: response) 17 | try checkError(response: response) 18 | } 19 | 20 | func checkAuthenticationRequired(response: Response) throws { 21 | guard 401 != response.metadata.statusCode else { 22 | throw URLError(.userAuthenticationRequired) 23 | } 24 | } 25 | 26 | func checkError(response: Response) throws { 27 | guard (200..<300).contains(response.metadata.statusCode) else { 28 | throw ResponseError(metadata: response.metadata) 29 | } 30 | } 31 | 32 | func checkError(response: Response) throws where Body == Data { 33 | guard (200..<300).contains(response.metadata.statusCode) else { 34 | if let dto = ResponseErrorDTO(from: response.body) { 35 | throw ResponseError(code: dto.errorCode, message: dto.message, fields: dto.fields, metadata: response.metadata) 36 | } 37 | else if let str = String(data: response.body) { 38 | throw ResponseError(message: str, metadata: response.metadata) 39 | } 40 | else { 41 | throw ResponseError(metadata: response.metadata) 42 | } 43 | } 44 | } 45 | } 46 | 47 | fileprivate struct ResponseErrorDTO: Decodable { 48 | 49 | let errorCode: String 50 | let message: String 51 | let fields: [String]? 52 | 53 | init?(from data: Data) { 54 | guard let dto = 55 | (try? JSONDecoder.salesforce.decode([ResponseErrorDTO].self, from: data).first) 56 | ?? (try? JSONDecoder.salesforce.decode(ResponseErrorDTO.self, from: data)) else { 57 | return nil 58 | } 59 | self = dto 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Salesforce.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Salesforce { 4 | 5 | public static func connect(configurationURL: URL? = nil, session: URLSession = .shared) throws -> Connection { 6 | 7 | guard let url = 8 | configurationURL 9 | ?? Bundle.main.url(forResource: "Salesforce", withExtension: "json") 10 | ?? Bundle.main.url(forResource: "salesforce", withExtension: "json") else { 11 | throw URLError(.badURL, userInfo: [NSURLErrorFailingURLStringErrorKey : "Salesforce.json"]) 12 | } 13 | let config = try JSONDecoder().decode(Configuration.self, from: try Data(contentsOf: url)) 14 | return try connect(consumerKey: config.consumerKey, callbackURL: config.callbackURL, authorizingHost: config.authorizingHost, session: session) 15 | } 16 | 17 | public static func connect(consumerKey: String, callbackURL: URL, authorizingHost: String? = nil, session: URLSession = .shared) throws -> Connection { 18 | 19 | let authorizer = DefaultAuthorizer(consumerKey: consumerKey, callbackURL: callbackURL, defaultHost: authorizingHost) 20 | let credentialStore = DefaultCredentialStore(consumerKey: consumerKey) 21 | guard let defaults = UserDefaults(suiteName: consumerKey) else { 22 | throw StateError("Failed to initialize user defaults") 23 | } 24 | return try connect(authorizer: authorizer, credentialStore: credentialStore, defaults: defaults, session: session) 25 | } 26 | 27 | public static func connect(authorizer: Authorizer, credentialStore: CredentialStore, defaults: UserDefaults, session: URLSession) throws -> Connection { 28 | return Connection(authorizer: authorizer, credentialStore: credentialStore, defaults: defaults, session: session) 29 | } 30 | } 31 | 32 | internal extension Salesforce { 33 | 34 | struct Configuration: Decodable { 35 | 36 | let consumerKey: String 37 | let callbackURL: URL 38 | let authorizingHost: String? 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Services/ApexService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct ApexService: DataService { 4 | 5 | typealias Output = T 6 | 7 | let path: String 8 | 9 | var method: String? = nil 10 | var queryItems: [String: String]? = nil 11 | var headers: [String:String]? = nil 12 | var body: Data? = nil 13 | var timeoutInterval: TimeInterval = URLRequest.defaultTimeoutInterval 14 | 15 | func createRequest(with credential: Credential) throws -> URLRequest { 16 | return try URLRequest( 17 | credential: credential, 18 | method: method, 19 | path: "/services/apexrest\(path.starts(with: "/") ? path : "/\(path)")", 20 | queryItems: queryItems, 21 | headers: headers, 22 | body: body, 23 | timeoutInterval: timeoutInterval 24 | ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Services/IdentityService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct IdentityService: DataService { 4 | 5 | public func createRequest(with credential: Credential) throws -> URLRequest { 6 | return URLRequest.identity(with: credential) 7 | } 8 | 9 | public func checkAuthenticationRequired(response: Response) throws { 10 | guard 403 != response.metadata.statusCode else { 11 | throw URLError(.userAuthenticationRequired) 12 | } 13 | } 14 | 15 | public func transform(data: Data) throws -> Identity { 16 | try JSONDecoder(dateDecodingStrategy: .iso8601).decode(Identity.self, from: data) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Services/Resource+Limits.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Resource { 4 | 5 | struct Limits: DataService { 6 | 7 | typealias Output = [String: Limit] 8 | 9 | func createRequest(with credential: Credential) throws -> URLRequest { 10 | let path = Resource.path(for: "limits") 11 | return try URLRequest(credential: credential, path: path) 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Services/Resource+Query.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Resource { 4 | 5 | struct Query { 6 | 7 | struct Run: DataService { 8 | 9 | typealias Output = QueryResult 10 | 11 | let soql: String 12 | 13 | var batchSize: Int? = nil 14 | 15 | func createRequest(with credential: Credential) throws -> URLRequest { 16 | let path = Resource.path(for: "query") 17 | let queryItems = ["q": soql] 18 | let headers = batchSize.map { ["Sforce-Query-Options" : "batchSize=\($0)"] } 19 | return try URLRequest(credential: credential, path: path, queryItems: queryItems, headers: headers) 20 | } 21 | } 22 | 23 | struct NextResultsPage: DataService { 24 | 25 | typealias Output = QueryResult 26 | 27 | let path: String 28 | 29 | var batchSize: Int? = nil 30 | 31 | func createRequest(with credential: Credential) throws -> URLRequest { 32 | let headers = batchSize.map { ["Sforce-Query-Options" : "batchSize=\($0)"] } 33 | return try URLRequest(credential: credential, path: path, headers: headers) 34 | } 35 | } 36 | 37 | struct MyRecords: DataService { 38 | 39 | typealias Output = QueryResult 40 | 41 | let type: String 42 | 43 | var fields: [String]? = nil 44 | var limit: Int? = nil 45 | var batchSize: Int? = nil 46 | 47 | func createRequest(with credential: Credential) throws -> URLRequest { 48 | let ownerId = credential.userID 49 | let fieldSpec = fields.map { $0.joined(separator: ",") } ?? "FIELDS(ALL)" 50 | let limitSpec = limit.map { "LIMIT \($0)" } ?? fields.map { _ in "" } ?? "LIMIT 200" 51 | let soql = "SELECT \(fieldSpec) FROM \(type) WHERE OwnerId = '\(ownerId)' \(limitSpec)" 52 | return try Resource.Query.Run(soql: soql, batchSize: batchSize).createRequest(with: credential) 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Services/Resource+SObjects.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Resource { 4 | 5 | struct SObjects { 6 | 7 | struct Create: DataService { 8 | 9 | let type: String 10 | let fields: [String: E] 11 | 12 | func createRequest(with credential: Credential) throws -> URLRequest { 13 | let method = HTTP.Method.post 14 | let path = Resource.path(for: "sobjects/\(type)") 15 | let body = try JSONEncoder().encode(fields) 16 | return try URLRequest(credential: credential, method: method, path: path, body: body) 17 | } 18 | 19 | func transform(data: Data) throws -> String { 20 | let result = try JSONDecoder(dateFormatter: .salesforce(.long)).decode(Result.self, from: data) 21 | return result.id 22 | } 23 | 24 | private struct Result: Decodable { 25 | var id: String 26 | } 27 | } 28 | 29 | //MARK: - Read record - 30 | struct Read: DataService { 31 | 32 | typealias Output = D 33 | 34 | let type: String 35 | let id: String 36 | 37 | var fields: [String]? = nil 38 | 39 | func createRequest(with credential: Credential) throws -> URLRequest { 40 | let method = HTTP.Method.get 41 | let path = Resource.path(for: "sobjects/\(type)/\(id)") 42 | let queryItems = fields.map { ["fields": $0.joined(separator: ",")] } 43 | return try URLRequest(credential: credential, method: method, path: path, queryItems: queryItems) 44 | } 45 | } 46 | 47 | //MARK: - Update record - 48 | struct Update: DataService { 49 | 50 | typealias Output = Void 51 | 52 | let type: String 53 | let id: String 54 | let fields: [String: E] 55 | 56 | public func createRequest(with credential: Credential) throws -> URLRequest { 57 | let method = HTTP.Method.patch 58 | let encoder = JSONEncoder() 59 | let path = Resource.path(for: "sobjects/\(type)/\(id)") 60 | let body = try encoder.encode(fields) 61 | return try URLRequest(credential: credential, method: method, path: path, body: body) 62 | } 63 | } 64 | 65 | //MARK: - Delete record - 66 | struct Delete: DataService { 67 | 68 | typealias Output = Void 69 | 70 | let type: String 71 | let id: String 72 | 73 | func createRequest(with credential: Credential) throws -> URLRequest { 74 | let method = HTTP.Method.delete 75 | let path = Resource.path(for: "sobjects/\(type)/\(id)") 76 | return try URLRequest(credential: credential, method: method, path: path) 77 | } 78 | } 79 | 80 | //MARK: - Describe SObject - 81 | struct Describe: DataService { 82 | 83 | typealias Output = ObjectDescription 84 | 85 | let type: String 86 | 87 | func createRequest(with credential: Credential) throws -> URLRequest { 88 | let method = HTTP.Method.get 89 | let path = Resource.path(for: "sobjects/\(type)/describe") 90 | return try URLRequest(credential: credential, method: method, path: path) 91 | } 92 | } 93 | 94 | //MARK: - Describe all SObjects - 95 | struct DescribeGlobal: DataService { 96 | 97 | func createRequest(with credential: Credential) throws -> URLRequest { 98 | let method = HTTP.Method.get 99 | let path = Resource.path(for: "sobjects") 100 | return try URLRequest(credential: credential, method: method, path: path) 101 | } 102 | 103 | func transform(data: Data) throws -> [ObjectDescription] { 104 | struct Result: Decodable { 105 | var sobjects: [ObjectDescription] 106 | } 107 | let result = try JSONDecoder(dateFormatter: .salesforce(.long)).decode(Result.self, from: data) 108 | return result.sobjects 109 | } 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/Services/Resource+Search.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Resource { 4 | 5 | struct Search: DataService { 6 | 7 | typealias Output = [Record] 8 | 9 | let sosl: String 10 | 11 | func createRequest(with credential: Credential) throws -> URLRequest { 12 | let path = Resource.path(for: "search") 13 | let queryItems = ["q": sosl] 14 | return try URLRequest(credential: credential, path: path, queryItems: queryItems) 15 | } 16 | 17 | func transform(data: Data) throws -> [Record] { 18 | struct SearchResult: Decodable { 19 | var searchRecords: [Record] 20 | } 21 | let decoder = JSONDecoder(dateFormatter: .salesforce(.long)) 22 | let searchResult = try decoder.decode(SearchResult.self, from: data) 23 | return searchResult.searchRecords 24 | } 25 | } 26 | } 27 | 28 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/UserIdentifier.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public typealias UserIdentifier = URL 4 | -------------------------------------------------------------------------------- /Sources/SwiftlySalesforce/WebAuthenticationSession.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import AuthenticationServices 3 | 4 | @MainActor 5 | struct WebAuthenticationSession { 6 | 7 | static let shared = WebAuthenticationSession() 8 | 9 | private init() { 10 | // Can't instantiate 11 | } 12 | 13 | func start(url: URL, callbackURLScheme: String) async throws -> URL { 14 | let contextProvider = ContextProvider() 15 | return try await withCheckedThrowingContinuation { continuation in 16 | let session = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackURLScheme) { (url, error) in 17 | if let error = error { 18 | continuation.resume(throwing: error) 19 | } 20 | else { 21 | guard let url = url else { 22 | return continuation.resume(throwing: StateError("Invalid state")) 23 | } 24 | continuation.resume(returning: url) 25 | } 26 | } 27 | session.prefersEphemeralWebBrowserSession = false // 'true' can cause presentation problems 28 | session.presentationContextProvider = contextProvider 29 | guard session.canStart, session.start() else { 30 | return continuation.resume(throwing: StateError("Failed to start web authentication session")) 31 | } 32 | } 33 | } 34 | } 35 | 36 | fileprivate class ContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding { 37 | 38 | public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { 39 | return ASPresentationAnchor() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import SwiftlySalesforceTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += SwiftlySalesforceTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/SwiftlySalesforceTests/AddressTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftlySalesforce 3 | 4 | class AddressTests: XCTestCase { 5 | 6 | func testThatItInitializes() throws { 7 | 8 | // Given 9 | let data = try load(resource: "MockAccount") 10 | let decoder = JSONDecoder(dateFormatter: .salesforce(.long)) 11 | 12 | // When 13 | let account = try decoder.decode(Record.self, from: data) 14 | let billingAddress = account.address(forField: "BillingAddress") 15 | 16 | // Then 17 | XCTAssertNotNil(billingAddress) 18 | XCTAssertEqual(billingAddress?.state, "AK") 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/SwiftlySalesforceTests/ApexServiceTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftlySalesforce 3 | 4 | class ApexServiceTests: DataServiceTests { 5 | 6 | func testsThatItCreatesRequestForApexRestService() throws { 7 | 8 | // Given 9 | let body = "Hello, World!" 10 | let service = ApexService(path: "Account/:ID", method: "PATCH", queryItems: nil, headers: ["Header1": "Value1"], body: body.data(using: .utf8), timeoutInterval: 12345) 11 | 12 | // When 13 | let req = try service.createRequest(with: mockCredential) 14 | 15 | // Then 16 | XCTAssertEqual(req.httpMethod?.uppercased(), HTTP.Method.patch.uppercased()) 17 | XCTAssertEqual(req.value(forHTTPHeaderField: "Authorization"), "Bearer \(mockCredential.accessToken)") 18 | XCTAssertEqual(req.url!.path, "/services/apexrest/Account/:ID") 19 | XCTAssertEqual(req.httpBody!, body.data(using: .utf8)) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Tests/SwiftlySalesforceTests/ConnectionTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftlySalesforce 3 | 4 | class ConnectionTests: XCTestCase { 5 | 6 | func testThatItHandlesMockRequest() async throws { 7 | 8 | // Given 9 | let data = try load(resource: "MockLimits") 10 | let session = URLSession.mock(responseBody: data, statusCode: 200) 11 | let service = Resource.Limits() 12 | let connection = try Salesforce.connect(session: session) 13 | 14 | // When 15 | let limits: [String: Limit] = try await connection.request(service: service) 16 | 17 | // Then 18 | XCTAssertTrue(limits.count > 0) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/SwiftlySalesforceTests/CredentialTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftlySalesforce 3 | 4 | class CredentialTests: XCTestCase { 5 | 6 | let callbackURL = URL(string: "https://www.customercontactinfo.com/user_callback.jsp#access_token=00Dx0000000BV7z%21AR8AQBM8J_xr9kLqmZIRyQxZgLcM4HVi41aGtW0qW3JCzf5xdTGGGSoVim8FfJkZEqxbjaFbberKGk8v8AnYrvChG4qJbQo8&refresh_token=5Aep8614iLM.Dq661ePDmPEgaAW9Oh_L3JKkDpB4xReb54_pZfVti1dPEk8aimw4Hr9ne7VXXVSIQ%3D%3D&instance_url=https://yourInstance.salesforce.com&id=https://login.salesforce.com%2Fid%2F00Dx0000000BV7z%2F005x00000012Q9P&issued_at=1278448101416&signature=miQQ1J4sdMPiduBsvyRYPCDozqhe43KRc1i9LmZHR70%3D&scope=id+api+refresh_token&token_type=Bearer&state=mystate")! 7 | 8 | let callbackURLWithUnparseableTimestamp = URL(string: "https://www.customercontactinfo.com/user_callback.jsp#access_token=00Dx0000000BV7z%21AR8AQBM8J_xr9kLqmZIRyQxZgLcM4HVi41aGtW0qW3JCzf5xdTGGGSoVim8FfJkZEqxbjaFbberKGk8v8AnYrvChG4qJbQo8&refresh_token=5Aep8614iLM.Dq661ePDmPEgaAW9Oh_L3JKkDpB4xReb54_pZfVti1dPEk8aimw4Hr9ne7VXXVSIQ%3D%3D&instance_url=https://yourInstance.salesforce.com&id=https://login.salesforce.com%2Fid%2F00Dx0000000BV7z%2F005x00000012Q9P&issued_at=1278448101416____&signature=miQQ1J4sdMPiduBsvyRYPCDozqhe43KRc1i9LmZHR70%3D&scope=id+api+refresh_token&token_type=Bearer&state=mystate")! 9 | 10 | let refreshTokenFlowResponseBody = "access_token=00Dx0000000BV7z%21AR8AQP0jITN80ESEsj5EbaZTFG0RNBaT1cyWk7TrqoDjoNIWQ2ME_sTZzBjfmOE6zMHq6y8PIW4eWze9JksNEkWUl.Cju7m4&token_type=Bearer&scope=id%20api%20refresh_token&instance_url=https%3A%2F%2FyourInstance.salesforce.com&id=https://login.salesforce.com%2Fid%2F00Dx0000000BV7z%2F005x00000012Q9P&issued_at=1278448101416&signature=CMJ4l%2BCCaPQiKjoOEwEig9H4wqhpuLSk4J2urAe%2BfVg%3D" 11 | 12 | func testThatItInitializesFromCallbackURL() throws { 13 | 14 | // Given 15 | let fragment = callbackURL.fragment! 16 | 17 | // When 18 | let cred = Credential(fromPercentEncoded: fragment)! 19 | 20 | // Then 21 | XCTAssertEqual(cred.accessToken, "00Dx0000000BV7z!AR8AQBM8J_xr9kLqmZIRyQxZgLcM4HVi41aGtW0qW3JCzf5xdTGGGSoVim8FfJkZEqxbjaFbberKGk8v8AnYrvChG4qJbQo8") 22 | XCTAssertEqual(cred.refreshToken, "5Aep8614iLM.Dq661ePDmPEgaAW9Oh_L3JKkDpB4xReb54_pZfVti1dPEk8aimw4Hr9ne7VXXVSIQ==") 23 | XCTAssertEqual(cred.instanceURL, URL(string: "https://yourInstance.salesforce.com")!) 24 | XCTAssertEqual(cred.identityURL, URL(string: "https://login.salesforce.com/id/00Dx0000000BV7z/005x00000012Q9P")!) 25 | XCTAssertEqual(cred.timestamp, Date(timeIntervalSince1970: 1278448101416/1_000)) 26 | XCTAssertNil(cred.siteID) 27 | XCTAssertNil(cred.siteURL) 28 | XCTAssertEqual(cred.userID, "005x00000012Q9P") 29 | XCTAssertEqual(cred.orgID, "00Dx0000000BV7z") 30 | } 31 | 32 | func testThatItFailsToIniatilize() throws { 33 | 34 | // Given 35 | let fragment = callbackURLWithUnparseableTimestamp.fragment! 36 | 37 | // When 38 | let cred = Credential(fromPercentEncoded: fragment) 39 | 40 | // Then 41 | XCTAssertNil(cred) 42 | } 43 | 44 | func testThatItInitializesFromRefreshTokenFlowResponseBody() throws { 45 | 46 | // Given 47 | let refreshToken = "5Aep8614iLM.Dq661ePDmPEgaAW9Oh_L3JKkDpB4xReb54_pZfVti1dPEk8aimw4Hr9ne7VXXVSIQ==" 48 | 49 | // When 50 | let cred = Credential(fromPercentEncoded: refreshTokenFlowResponseBody, andRefreshToken: refreshToken)! 51 | 52 | // Then 53 | XCTAssertEqual(cred.accessToken, "00Dx0000000BV7z!AR8AQP0jITN80ESEsj5EbaZTFG0RNBaT1cyWk7TrqoDjoNIWQ2ME_sTZzBjfmOE6zMHq6y8PIW4eWze9JksNEkWUl.Cju7m4") 54 | XCTAssertEqual(cred.refreshToken, refreshToken) 55 | XCTAssertEqual(cred.instanceURL, URL(string: "https://yourInstance.salesforce.com")!) 56 | XCTAssertEqual(cred.identityURL, URL(string: "https://login.salesforce.com/id/00Dx0000000BV7z/005x00000012Q9P")!) 57 | XCTAssertEqual(cred.timestamp, Date(timeIntervalSince1970: 1278448101416/1_000)) 58 | XCTAssertNil(cred.siteID) 59 | XCTAssertNil(cred.siteURL) 60 | XCTAssertEqual(cred.userID, "005x00000012Q9P") 61 | XCTAssertEqual(cred.orgID, "00Dx0000000BV7z") 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Tests/SwiftlySalesforceTests/DataServiceTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftlySalesforce 3 | 4 | class DataServiceTests: XCTestCase { 5 | 6 | override func setUpWithError() throws { 7 | try super.setUpWithError() 8 | MockURLProtocol.loadingHandler = nil 9 | } 10 | 11 | override func tearDownWithError() throws { 12 | MockURLProtocol.loadingHandler = nil 13 | try super.tearDownWithError() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Tests/SwiftlySalesforceTests/DefaultAuthorizerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftlySalesforce 3 | 4 | class DefaultAuthorizerTests: XCTestCase { 5 | 6 | // Note: user must successfully authenticate for the test to pass 7 | func testThatItAuthenticates() async throws { 8 | 9 | // Given 10 | let config = try loadConfiguration() 11 | let authorizer = DefaultAuthorizer(consumerKey: config.consumerKey, callbackURL: config.callbackURL) 12 | 13 | // When 14 | let credential = try await authorizer.grantCredential(refreshing: nil) 15 | 16 | // Then 17 | XCTAssertNotNil(credential) 18 | } 19 | 20 | // Note: testing user must successfully authenticate for this test to pass 21 | func testThatItAuthenticatesConcurrently() async throws { 22 | 23 | // Given 24 | let config = try loadConfiguration() 25 | let authorizer = DefaultAuthorizer(consumerKey: config.consumerKey, callbackURL: config.callbackURL) 26 | let taskCount = 30 27 | 28 | // When 29 | let credentials = try await withThrowingTaskGroup(of: Credential.self) { group -> [Credential] in 30 | 31 | var creds = [Credential]() 32 | creds.reserveCapacity(taskCount) 33 | 34 | for _ in 0.. [Credential] in 61 | 62 | var creds = [Credential]() 63 | creds.reserveCapacity(taskCount) 64 | 65 | for n in 0.. (HTTPURLResponse, Data?, Error?))?) -> URLSession { 6 | let configuration = URLSessionConfiguration.ephemeral 7 | configuration.protocolClasses = [MockURLProtocol.self] 8 | MockURLProtocol.loadingHandler = withLoadingHandler 9 | return URLSession.init(configuration: configuration) 10 | } 11 | 12 | static func mock(responseBody: Data, statusCode: Int) -> URLSession { 13 | let loadingHandler: ((URLRequest) -> (HTTPURLResponse, Data?, Error?))? = { request in 14 | let metadata = HTTPURLResponse(url: request.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)! 15 | return (metadata, responseBody, nil) 16 | } 17 | return mock(withLoadingHandler: loadingHandler) 18 | } 19 | 20 | static func mock(error: Error, statusCode: Int) -> URLSession { 21 | let loadingHandler: ((URLRequest) -> (HTTPURLResponse, Data?, Error?))? = { request in 22 | let metadata = HTTPURLResponse(url: request.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)! 23 | return (metadata, nil, error) 24 | } 25 | return mock(withLoadingHandler: loadingHandler) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Tests/SwiftlySalesforceTests/Extensions/XCTestCase+Helpers.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | @testable import SwiftlySalesforce 4 | 5 | extension XCTestCase { 6 | 7 | static var connection: Connection = try! Salesforce.connect() 8 | 9 | func load(resource: String, withExtension: String = "json", inBundle: Bundle? = nil) throws -> Data { 10 | let bundle = inBundle ?? Bundle.module 11 | guard let url = bundle.url(forResource: resource, withExtension: withExtension) else { 12 | throw URLError(.fileDoesNotExist, userInfo: [NSURLErrorFailingURLErrorKey: "\(resource).\(withExtension)"]) 13 | } 14 | return try Data(contentsOf: url) 15 | } 16 | 17 | func loadConfiguration(at url: URL? = nil) throws -> Salesforce.Configuration { 18 | let data = try load(resource: "Salesforce", inBundle: .main) 19 | return try JSONDecoder().decode(Salesforce.Configuration.self, from: data) 20 | } 21 | 22 | var mockCredential: Credential { 23 | return Credential(accessToken: "ACCESS TOKEN", instanceURL: URL(string: "https://na5.salesforce.com")!, identityURL: URL(string: "https://login.salesforce.com/id/:ORGID/:USERID")!, timestamp: Date()) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Tests/SwiftlySalesforceTests/IdentityServiceTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftlySalesforce 3 | 4 | class IdentityServiceTests: DataServiceTests { 5 | 6 | func testThatItLoadsMockIdentity() async throws { 7 | 8 | // Given 9 | let data = try load(resource: "MockIdentity") 10 | let session = URLSession.mock(responseBody: data, statusCode: 200) 11 | let service = IdentityService() 12 | 13 | // When 14 | let identity = try await service.request(with: mockCredential, using: session) 15 | 16 | // Then 17 | XCTAssertNotNil(identity) 18 | } 19 | 20 | func testThatItRequiresAuthentication() async throws { 21 | 22 | // Given 23 | let responseBody = """ 24 | [{"message": "Something went wrong!", "errorCode": "SOMETHING_WENT_WRONG"}] 25 | """ 26 | let session = URLSession.mock(responseBody: responseBody.data(using: .utf8)!, statusCode: 403) 27 | let service = IdentityService() 28 | 29 | // When 30 | var err: Error? = nil 31 | do { 32 | let _ = try await service.request(with: mockCredential, using: session) 33 | } 34 | catch let error { 35 | err = error 36 | } 37 | 38 | // Then 39 | XCTAssertNotNil(err) 40 | XCTAssertTrue(err!.isAuthenticationRequired) 41 | } 42 | 43 | func testThatItHandlesMockResponseError() async throws { 44 | 45 | // Given 46 | let responseBody = """ 47 | [{"message": "Something went wrong!", "errorCode": "SOMETHING_WENT_WRONG"}] 48 | """ 49 | let session = URLSession.mock(responseBody: responseBody.data(using: .utf8)!, statusCode: 414) 50 | let service = IdentityService() 51 | 52 | // When 53 | var err: Error? = nil 54 | do { 55 | let _ = try await service.request(with: mockCredential, using: session) 56 | } 57 | catch { 58 | err = error 59 | } 60 | 61 | // Then 62 | XCTAssertNotNil(err) 63 | XCTAssertTrue(err is ResponseError) 64 | XCTAssertEqual((err as! ResponseError).code, "SOMETHING_WENT_WRONG") 65 | XCTAssertEqual((err as! ResponseError).message, "Something went wrong!") 66 | XCTAssertEqual((err as! ResponseError).metadata.statusCode, 414) 67 | } 68 | 69 | func testThatItFailsToDecodeMockIdentity() async throws { 70 | 71 | // Given 72 | let responseBody = """ 73 | {"message": "I am not an Identity JSON structure", "errorCode": "SOMETHING_WENT_WRONG"} 74 | """ 75 | let session = URLSession.mock(responseBody: responseBody.data(using: .utf8)!, statusCode: 201) 76 | let service = IdentityService() 77 | 78 | // When 79 | var err: Error? = nil 80 | do { 81 | let _ = try await service.request(with: mockCredential, using: session) 82 | } 83 | catch { 84 | err = error 85 | } 86 | 87 | // Then 88 | XCTAssertNotNil(err) 89 | XCTAssertTrue(err is DecodingError) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Tests/SwiftlySalesforceTests/IdentityTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftlySalesforce 3 | 4 | class IdentityTests: XCTestCase { 5 | 6 | func testThatItInitializes() throws { 7 | 8 | // Given 9 | let data = try load(resource: "MockIdentity", withExtension: "json") 10 | let decoder = JSONDecoder(dateDecodingStrategy: .iso8601) 11 | 12 | // When 13 | let identity = try decoder.decode(Identity.self, from: data) 14 | 15 | // Then 16 | XCTAssertNotNil(identity) 17 | XCTAssertEqual(identity.username, "john@playground.com") 18 | XCTAssertEqual(identity.state, "CA") 19 | XCTAssertNil(identity.mobilePhone) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Tests/SwiftlySalesforceTests/MockAccount.json: -------------------------------------------------------------------------------- 1 | { 2 | "attributes" : { 3 | "type" : "Account", 4 | "url" : "/services/data/v51.0/sobjects/Account/0011Y00003HVMu4QAH" 5 | }, 6 | "Id" : "0011Y00003HVMu4QAH", 7 | "IsDeleted" : false, 8 | "MasterRecordId" : null, 9 | "Name" : "Alaska Center for Performing Arts", 10 | "Type" : "Customer - Channel", 11 | "RecordTypeId" : null, 12 | "ParentId" : null, 13 | "BillingStreet" : "621 West 6th Avenue", 14 | "BillingCity" : "Anchorage", 15 | "BillingState" : "AK", 16 | "BillingPostalCode" : "99501", 17 | "BillingCountry" : "US", 18 | "BillingLatitude" : 61.217062, 19 | "BillingLongitude" : -149.894342, 20 | "BillingGeocodeAccuracy" : "Address", 21 | "BillingAddress" : { 22 | "city" : "Anchorage", 23 | "country" : "US", 24 | "geocodeAccuracy" : "Address", 25 | "latitude" : 61.217061, 26 | "longitude" : -149.894342, 27 | "postalCode" : "99501", 28 | "state" : "AK", 29 | "street" : "621 West 6th Avenue" 30 | }, 31 | "ShippingStreet" : null, 32 | "ShippingCity" : null, 33 | "ShippingState" : null, 34 | "ShippingPostalCode" : null, 35 | "ShippingCountry" : null, 36 | "ShippingLatitude" : null, 37 | "ShippingLongitude" : null, 38 | "ShippingGeocodeAccuracy" : null, 39 | "ShippingAddress" : null, 40 | "Phone" : "9075551212", 41 | "Fax" : null, 42 | "AccountNumber" : "445", 43 | "Website" : "www.coolcorp1.net", 44 | "PhotoUrl" : "/services/images/photo/0011Y00003HVMu4QAH", 45 | "Sic" : null, 46 | "Industry" : null, 47 | "AnnualRevenue" : null, 48 | "NumberOfEmployees" : null, 49 | "Ownership" : null, 50 | "TickerSymbol" : null, 51 | "Description" : "A very large customer.", 52 | "Rating" : null, 53 | "Site" : null, 54 | "OwnerId" : "005i00000016PdaAAE", 55 | "CreatedDate" : "2021-04-08T18:39:16.000+0000", 56 | "CreatedById" : "005i00000016PdaAAE", 57 | "LastModifiedDate" : "2021-04-27T19:33:04.000+0000", 58 | "LastModifiedById" : "005i00000016PdaAAE", 59 | "SystemModstamp" : "2021-04-27T19:33:04.000+0000", 60 | "LastActivityDate" : null, 61 | "LastViewedDate" : "2021-04-27T19:33:05.000+0000", 62 | "LastReferencedDate" : "2021-04-27T19:33:05.000+0000", 63 | "Jigsaw" : null, 64 | "JigsawCompanyId" : null, 65 | "AccountSource" : null, 66 | "SicDesc" : null, 67 | "playgroundorg__CustomerPriority__c" : null, 68 | "playgroundorg__SLA__c" : null, 69 | "playgroundorg__Active__c" : null, 70 | "playgroundorg__NumberofLocations__c" : null, 71 | "playgroundorg__UpsellOpportunity__c" : null, 72 | "playgroundorg__SLASerialNumber__c" : null, 73 | "playgroundorg__SLAExpirationDate__c" : null, 74 | "playgroundorg__Owners_Manager_Name__c" : "User, Test", 75 | "mikesnamespace1__Active__c" : null, 76 | "mikesnamespace1__CustomerPriority__c" : null, 77 | "mikesnamespace1__NumberofLocations__c" : null, 78 | "mikesnamespace1__SLAExpirationDate__c" : null, 79 | "mikesnamespace1__SLASerialNumber__c" : null, 80 | "mikesnamespace1__SLA__c" : null, 81 | "mikesnamespace1__UpsellOpportunity__c" : null, 82 | "playgroundorg__Custom_Date_Field__c" : "2021-04-08", 83 | "playgroundorg__Controlling_Picklist__c" : "USA", 84 | "playgroundorg__Controlled_Picklist__c" : "Chicago", 85 | "namespace2__Is_Covered__c" : true 86 | } 87 | -------------------------------------------------------------------------------- /Tests/SwiftlySalesforceTests/MockAccountMissingURLAttribute.json: -------------------------------------------------------------------------------- 1 | { 2 | "attributes" : { 3 | "type" : "Account", 4 | }, 5 | "Id" : "0011Y00003HVMu4QAH", 6 | "IsDeleted" : false, 7 | "MasterRecordId" : null, 8 | "Name" : "Alaska Center for Performing Arts", 9 | "Type" : "Customer - Channel", 10 | "RecordTypeId" : null, 11 | "ParentId" : null, 12 | "BillingStreet" : "621 West 6th Avenue", 13 | "BillingCity" : "Anchorage", 14 | "BillingState" : "AK", 15 | "BillingPostalCode" : "99501", 16 | "BillingCountry" : "US", 17 | "BillingLatitude" : 61.217061, 18 | "BillingLongitude" : -149.894342, 19 | "BillingGeocodeAccuracy" : "Address", 20 | "BillingAddress" : { 21 | "city" : "Anchorage", 22 | "country" : "US", 23 | "geocodeAccuracy" : "Address", 24 | "latitude" : 61.217061, 25 | "longitude" : -149.894342, 26 | "postalCode" : "99501", 27 | "state" : "AK", 28 | "street" : "621 West 6th Avenue" 29 | }, 30 | "ShippingStreet" : null, 31 | "ShippingCity" : null, 32 | "ShippingState" : null, 33 | "ShippingPostalCode" : null, 34 | "ShippingCountry" : null, 35 | "ShippingLatitude" : null, 36 | "ShippingLongitude" : null, 37 | "ShippingGeocodeAccuracy" : null, 38 | "ShippingAddress" : null, 39 | "Phone" : "9075551212", 40 | "Fax" : null, 41 | "AccountNumber" : "445", 42 | "Website" : "www.coolcorp1.net", 43 | "PhotoUrl" : "/services/images/photo/0011Y00003HVMu4QAH", 44 | "Sic" : null, 45 | "Industry" : null, 46 | "AnnualRevenue" : null, 47 | "NumberOfEmployees" : null, 48 | "Ownership" : null, 49 | "TickerSymbol" : null, 50 | "Description" : "A very large customer.", 51 | "Rating" : null, 52 | "Site" : null, 53 | "OwnerId" : "005i00000016PdaAAE", 54 | "CreatedDate" : "2021-04-08T18:39:16.000+0000", 55 | "CreatedById" : "005i00000016PdaAAE", 56 | "LastModifiedDate" : "2021-04-27T19:33:04.000+0000", 57 | "LastModifiedById" : "005i00000016PdaAAE", 58 | "SystemModstamp" : "2021-04-27T19:33:04.000+0000", 59 | "LastActivityDate" : null, 60 | "LastViewedDate" : "2021-04-27T19:33:05.000+0000", 61 | "LastReferencedDate" : "2021-04-27T19:33:05.000+0000", 62 | "Jigsaw" : null, 63 | "JigsawCompanyId" : null, 64 | "AccountSource" : null, 65 | "SicDesc" : null, 66 | "playgroundorg__CustomerPriority__c" : null, 67 | "playgroundorg__SLA__c" : null, 68 | "playgroundorg__Active__c" : null, 69 | "playgroundorg__NumberofLocations__c" : null, 70 | "playgroundorg__UpsellOpportunity__c" : null, 71 | "playgroundorg__SLASerialNumber__c" : null, 72 | "playgroundorg__SLAExpirationDate__c" : null, 73 | "playgroundorg__Owners_Manager_Name__c" : "User, Test", 74 | "mikesnamespace1__Active__c" : null, 75 | "mikesnamespace1__CustomerPriority__c" : null, 76 | "mikesnamespace1__NumberofLocations__c" : null, 77 | "mikesnamespace1__SLAExpirationDate__c" : null, 78 | "mikesnamespace1__SLASerialNumber__c" : null, 79 | "mikesnamespace1__SLA__c" : null, 80 | "mikesnamespace1__UpsellOpportunity__c" : null, 81 | "playgroundorg__Custom_Date_Field__c" : "2021-04-08", 82 | "playgroundorg__Controlling_Picklist__c" : "USA", 83 | "playgroundorg__Controlled_Picklist__c" : "Chicago", 84 | "namespace2__Is_Covered__c" : true 85 | } 86 | 87 | -------------------------------------------------------------------------------- /Tests/SwiftlySalesforceTests/MockAggregateQueryResult.json: -------------------------------------------------------------------------------- 1 | { 2 | "totalSize": 7, 3 | "done": true, 4 | "records": [ 5 | { 6 | "attributes": { 7 | "type": "AggregateResult" 8 | }, 9 | "WhatId": null, 10 | "ActivityDate": null, 11 | "Status": "Not Started", 12 | "MyCount": 10 13 | }, 14 | { 15 | "attributes": { 16 | "type": "AggregateResult" 17 | }, 18 | "WhatId": null, 19 | "ActivityDate": null, 20 | "Status": "Deferred", 21 | "MyCount": 3 22 | }, 23 | { 24 | "attributes": { 25 | "type": "AggregateResult" 26 | }, 27 | "WhatId": null, 28 | "ActivityDate": "2017-10-15", 29 | "Status": "Not Started", 30 | "MyCount": 1 31 | }, 32 | { 33 | "attributes": { 34 | "type": "AggregateResult" 35 | }, 36 | "WhatId": null, 37 | "ActivityDate": "2017-10-15", 38 | "Status": "Waiting on someone else", 39 | "MyCount": 1 40 | }, 41 | { 42 | "attributes": { 43 | "type": "AggregateResult" 44 | }, 45 | "WhatId": null, 46 | "ActivityDate": "2017-10-15", 47 | "Status": "Deferred", 48 | "MyCount": 1 49 | }, 50 | { 51 | "attributes": { 52 | "type": "AggregateResult" 53 | }, 54 | "WhatId": "006i0000005W4fdAAC", 55 | "ActivityDate": null, 56 | "Status": "Deferred", 57 | "MyCount": 1 58 | }, 59 | { 60 | "attributes": { 61 | "type": "AggregateResult" 62 | }, 63 | "WhatId": "500i0000001ruqeAAA", 64 | "ActivityDate": "2017-10-03", 65 | "Status": "Not Started", 66 | "MyCount": 1 67 | } 68 | ] 69 | } 70 | -------------------------------------------------------------------------------- /Tests/SwiftlySalesforceTests/MockConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "consumerKey": "consumer-key", 3 | "callbackURL": "myplaygroundapp://authorized", 4 | "authorizingHost": "login.salesforce.com" 5 | } 6 | -------------------------------------------------------------------------------- /Tests/SwiftlySalesforceTests/MockIdentity.json: -------------------------------------------------------------------------------- 1 | { 2 | "id" : "https://login.salesforce.com/id/00Di0000000bAAAAAAA/005i00000016AAAAAA", 3 | "asserted_user" : true, 4 | "user_id" : "005i00000016AAAAAA", 5 | "organization_id" : "00Di0000000bAAAAAAA", 6 | "username" : "john@playground.com", 7 | "nick_name" : "john.3739147649264", 8 | "display_name" : "John Smith", 9 | "email" : "jsmith@megacorp.com", 10 | "email_verified" : true, 11 | "first_name" : "John", 12 | "last_name" : "Smith", 13 | "timezone" : "America/Los_Angeles", 14 | "photos" : { 15 | "picture" : "https://theplayground-dev-ed--c.na161.content.force.com/profilephoto/7291Y000000ENO1/F", 16 | "thumbnail" : "https://theplayground-dev-ed--c.na161.content.force.com/profilephoto/7291Y000000ENO1/T" 17 | }, 18 | "addr_street" : "123 Mission Street", 19 | "addr_city" : "San Francisco", 20 | "addr_state" : "CA", 21 | "addr_country" : "US", 22 | "addr_zip" : null, 23 | "mobile_phone" : null, 24 | "mobile_phone_verified" : false, 25 | "is_lightning_login_user" : false, 26 | "status" : { 27 | "created_date" : null, 28 | "body" : null 29 | }, 30 | "urls" : { 31 | "enterprise" : "https://theplayground-dev-ed.my.salesforce.com/services/Soap/c/{version}/00Di0000000bAAA", 32 | "metadata" : "https://theplayground-dev-ed.my.salesforce.com/services/Soap/m/{version}/00Di0000000bAAA", 33 | "partner" : "https://theplayground-dev-ed.my.salesforce.com/services/Soap/u/{version}/00Di0000000bAAA", 34 | "rest" : "https://theplayground-dev-ed.my.salesforce.com/services/data/v{version}/", 35 | "sobjects" : "https://theplayground-dev-ed.my.salesforce.com/services/data/v{version}/sobjects/", 36 | "search" : "https://theplayground-dev-ed.my.salesforce.com/services/data/v{version}/search/", 37 | "query" : "https://theplayground-dev-ed.my.salesforce.com/services/data/v{version}/query/", 38 | "recent" : "https://theplayground-dev-ed.my.salesforce.com/services/data/v{version}/recent/", 39 | "tooling_soap" : "https://theplayground-dev-ed.my.salesforce.com/services/Soap/T/{version}/00Di0000000bAAA", 40 | "tooling_rest" : "https://theplayground-dev-ed.my.salesforce.com/services/data/v{version}/tooling/", 41 | "profile" : "https://theplayground-dev-ed.my.salesforce.com/005i00000016AAAAAA", 42 | "feeds" : "https://theplayground-dev-ed.my.salesforce.com/services/data/v{version}/chatter/feeds", 43 | "groups" : "https://theplayground-dev-ed.my.salesforce.com/services/data/v{version}/chatter/groups", 44 | "users" : "https://theplayground-dev-ed.my.salesforce.com/services/data/v{version}/chatter/users", 45 | "feed_items" : "https://theplayground-dev-ed.my.salesforce.com/services/data/v{version}/chatter/feed-items", 46 | "feed_elements" : "https://theplayground-dev-ed.my.salesforce.com/services/data/v{version}/chatter/feed-elements", 47 | "custom_domain" : "https://theplayground-dev-ed.my.salesforce.com" 48 | }, 49 | "active" : true, 50 | "user_type" : "STANDARD", 51 | "language" : "en_US", 52 | "locale" : "en_US", 53 | "utcOffset" : -28800000, 54 | "last_modified_date" : "2022-02-15T23:21:10Z", 55 | "is_app_installed" : true 56 | } 57 | -------------------------------------------------------------------------------- /Tests/SwiftlySalesforceTests/MockLimits.json: -------------------------------------------------------------------------------- 1 | { 2 | "AnalyticsExternalDataSizeMB": { 3 | "Max": 40960, 4 | "Remaining": 40960 5 | }, 6 | "BOZosCalloutHourlyLimit": { 7 | "Max": 20000, 8 | "Remaining": 20000 9 | }, 10 | "ConcurrentAsyncGetReportInstances": { 11 | "Max": 200, 12 | "Remaining": 200 13 | }, 14 | "ConcurrentEinsteinDataInsightsStoryCreation": { 15 | "Max": 5, 16 | "Remaining": 5 17 | }, 18 | "ConcurrentEinsteinDiscoveryStoryCreation": { 19 | "Max": 2, 20 | "Remaining": 2 21 | }, 22 | "ConcurrentSyncReportRuns": { 23 | "Max": 20, 24 | "Remaining": 20 25 | }, 26 | "DailyAnalyticsDataflowJobExecutions": { 27 | "Max": 60, 28 | "Remaining": 60 29 | }, 30 | "DailyAnalyticsUploadedFilesSizeMB": { 31 | "Max": 51200, 32 | "Remaining": 51200 33 | }, 34 | "DailyApiRequests": { 35 | "Max": 15000, 36 | "Remaining": 14953 37 | }, 38 | "DailyAsyncApexExecutions": { 39 | "Max": 250000, 40 | "Remaining": 250000 41 | }, 42 | "DailyBulkApiBatches": { 43 | "Max": 15000, 44 | "Remaining": 15000, 45 | "Ant Migration Tool": { 46 | "Max": 0, 47 | "Remaining": 0 48 | }, 49 | "Dataloader Bulk": { 50 | "Max": 0, 51 | "Remaining": 0 52 | }, 53 | "Dataloader Partner": { 54 | "Max": 0, 55 | "Remaining": 0 56 | }, 57 | "Force.com IDE": { 58 | "Max": 0, 59 | "Remaining": 0 60 | }, 61 | "Limits": { 62 | "Max": 0, 63 | "Remaining": 0 64 | }, 65 | "My Playground App": { 66 | "Max": 0, 67 | "Remaining": 0 68 | }, 69 | "My Salesforce Accounts": { 70 | "Max": 0, 71 | "Remaining": 0 72 | }, 73 | "My Test Canvas App": { 74 | "Max": 0, 75 | "Remaining": 0 76 | }, 77 | "Salesforce Mobile Dashboards": { 78 | "Max": 0, 79 | "Remaining": 0 80 | }, 81 | "Salesforce Touch": { 82 | "Max": 0, 83 | "Remaining": 0 84 | }, 85 | "Salesforce for Outlook": { 86 | "Max": 0, 87 | "Remaining": 0 88 | }, 89 | "TEST APP": { 90 | "Max": 0, 91 | "Remaining": 0 92 | }, 93 | "Workbench": { 94 | "Max": 0, 95 | "Remaining": 0 96 | } 97 | }, 98 | "DailyBulkV2QueryFileStorageMB": { 99 | "Max": 976562, 100 | "Remaining": 976562 101 | }, 102 | "DailyBulkV2QueryJobs": { 103 | "Max": 10000, 104 | "Remaining": 10000 105 | }, 106 | "DailyDurableGenericStreamingApiEvents": { 107 | "Max": 10000, 108 | "Remaining": 10000 109 | }, 110 | "DailyDurableStreamingApiEvents": { 111 | "Max": 10000, 112 | "Remaining": 10000 113 | }, 114 | "DailyEinsteinDataInsightsStoryCreation": { 115 | "Max": 1000, 116 | "Remaining": 1000 117 | }, 118 | "DailyEinsteinDiscoveryPredictAPICalls": { 119 | "Max": 50000, 120 | "Remaining": 50000 121 | }, 122 | "DailyEinsteinDiscoveryPredictionsByCDC": { 123 | "Max": 500000, 124 | "Remaining": 500000 125 | }, 126 | "DailyEinsteinDiscoveryStoryCreation": { 127 | "Max": 100, 128 | "Remaining": 100 129 | }, 130 | "DailyFunctionsApiCallLimit": { 131 | "Max": 50000, 132 | "Remaining": 50000 133 | }, 134 | "DailyGenericStreamingApiEvents": { 135 | "Max": 10000, 136 | "Remaining": 10000, 137 | "Ant Migration Tool": { 138 | "Max": 0, 139 | "Remaining": 0 140 | }, 141 | "Dataloader Bulk": { 142 | "Max": 0, 143 | "Remaining": 0 144 | }, 145 | "Dataloader Partner": { 146 | "Max": 0, 147 | "Remaining": 0 148 | }, 149 | "Force.com IDE": { 150 | "Max": 0, 151 | "Remaining": 0 152 | }, 153 | "Limits": { 154 | "Max": 0, 155 | "Remaining": 0 156 | }, 157 | "My Playground App": { 158 | "Max": 0, 159 | "Remaining": 0 160 | }, 161 | "My Salesforce Accounts": { 162 | "Max": 0, 163 | "Remaining": 0 164 | }, 165 | "My Test Canvas App": { 166 | "Max": 0, 167 | "Remaining": 0 168 | }, 169 | "Salesforce Mobile Dashboards": { 170 | "Max": 0, 171 | "Remaining": 0 172 | }, 173 | "Salesforce Touch": { 174 | "Max": 0, 175 | "Remaining": 0 176 | }, 177 | "Salesforce for Outlook": { 178 | "Max": 0, 179 | "Remaining": 0 180 | }, 181 | "TEST APP": { 182 | "Max": 0, 183 | "Remaining": 0 184 | }, 185 | "Workbench": { 186 | "Max": 0, 187 | "Remaining": 0 188 | } 189 | }, 190 | "DailyStandardVolumePlatformEvents": { 191 | "Max": 10000, 192 | "Remaining": 10000 193 | }, 194 | "DailyStreamingApiEvents": { 195 | "Max": 10000, 196 | "Remaining": 10000, 197 | "Ant Migration Tool": { 198 | "Max": 0, 199 | "Remaining": 0 200 | }, 201 | "Dataloader Bulk": { 202 | "Max": 0, 203 | "Remaining": 0 204 | }, 205 | "Dataloader Partner": { 206 | "Max": 0, 207 | "Remaining": 0 208 | }, 209 | "Force.com IDE": { 210 | "Max": 0, 211 | "Remaining": 0 212 | }, 213 | "Limits": { 214 | "Max": 0, 215 | "Remaining": 0 216 | }, 217 | "My Playground App": { 218 | "Max": 0, 219 | "Remaining": 0 220 | }, 221 | "My Salesforce Accounts": { 222 | "Max": 0, 223 | "Remaining": 0 224 | }, 225 | "My Test Canvas App": { 226 | "Max": 0, 227 | "Remaining": 0 228 | }, 229 | "Salesforce Mobile Dashboards": { 230 | "Max": 0, 231 | "Remaining": 0 232 | }, 233 | "Salesforce Touch": { 234 | "Max": 0, 235 | "Remaining": 0 236 | }, 237 | "Salesforce for Outlook": { 238 | "Max": 0, 239 | "Remaining": 0 240 | }, 241 | "TEST APP": { 242 | "Max": 0, 243 | "Remaining": 0 244 | }, 245 | "Workbench": { 246 | "Max": 0, 247 | "Remaining": 0 248 | } 249 | }, 250 | "DailyWorkflowEmails": { 251 | "Max": 195, 252 | "Remaining": 195 253 | }, 254 | "DataStorageMB": { 255 | "Max": 5, 256 | "Remaining": 3 257 | }, 258 | "DurableStreamingApiConcurrentClients": { 259 | "Max": 20, 260 | "Remaining": 20 261 | }, 262 | "FileStorageMB": { 263 | "Max": 20, 264 | "Remaining": 20 265 | }, 266 | "HourlyAsyncReportRuns": { 267 | "Max": 1200, 268 | "Remaining": 1200 269 | }, 270 | "HourlyDashboardRefreshes": { 271 | "Max": 200, 272 | "Remaining": 200 273 | }, 274 | "HourlyDashboardResults": { 275 | "Max": 5000, 276 | "Remaining": 5000 277 | }, 278 | "HourlyDashboardStatuses": { 279 | "Max": 999999999, 280 | "Remaining": 999999999 281 | }, 282 | "HourlyLongTermIdMapping": { 283 | "Max": 100000, 284 | "Remaining": 100000 285 | }, 286 | "HourlyManagedContentPublicRequests": { 287 | "Max": 50000, 288 | "Remaining": 50000 289 | }, 290 | "HourlyODataCallout": { 291 | "Max": 1000, 292 | "Remaining": 1000 293 | }, 294 | "HourlyPublishedPlatformEvents": { 295 | "Max": 50000, 296 | "Remaining": 50000 297 | }, 298 | "HourlyPublishedStandardVolumePlatformEvents": { 299 | "Max": 1000, 300 | "Remaining": 1000 301 | }, 302 | "HourlyShortTermIdMapping": { 303 | "Max": 100000, 304 | "Remaining": 100000 305 | }, 306 | "HourlySyncReportRuns": { 307 | "Max": 500, 308 | "Remaining": 500 309 | }, 310 | "HourlyTimeBasedWorkflow": { 311 | "Max": 50, 312 | "Remaining": 50 313 | }, 314 | "MassEmail": { 315 | "Max": 10, 316 | "Remaining": 10 317 | }, 318 | "MonthlyEinsteinDiscoveryStoryCreation": { 319 | "Max": 500, 320 | "Remaining": 500 321 | }, 322 | "MonthlyPlatformEventsUsageEntitlement": { 323 | "Max": 0, 324 | "Remaining": 0 325 | }, 326 | "Package2VersionCreates": { 327 | "Max": 6, 328 | "Remaining": 6 329 | }, 330 | "Package2VersionCreatesWithoutValidation": { 331 | "Max": 500, 332 | "Remaining": 500 333 | }, 334 | "PermissionSets": { 335 | "Max": 1500, 336 | "Remaining": 1491, 337 | "CreateCustom": { 338 | "Max": 1000, 339 | "Remaining": 992 340 | } 341 | }, 342 | "PrivateConnectOutboundCalloutHourlyLimitMB": { 343 | "Max": 0, 344 | "Remaining": 0 345 | }, 346 | "SingleEmail": { 347 | "Max": 15, 348 | "Remaining": 15 349 | }, 350 | "StreamingApiConcurrentClients": { 351 | "Max": 20, 352 | "Remaining": 20 353 | } 354 | } 355 | -------------------------------------------------------------------------------- /Tests/SwiftlySalesforceTests/MockSearchResults.json: -------------------------------------------------------------------------------- 1 | { 2 | "searchRecords": [ 3 | { 4 | "attributes": { 5 | "type": "Contact", 6 | "url": "/services/data/v53.0/sobjects/Contact/0035d00006bOrFOAA0" 7 | }, 8 | "FirstName": "Dirk", 9 | "LastName": "Birk", 10 | "Department": null 11 | }, 12 | { 13 | "attributes": { 14 | "type": "Contact", 15 | "url": "/services/data/v53.0/sobjects/Contact/003i000000DsuoGAAR" 16 | }, 17 | "FirstName": "Rose", 18 | "LastName": "Gonzalez", 19 | "Department": "Procurement" 20 | }, 21 | { 22 | "attributes": { 23 | "type": "Contact", 24 | "url": "/services/data/v53.0/sobjects/Contact/003i000000DsuoHAAR" 25 | }, 26 | "FirstName": "Sean", 27 | "LastName": "Forbes", 28 | "Department": "Finance" 29 | }, 30 | { 31 | "attributes": { 32 | "type": "Account", 33 | "url": "/services/data/v53.0/sobjects/Account/0011Y00003I0OpPQAV" 34 | }, 35 | "Name": "Acme Construction Co., Inc.", 36 | "BillingCity": "Round Rock", 37 | "NumberOfEmployees": null 38 | }, 39 | { 40 | "attributes": { 41 | "type": "Account", 42 | "url": "/services/data/v53.0/sobjects/Account/001i000000JEK7mAAH" 43 | }, 44 | "Name": "Edge Communications", 45 | "BillingCity": "Champaign", 46 | "NumberOfEmployees": 1000 47 | }, 48 | { 49 | "attributes": { 50 | "type": "Case", 51 | "url": "/services/data/v53.0/sobjects/Case/500i0000001ruqgAAA" 52 | }, 53 | "CaseNumber": "00001002" 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /Tests/SwiftlySalesforceTests/MockURLProtocol.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | // Adapted from https://augmentedcode.io/2019/05/26/testing-networking-code-with-custom-urlprotocol-on-ios/ 5 | final class MockURLProtocol: URLProtocol { 6 | 7 | override class func canInit(with request: URLRequest) -> Bool { 8 | return true 9 | } 10 | 11 | override class func canonicalRequest(for request: URLRequest) -> URLRequest { 12 | return request 13 | } 14 | 15 | static var loadingHandler: ((URLRequest) -> (HTTPURLResponse, Data?, Error?))? 16 | 17 | override func startLoading() { 18 | 19 | guard let handler = MockURLProtocol.loadingHandler else { 20 | return 21 | } 22 | let (response, data, error) = handler(request) 23 | if let data = data { 24 | client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) 25 | client?.urlProtocol(self, didLoad: data) 26 | client?.urlProtocolDidFinishLoading(self) 27 | } 28 | else { 29 | client?.urlProtocol(self, didFailWithError: error!) 30 | } 31 | } 32 | 33 | override func stopLoading() { 34 | // Do nothing 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Tests/SwiftlySalesforceTests/RecordTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftlySalesforce 3 | 4 | class RecordTests: XCTestCase { 5 | 6 | let decoder = JSONDecoder(dateFormatter: .salesforce(.long)) 7 | 8 | override func setUpWithError() throws { 9 | // Put setup code here. This method is called before the invocation of each test method in the class. 10 | } 11 | 12 | override func tearDownWithError() throws { 13 | // Put teardown code here. This method is called after the invocation of each test method in the class. 14 | } 15 | 16 | func testThatItInitializesWithJSONData() throws { 17 | 18 | // Given 19 | let data = try load(resource: "MockAccount") 20 | 21 | // When 22 | let record = try decoder.decode(Record.self, from: data) 23 | 24 | // Then 25 | XCTAssertEqual(record.id, "0011Y00003HVMu4QAH") 26 | XCTAssertEqual(record.type, "Account") 27 | XCTAssertFalse(record["IsDeleted"]! as Bool) 28 | XCTAssertTrue(record["namespace2__Is_Covered__c"]! as Bool) 29 | XCTAssertEqual(record["BillingState"]! as String, "AK") 30 | XCTAssert(record["BillingLatitude"]! as Double == 61.217061) 31 | let billingAddress: Address = record["BillingAddress"]! 32 | XCTAssert(billingAddress.state == "AK") 33 | XCTAssert(billingAddress.latitude == 61.217061) 34 | XCTAssert(record["ParentId"] as String? == nil) 35 | XCTAssert(record["Type"] as String? != nil) 36 | let lastModDate: Date = record["LastModifiedDate"]! 37 | let components = Calendar(identifier: .gregorian).dateComponents([.minute], from: lastModDate) 38 | XCTAssert(components.minute == 33) 39 | } 40 | 41 | func testThatItInitializesWithAggregateQueryResult() throws { 42 | 43 | // Given 44 | let data = try load(resource: "MockAggregateQueryResult") 45 | 46 | // When 47 | let result = try decoder.decode(QueryResult.self, from: data) 48 | 49 | // Then 50 | XCTAssertTrue(result.records.count > 0) 51 | for record in result.records { 52 | XCTAssertEqual(record.type, "AggregateResult") 53 | XCTAssertEqual(record.id, "") 54 | } 55 | } 56 | 57 | func testThatItFailsToInitializeWithBadRecordJSON() throws { 58 | 59 | // Given 60 | let data = try load(resource: "MockAccountMissingURLAttribute") 61 | 62 | // When 63 | let result = try? decoder.decode(Record.self, from: data) 64 | 65 | // Then 66 | XCTAssertNil(result) 67 | } 68 | 69 | func testThatHelperMethodsWork() throws { 70 | 71 | // Given 72 | let data = try load(resource: "MockAccount") 73 | 74 | // When 75 | let record = try decoder.decode(Record.self, from: data) 76 | 77 | // Then 78 | XCTAssertTrue(record.hasField(named: "Name")) 79 | XCTAssertFalse(record.hasField(named: UUID().uuidString)) 80 | XCTAssertNotNil(record.double(forField: "BillingLatitude")) 81 | XCTAssertNil(record.string(forField: "BillingLatitude")) 82 | XCTAssertNotNil(record.string(forField: "Website")) 83 | XCTAssertNotNil(record.url(forField: "Website")) 84 | XCTAssertNotNil(record.date(forField: "CreatedDate")) 85 | XCTAssertNil(record.int(forField: "CreatedDate")) 86 | XCTAssertNil(record.date(forField: "LastActivityDate")) 87 | XCTAssertNotNil(record.address(forField: "BillingAddress")) 88 | XCTAssert(record.address(forField: "BillingAddress")!.city == "Anchorage") 89 | XCTAssertNotNil(record.float(forField: "BillingLatitude")) 90 | XCTAssertNil(record.float(forField: "BillingCity")) 91 | XCTAssertTrue(record.bool(forField: "namespace2__Is_Covered__c")!) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Tests/SwiftlySalesforceTests/Resource_LimitsTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftlySalesforce 3 | 4 | class Resource_LimitsTests: DataServiceTests { 5 | 6 | func testThatItCreatesURLRequest() throws { 7 | 8 | // Given 9 | let service = Resource.Limits() 10 | 11 | // When 12 | let req = try service.createRequest(with: mockCredential) 13 | 14 | // Then 15 | XCTAssertEqual(req.httpMethod?.uppercased(), HTTP.Method.get.uppercased()) 16 | XCTAssertEqual(req.value(forHTTPHeaderField: "Authorization"), "Bearer \(mockCredential.accessToken)") 17 | XCTAssertEqual(req.url?.path, "/services/data/v\(Resource.defaultVersion)/limits") 18 | } 19 | 20 | func testThatItLoadsMockLimits() async throws { 21 | 22 | // Given 23 | let data = try load(resource: "MockLimits") 24 | let session = URLSession.mock(responseBody: data, statusCode: 200) 25 | let service = Resource.Limits() 26 | 27 | // When 28 | let limits = try await service.request(with: mockCredential, using: session) 29 | 30 | // Then 31 | XCTAssertTrue(limits.count > 0) 32 | XCTAssertTrue(limits["DailyApiRequests"]!.remaining >= 0) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Tests/SwiftlySalesforceTests/Resource_QueryTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftlySalesforce 3 | 4 | class Resource_QueryTests: XCTestCase { 5 | 6 | func testThatItLoadsMockAggregateQueryResults() async throws { 7 | 8 | // Given 9 | let data = try load(resource: "MockAggregateQueryResult") 10 | let soql = "" 11 | let session = URLSession.mock(responseBody: data, statusCode: 200) 12 | let service = Resource.Query.Run(soql: soql) 13 | 14 | // When 15 | let queryResults = try await service.request(with: mockCredential, using: session) 16 | 17 | // Then 18 | XCTAssertTrue(queryResults.records.count > 0) 19 | for record in queryResults.records { 20 | XCTAssertEqual(record.type, "AggregateResult") 21 | XCTAssertTrue(record.int(forField: "MyCount")! >= 0) 22 | } 23 | } 24 | 25 | func testThatItQueries() async throws { 26 | 27 | // Given 28 | let soql = "SELECT ActivityDate, Status, Count(Id) MyCount FROM Task WHERE CreatedDate > 2011-04-26T10:00:00+01:00 GROUP BY ActivityDate, Status" 29 | let service = Resource.Query.Run(soql: soql) 30 | 31 | // When 32 | let queryResults = try await XCTestCase.connection.request(service: service) 33 | 34 | // Then 35 | for record in queryResults.records { 36 | XCTAssertEqual(record.type, "AggregateResult") 37 | XCTAssertEqual(record.id, "") 38 | } 39 | } 40 | 41 | func testThatItHandlesResponseError() async throws { 42 | 43 | // Given 44 | let soql = "SELECT FieldThatDoesNotExist FROM Account" 45 | let service = Resource.Query.Run(soql: soql) 46 | var err: Error? = nil 47 | 48 | // When 49 | do { 50 | let _ = try await XCTestCase.connection.request(service: service) 51 | } 52 | catch { 53 | err = error 54 | } 55 | 56 | // Then 57 | XCTAssertNotNil(err) 58 | XCTAssertTrue(err is ResponseError) 59 | } 60 | 61 | func testThatItCreatesRequestForMyAccountsWithLimit() async throws { 62 | 63 | // Given 64 | let service = Resource.Query.MyRecords(type: "SomeObject", batchSize: 203) 65 | 66 | // When 67 | let req = try service.createRequest(with: mockCredential) 68 | let comps = URLComponents(url: req.url!, resolvingAgainstBaseURL: false)! 69 | let soql: String = comps.queryItems!.first { $0.name == "q" }!.value! 70 | 71 | // Then 72 | XCTAssertTrue(soql.hasSuffix("LIMIT 200")) 73 | XCTAssertTrue(soql.contains("FIELDS(ALL)")) 74 | XCTAssertTrue(soql.filter{ !$0.isWhitespace }.contains("OwnerId='\(mockCredential.userID)'")) 75 | XCTAssertEqual(req.value(forHTTPHeaderField: "Sforce-Query-Options"), "batchSize=203") 76 | } 77 | 78 | func testThatItCreatesRequestForMyAccountsWithoutLimit() async throws { 79 | 80 | // Given 81 | let service = Resource.Query.MyRecords(type: "SomeObject", fields: ["Name","LastModifiedDate"]) 82 | 83 | // When 84 | let req = try service.createRequest(with: mockCredential) 85 | let comps = URLComponents(url: req.url!, resolvingAgainstBaseURL: false)! 86 | let soql: String = comps.queryItems!.first { $0.name == "q" }!.value! 87 | 88 | // Then 89 | XCTAssertFalse(soql.hasSuffix("LIMIT 200")) 90 | XCTAssertFalse(soql.contains("FIELDS(ALL)")) 91 | XCTAssertTrue(soql.contains("SELECT Name,LastModifiedDate FROM SomeObject")) 92 | XCTAssertTrue(soql.filter{ !$0.isWhitespace }.contains("OwnerId='\(mockCredential.userID)'")) 93 | } 94 | 95 | func testThatItQueriesMyAccounts() async throws { 96 | 97 | // Given 98 | struct MyAccount: Decodable { 99 | var Id: String 100 | var Name: String 101 | var LastModifiedDate: Date 102 | var OwnerId: String 103 | var BillingCountry: String? 104 | } 105 | let service = Resource.Query.MyRecords(type: "Account", fields: ["Id", "Name", "LastModifiedDate", "OwnerId", "BillingCountry"], batchSize: 203) 106 | 107 | // When 108 | let queryResults = try await XCTestCase.connection.request(service: service) 109 | 110 | // Then 111 | XCTAssertTrue(queryResults.records.dropLast().allSatisfy { $0.OwnerId == queryResults.records.last?.OwnerId }) 112 | } 113 | 114 | func testThatItQueriesNextPageIfAvailable() async throws { 115 | 116 | // Given 117 | let soql = "SELECT Id,Name FROM Account" 118 | let service = Resource.Query.Run(soql: soql, batchSize: 203) 119 | 120 | // When 121 | if let path = try await XCTestCase.connection.request(service: service).nextRecordsPath { 122 | let nextPage = try await XCTestCase.connection.request(service: Resource.Query.NextResultsPage(path: path, batchSize: 204)) 123 | XCTAssertTrue(nextPage.isDone == (nextPage.nextRecordsPath == nil)) 124 | } 125 | 126 | // Then 127 | // Done 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /Tests/SwiftlySalesforceTests/Resource_SObjectsTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftlySalesforce 3 | 4 | class Resource_SObjectsTests: DataServiceTests { 5 | 6 | func testThatItCreatesCreateRecordRequest() throws { 7 | 8 | // Given 9 | let fields = ["Name": "Acme Corp."] 10 | let service = Resource.SObjects.Create(type: "Account", fields: fields) 11 | 12 | // When 13 | let req = try service.createRequest(with: mockCredential) 14 | 15 | // Then 16 | XCTAssertEqual(req.httpMethod?.uppercased(), HTTP.Method.post.uppercased()) 17 | XCTAssertEqual(req.value(forHTTPHeaderField: "Authorization"), "Bearer \(mockCredential.accessToken)") 18 | XCTAssertEqual(req.url?.path, "/services/data/v\(Resource.defaultVersion)/sobjects/Account") 19 | XCTAssertEqual(req.httpBody!, try JSONEncoder().encode(fields)) 20 | } 21 | 22 | func testThatItHandlesMockCreateRecordResult() async throws { 23 | 24 | // Given 25 | let data = "{\"id\":\"0015d00003TCWCUAA5\",\"success\":true,\"errors\":[]}".data(using: .utf8)! 26 | let session = URLSession.mock(responseBody: data, statusCode: 201) 27 | let service = Resource.SObjects.Create(type: "Account", fields: ["Name": "Acme Corp."]) 28 | 29 | // When 30 | let recordID = try await service.request(with: mockCredential, using: session) 31 | 32 | // Then 33 | XCTAssertEqual(recordID, "0015d00003TCWCUAA5") 34 | } 35 | 36 | func testThatItFailsToCreateRecord() async throws { 37 | 38 | // Given 39 | let fields = ["BillingCity": "Austin"] 40 | let service = Resource.SObjects.Create(type: "Account", fields: fields) 41 | 42 | // When 43 | var err: Error? 44 | do { 45 | let _ = try await XCTestCase.connection.request(service: service) 46 | } 47 | catch { 48 | err = error 49 | } 50 | 51 | // Then 52 | XCTAssertNotNil(err) 53 | XCTAssertTrue(err is ResponseError) 54 | XCTAssertEqual((err as! ResponseError).code, "REQUIRED_FIELD_MISSING") 55 | } 56 | 57 | func testThatItCreatesReadRecordRequest() throws { 58 | 59 | // Given 60 | let service = Resource.SObjects.Read(type: "Account", id: ":ID", fields: ["Id", "Name", "CustomField__c"]) 61 | 62 | // When 63 | let req = try service.createRequest(with: mockCredential) 64 | 65 | // Then 66 | let comps = URLComponents(url: req.url!, resolvingAgainstBaseURL: false)! 67 | let fields: String = comps.queryItems!.first { $0.name == "fields" }!.value! 68 | XCTAssertEqual(req.httpMethod?.uppercased(), HTTP.Method.get.uppercased()) 69 | XCTAssertEqual(req.value(forHTTPHeaderField: "Authorization"), "Bearer \(mockCredential.accessToken)") 70 | XCTAssertEqual(req.url?.path, "/services/data/v\(Resource.defaultVersion)/sobjects/Account/:ID") 71 | XCTAssertEqual(fields.filter{ !$0.isWhitespace }, "Id,Name,CustomField__c") 72 | } 73 | 74 | func testThatItReadsMockRecord() async throws { 75 | 76 | // Given 77 | let data = try load(resource: "MockAccount") 78 | let session = URLSession.mock(responseBody: data, statusCode: 200) 79 | let service = Resource.SObjects.Read(type: "", id: "") 80 | 81 | // When 82 | let record = try await service.request(with: mockCredential, using: session) 83 | 84 | // Then 85 | XCTAssertNotNil(record.id) 86 | } 87 | 88 | func testThatItHandlesMockFailureToReadRecord() async throws { 89 | 90 | // Given 91 | let data = """ 92 | [{"errorCode":"NOT_FOUND","message":"Provided external ID field does not exist or is not accessible: 123"}] 93 | """.data(using: .utf8)! 94 | let session = URLSession.mock(responseBody: data, statusCode: 404) 95 | let service = Resource.SObjects.Read(type: "", id: "") 96 | 97 | // When 98 | var err: Error? 99 | do { 100 | let _ = try await service.request(with: mockCredential, using: session) 101 | } 102 | catch { 103 | err = error 104 | } 105 | 106 | // Then 107 | XCTAssertNotNil(err) 108 | XCTAssertTrue(err is ResponseError) 109 | XCTAssertEqual((err as! ResponseError).code, "NOT_FOUND") 110 | } 111 | 112 | func testThatItCreatesUpdateRecordRequest() throws { 113 | 114 | // Given 115 | let fields = ["Name": "Acme Corp."] 116 | let service = Resource.SObjects.Update(type: "Account", id: ":ID", fields: fields) 117 | 118 | // When 119 | let req = try service.createRequest(with: mockCredential) 120 | 121 | // Then 122 | XCTAssertEqual(req.httpMethod?.uppercased(), HTTP.Method.patch.uppercased()) 123 | XCTAssertEqual(req.value(forHTTPHeaderField: "Authorization"), "Bearer \(mockCredential.accessToken)") 124 | XCTAssertEqual(req.url?.path, "/services/data/v\(Resource.defaultVersion)/sobjects/Account/:ID") 125 | XCTAssertEqual(req.httpBody!, try JSONEncoder().encode(fields)) 126 | } 127 | 128 | func testThatItCreatesDeleteRecordRequest() throws { 129 | 130 | // Given 131 | let service = Resource.SObjects.Delete(type: "CustomObject__c", id: ":ID") 132 | 133 | // When 134 | let req = try service.createRequest(with: mockCredential) 135 | 136 | // Then 137 | XCTAssertEqual(req.httpMethod?.uppercased(), HTTP.Method.delete.uppercased()) 138 | XCTAssertEqual(req.value(forHTTPHeaderField: "Authorization"), "Bearer \(mockCredential.accessToken)") 139 | XCTAssertEqual(req.url?.path, "/services/data/v\(Resource.defaultVersion)/sobjects/CustomObject__c/:ID") 140 | } 141 | 142 | func testThatItCreatesUpdatesAndDeletesRecord() async throws { 143 | 144 | // Given 145 | let uuid = UUID().uuidString 146 | 147 | // When 148 | let id = try await XCTestCase.connection.request(service: Resource.SObjects.Create(type: "Account", fields: ["Name": uuid])) 149 | try await XCTestCase.connection.request(service: Resource.SObjects.Update(type: "Account", id: id, fields: ["BillingCity": uuid])) 150 | let account = try await XCTestCase.connection.request(service: Resource.SObjects.Read(type: "Account", id: id)) 151 | try await XCTestCase.connection.request(service: Resource.SObjects.Delete(type: "Account", id: id)) // Remove the just-created record 152 | let queryResult = try await XCTestCase.connection.request(service: Resource.Query.Run(soql: "SELECT Id FROM Account WHERE Id = '\(id)'")) 153 | 154 | // Then 155 | XCTAssertEqual(account.string(forField: "BillingCity"), uuid) 156 | XCTAssertEqual(queryResult.totalSize, 0) 157 | } 158 | 159 | func testThatItCreatesDescribeObjectRequest() async throws { 160 | 161 | // Given 162 | let service = Resource.SObjects.Describe(type: "CustomObject__c") 163 | 164 | // When 165 | let req = try service.createRequest(with: mockCredential) 166 | 167 | // Then 168 | XCTAssertEqual(req.httpMethod?.uppercased(), HTTP.Method.get.uppercased()) 169 | XCTAssertEqual(req.value(forHTTPHeaderField: "Authorization"), "Bearer \(mockCredential.accessToken)") 170 | XCTAssertEqual(req.url?.path, "/services/data/v\(Resource.defaultVersion)/sobjects/CustomObject__c/describe") 171 | } 172 | 173 | func testThatItLoadsMockObjectDescribe() async throws { 174 | 175 | // Given 176 | let data = try load(resource: "MockAccountMetadata") 177 | let session = URLSession.mock(responseBody: data, statusCode: 200) 178 | let service = Resource.SObjects.Describe(type: "SomeSObject") 179 | 180 | // When 181 | let metadata = try await service.request(with: mockCredential, using: session) 182 | 183 | // Then 184 | XCTAssertEqual(metadata.name, "Account") 185 | } 186 | 187 | func testThatItDescribesAllObjects() async throws { 188 | 189 | // Given 190 | let service = Resource.SObjects.DescribeGlobal() 191 | 192 | // When 193 | let describes = try await XCTestCase.connection.request(service: service) 194 | 195 | // Then 196 | XCTAssertTrue(describes.count > 0) 197 | XCTAssertTrue(describes.contains { $0.name == "Account" }) 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /Tests/SwiftlySalesforceTests/Resource_SearchTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftlySalesforce 3 | 4 | class Resource_SearchTests: DataServiceTests { 5 | 6 | func testThatItLoadsMockSearchResults() async throws { 7 | 8 | // Given 9 | let data = try load(resource: "MockSearchResults") 10 | let sosl = "" 11 | let session = URLSession.mock(responseBody: data, statusCode: 200) 12 | let service = Resource.Search(sosl: sosl) 13 | 14 | // When 15 | let searchResults = try await service.request(with: mockCredential, using: session) 16 | 17 | // Then 18 | XCTAssertTrue(searchResults.count > 0) 19 | XCTAssertEqual(searchResults.first!.type, "Contact") 20 | XCTAssertEqual(searchResults[searchResults.count - 2].int(forField: "NumberOfEmployees"), 1000) 21 | } 22 | 23 | func testThatItSearches() async throws { 24 | 25 | // Given 26 | let sosl = "FIND {*ac*} IN Name FIELDS RETURNING Account(Id, Name, BillingStreet, BillingCity, BillingPostalCode, BillingState, BillingCountry, LastActivityDate), Contact(Id, Name, MailingStreet, MailingCity, MailingPostalCode, MailingState, MailingCountry), Opportunity(Id, Name, ExpectedRevenue)" 27 | let service = Resource.Search(sosl: sosl) 28 | 29 | // When 30 | let searchResults = try await XCTestCase.connection.request(service: service) 31 | 32 | // Then 33 | XCTAssertNotNil(searchResults) 34 | } 35 | 36 | func testThatItHandlesSyntaxError() async throws { 37 | 38 | // Given 39 | let sosl = "FIND %ac% IN Name FIELDS RETURNING Account(Id, Name)" // Badly formed SOSL string 40 | let service = Resource.Search(sosl: sosl) 41 | 42 | // When 43 | var err: Error? 44 | do { 45 | let _ = try await XCTestCase.connection.request(service: service) 46 | } 47 | catch { 48 | err = error 49 | } 50 | 51 | // Then 52 | XCTAssertNotNil(err) 53 | XCTAssertTrue(err is ResponseError) 54 | XCTAssertTrue((400..<500).contains((err as! ResponseError).metadata.statusCode)) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Tests/SwiftlySalesforceTests/SalesforceTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftlySalesforce 3 | 4 | class SalesforceTests: XCTestCase { 5 | 6 | override func setUpWithError() throws { 7 | // Put setup code here. This method is called before the invocation of each test method in the class. 8 | } 9 | 10 | override func tearDownWithError() throws { 11 | // Put teardown code here. This method is called after the invocation of each test method in the class. 12 | } 13 | 14 | func testThatItConnectsWithConfigURL() throws { 15 | 16 | // Given 17 | let url = Bundle(for: type(of: self)).url(forResource: "MockConfig", withExtension: "json")! 18 | 19 | // When 20 | let connection = try Salesforce.connect(configurationURL: url) 21 | 22 | // Then 23 | XCTAssertNotNil(connection.authorizer) 24 | XCTAssertTrue(connection.authorizer is DefaultAuthorizer) 25 | XCTAssertTrue(connection.credentialStore is DefaultCredentialStore) 26 | } 27 | 28 | func testThatItConnectsWithDefaultConfig() throws { 29 | 30 | // Given 31 | 32 | // When 33 | let connection = try Salesforce.connect() 34 | 35 | // Then 36 | XCTAssertNotNil(connection.authorizer) 37 | XCTAssertTrue(connection.authorizer is DefaultAuthorizer) 38 | XCTAssertTrue(connection.credentialStore is DefaultCredentialStore) 39 | } 40 | 41 | func testThatItConnectsWithArguments() throws { 42 | 43 | // Given 44 | let consumerKey = "CONSUMER_KEY" 45 | let callbackURL = URL(string: "callback://done")! 46 | let authHost = "auth.salesforce.com" 47 | 48 | // When 49 | let connection = try Salesforce.connect(consumerKey: consumerKey, callbackURL: callbackURL, authorizingHost: authHost) 50 | 51 | // Then 52 | XCTAssertNotNil(connection.authorizer) 53 | XCTAssertTrue(connection.authorizer is DefaultAuthorizer) 54 | XCTAssertTrue(connection.credentialStore is DefaultCredentialStore) 55 | } 56 | } 57 | --------------------------------------------------------------------------------