├── .github └── workflows │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── contents.xcworkspacedata │ └── xcshareddata │ └── xcschemes │ └── Apexy.xcscheme ├── Apexy.podspec ├── ApexyLoaderExample ├── ApexyLoaderExample.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── ApexyLoaderExample │ ├── Resources │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ └── Base.lproj │ │ └── LaunchScreen.storyboard │ ├── Sources │ ├── Application │ │ └── AppDelegate.swift │ ├── Business Logic │ │ ├── Endpoint │ │ │ ├── BaseEndpoint.swift │ │ │ ├── OrganizationEndpoint.swift │ │ │ └── RepositoriesEndpoint.swift │ │ ├── Loaders │ │ │ ├── OrganizationLoader.swift │ │ │ └── RepositoriesLoader.swift │ │ ├── Models │ │ │ ├── Organization.swift │ │ │ ├── OrganizationRepositories.swift │ │ │ └── Repository.swift │ │ └── ServiceLayer.swift │ └── Presentation │ │ ├── Fetch │ │ ├── FetchViewController.swift │ │ └── FetchViewController.xib │ │ └── Result │ │ ├── ResultViewController.swift │ │ └── ResultViewController.xib │ └── Supporting │ └── Info.plist ├── Documentation ├── error_handling.md ├── error_handling.ru.md ├── loader.md ├── loader_ru.md ├── nested_response.md ├── nested_response.ru.md ├── reactive.md ├── reactive.ru.md ├── resources │ ├── demo.gif │ ├── img_1.png │ └── uml_state.png ├── tests.md └── tests.ru.md ├── Example ├── Example.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ ├── Example.xcscheme │ │ └── ExampleAPI.xcscheme ├── Example.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── Example │ ├── Resources │ │ ├── Assets.xcassets │ │ │ ├── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ └── Base.lproj │ │ │ └── LaunchScreen.storyboard │ ├── Sources │ │ ├── AppDelegate.swift │ │ ├── Business Logic │ │ │ ├── Service │ │ │ │ ├── BookService.swift │ │ │ │ └── FileService.swift │ │ │ └── ServiceLayer.swift │ │ └── Presentation │ │ │ ├── Base.lproj │ │ │ └── Main.storyboard │ │ │ ├── Helpers │ │ │ └── Streamer.swift │ │ │ └── ViewController.swift │ └── Supporting Files │ │ └── Info.plist ├── ExampleAPI │ ├── Common │ │ ├── APIError.swift │ │ ├── Codable.swift │ │ ├── HTTPError.swift │ │ └── ResponseValidator.swift │ ├── Endpoint │ │ ├── Base │ │ │ ├── EmptyEndpoint.swift │ │ │ └── JsonEndpoint.swift │ │ ├── BookListEndpoint.swift │ │ ├── FileUploadEndpoint.swift │ │ └── StreamUploadEndpoint.swift │ ├── ExampleAPI.h │ ├── Info.plist │ └── Model │ │ ├── Book.swift │ │ └── Form.swift ├── ExampleAPITests │ ├── Common │ │ └── Asserts.swift │ ├── Endpoint │ │ ├── BookListEndpointTests.swift │ │ ├── FileUploadEndpointTests.swift │ │ └── StreamUploadEndpointTests.swift │ └── Info.plist ├── Podfile └── Podfile.lock ├── Images └── apexy.png ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── README.ru.md ├── Sources ├── Apexy │ ├── APIResult.swift │ ├── Client.swift │ ├── Clients │ │ ├── CombineClient.swift │ │ └── ConcurrencyClient.swift │ ├── Endpoint.swift │ ├── HTTPBody.swift │ ├── ResponseObserver.swift │ ├── URLRequestBuildable.swift │ └── UploadEndpoint.swift ├── ApexyAlamofire │ ├── AlamofireClient+Concurrency.swift │ ├── AlamofireClient.swift │ └── BaseRequestInterceptor.swift ├── ApexyLoader │ ├── ContentLoader.swift │ ├── LoaderObservation.swift │ ├── LoadingState.swift │ ├── ObservableLoader.swift │ └── WebLoader.swift └── ApexyURLSession │ ├── BaseRequestAdapter.swift │ ├── URLSessionClient+Concurrency.swift │ └── URLSessionClient.swift └── Tests ├── ApexyAlamofireTests ├── AlamofireClientCombineTests.swift ├── AlamofireClientTests.swift ├── BaseRequestInterceptorTests.swift └── Helpers │ ├── EmptyEndpoint.swift │ ├── MockURLProtocol.swift │ └── SimpleUploadEndpoint.swift ├── ApexyLoaderTests ├── ContentLoaderTests.swift ├── LoaderObservationTests.swift └── LoadingStateTests.swift ├── ApexyTests ├── HTTPBodyTests.swift └── URLRequestBuildableTests.swift └── ApexyURLSessionTests ├── BaseRequestAdapterTests.swift └── URLSessionClientTests.swift /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | release: 10 | name: Make CocoaPods release 11 | runs-on: macos-11 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | 16 | - name: Change Xcode 17 | run: sudo xcode-select -s /Applications/Xcode_13.2.1.app 18 | 19 | - name: Install Cocoapods 20 | run: gem install cocoapods 21 | 22 | - name: Deploy to Cocoapods 23 | run: | 24 | set -eo pipefail 25 | pod trunk push Apexy.podspec --allow-warnings --verbose 26 | env: 27 | COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | # Trigger the workflow on push or pull request, 4 | # for all branches, but never trigger on new tags 5 | push: 6 | branches: 7 | - '**' 8 | tags-ignore: 9 | - '**' 10 | pull_request: 11 | branches: 12 | - '**' 13 | 14 | jobs: 15 | test: 16 | name: Run tests 17 | runs-on: macos-13 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v2 21 | - name: Change Xcode 22 | run: sudo xcode-select -s /Applications/Xcode_15.0.1.app 23 | - name: Build and test 24 | run: swift test --enable-code-coverage --disable-automatic-resolution 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | # Xcode 3 | # 4 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 5 | 6 | ## Build generated 7 | build/ 8 | DerivedData/ 9 | 10 | ## Various settings 11 | *.pbxuser 12 | !default.pbxuser 13 | *.mode1v3 14 | !default.mode1v3 15 | *.mode2v3 16 | !default.mode2v3 17 | *.perspectivev3 18 | !default.perspectivev3 19 | xcuserdata/ 20 | 21 | ## Other 22 | *.moved-aside 23 | *.xccheckout 24 | *.xcscmblueprint 25 | 26 | ## Obj-C/Swift specific 27 | *.hmap 28 | *.ipa 29 | *.dSYM.zip 30 | *.dSYM 31 | 32 | ## Playgrounds 33 | timeline.xctimeline 34 | playground.xcworkspace 35 | 36 | # Swift Package Manager 37 | # 38 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 39 | # Packages/ 40 | # Package.pins 41 | # Package.resolved 42 | .build/ 43 | 44 | # CocoaPods 45 | # 46 | # We recommend against adding the Pods directory to your .gitignore. However 47 | # you should judge for yourself, the pros and cons are mentioned at: 48 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 49 | # 50 | Pods/ 51 | Example/Pods/ 52 | Source/Pods/ 53 | # 54 | # Add this line if you want to avoid checking in source code from the Xcode workspace 55 | # *.xcworkspace 56 | 57 | # Carthage 58 | # 59 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 60 | # Carthage/Checkouts 61 | 62 | Carthage/Build 63 | 64 | # Accio dependency management 65 | Dependencies/ 66 | .accio/ 67 | 68 | # fastlane 69 | # 70 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 71 | # screenshots whenever they are needed. 72 | # For more information about the recommended setup visit: 73 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 74 | 75 | fastlane/report.xml 76 | fastlane/Preview.html 77 | fastlane/screenshots/**/*.png 78 | fastlane/test_output 79 | 80 | # Code Injection 81 | # 82 | # After new code Injection tools there's a generated folder /iOSInjectionProject 83 | # https://github.com/johnno1962/injectionforxcode 84 | 85 | iOSInjectionProject/ -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/Apexy.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 43 | 49 | 50 | 51 | 57 | 63 | 64 | 65 | 66 | 67 | 72 | 73 | 75 | 81 | 82 | 83 | 84 | 85 | 95 | 96 | 102 | 103 | 109 | 110 | 111 | 112 | 114 | 115 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /Apexy.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = "Apexy" 3 | s.version = "1.7.4" 4 | s.summary = "HTTP transport library" 5 | s.homepage = "https://github.com/RedMadRobot/apexy-ios" 6 | s.license = { :type => "MIT"} 7 | s.author = { "Alexander Ignatiev" => "ai@redmadrobot.com" } 8 | s.source = { :git => "https://github.com/RedMadRobot/apexy-ios.git", :tag => "#{s.version}" } 9 | 10 | s.ios.deployment_target = "13.0" 11 | s.tvos.deployment_target = "13.0" 12 | s.osx.deployment_target = "10.15" 13 | s.watchos.deployment_target = "6.0" 14 | 15 | s.swift_version = "5.3" 16 | 17 | s.subspec 'Core' do |sp| 18 | sp.source_files = "Sources/Apexy/**/*.swift" 19 | end 20 | 21 | s.subspec 'Alamofire' do |sp| 22 | sp.source_files = "Sources/ApexyAlamofire/*.swift" 23 | sp.dependency "Apexy/Core" 24 | sp.dependency "Alamofire", '~>5.6' 25 | end 26 | 27 | s.subspec 'URLSession' do |sp| 28 | sp.source_files = "Sources/ApexyURLSession/*.swift" 29 | sp.dependency "Apexy/Core" 30 | end 31 | 32 | s.subspec 'Loader' do |sp| 33 | sp.source_files = "Sources/ApexyLoader/*.swift" 34 | sp.dependency "Apexy/Core" 35 | end 36 | 37 | s.default_subspecs = ["Alamofire"] 38 | 39 | end 40 | -------------------------------------------------------------------------------- /ApexyLoaderExample/ApexyLoaderExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ApexyLoaderExample/ApexyLoaderExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ApexyLoaderExample/ApexyLoaderExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Alamofire", 6 | "repositoryURL": "https://github.com/Alamofire/Alamofire.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "eaf6e622dd41b07b251d8f01752eab31bc811493", 10 | "version": "5.4.1" 11 | } 12 | }, 13 | { 14 | "package": "RxSwift", 15 | "repositoryURL": "https://github.com/ReactiveX/RxSwift.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "b4307ba0b6425c0ba4178e138799946c3da594f8", 19 | "version": "6.5.0" 20 | } 21 | } 22 | ] 23 | }, 24 | "version": 1 25 | } 26 | -------------------------------------------------------------------------------- /ApexyLoaderExample/ApexyLoaderExample/Resources/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ApexyLoaderExample/ApexyLoaderExample/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /ApexyLoaderExample/ApexyLoaderExample/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ApexyLoaderExample/ApexyLoaderExample/Resources/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /ApexyLoaderExample/ApexyLoaderExample/Sources/Application/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // ApexyLoaderExample 4 | // 5 | // Created by Daniil Subbotin on 04.03.2021. 6 | // 7 | 8 | import UIKit 9 | 10 | @UIApplicationMain 11 | final class AppDelegate: UIResponder, UIApplicationDelegate { 12 | 13 | var window: UIWindow? 14 | 15 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 16 | 17 | let window = UIWindow() 18 | self.window = window 19 | 20 | let fetchVC = FetchViewController() 21 | fetchVC.tabBarItem = UITabBarItem( 22 | title: "Fetch", 23 | image: UIImage(systemName: "arrow.down.square.fill"), 24 | tag: 0) 25 | 26 | let resultVC = ResultViewController() 27 | resultVC.tabBarItem = UITabBarItem( 28 | title: "Result", 29 | image: UIImage(systemName: "list.bullet"), 30 | tag: 1) 31 | 32 | let tbc = UITabBarController() 33 | tbc.viewControllers = [fetchVC, resultVC] 34 | 35 | window.frame = UIScreen.main.bounds 36 | window.rootViewController = tbc 37 | window.makeKeyAndVisible() 38 | 39 | return true 40 | } 41 | 42 | } 43 | 44 | -------------------------------------------------------------------------------- /ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Endpoint/BaseEndpoint.swift: -------------------------------------------------------------------------------- 1 | import Apexy 2 | import Foundation 3 | 4 | /// Base Endpoint for application remote resource. 5 | /// 6 | /// Contains shared logic for all endpoints in app. 7 | protocol BaseEndpoint: Endpoint where Content: Decodable { 8 | /// Content wrapper. 9 | associatedtype Root: Decodable = Content 10 | 11 | /// Extract content from root. 12 | func content(from root: Root) -> Content 13 | } 14 | 15 | extension BaseEndpoint where Root == Content { 16 | func content(from root: Root) -> Content { return root } 17 | } 18 | 19 | extension BaseEndpoint { 20 | 21 | public func content(from response: URLResponse?, with body: Data) throws -> Content { 22 | let resource = try JSONDecoder.default.decode(Root.self, from: body) 23 | return content(from: resource) 24 | } 25 | } 26 | 27 | extension JSONDecoder { 28 | internal static let `default`: JSONDecoder = { 29 | let decoder = JSONDecoder() 30 | decoder.keyDecodingStrategy = .convertFromSnakeCase 31 | return decoder 32 | }() 33 | } 34 | -------------------------------------------------------------------------------- /ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Endpoint/OrganizationEndpoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OrganizationEndpoint.swift 3 | // ApexyLoaderExample 4 | // 5 | // Created by Daniil Subbotin on 04.03.2021. 6 | // 7 | 8 | import Apexy 9 | import Foundation 10 | 11 | struct OrganizationEndpoint: BaseEndpoint { 12 | 13 | typealias Content = Organization 14 | 15 | func makeRequest() -> URLRequest { 16 | let url = URL(string: "orgs/RedMadRobot")! 17 | return URLRequest(url: url) 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Endpoint/RepositoriesEndpoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RepositoriesEndpoint.swift 3 | // ApexyLoaderExample 4 | // 5 | // Created by Daniil Subbotin on 04.03.2021. 6 | // 7 | 8 | import Apexy 9 | import Foundation 10 | 11 | /// List of all Redmadrobot repositories on GitHub 12 | struct RepositoriesEndpoint: BaseEndpoint { 13 | 14 | typealias Content = [Repository] 15 | 16 | func makeRequest() -> URLRequest { 17 | let url = URL(string: "orgs/RedMadRobot/repos")! 18 | return URLRequest(url: url) 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Loaders/OrganizationLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OrganizationLoader.swift 3 | // ApexyLoaderExample 4 | // 5 | // Created by Daniil Subbotin on 04.03.2021. 6 | // 7 | 8 | import Foundation 9 | import ApexyLoader 10 | 11 | protocol OrganizationLoading: ContentLoading { 12 | var state: LoadingState { get } 13 | } 14 | 15 | final class OrganizationLoader: WebLoader, OrganizationLoading { 16 | func load() { 17 | guard startLoading() else { return } 18 | request(OrganizationEndpoint()) { result in 19 | // imitation of waiting for the request for 3 seconds 20 | DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { 21 | self.finishLoading(result) 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Loaders/RepositoriesLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RepositoriesLoader.swift 3 | // ApexyLoaderExample 4 | // 5 | // Created by Daniil Subbotin on 04.03.2021. 6 | // 7 | 8 | import Foundation 9 | import ApexyLoader 10 | 11 | protocol RepoLoading: ContentLoading { 12 | var state: LoadingState<[Repository]> { get } 13 | } 14 | 15 | final class RepositoriesLoader: WebLoader<[Repository]>, RepoLoading { 16 | func load() { 17 | guard startLoading() else { return } 18 | request(RepositoriesEndpoint()) { result in 19 | // imitation of waiting for the request for 5 seconds 20 | DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { 21 | self.finishLoading(result) 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Models/Organization.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Organization.swift 3 | // ApexyLoaderExample 4 | // 5 | // Created by Daniil Subbotin on 09.03.2021. 6 | // 7 | 8 | struct Organization: Decodable { 9 | let name: String 10 | } 11 | 12 | -------------------------------------------------------------------------------- /ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Models/OrganizationRepositories.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OrganizationRepositories.swift 3 | // ApexyLoaderExample 4 | // 5 | // Created by Daniil Subbotin on 09.03.2021. 6 | // 7 | 8 | import Foundation 9 | 10 | struct OrganizationRepositories { 11 | let org: Organization 12 | let repos: [Repository] 13 | } 14 | -------------------------------------------------------------------------------- /ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Models/Repository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Repository.swift 3 | // ApexyLoaderExample 4 | // 5 | // Created by Daniil Subbotin on 09.03.2021. 6 | // 7 | 8 | struct Repository: Decodable { 9 | let name: String 10 | } 11 | 12 | -------------------------------------------------------------------------------- /ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/ServiceLayer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServiceLayer.swift 3 | // ApexyLoaderExample 4 | // 5 | // Created by Daniil Subbotin on 04.03.2021. 6 | // 7 | 8 | import Apexy 9 | import ApexyURLSession 10 | import Foundation 11 | 12 | final class ServiceLayer { 13 | static let shared = ServiceLayer() 14 | private init() {} 15 | 16 | private(set) lazy var repoLoader: RepoLoading = RepositoriesLoader(apiClient: apiClient) 17 | private(set) lazy var orgLoader: OrganizationLoading = OrganizationLoader(apiClient: apiClient) 18 | 19 | private lazy var apiClient: Client = { 20 | URLSessionClient( 21 | baseURL: URL(string: "https://api.github.com")!, 22 | configuration: .ephemeral 23 | ) 24 | }() 25 | } 26 | -------------------------------------------------------------------------------- /ApexyLoaderExample/ApexyLoaderExample/Sources/Presentation/Fetch/FetchViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchViewController.swift 3 | // ApexyLoaderExample 4 | // 5 | // Created by Daniil Subbotin on 04.03.2021. 6 | // 7 | 8 | import ApexyLoader 9 | import UIKit 10 | 11 | final class FetchViewController: UIViewController { 12 | 13 | // MARK: - Private Properties 14 | 15 | @IBOutlet private var downloadButton: UIButton! 16 | @IBOutlet private var activityIndicatorView: UIActivityIndicatorView! 17 | @IBOutlet private var repoTextView: UITextView! 18 | 19 | private let repoLoader: RepoLoading 20 | private let orgLoader: OrganizationLoading 21 | 22 | private var observers = [LoaderObservation]() 23 | 24 | // MARK: - Init 25 | 26 | init( 27 | repoLoader: RepoLoading = ServiceLayer.shared.repoLoader, 28 | orgLoader: OrganizationLoading = ServiceLayer.shared.orgLoader) { 29 | 30 | self.repoLoader = repoLoader 31 | self.orgLoader = orgLoader 32 | 33 | super.init(nibName: nil, bundle: nil) 34 | } 35 | 36 | required init?(coder: NSCoder) { 37 | fatalError("init(coder:) has not been implemented") 38 | } 39 | 40 | // MARK: - UIViewController 41 | 42 | override func viewDidLoad() { 43 | super.viewDidLoad() 44 | 45 | observers.append(repoLoader.observe { [weak self] in 46 | self?.stateDidChange() 47 | }) 48 | 49 | observers.append(orgLoader.observe { [weak self] in 50 | self?.stateDidChange() 51 | }) 52 | } 53 | 54 | // MARK: - Private Methods 55 | 56 | private func stateDidChange() { 57 | 58 | let state = orgLoader.state.merge(repoLoader.state) { org, repos in 59 | OrganizationRepositories(org: org, repos: repos) 60 | } 61 | 62 | if state.isLoading { 63 | activityIndicatorView.startAnimating() 64 | } else { 65 | activityIndicatorView.stopAnimating() 66 | } 67 | 68 | switch state { 69 | case .failure(_, let content?), 70 | .loading(let content?), 71 | .success(let content): 72 | let repos = content.repos.map { $0.name }.joined(separator: "\n") 73 | repoTextView.text = "Repositories of the \(content.org.name) organization:\n\n\(repos)" 74 | default: 75 | break 76 | } 77 | } 78 | 79 | @IBAction private func fetchFileURL() { 80 | repoLoader.load() 81 | orgLoader.load() 82 | } 83 | 84 | } 85 | 86 | -------------------------------------------------------------------------------- /ApexyLoaderExample/ApexyLoaderExample/Sources/Presentation/Fetch/FetchViewController.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 34 | 40 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /ApexyLoaderExample/ApexyLoaderExample/Sources/Presentation/Result/ResultViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResultViewController.swift 3 | // ApexyLoaderExample 4 | // 5 | // Created by Daniil Subbotin on 04.03.2021. 6 | // 7 | 8 | import ApexyLoader 9 | import UIKit 10 | 11 | final class ResultViewController: UIViewController { 12 | 13 | // MARK: - Private Properties 14 | 15 | @IBOutlet private var activityIndicatorView: UIActivityIndicatorView! 16 | @IBOutlet private var repoTextView: UITextView! 17 | 18 | private let repoLoader: RepoLoading 19 | private var observer: LoaderObservation? 20 | 21 | // MARK: - Init 22 | 23 | init(repoLoader: RepoLoading = ServiceLayer.shared.repoLoader) { 24 | self.repoLoader = repoLoader 25 | super.init(nibName: nil, bundle: nil) 26 | } 27 | 28 | required init?(coder: NSCoder) { 29 | fatalError("init(coder:) has not been implemented") 30 | } 31 | 32 | // MARK: - UIViewController 33 | 34 | override func viewDidLoad() { 35 | super.viewDidLoad() 36 | 37 | observer = repoLoader.observe { [weak self] in 38 | self?.stateDidUpdate() 39 | } 40 | stateDidUpdate() 41 | } 42 | 43 | // MARK: - Private Methods 44 | 45 | private func stateDidUpdate() { 46 | if repoLoader.state.isLoading { 47 | activityIndicatorView.startAnimating() 48 | } else { 49 | activityIndicatorView.stopAnimating() 50 | } 51 | 52 | switch repoLoader.state { 53 | case .failure(_, let content?), 54 | .loading(let content?), 55 | .success(let content): 56 | let repos = content.map { $0.name }.joined(separator: "\n") 57 | repoTextView.text = "Repositories:\n\n\(repos)" 58 | default: 59 | break 60 | } 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /ApexyLoaderExample/ApexyLoaderExample/Sources/Presentation/Result/ResultViewController.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 29 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /ApexyLoaderExample/ApexyLoaderExample/Supporting/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSupportsIndirectInputEvents 24 | 25 | UILaunchStoryboardName 26 | LaunchScreen 27 | UIRequiredDeviceCapabilities 28 | 29 | armv7 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | 35 | UISupportedInterfaceOrientations~ipad 36 | 37 | UIInterfaceOrientationPortrait 38 | UIInterfaceOrientationPortraitUpsideDown 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /Documentation/error_handling.md: -------------------------------------------------------------------------------- 1 | # Error handling 2 | 3 | ## The types of errors 4 | 5 | There are several types of errors: 6 | * API Errors — e.g. when the username or password is wrong. 7 | * Network errors (URLError) — e.g. when the internet isn't available (URLError.notConnectedToInternet). 8 | * HTTP errors (HTTPURLResponse) — e.g. if a resource isn't found HTTPURLResponse's statusCode will be 404. 9 | * Decoding errors (DecodingError) — e.g. if there's a type mismatch during decoding. 10 | 11 | ## Preparing for error handling 12 | 13 | API and HTTP error handling should take place before trying to decode a response from a server in the method `func content(from response: URLResponse?, with body: Data) throws -> Content` of `Endpoint` protocol. Below you can see an example of the basic `BaseEndpoint` protocol to which all other `Endpoint` will conforms. In `BaseEndpoint` the response from the server is validated and decoded. 14 | 15 | **BaseEndpoint.swift** 16 | ```swift 17 | import Foundation 18 | 19 | protocol BaseEndpoint: Endpoint where Content: Decodable { 20 | associatedtype Root: Decodable = Content 21 | 22 | func content(from root: Root) -> Content 23 | } 24 | 25 | extension BaseEndpoint where Root == Content { 26 | func content(from root: Root) -> Content { return root } 27 | } 28 | 29 | extension BaseEndpoint { 30 | 31 | var encoder: JSONEncoder { return JSONEncoder.default } 32 | 33 | public func content(from response: URLResponse?, with body: Data) throws -> Content { 34 | try ResponseValidator.validate(response, with: body) 35 | let resource = try JSONDecoder.default.decode(ResponseData.self, from: body) 36 | return content(from: resource.data) 37 | } 38 | } 39 | 40 | // MARK: - Response 41 | 42 | struct ResponseData: Decodable where Resource: Decodable { 43 | let data: Resource 44 | } 45 | ``` 46 | 47 | `BaseEndpoint` protocol has `associatedtype Root: Decodable` which allows you to specify the decodable type in `Endpoint` objects that conforms to the `BaseEndpoint` protocol. Example: 48 | ```swift 49 | public struct BookListEndpoint: BaseEndpoint { 50 | public typealias Content = [Book] 51 | ... 52 | } 53 | ``` 54 | 55 | In `BaseEndpoint` it is assumed that the response from the server will always come to the data field. 56 | ```json 57 | { 58 | "data": { decodable object } 59 | } 60 | ``` 61 | 62 | ## Handling decoding errors (DecodingError) 63 | 64 | In the example above, a decoding error can occurs in the method `public func content(from response: URLResponse?, with body: Data) throws -> Content {`. The error will be passed to `completionHandler` when calling the `request` method of `Client` instance. 65 | 66 | ## Handling network errors (URLError) 67 | 68 | If a network error occurs it will be passed to `completionHandler` when calling the `request` method from an instance of `Client`. 69 | 70 | ## Handling API errors 71 | 72 | Usually, an API specification contains a description of the error format. Here is an example: 73 | ```json 74 | { 75 | "error": { 76 | "code": "token_invalid", 77 | "title": "Token invalid" 78 | } 79 | } 80 | ``` 81 | 82 | A model object describing this error looks like this: 83 | 84 | ```swift 85 | struct ResponseError: Decodable { 86 | let error: APIError 87 | } 88 | 89 | struct APIError: Decodable, Error { 90 | let code: String 91 | let title: String 92 | } 93 | ``` 94 | 95 | To check the response from the server for an API error, create `ResponseValidator` as shown in the example below. 96 | 97 | ```swift 98 | enum ResponseValidator { 99 | 100 | static func validate(_ response: URLResponse?, with body: Data) throws { 101 | try validateAPIResponse(response, with: body) 102 | } 103 | 104 | private static func validateAPIResponse(_ response: URLResponse?, with body: Data) throws { 105 | let decoder = JSONDecoder.default 106 | guard var error = try? decoder.decode(ResponseError.self, from: body).error else { 107 | return 108 | } 109 | throw error 110 | } 111 | } 112 | ``` 113 | 114 | In the example above, when calling the `validate` method, an attempt is made to decode the response as an error. If there is a decoding error, then the response from the server is not an error. 115 | 116 | ## Handling HTTP Errors 117 | 118 | HTTP error has a status code, URL, and description. Let's create a structure describing an HTTP error. 119 | 120 | ```swift 121 | public struct HTTPError: Error { 122 | public let statusCode: Int 123 | public let url: URL? 124 | 125 | public var localizedDescription: String { 126 | return HTTPURLResponse.localizedString(forStatusCode: statusCode) 127 | } 128 | } 129 | ``` 130 | 131 | Let's add a method to validate HTTP errors in `ResponseValidator.validate()`. 132 | 133 | ```swift 134 | ... 135 | static func validate(_ response: URLResponse?, with body: Data) throws { 136 | try validateAPIResponse(response, with: body) 137 | try validateHTTPstatus(response) 138 | } 139 | ... 140 | private static func validateHTTPstatus(_ response: URLResponse?) throws { 141 | guard let httpResponse = response as? HTTPURLResponse, 142 | !(200..<300).contains(httpResponse.statusCode) else { return } 143 | 144 | throw HTTPError(statusCode: httpResponse.statusCode, url: httpResponse.url) 145 | } 146 | ``` 147 | 148 | If a status code doesn't belong to the 200...<300 range, the validate method will throw an HTTPError. 149 | -------------------------------------------------------------------------------- /Documentation/error_handling.ru.md: -------------------------------------------------------------------------------- 1 | # Обработка ошибок 2 | 3 | ## Типы ошибок 4 | 5 | Есть несколько типов ошибок: 6 | * Ошибки API — например, когда неправильно введен логин или пароль. 7 | * Ошибки сети (URLError) — например, когда интернет не доступен (URLError.notConnectedToInternet) 8 | * HTTP ошибки (HTTPURLResponse) — например, если страница не найдена то statusCode у HTTPURLResponse будет равен 404. 9 | * Ошибки парсинга (DecodingError) — например если при декодинге есть несоответствие типов. В модельном объекте `var id: String`, а с сервера пришло `"id": 123` 10 | 11 | ## Подготовка к обработке ошибок 12 | 13 | Обработка API и HTTP ошибок должна происходить перед попыткой декодировать ответ от сервера в методе `func content(from response: URLResponse?, with body: Data) throws -> Content` протокола `Endpoint`. Ниже показан пример базового протокола `BaseEndpoint` которому будут соответствовать все остальные `Endpoint`. В `BaseEndpoint` происходит валидация и декодирование ответа от сервера. 14 | 15 | **BaseEndpoint.swift** 16 | ```swift 17 | import Foundation 18 | 19 | protocol BaseEndpoint: Endpoint where Content: Decodable { 20 | associatedtype Root: Decodable = Content 21 | 22 | func content(from root: Root) -> Content 23 | } 24 | 25 | extension BaseEndpoint where Root == Content { 26 | func content(from root: Root) -> Content { return root } 27 | } 28 | 29 | extension BaseEndpoint { 30 | 31 | var encoder: JSONEncoder { return JSONEncoder.default } 32 | 33 | public func content(from response: URLResponse?, with body: Data) throws -> Content { 34 | try ResponseValidator.validate(response, with: body) 35 | let resource = try JSONDecoder.default.decode(ResponseData.self, from: body) 36 | return content(from: resource.data) 37 | } 38 | } 39 | 40 | // MARK: - Response 41 | 42 | struct ResponseData: Decodable where Resource: Decodable { 43 | let data: Resource 44 | } 45 | ``` 46 | 47 | `BaseEndpoint` протокол имеет `associatedtype Root: Decodable` что позволяет указывать декодируемый тип в объектах `Endpoint` соответствующих `BaseEndpoint`. Пример: 48 | ```swift 49 | public struct BookListEndpoint: BaseEndpoint { 50 | public typealias Content = [Book] 51 | ... 52 | } 53 | ``` 54 | 55 | В `BaseEndpoint` считается что ответ от сервера всегда будет приходить в поле data. 56 | ```json 57 | { 58 | "data": { декодируемый объект } 59 | } 60 | ``` 61 | 62 | ## Обработка ошибок декодинга (DecodingError) 63 | 64 | В примере выше ошибка декодинга может произойти в методе `public func content(from response: URLResponse?, with body: Data) throws -> Content {`. Она будет передана в completionHandler при вызове метода `request` у экземпляра `Client`. 65 | 66 | ## Обработка сетевых ошибок (URLError) 67 | 68 | Если возникнет сетевая ошибка, то она будет передана в completionHandler при вызове метода `request` у экземпляра `Client`. 69 | 70 | ## Обработка ошибок API 71 | 72 | Обычно в спецификации API есть описание формата ошибок. Пример: 73 | ```json 74 | { 75 | "error": { 76 | "code": "token_invalid", 77 | "title": "Токен неверный" 78 | } 79 | } 80 | ``` 81 | 82 | В коде модельный объект описывающий эту ошибку выглядит так: 83 | 84 | ```swift 85 | struct ResponseError: Decodable { 86 | let error: APIError 87 | } 88 | 89 | struct APIError: Decodable, Error { 90 | let code: String 91 | let title: String 92 | } 93 | ``` 94 | 95 | Чтобы проверить ответ от сервера на наличие API ошибки создайте `ResponseValidator` как показано в примере ниже. 96 | 97 | ```swift 98 | enum ResponseValidator { 99 | 100 | static func validate(_ response: URLResponse?, with body: Data) throws { 101 | try validateAPIResponse(response, with: body) 102 | } 103 | 104 | private static func validateAPIResponse(_ response: URLResponse?, with body: Data) throws { 105 | let decoder = JSONDecoder.default 106 | guard var error = try? decoder.decode(ResponseError.self, from: body).error else { 107 | return 108 | } 109 | throw error 110 | } 111 | } 112 | ``` 113 | 114 | В примере выше при вызове метода `validate` происходит попытка декодировать ответ в виде ошибки. Если в процессе декодирования произошла ошибка — значит ответ от сервера не является ошибкой. 115 | 116 | ## Обработка HTTP ошибок 117 | 118 | HTTP ошибка имеет статус код, URL и описание. Создадим стуктуру описывающую HTTP ошибку. 119 | 120 | ```swift 121 | public struct HTTPError: Error { 122 | public let statusCode: Int 123 | public let url: URL? 124 | 125 | public var localizedDescription: String { 126 | return HTTPURLResponse.localizedString(forStatusCode: statusCode) 127 | } 128 | } 129 | ``` 130 | 131 | Добавим метод для валидации HTTP ошибок в `ResponseValidator`. 132 | ```swift 133 | ... 134 | static func validate(_ response: URLResponse?, with body: Data) throws { 135 | try validateAPIResponse(response, with: body) 136 | try validateHTTPstatus(response) 137 | } 138 | ... 139 | private static func validateHTTPstatus(_ response: URLResponse?) throws { 140 | guard let httpResponse = response as? HTTPURLResponse, 141 | !(200..<300).contains(httpResponse.statusCode) else { return } 142 | 143 | throw HTTPError(statusCode: httpResponse.statusCode, url: httpResponse.url) 144 | } 145 | ``` 146 | Если статус код не будет лежать в диапазоне 200..<300 то метод validate кинет ошибку HTTPError. 147 | -------------------------------------------------------------------------------- /Documentation/loader.md: -------------------------------------------------------------------------------- 1 | # ApexyLoader 2 | 3 | ApexyLoader is an add-on for Apexy that lets you store fetched data in memory and observe the loading state. 4 | 5 | The main concepts of ApexyLoader are loader and state. 6 | 7 | ## Loader 8 | 9 | A loader is an object that fetches, stores data, and notifies subscribers about loading state changes. 10 | 11 | Loader inherits from `WebLoader`. When inheriting from this class you must specify the content type, which must be the same as the content type of `Endpoint`. For example `WebLoader`. 12 | 13 | In the example below a user profile loader is shown. 14 | 15 | `UserProfileEndpoint` returns `UserProfile` and `UserProfileLoader` also must returns `UserProfile`. 16 | 17 | ```swift 18 | import Foundation 19 | import ApexyLoader 20 | 21 | protocol UserProfileLoading: ContentLoading { 22 | var state: LoadingState { get } 23 | } 24 | 25 | final class UserProfileLoader: WebLoader, UserProfileLoading { 26 | func load() { 27 | guard startLoading() else { return } 28 | request(UserProfileEndpoint()) 29 | } 30 | } 31 | ``` 32 | 33 | When you create a Loader, you must pass a class that conforms to the `Client` protocol from the Apexy library. 34 | 35 | Example of creating a loader using the Service Locator pattern: 36 | 37 | ```swift 38 | import Apexy 39 | import ApexyURLSession 40 | import Foundation 41 | 42 | final class ServiceLayer { 43 | static let shared = ServiceLayer() 44 | private init() {} 45 | 46 | private(set) lazy var userProfileLoader: UserProfileLoading = UserProfileLoader(apiClient: apiClient) 47 | 48 | private lazy var apiClient: Client = { 49 | URLSessionClient(baseURL: URL(string: "https://api.server.com")!, configuration: .ephemeral) 50 | }() 51 | } 52 | ``` 53 | 54 | Example of passing a Loader to the `UIViewController`. 55 | 56 | ```swift 57 | final class ProfileViewController: UIViewController { 58 | 59 | private let profileLoader: UserProfileLoading 60 | 61 | init(profileLoader: UserProfileLoading = ServiceLayer.shared.userProfileLoader) { 62 | self.profileLoader = profileLoader 63 | super.init(nibName: nil, bundle: nil) 64 | } 65 | } 66 | ``` 67 | 68 | ## Loading state 69 | 70 | The `enum LoadingState` represents a loading state. It may have the following states: 71 | - `initial` — initial state when content loading has not yet started. 72 | - `loading(cache: Content?)` — content is loading, and there may be cached (previously loaded) content. 73 | - `success(content: Content)` — content successfully loaded. 74 | - `failure(error: Error, cache: Content?)` — unable to load content, there may be cached (previously loaded) content. 75 | 76 | When you create a loader its initial state is `initial`. The loader has `startLoading()` method which must be called to change the state to `loading`. Immediately after the first call of this method the state of the loader becomes `loading(cache: nil)`. If an error occurs then the state becomes `failure(error: Error, cache: nil)`, otherwise `success(Content)`. If after successful content loading the loading content is repeated (e.g. by a pull to refresh), the `loading` and `failure` states will contain the previously loaded content in the `cache` argument. 77 | 78 | 79 | 80 | The state of multiple loaders can be combined using the `merge` method of `LoadingState`. This method takes a second state and closure which returns a new content based on the content of both states. 81 | 82 | In the example below there are two states: the state of loading user info and the state of loading service list. The `merge` method combines these two states into one. Instead of two model objects: `User` and `Service` there will be one `UserServices`. 83 | 84 | ```swift 85 | let userState = LoadingState.loading(cache: nil) 86 | let servicesState = LoadingState<[Service]>.success(content: 3) 87 | 88 | let state = userState.merge(servicesState) { user, services in 89 | UserServices(user: user, services: services) 90 | } 91 | 92 | switch state { 93 | case .initial: 94 | // initial state 95 | case .loading(let userServices): 96 | // loading state with optional cache (info about user and list of services) 97 | case .success(let userServices): 98 | // successfull state with info about user and list of services 99 | case .failure(let error, let userServices): 100 | // failed state with optional cache (info about user and list of services) 101 | } 102 | ``` 103 | 104 | ## Observing loading state 105 | 106 | The `observe` method is used to keep track of the loader state. As with RxSwift and Combine, and in the case of ApexyLoader you need to save the reference to the observer. To do this, you need to declare a variable of `LoaderObservation` type in class properties. 107 | 108 | ```swift 109 | final class ProfileViewController: UIViewController { 110 | private var observer: LoaderObservation? 111 | ... 112 | override func viewDidLoad() { 113 | super.viewDidLoad() 114 | observer = userProfileLoader.observe { [weak self] in 115 | guard let self = self else { return } 116 | 117 | switch self.userProfileLoader.state { 118 | case .initial: 119 | // 120 | case .loading(let cache): 121 | // 122 | case .success(let content): 123 | // 124 | case .failure(let error, let cache): 125 | // 126 | } 127 | } 128 | } 129 | } 130 | ``` 131 | 132 | ## Observing loading state via Combine 133 | 134 | To keep track of the loader state via Combine use `statePublisher`. 135 | 136 | ```swift 137 | final class ProfileViewController: UIViewController { 138 | private var bag = Set() 139 | ... 140 | override func viewDidLoad() { 141 | super.viewDidLoad() 142 | userProfileLoader.statePublisher.sink { [weak self] newState in 143 | guard let self = self else { return } 144 | 145 | switch newState { 146 | case .initial: 147 | // 148 | case .loading(let cache): 149 | // 150 | case .success(let content): 151 | // 152 | case .failure(let error, let cache): 153 | // 154 | } 155 | }.store(in: &bag) 156 | } 157 | } 158 | ``` 159 | 160 | ## Use cases 161 | 162 | ApexyLoader used in the following scenarios: 163 | 1. When you want to store the loaded data in memory. 164 | For example, to use previously loaded data instead of loading it again each time you open a screen. 165 | 2. The fetch progress and the fetched data itself are displayed on different screens. 166 | For example, one screen may have a button that initiates a long loading operation. Once the data is fetched, it may be displayed on different screens. The loading process itself may also be displayed on different screens. 167 | 168 | 3. When you want to load data from multiple sources and show the loading process and the result as a whole. 169 | 170 | Example: 171 | 172 | 173 | 174 | In this app, the main screen loads a lot of data from different sources: a list of cameras, intercoms, barriers, notifications, user profile. Each loader has its own state. The states of all loaders can be combined into one state and show the result of loading as a whole. 175 | 176 | The camera list loader is reused on the camera list screen. When you go to the camera list screen, you can immediately display the previously loaded data. If you make pull-to-refresh on this screen, the camera list on the main screen will also be updated. 177 | 178 | ## Example project 179 | 180 | In the `ApexyLoaderExample` folder, you can see an example of how to use the `ApexyLoader`. 181 | 182 | This app consists of two screens. On the first screen, you can start downloading data, see the download progress and the result (list of repositories and organization name). On the second screen, you can see the download progress and the result (list of repositories). 183 | 184 | This example demonstrates how to use a shared loader between multiple screens, how to observe the loading state, and to merge the states. 185 | 186 | 187 | -------------------------------------------------------------------------------- /Documentation/loader_ru.md: -------------------------------------------------------------------------------- 1 | # ApexyLoader 2 | 3 | ApexyLoader — дополнение для Apexy, которое позволяет хранить загруженные данные в памяти и следить за состоянием загрузки. 4 | 5 | Основными понятиями ApexyLoader являются: загрузчик и состояние. 6 | 7 | ## Загрузчик 8 | 9 | Загрузчик — объект который занимается загрузкой, хранением данных и уведомляет подписчиков об изменении состояния загрузки. 10 | 11 | Загрузчик является наследником `WebLoader`. При наследовании от этого класса необходимо указать тип контента который должен быть таким же как и тип контента у `Endpoint`. Например `WebLoader`. 12 | 13 | В примере ниже показан загрузчик профиля пользователя. 14 | `UserProfileEndpoint` возвращает `UserProfile` следовательно и `UserProfileLoader` тоже должен возвращать `UserProfile`. 15 | 16 | ```swift 17 | import Foundation 18 | import ApexyLoader 19 | 20 | protocol UserProfileLoading: ContentLoading { 21 | var state: LoadingState { get } 22 | } 23 | 24 | final class UserProfileLoader: WebLoader, UserProfileLoading { 25 | func load() { 26 | guard startLoading() else { return } 27 | request(UserProfileEndpoint()) 28 | } 29 | } 30 | ``` 31 | 32 | При создании загрузчика необходимо передать класс который реализует протокол `Client` из библиотеки Apexy. 33 | 34 | Пример создания загрузчика используя паттерн Service Locator: 35 | 36 | ```swift 37 | import Apexy 38 | import ApexyURLSession 39 | import Foundation 40 | 41 | final class ServiceLayer { 42 | static let shared = ServiceLayer() 43 | private init() {} 44 | 45 | private(set) lazy var userProfileLoader: UserProfileLoading = UserProfileLoader(apiClient: apiClient) 46 | 47 | private lazy var apiClient: Client = { 48 | URLSessionClient(baseURL: URL(string: "https://api.server.com")!, configuration: .ephemeral) 49 | }() 50 | } 51 | ``` 52 | 53 | Пример передачи зависимости в `UIViewController`. 54 | 55 | ```swift 56 | final class ProfileViewController: UIViewController { 57 | 58 | private let profileLoader: UserProfileLoading 59 | 60 | init(profileLoader: UserProfileLoading = ServiceLayer.shared.userProfileLoader) { 61 | self.profileLoader = profileLoader 62 | super.init(nibName: nil, bundle: nil) 63 | } 64 | } 65 | ``` 66 | 67 | ## Состояния загрузки 68 | 69 | За состояние загрузки отвечает `enum LoadingState`. У него могут быть следующие состояния: 70 | - `initial` — начальное состояние, когда загрузка данных ещё не начата. 71 | - `loading(cache: Content?)` — данные загружаются, при этом может быть закэшированный (ранее загруженный) контент. 72 | - `success(content: Content)` — данные успешно загружены. 73 | - `failure(error: Error, cache: Content?)` — ошибка загрузки данных, при этом может быть закэшированный (ранее загруженный) контент. 74 | 75 | При создании загрузчика его начальное состояние будет `initial`. У загрузчика есть метод `startLoading()` который необходимо вызвать чтобы поменять состояние на `loading`. Сразу после первого вызова этого метода состояние загрузчика становится `loading(cache: nil)`. Если возникнет ошибка то состояние станет `failure(error: Error, cache: nil)`, иначе `success(Content)`. Если после успешной загрузки данных повторить загрузку данных (например при pull to refresh), то состояния `loading` и `failure` будут содержать в аргументе `cache` ранее загруженные данные. 76 | 77 | 78 | 79 | Состояния нескольких загрузчиков можно объединить с помощью метода `merge` у `LoadingState`. Этот метод принимает второе состояние и замыкание которое возвращает новый контент на основе контента обоих состояний. 80 | 81 | В примере ниже есть два состояния: состояние загрузки информации о пользователе и состояние загрузки списка услуг. С помощью метода `merge` эти два состояния объединяются в одно. Вместо двух модельных объектов: `User` и `Service` будет один `UserServices`. 82 | 83 | ```swift 84 | let userState = LoadingState.loading(cache: nil) 85 | let servicesState = LoadingState<[Service]>.success(content: 3) 86 | 87 | let state = userState.merge(servicesState) { user, services in 88 | UserServices(user: user, services: services) 89 | } 90 | 91 | switch state { 92 | case .initial: 93 | // initial state 94 | case .loading(let userServices): 95 | // loading state with optional cache (info about user and list of services) 96 | case .success(let userServices): 97 | // successful state with info about user and list of services 98 | case .failure(let error, let userServices): 99 | // failed state with optional cache (info about user and list of services) 100 | } 101 | ``` 102 | 103 | ## Отслеживание состояния загрузки 104 | 105 | Чтобы следить за состоянием загрузчика используется метод `observe`. Как с RxSwift и Combine так и в случае ApexyLoader нужно сохранить ссылку на обсервер. Для этого нужно в свойствах класса объявить переменную типа `LoaderObservation`. 106 | 107 | ```swift 108 | final class ProfileViewController: UIViewController { 109 | private var observer: LoaderObservation? 110 | ... 111 | override func viewDidLoad() { 112 | super.viewDidLoad() 113 | observer = userProfileLoader.observe { [weak self] in 114 | guard let self = self else { return } 115 | 116 | switch self.userProfileLoader.state { 117 | case .initial: 118 | // 119 | case .loading(let cache): 120 | // 121 | case .success(let content): 122 | // 123 | case .failure(let error, let cache): 124 | // 125 | } 126 | } 127 | } 128 | } 129 | ``` 130 | 131 | ## Отслеживание состояния загрузки через Combine 132 | 133 | Чтобы следить за состоянием загрузчика с помощью Combine используйте паблишер `statePublisher`. 134 | 135 | ```swift 136 | final class ProfileViewController: UIViewController { 137 | private var bag = Set() 138 | ... 139 | override func viewDidLoad() { 140 | super.viewDidLoad() 141 | userProfileLoader.statePublisher.sink { [weak self] newState in 142 | guard let self = self else { return } 143 | 144 | switch newState { 145 | case .initial: 146 | // 147 | case .loading(let cache): 148 | // 149 | case .success(let content): 150 | // 151 | case .failure(let error, let cache): 152 | // 153 | } 154 | }.store(in: &bag) 155 | } 156 | } 157 | ``` 158 | 159 | ## Сценарии использования 160 | 161 | ApexyLoader применяется когда: 162 | 1. Необходимо хранить загруженные данные в памяти. 163 | Например, чтобы при каждом заходе на экран не загружать данные заново, а использовать уже загруженные данные. 164 | 2. Процесс загрузки и сами загруженные данные отображаются на разных экранах. 165 | Например, на одном экране может быть кнопка которая инициирует долгую операцию загрузки. После загрузки данных они могут отображаться на разных экранах. Сам процесс загрузки также может отображаться на разных экранах. 166 | 3. Необходимо загрузить данные из нескольких источников и показать процесс загрузки и результат как одно целое. 167 | 168 | Пример: 169 | 170 | 171 | 172 | В этом приложении на главном экране загружается большое кол-во данных из разных источников: список камер, домофонов, шлагбаумов, уведомления, профиль пользователя. Каждый загрузчик имеет своё состояние. Состояния всех загрузчиков можно объединить в одно состояние и показывать результат загрузки как одно целое. 173 | 174 | Загрузчик списка камер переиспользуется на отдельном экране со списком камер. За счет этого, при переходе на экран со списком камер, можно сразу отобразить загруженные ранее данные. При этом, если на этом экране сделать pull-to-refresh, то список камер на главном экране тоже обновится. 175 | 176 | ## Example проект 177 | 178 | Пример использования `ApexyLoader` смотри в папке `ApexyLoaderExample`. 179 | 180 | Это приложение состоит из двух экранов. На первом экране можно начать загрузку данных, видеть индикацию загрузки и результат (список репозиториев и название организации). На втором экране можно видеть индикацию загрузки и результат (список репозиториев). 181 | 182 | В этом примере демонстрируется шаринг загрузчика между экранами, отслеживание состояния загрузки и объединение состояний. 183 | 184 | 185 | -------------------------------------------------------------------------------- /Documentation/nested_response.md: -------------------------------------------------------------------------------- 1 | # Nested Responses 2 | 3 | A server almost always returns JSON objects nested in other objects. 4 | 5 | Consider a pair of requests and two cases where a server can return nested responses. 6 | 7 | Requests: 8 | - `GET books/` Returns a list of books as an array of `Book`. 9 | - `GET books/{book_id}` Returns a `Book` by `id`. 10 | 11 | ```swift 12 | public struct Book: Codable, Identifiable { 13 | public let id: Int 14 | public let name: String 15 | } 16 | ``` 17 | 18 | ## Nested responses with the same key 19 | 20 | In the first case, a server will wrap the response objects in `data`. 21 | 22 | ```json 23 | { 24 | "data": "content" 25 | } 26 | ``` 27 | 28 | `GET books/` 29 | 30 | The request to receive all the books will return an array wrapped in `data`. 31 | 32 | ```json 33 | { 34 | "data": [ 35 | { 36 | "id": 1, 37 | "name": "Mu mu", 38 | } 39 | ] 40 | } 41 | ``` 42 | 43 | `GET books/{book_id}` 44 | 45 | The request to receive a book by `id` will return one book wrapped in `data` 46 | 47 | ```json 48 | { 49 | "data": { 50 | "id": "A-1", 51 | "name": "Mu mu", 52 | } 53 | } 54 | ``` 55 | 56 | To hide the `data` wrapper, let's create `JsonEndpoint`, which will get us the necessary `Content`. 57 | 58 | ```swift 59 | protocol JsonEndpoint: Endpoint where Content: Decodable {} 60 | 61 | extension JsonEndpoint { 62 | 63 | public func content(from response: URLResponse?, with body: Data) throws -> Content { 64 | let decoder = JSONDecoder() 65 | let value = try decoder.decode(ResponseData.self, from: body) 66 | return value.data 67 | } 68 | } 69 | 70 | private struct ResponseData: Decodable where Content: Decodable { 71 | let data: Content 72 | } 73 | ``` 74 | 75 | As a result, our requests hide the nesting of the response. 76 | 77 | - `BookListEndpoint.Content = [Book]` 78 | - `BookEndpoint.Content = Book` 79 | 80 | ```swift 81 | public struct BookEndpoint: JsonEndpoint { 82 | public typealias Content = Book 83 | // .., 84 | } 85 | 86 | public struct BookListEndpoint: JsonEndpoint { 87 | public typealias Content = [Book] 88 | // .., 89 | } 90 | ``` 91 | 92 | ## Nested responses with different keys 93 | 94 | In the second more complex case, the server will send responses nested with different keys. 95 | 96 | `GET books/` 97 | 98 | The request to receive all books will return an array wrapped in `book_list`. 99 | 100 | ```json 101 | { 102 | "book_list": [ 103 | { 104 | "id": 1, 105 | "name": "Mu mu", 106 | } 107 | ] 108 | } 109 | ``` 110 | 111 | `GET books/{book_id}` 112 | 113 | The request to receive a book by id will return a book wrapped in `book`. 114 | 115 | ```json 116 | { 117 | "book": { 118 | "id": "A-1", 119 | "name": "Mu mu", 120 | } 121 | } 122 | ``` 123 | 124 | To unwrap responses, create `JsonEndpoint` with the `content(from:)` method which will unwrap the responses. 125 | 126 | ```swift 127 | protocol JsonEndpoint: Endpoint where Content: Decodable { 128 | associatedtype Root: Decodable = Content 129 | 130 | func content(from root: Root) -> Content 131 | } 132 | 133 | extension JsonEndpoint { 134 | 135 | public func content(from response: URLResponse?, with body: Data) throws -> Content { 136 | let decoder = JSONDecoder() 137 | decoder.keyDecodingStrategy = .convertFromSnakeCase 138 | let root = try decoder.decode(Root.self, from: body) 139 | return content(from: root) 140 | } 141 | } 142 | ``` 143 | 144 | Thus, the request to receive all the books will look like this. 145 | 146 | ```swift 147 | struct BookListResponse: Decodable { 148 | let bookList: [Book] 149 | } 150 | 151 | public struct BookListEndpoint: JsonEndpoint { 152 | public typealias Content = [Book] 153 | 154 | func content(from root: BookListResponse) -> Content { 155 | return root.bookList 156 | } 157 | 158 | public func makeRequest() throws -> URLRequest { 159 | return URLRequest(url: URL(string: "books")!) 160 | } 161 | } 162 | ``` 163 | 164 | > Notice that `BookListResponse` and `content(from:)` remains `internal` and hide the features of the response format. 165 | 166 | The request to get a book by `id` will look like this. 167 | 168 | ```swift 169 | struct BookResponse: Decodable { 170 | let book: Book 171 | } 172 | 173 | public struct BookEndpoint: JsonEndpoint { 174 | public typealias Content = Book 175 | 176 | public let id: Book.ID 177 | 178 | public init(id: Book.ID) { 179 | self.id = id 180 | } 181 | 182 | func content(from root: BookResponse) -> Content { 183 | return root.book 184 | } 185 | 186 | public func makeRequest() throws -> URLRequest { 187 | let url = URL(string: "books")!.appendingPathComponent(id) 188 | return URLRequest(url: url) 189 | } 190 | } 191 | ``` 192 | 193 | # Conclusion 194 | 195 | In the end, I would note that these two cases can be combined, and it will allow you to work without a boilerplate with complex APIs. -------------------------------------------------------------------------------- /Documentation/nested_response.ru.md: -------------------------------------------------------------------------------- 1 | # Вложенные ответы 2 | 3 | Почти всегда сервер возвращает json объекты вложенные в другие объекты. 4 | 5 | Рассмотрим пару запросов и два случая, когда сервер может может возвращать ответ вложенным. 6 | 7 | Запросы: 8 | - `GET books/` Получение списка книг. Ожидаем массив книг `Book`. 9 | - `GET books/{book_id}` Получение книги по `id` Ожидаем просто книгу `Book`. 10 | 11 | ```swift 12 | public struct Book: Codable, Identifiable { 13 | public let id: Int 14 | public let name: String 15 | } 16 | ``` 17 | 18 | ## Вложенные ответы с одинаковым ключом 19 | 20 | В первом случае сервер будет оборачивать объекты ответов в `data`. 21 | 22 | ```json 23 | { 24 | "data": "content" 25 | } 26 | ``` 27 | 28 | `GET books/` 29 | 30 | На запрос получения всех книг вернется массив обернутый в `data`. 31 | 32 | ```json 33 | { 34 | "data": [ 35 | { 36 | "id": 1, 37 | "name": "Mu mu", 38 | } 39 | ] 40 | } 41 | ``` 42 | 43 | 44 | `GET books/{book_id}` 45 | 46 | На запрос получения книги по `id` вернется одна книга обернутая в `data`. 47 | 48 | ```json 49 | { 50 | "data": { 51 | "id": "A-1", 52 | "name": "Mu mu", 53 | } 54 | } 55 | ``` 56 | 57 | Чтобы скрыть обертку `data`, создадим `JsonEndpoint`, который будет доставать необходимый нам `Content`. 58 | 59 | ```swift 60 | protocol JsonEndpoint: Endpoint where Content: Decodable {} 61 | 62 | extension JsonEndpoint { 63 | 64 | public func content(from response: URLResponse?, with body: Data) throws -> Content { 65 | let decoder = JSONDecoder() 66 | let value = try decoder.decode(ResponseData.self, from: body) 67 | return value.data 68 | } 69 | } 70 | 71 | private struct ResponseData: Decodable where Content: Decodable { 72 | let data: Content 73 | } 74 | ``` 75 | 76 | В итоге наши запросы скрывают вложенность ответа. 77 | 78 | - `BookListEndpoint.Content = [Book]` 79 | - `BookEndpoint.Content = Book` 80 | 81 | ```swift 82 | public struct BookEndpoint: JsonEndpoint { 83 | public typealias Content = Book 84 | // .., 85 | } 86 | 87 | public struct BookListEndpoint: JsonEndpoint { 88 | public typealias Content = [Book] 89 | // .., 90 | } 91 | ``` 92 | 93 | ## Вложенные ответы с разными ключами 94 | 95 | Во втором более сложном случае сервер будет отправлять ответы вложенные в разные ключи. 96 | 97 | `GET books/` 98 | 99 | На запрос получения всех книг вернется массив обернутый в `book_list`. 100 | 101 | ```json 102 | { 103 | "book_list": [ 104 | { 105 | "id": 1, 106 | "name": "Mu mu", 107 | } 108 | ] 109 | } 110 | ``` 111 | 112 | `GET books/{book_id}` 113 | 114 | На запрос получения книги по `id` вернется одна книга обернутая в `book`. 115 | 116 | ```json 117 | { 118 | "book": { 119 | "id": "A-1", 120 | "name": "Mu mu", 121 | } 122 | } 123 | ``` 124 | 125 | Для разворачивания ответов создадим `JsonEndpoint` c методом `content(from:)`, который будет разворачивать ответы. 126 | 127 | ```swift 128 | protocol JsonEndpoint: Endpoint where Content: Decodable { 129 | associatedtype Root: Decodable = Content 130 | 131 | func content(from root: Root) -> Content 132 | } 133 | 134 | extension JsonEndpoint { 135 | 136 | public func content(from response: URLResponse?, with body: Data) throws -> Content { 137 | let decoder = JSONDecoder() 138 | decoder.keyDecodingStrategy = .convertFromSnakeCase 139 | let root = try decoder.decode(Root.self, from: body) 140 | return content(from: root) 141 | } 142 | } 143 | ``` 144 | 145 | Таким образом запрос получения всех книг будет оформлен так. 146 | 147 | ```swift 148 | struct BookListResponse: Decodable { 149 | let bookList: [Book] 150 | } 151 | 152 | public struct BookListEndpoint: JsonEndpoint { 153 | public typealias Content = [Book] 154 | 155 | func content(from root: BookListResponse) -> Content { 156 | return root.bookList 157 | } 158 | 159 | public func makeRequest() throws -> URLRequest { 160 | return URLRequest(url: URL(string: "books")!) 161 | } 162 | } 163 | ``` 164 | 165 | > Обратите внимание, что `BookListResponse` и `content(from:)` остались `internal` и скрывают особенности формата ответа. 166 | 167 | Для получения книги по `id` запрос будет таким. 168 | 169 | ```swift 170 | struct BookResponse: Decodable { 171 | let book: Book 172 | } 173 | 174 | public struct BookEndpoint: JsonEndpoint { 175 | public typealias Content = Book 176 | 177 | public let id: Book.ID 178 | 179 | public init(id: Book.ID) { 180 | self.id = id 181 | } 182 | 183 | func content(from root: BookResponse) -> Content { 184 | return root.book 185 | } 186 | 187 | public func makeRequest() throws -> URLRequest { 188 | let url = URL(string: "books")!.appendingPathComponent(id) 189 | return URLRequest(url: url) 190 | } 191 | } 192 | ``` 193 | 194 | # Заключение 195 | 196 | В конце я бы отметил, что эти два случая могут комбинироваться, и это позволит вам работать без бойлерплейта со сложными API. 197 | -------------------------------------------------------------------------------- /Documentation/reactive.md: -------------------------------------------------------------------------------- 1 | # Reactive programming 2 | 3 | ## Combine 4 | 5 | Apexy supports Combine framework 6 | 7 | How to use by example `BookService` (see Example project). 8 | 9 | ```swift 10 | final class BookService { 11 | ... 12 | func fetchBooks() -> AnyPublisher<[Book], Error> { 13 | let endpoint = BookListEndpoint() 14 | return apiClient.request(endpoint) 15 | } 16 | ... 17 | } 18 | ``` 19 | 20 | ```swift 21 | bookService.fetchBooks().sink(receiveCompletion: { [weak self] completion in 22 | self?.activityView.isHidden = true 23 | switch completion { 24 | case .finished: 25 | break 26 | case .failure(let error): 27 | self?.resultLabel.text = error.localizedDescription 28 | } 29 | }, receiveValue: { [weak self] books in 30 | self?.show(books: books) 31 | }).store(in: &bag) 32 | ``` 33 | -------------------------------------------------------------------------------- /Documentation/reactive.ru.md: -------------------------------------------------------------------------------- 1 | # Реактивное программирование 2 | 3 | ## Combine 4 | 5 | Apexy поддерживает Combine. 6 | 7 | Как использовать на примере `BookService` (смотри Example проект). 8 | 9 | ```swift 10 | final class BookService { 11 | ... 12 | func fetchBooks() -> AnyPublisher<[Book], Error> { 13 | let endpoint = BookListEndpoint() 14 | return apiClient.request(endpoint) 15 | } 16 | ... 17 | } 18 | ``` 19 | 20 | ```swift 21 | bookService.fetchBooks().sink(receiveCompletion: { [weak self] completion in 22 | self?.activityView.isHidden = true 23 | switch completion { 24 | case .finished: 25 | break 26 | case .failure(let error): 27 | self?.resultLabel.text = error.localizedDescription 28 | } 29 | }, receiveValue: { [weak self] books in 30 | self?.show(books: books) 31 | }).store(in: &bag) 32 | ``` 33 | -------------------------------------------------------------------------------- /Documentation/resources/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedMadRobot/apexy-ios/ad19a372695ef3191c5fb8e70624cb7439224f4d/Documentation/resources/demo.gif -------------------------------------------------------------------------------- /Documentation/resources/img_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedMadRobot/apexy-ios/ad19a372695ef3191c5fb8e70624cb7439224f4d/Documentation/resources/img_1.png -------------------------------------------------------------------------------- /Documentation/resources/uml_state.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedMadRobot/apexy-ios/ad19a372695ef3191c5fb8e70624cb7439224f4d/Documentation/resources/uml_state.png -------------------------------------------------------------------------------- /Documentation/tests.md: -------------------------------------------------------------------------------- 1 | # Testing Apexy 2 | 3 | ## What to test? 4 | You can test all the Endpoints and models which contain business logic. 5 | 6 | ### Endpoint 7 | In the case of Endpoint, test how it creates the URLRequest object (method `makeRequest`): 8 | * HTTP method 9 | * URL address 10 | * HTTP Body 11 | * HTTP headers 12 | 13 | **Example** 14 | 15 | There is a `BookListEndpoint` in the example project. This endpoint is used to obtain a list of books. The following example shows how to test this Endpoint. 16 | 17 | ```swift 18 | import ExampleAPI 19 | import XCTest 20 | 21 | final class BookListEndpointTests: XCTestCase { 22 | 23 | func testMakeRequest() throws { 24 | let endpoint = BookListEndpoint() 25 | 26 | let urlRequest = try endpoint.makeRequest() 27 | 28 | XCTAssertEqual(urlRequest.httpMethod, "GET") 29 | XCTAssertNil(urlRequest.httpBody) 30 | XCTAssertEqual(urlRequest.url?.absoluteString, "books") 31 | } 32 | } 33 | ``` 34 | This test checks that: 35 | * HTTP method equals to "GET" 36 | * HTTP body doesn't exist 37 | * URL equals to "books" 38 | 39 | ### Model 40 | If a model object contains business logic, then this object must be tested. For example, if a model object has computed properties where data is formatted. 41 | 42 | You can also test the decoding of a model object in the case of complex transformations, for example, converting a string to a date. 43 | 44 | ```swift 45 | /// An abstract access code that has an expiration date 46 | struct Code: Decodable, Equatable { 47 | /// Code value, e.g. "1234" 48 | let code: String 49 | /// Code expiration date 50 | let endDate: Date 51 | } 52 | 53 | final class CodeTests: XCTestCase { 54 | 55 | func testDecode() throws { 56 | let json = """ 57 | { 58 | "code": "1234", 59 | "end_date": "2019-03-21T13:13:36Z" 60 | } 61 | """.data(using: .utf8)! 62 | 63 | let code = try JSONDecoder().decode(Code.self, from: json) 64 | 65 | XCTAssertEqual( 66 | code.endDate, 67 | makeDate(year: 2019, month: 3, day: 21, hour: 13, minute: 13, second: 36)) 68 | } 69 | 70 | private func makeDate(year: Int, month: Int, day: Int, hour: Int, minute: Int, second: Int) -> Date { 71 | return DateComponents( 72 | calendar: .current, 73 | timeZone: TimeZone(secondsFromGMT: 0), 74 | year: year, month: month, day: day, 75 | hour: hour, minute: minute, second: second).date! 76 | } 77 | } 78 | ``` 79 | 80 | ## Helpers 81 | The following helpers can be used to improve readability and reduce the amount of code in tests: 82 | 83 | _Asserts.swift_ 84 | ```swift 85 | func assertGET(_ urlRequest: URLRequest, file: StaticString = #file, line: UInt = #line) { 86 | guard let method = urlRequest.httpMethod else { 87 | return XCTFail("The request does not contains HTTP method", file: file, line: line) 88 | } 89 | XCTAssertEqual(method, "GET", file: file, line: line) 90 | XCTAssertNil(urlRequest.httpBody, "GET request must not contains body", file: file, line: line) 91 | } 92 | 93 | func assertPOST(_ urlRequest: URLRequest, file: StaticString = #file, line: UInt = #line) { 94 | guard let method = urlRequest.httpMethod else { 95 | return XCTFail("The request does not contains HTTP method", file: file, line: line) 96 | } 97 | XCTAssertEqual(method, "POST", file: file, line: line) 98 | } 99 | 100 | func assertDELETE(_ urlRequest: URLRequest, file: StaticString = #file, line: UInt = #line) { 101 | guard let method = urlRequest.httpMethod else { 102 | return XCTFail("The request does not contains HTTP method", file: file, line: line) 103 | } 104 | XCTAssertEqual(method, "DELETE", file: file, line: line) 105 | } 106 | 107 | func assertPATCH(_ urlRequest: URLRequest, file: StaticString = #file, line: UInt = #line) { 108 | guard let method = urlRequest.httpMethod else { 109 | return XCTFail("The request does not contains HTTP method", file: file, line: line) 110 | } 111 | XCTAssertEqual(method, "PATCH", file: file, line: line) 112 | } 113 | 114 | func assertPath(_ urlRequest: URLRequest, _ path: String, file: StaticString = #file, line: UInt = #line) { 115 | guard let url = urlRequest.url else { 116 | return XCTFail("The request does not contains HTTP method", file: file, line: line) 117 | } 118 | XCTAssertEqual(url.path, path, "Paths does not equal", file: file, line: line) 119 | } 120 | 121 | func assertURL(_ urlRequest: URLRequest, _ urlString: String, file: StaticString = #file, line: UInt = #line) { 122 | guard let url = urlRequest.url else { 123 | return XCTFail("The request does not contains HTTP method", file: file, line: line) 124 | } 125 | XCTAssertEqual(url.absoluteString, urlString, "URLs does not equal", file: file, line: line) 126 | } 127 | ``` 128 | 129 | The example above could be written like this: 130 | ```swift 131 | func testMakeRequest() throws { 132 | let endpoint = BookListEndpoint() 133 | let urlRequest = try endpoint.makeRequest() 134 | 135 | assertGET(urlRequest) 136 | assertURL(urlRequest, "books") 137 | } 138 | ``` 139 | -------------------------------------------------------------------------------- /Documentation/tests.ru.md: -------------------------------------------------------------------------------- 1 | # Тестирование Apexy 2 | 3 | ## Что тестировать 4 | Нужно тестировать все Endpoint'ы и модельные объекты, если в них есть логика. 5 | 6 | ### Endpoint 7 | В случае Endpoint тестируется то как он создает объект URLRequest (метод `makeRequest`): 8 | * HTTP метод 9 | * URL адрес 10 | * Тело запроса 11 | * HTTP заголовки 12 | 13 | **Пример** 14 | 15 | В example проекте есть `BookListEndpoint` для получения списка книг. В примере ниже показано как его тестировать. 16 | 17 | ```swift 18 | import ExampleAPI 19 | import XCTest 20 | 21 | final class BookListEndpointTests: XCTestCase { 22 | 23 | func testMakeRequest() throws { 24 | let endpoint = BookListEndpoint() 25 | 26 | let urlRequest = try endpoint.makeRequest() 27 | 28 | XCTAssertEqual(urlRequest.httpMethod, "GET") 29 | XCTAssertNil(urlRequest.httpBody) 30 | XCTAssertEqual(urlRequest.url?.absoluteString, "books") 31 | } 32 | } 33 | ``` 34 | В тесте проверяется что: 35 | * HTTP метод равен "GET" 36 | * Тело запроса отсутствует 37 | * url равен "books" 38 | 39 | ### Model 40 | Если модельный объект содержит логику то на эту логику надо написать тесты. Например, если модельный объект имеет свойства где происходит форматирование данных, то это нужно протестировать. 41 | 42 | Ещё можно протестировать декодинг модельного объекта в случае сложных преобразований, например конвертации строки в дату. 43 | 44 | ```swift 45 | /// Абстрактный код доступа у которого есть дата окончания действия 46 | struct Code: Decodable, Equatable { 47 | /// Значение кода, например "1234" 48 | let code: String 49 | /// Дата окончания действия кода 50 | let endDate: Date 51 | } 52 | 53 | final class CodeTests: XCTestCase { 54 | 55 | func testDecode() throws { 56 | let json = """ 57 | { 58 | "code": "1234", 59 | "end_date": "2019-03-21T13:13:36Z" 60 | } 61 | """.data(using: .utf8)! 62 | 63 | let code = try JSONDecoder().decode(Code.self, from: json) 64 | 65 | XCTAssertEqual( 66 | code.endDate, 67 | makeDate(year: 2019, month: 3, day: 21, hour: 13, minute: 13, second: 36)) 68 | } 69 | 70 | private func makeDate(year: Int, month: Int, day: Int, hour: Int, minute: Int, second: Int) -> Date { 71 | return DateComponents( 72 | calendar: .current, 73 | timeZone: TimeZone(secondsFromGMT: 0), 74 | year: year, month: month, day: day, 75 | hour: hour, minute: minute, second: second).date! 76 | } 77 | } 78 | ``` 79 | 80 | ## Хелперы 81 | Для улучшения читаемости и уменьшения количества кода в тестах можно использовать следующие хелперы: 82 | 83 | _Asserts.swift_ 84 | ```swift 85 | func assertGET(_ urlRequest: URLRequest, file: StaticString = #file, line: UInt = #line) { 86 | guard let method = urlRequest.httpMethod else { 87 | return XCTFail("У запроса остутствует HTTP метод", file: file, line: line) 88 | } 89 | XCTAssertEqual(method, "GET", file: file, line: line) 90 | XCTAssertNil(urlRequest.httpBody, "GET запрос не должен иметь тела", file: file, line: line) 91 | } 92 | 93 | func assertPOST(_ urlRequest: URLRequest, file: StaticString = #file, line: UInt = #line) { 94 | guard let method = urlRequest.httpMethod else { 95 | return XCTFail("У запроса остутствует HTTP метод", file: file, line: line) 96 | } 97 | XCTAssertEqual(method, "POST", file: file, line: line) 98 | } 99 | 100 | func assertDELETE(_ urlRequest: URLRequest, file: StaticString = #file, line: UInt = #line) { 101 | guard let method = urlRequest.httpMethod else { 102 | return XCTFail("У запроса остутствует HTTP метод", file: file, line: line) 103 | } 104 | XCTAssertEqual(method, "DELETE", file: file, line: line) 105 | } 106 | 107 | func assertPATCH(_ urlRequest: URLRequest, file: StaticString = #file, line: UInt = #line) { 108 | guard let method = urlRequest.httpMethod else { 109 | return XCTFail("У запроса остутствует HTTP метод", file: file, line: line) 110 | } 111 | XCTAssertEqual(method, "PATCH", file: file, line: line) 112 | } 113 | 114 | func assertPath(_ urlRequest: URLRequest, _ path: String, file: StaticString = #file, line: UInt = #line) { 115 | guard let url = urlRequest.url else { 116 | return XCTFail("У запроса остутствует URL", file: file, line: line) 117 | } 118 | XCTAssertEqual(url.path, path, "путь запроса не совпадает", file: file, line: line) 119 | } 120 | 121 | func assertURL(_ urlRequest: URLRequest, _ urlString: String, file: StaticString = #file, line: UInt = #line) { 122 | guard let url = urlRequest.url else { 123 | return XCTFail("У запроса остутствует URL", file: file, line: line) 124 | } 125 | XCTAssertEqual(url.absoluteString, urlString, "URL запроса не совпадает", file: file, line: line) 126 | } 127 | ``` 128 | 129 | Пример выше мог бы быть записан так: 130 | ```swift 131 | func testMakeRequest() throws { 132 | let endpoint = BookListEndpoint() 133 | let urlRequest = try endpoint.makeRequest() 134 | 135 | assertGET(urlRequest) 136 | assertURL(urlRequest, "books") 137 | } 138 | ``` -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 34 | 40 | 41 | 42 | 43 | 44 | 54 | 56 | 62 | 63 | 64 | 65 | 71 | 73 | 79 | 80 | 81 | 82 | 84 | 85 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/xcshareddata/xcschemes/ExampleAPI.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 33 | 34 | 44 | 45 | 51 | 52 | 58 | 59 | 60 | 61 | 63 | 64 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /Example/Example.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Example/Example.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/Example/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /Example/Example/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Example/Example/Resources/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Example/Example/Sources/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Example 4 | // 5 | // Created by Anton Glezman on 18/06/2019. 6 | // Copyright © 2019 RedMadRobot. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @UIApplicationMain 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | var window: UIWindow? 15 | 16 | func application( 17 | _ application: UIApplication, 18 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 19 | return true 20 | } 21 | } 22 | 23 | -------------------------------------------------------------------------------- /Example/Example/Sources/Business Logic/Service/BookService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BooksService.swift 3 | // Example 4 | // 5 | // Created by Anton Glezman on 18.06.2020. 6 | // Copyright © 2020 RedMadRobot. All rights reserved. 7 | // 8 | 9 | import Apexy 10 | import ExampleAPI 11 | 12 | typealias Book = ExampleAPI.Book 13 | 14 | protocol BookService { 15 | func fetchBooks() async throws -> [Book] 16 | } 17 | 18 | 19 | final class BookServiceImpl: BookService { 20 | 21 | let apiClient: ConcurrencyClient 22 | 23 | init(apiClient: ConcurrencyClient) { 24 | self.apiClient = apiClient 25 | } 26 | 27 | func fetchBooks() async throws -> [Book] { 28 | let endpoint = BookListEndpoint() 29 | return try await apiClient.request(endpoint) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Example/Example/Sources/Business Logic/Service/FileService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileService.swift 3 | // DemoApp 4 | // 5 | // Created by Anton Glezman on 17/06/2019. 6 | // Copyright © 2019 RedMadRobot. All rights reserved. 7 | // 8 | 9 | import Apexy 10 | import ExampleAPI 11 | 12 | protocol FileService { 13 | func upload(file: URL) async throws 14 | func upload(stream: InputStream, size: Int) async throws 15 | } 16 | 17 | 18 | final class FileServiceImpl: FileService { 19 | 20 | let apiClient: ConcurrencyClient 21 | 22 | init(apiClient: ConcurrencyClient) { 23 | self.apiClient = apiClient 24 | } 25 | 26 | func upload(file: URL) async throws { 27 | let endpoint = FileUploadEndpoint(fileURL: file) 28 | return try await apiClient.upload(endpoint) 29 | } 30 | 31 | func upload(stream: InputStream, size: Int) async throws { 32 | let endpoint = StreamUploadEndpoint(stream: stream, size: size) 33 | return try await apiClient.upload(endpoint) 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /Example/Example/Sources/Business Logic/ServiceLayer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServiceLayer.swift 3 | // Example 4 | // 5 | // Created by Anton Glezman on 18/06/2019. 6 | // Copyright © 2019 RedMadRobot. All rights reserved. 7 | // 8 | 9 | import Apexy 10 | import ExampleAPI 11 | 12 | final class ServiceLayer { 13 | 14 | // MARK: - Public properties 15 | 16 | static let shared = ServiceLayer() 17 | 18 | private(set) lazy var apiClient: ConcurrencyClient = AlamofireClient( 19 | baseURL: URL(string: "https://library.mock-object.redmadserver.com/api/v1/")!, 20 | configuration: .ephemeral, 21 | responseObserver: { [weak self] request, response, data, error in 22 | self?.validateSession(responseError: error) 23 | }) 24 | 25 | private(set) lazy var bookService: BookService = BookServiceImpl(apiClient: apiClient) 26 | 27 | private(set) lazy var fileService: FileService = FileServiceImpl(apiClient: apiClient) 28 | 29 | 30 | // MARK: - Private methods 31 | 32 | private func validateSession(responseError: Error?) { 33 | if let error = responseError as? APIError, error.code == .tokenInvalid { 34 | // TODO: Logout 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Example/Example/Sources/Presentation/Helpers/Streamer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StreamExample.swift 3 | // Example 4 | // 5 | // Created by Anton Glezman on 17.06.2020. 6 | // Copyright © 2020 RedMadRobot. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// This class contains an implementation of slow writing data to a stream. 12 | /// It is used only for example of tracking network upload progress. 13 | final class Streamer: NSObject, StreamDelegate { 14 | 15 | struct Streams { 16 | let input: InputStream 17 | let output: OutputStream 18 | } 19 | 20 | lazy var boundStreams: Streams = { 21 | var inputOrNil: InputStream? = nil 22 | var outputOrNil: OutputStream? = nil 23 | Stream.getBoundStreams(withBufferSize: chunkSize, 24 | inputStream: &inputOrNil, 25 | outputStream: &outputOrNil) 26 | guard let input = inputOrNil, let output = outputOrNil else { 27 | fatalError("On return of `getBoundStreams`, both `inputStream` and `outputStream` will contain non-nil streams.") 28 | } 29 | output.schedule(in: .current, forMode: .default) 30 | output.open() 31 | return Streams(input: input, output: output) 32 | }() 33 | 34 | let totalDataSize = 4096 35 | let chunkSize = 128 36 | let chunksCount = 32 37 | private var timer: Timer? 38 | private var counter: Int = 0 39 | 40 | func run() { 41 | counter = 0 42 | timer = Timer.scheduledTimer( 43 | withTimeInterval: 0.5, 44 | repeats: true) { [weak self] _ in 45 | self?.timerFired() 46 | } 47 | } 48 | 49 | func stop() { 50 | boundStreams.output.close() 51 | boundStreams.input.close() 52 | timer?.invalidate() 53 | } 54 | 55 | private func timerFired() { 56 | if counter == chunksCount { 57 | boundStreams.output.close() 58 | timer?.invalidate() 59 | timer = nil 60 | } else { 61 | let data = Data(count: chunkSize) 62 | _ = data.withUnsafeBytes { 63 | boundStreams.output.write($0.bindMemory(to: UInt8.self).baseAddress!, maxLength: data.count) 64 | } 65 | counter += 1 66 | } 67 | } 68 | 69 | deinit { 70 | stop() 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Example/Example/Sources/Presentation/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // Example 4 | // 5 | // Created by Anton Glezman on 18/06/2019. 6 | // Copyright © 2019 RedMadRobot. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class ViewController: UIViewController { 12 | 13 | @IBOutlet private weak var activityView: UIStackView! 14 | @IBOutlet private weak var resultLabel: UILabel! 15 | 16 | private let bookService: BookService = ServiceLayer.shared.bookService 17 | private let fileService: FileService = ServiceLayer.shared.fileService 18 | 19 | private var observation: NSKeyValueObservation? 20 | private var progress: Progress? 21 | 22 | private var task: Any? 23 | private var streamer: Streamer? 24 | 25 | override func viewDidLoad() { 26 | super.viewDidLoad() 27 | resultLabel.text = nil 28 | } 29 | 30 | @IBAction private func performRequest() { 31 | activityView.isHidden = false 32 | 33 | task = Task { 34 | do { 35 | let books = try await bookService.fetchBooks() 36 | show(books: books) 37 | } catch { 38 | show(error: error) 39 | } 40 | activityView.isHidden = true 41 | } 42 | } 43 | 44 | @IBAction private func upload() { 45 | guard let file = Bundle.main.url(forResource: "Info", withExtension: "plist") else { return } 46 | activityView.isHidden = false 47 | 48 | task = Task { 49 | do { 50 | try await fileService.upload(file: file) 51 | showOKUpload() 52 | } catch { 53 | show(error: error) 54 | } 55 | activityView.isHidden = true 56 | } 57 | } 58 | 59 | @IBAction private func uploadStream() { 60 | let streamer = Streamer() 61 | self.streamer = streamer 62 | activityView.isHidden = false 63 | 64 | streamer.run() 65 | 66 | task = Task { 67 | do { 68 | try await fileService.upload(stream: streamer.boundStreams.input, size: streamer.totalDataSize) 69 | } catch { 70 | show(error: error) 71 | self.streamer = nil 72 | } 73 | } 74 | } 75 | 76 | @IBAction private func cancel() { 77 | if #available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) { 78 | (task as? Task)?.cancel() 79 | } else { 80 | progress?.cancel() 81 | } 82 | } 83 | 84 | private func show(books: [Book]) { 85 | resultLabel.text = books.map { "• \($0.title)" }.joined(separator: "\n") 86 | } 87 | 88 | private func show(error: Error) { 89 | resultLabel.text = error.localizedDescription 90 | } 91 | 92 | private func showOKUpload() { 93 | resultLabel.text = "ok" 94 | } 95 | 96 | } 97 | 98 | -------------------------------------------------------------------------------- /Example/Example/Supporting Files/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UILaunchStoryboardName 24 | LaunchScreen 25 | UIMainStoryboardFile 26 | Main 27 | UIRequiredDeviceCapabilities 28 | 29 | armv7 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /Example/ExampleAPI/Common/APIError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIError.swift 3 | // 4 | // Created by Alexander Ignatev on 08/02/2019. 5 | // Copyright © 2019 RedMadRobot. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Error from API. 11 | public struct APIError: Decodable, Error { 12 | 13 | public struct Code: RawRepresentable, Decodable, Equatable { 14 | public var rawValue: String 15 | 16 | public init(rawValue: String) { 17 | self.rawValue = rawValue 18 | } 19 | 20 | public init(_ rawValue: String) { 21 | self.rawValue = rawValue 22 | } 23 | } 24 | 25 | /// Error code. 26 | public let code: Code 27 | 28 | /// Error description. 29 | public let description: String? 30 | 31 | public init( 32 | code: Code, 33 | description: String? = nil) { 34 | 35 | self.code = code 36 | self.description = description 37 | } 38 | } 39 | 40 | // MARK: - General Error Code 41 | 42 | extension APIError.Code { 43 | 44 | /// Invalid Token Error. 45 | public static let tokenInvalid = APIError.Code("token_invalid") 46 | 47 | } 48 | -------------------------------------------------------------------------------- /Example/ExampleAPI/Common/Codable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Codable.swift 3 | // 4 | // Created by Alexander Ignatev on 18/02/2019. 5 | // Copyright © 2019 RedMadRobot. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | extension JSONEncoder { 11 | internal static let `default`: JSONEncoder = { 12 | let encoder = JSONEncoder() 13 | encoder.keyEncodingStrategy = .convertToSnakeCase 14 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 15 | return encoder 16 | }() 17 | } 18 | 19 | extension JSONDecoder { 20 | internal static let `default`: JSONDecoder = { 21 | let decoder = JSONDecoder() 22 | decoder.keyDecodingStrategy = .convertFromSnakeCase 23 | return decoder 24 | }() 25 | } 26 | -------------------------------------------------------------------------------- /Example/ExampleAPI/Common/HTTPError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HTTPError.swift 3 | // 4 | // Created by Alexander Ignatev on 15/03/2019. 5 | // Copyright © 2019 RedMadRobot. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | struct HTTPError: Error { 11 | let statusCode: Int 12 | let url: URL? 13 | 14 | var localizedDescription: String { 15 | return HTTPURLResponse.localizedString(forStatusCode: statusCode) 16 | } 17 | } 18 | 19 | // MARK: - CustomNSError 20 | 21 | extension HTTPError: CustomNSError { 22 | static var errorDomain = "Example.HTTPErrorDomain" 23 | 24 | public var errorCode: Int { return statusCode } 25 | 26 | public var errorUserInfo: [String: Any] { 27 | var userInfo: [String: Any] = [NSLocalizedDescriptionKey: localizedDescription] 28 | userInfo[NSURLErrorKey] = url 29 | return userInfo 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Example/ExampleAPI/Common/ResponseValidator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResponseValidator.swift 3 | // 4 | // Created by Alexander Ignatev on 19/03/2019. 5 | // Copyright © 2019 RedMadRobot. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | private struct ResponseError: Decodable { 11 | let error: APIError 12 | } 13 | 14 | /// Response validation helper. 15 | internal enum ResponseValidator { 16 | 17 | /// Error response validation. 18 | /// 19 | /// - Parameters: 20 | /// - response: The metadata associated with the response. 21 | /// - body: The response body. 22 | /// - Throws: `APIError`. 23 | internal static func validate(_ response: URLResponse?, with body: Data) throws { 24 | try validateAPIResponse(response, with: body) 25 | try validateHTTPstatus(response) 26 | } 27 | 28 | private static func validateAPIResponse(_ response: URLResponse?, with body: Data) throws { 29 | let decoder = JSONDecoder.default 30 | if let error = try? decoder.decode(ResponseError.self, from: body).error { 31 | throw error 32 | } 33 | } 34 | 35 | private static func validateHTTPstatus(_ response: URLResponse?) throws { 36 | guard let httpResponse = response as? HTTPURLResponse, 37 | !(200..<300).contains(httpResponse.statusCode) else { return } 38 | 39 | throw HTTPError(statusCode: httpResponse.statusCode, url: httpResponse.url) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Example/ExampleAPI/Endpoint/Base/EmptyEndpoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmptyEndpoint.swift 3 | // 4 | // Created by Alexander Ignatev on 18/02/2019. 5 | // Copyright © 2019 RedMadRobot. All rights reserved. 6 | // 7 | 8 | import Apexy 9 | 10 | /// Empty Body Request Endpoint. 11 | protocol EmptyEndpoint: Endpoint, URLRequestBuildable where Content == Void {} 12 | 13 | extension EmptyEndpoint { 14 | 15 | public func content(from response: URLResponse?, with body: Data) throws { 16 | try ResponseValidator.validate(response, with: body) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Example/ExampleAPI/Endpoint/Base/JsonEndpoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JsonEndpoint.swift 3 | // 4 | // Created by Alexander Ignatev on 08/02/2019. 5 | // Copyright © 2019 RedMadRobot. All rights reserved. 6 | // 7 | 8 | import Apexy 9 | 10 | /// Base Endpoint for application remote resource. 11 | /// 12 | /// Contains shared logic for all endpoints in app. 13 | protocol JsonEndpoint: Endpoint, URLRequestBuildable where Content: Decodable {} 14 | 15 | extension JsonEndpoint { 16 | 17 | /// Request body encoder. 18 | internal var encoder: JSONEncoder { return JSONEncoder.default } 19 | 20 | public func content(from response: URLResponse?, with body: Data) throws -> Content { 21 | try ResponseValidator.validate(response, with: body) 22 | let resource = try JSONDecoder.default.decode(ResponseData.self, from: body) 23 | return resource.data 24 | } 25 | } 26 | 27 | // MARK: - Response 28 | 29 | private struct ResponseData: Decodable where Resource: Decodable { 30 | let data: Resource 31 | } 32 | -------------------------------------------------------------------------------- /Example/ExampleAPI/Endpoint/BookListEndpoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookListEndpoint.swift 3 | // 4 | // Created by Anton Glezman on 17/06/2019. 5 | // Copyright © 2019 RedMadRobot. All rights reserved. 6 | // 7 | 8 | import Apexy 9 | 10 | /// Example of GET request. 11 | public struct BookListEndpoint: JsonEndpoint { 12 | 13 | public typealias Content = [Book] 14 | 15 | public init() {} 16 | 17 | public func makeRequest() throws -> URLRequest { 18 | return get(URL(string: "books")!) 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /Example/ExampleAPI/Endpoint/FileUploadEndpoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileUploadEndpoint.swift 3 | // ExampleAPI 4 | // 5 | // Created by Anton Glezman on 17.06.2020. 6 | // Copyright © 2020 RedMadRobot. All rights reserved. 7 | // 8 | 9 | import Apexy 10 | 11 | /// Endpoint for uploading a file 12 | public struct FileUploadEndpoint: UploadEndpoint { 13 | 14 | public typealias Content = Void 15 | 16 | private let fileURL: URL 17 | 18 | public init(fileURL: URL) { 19 | self.fileURL = fileURL 20 | } 21 | 22 | public func content(from response: URLResponse?, with body: Data) throws { 23 | try ResponseValidator.validate(response, with: body) 24 | } 25 | 26 | public func makeRequest() throws -> (URLRequest, UploadEndpointBody) { 27 | var request = URLRequest(url: URL(string: "upload")!) 28 | request.httpMethod = "POST" 29 | request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") 30 | return (request, .file(fileURL)) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Example/ExampleAPI/Endpoint/StreamUploadEndpoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StreamUploadEndpoint.swift 3 | // ExampleAPI 4 | // 5 | // Created by Anton Glezman on 18.06.2020. 6 | // Copyright © 2020 RedMadRobot. All rights reserved. 7 | // 8 | 9 | import Apexy 10 | 11 | /// Endpoint for uploading a data form a stream 12 | public struct StreamUploadEndpoint: UploadEndpoint { 13 | 14 | public typealias Content = Void 15 | 16 | private let stream: InputStream 17 | private let size: Int 18 | 19 | public init(stream: InputStream, size: Int) { 20 | self.stream = stream 21 | self.size = size 22 | } 23 | 24 | public func content(from response: URLResponse?, with body: Data) throws { 25 | try ResponseValidator.validate(response, with: body) 26 | } 27 | 28 | public func makeRequest() throws -> (URLRequest, UploadEndpointBody) { 29 | var request = URLRequest(url: URL(string: "upload")!) 30 | request.httpMethod = "POST" 31 | request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") 32 | 33 | // To track upload progress, it is important to set the Content-Length value. 34 | request.setValue("\(size)", forHTTPHeaderField: "Content-Length") 35 | return (request, .stream(stream)) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Example/ExampleAPI/ExampleAPI.h: -------------------------------------------------------------------------------- 1 | // 2 | // ExampleAPI.h 3 | // ExampleAPI 4 | // 5 | // Created by Daniil Subbotin on 28.07.2020. 6 | // Copyright © 2020 RedMadRobot. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | //! Project version number for ExampleAPI. 12 | FOUNDATION_EXPORT double ExampleAPIVersionNumber; 13 | 14 | //! Project version string for ExampleAPI. 15 | FOUNDATION_EXPORT const unsigned char ExampleAPIVersionString[]; 16 | 17 | // In this header, you should import all the public headers of your framework using statements like #import 18 | 19 | 20 | -------------------------------------------------------------------------------- /Example/ExampleAPI/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | 22 | 23 | -------------------------------------------------------------------------------- /Example/ExampleAPI/Model/Book.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Book.swift 3 | // 4 | // Created by Anton Glezman on 17/06/2019. 5 | // Copyright © 2019 RedMadRobot. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Response model. 11 | public struct Book: Decodable, Identifiable { 12 | 13 | public let id: Int 14 | public let title: String 15 | public let authors: String 16 | public let isbn: String? 17 | 18 | } 19 | -------------------------------------------------------------------------------- /Example/ExampleAPI/Model/Form.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Form.swift 3 | // ExampleAPI 4 | // 5 | // Created by Anton Glezman on 19.06.2020. 6 | // Copyright © 2020 RedMadRobot. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct Form { 12 | public let fields: [FormField] 13 | 14 | public init(fields: [FormField]) { 15 | self.fields = fields 16 | } 17 | } 18 | 19 | public struct FormField { 20 | public let name: String 21 | public let data: Data 22 | public let fileName: String? 23 | public let mimeType: String? 24 | 25 | public init(data: Data, name: String) { 26 | self.data = data 27 | self.name = name 28 | self.fileName = nil 29 | self.mimeType = nil 30 | } 31 | 32 | public init(data: Data, name: String, mimeType: String) { 33 | self.data = data 34 | self.name = name 35 | self.fileName = nil 36 | self.mimeType = mimeType 37 | } 38 | 39 | public init(data: Data, name: String, fileName: String, mimeType: String) { 40 | self.data = data 41 | self.name = name 42 | self.fileName = fileName 43 | self.mimeType = mimeType 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Example/ExampleAPITests/Common/Asserts.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Asserts.swift 3 | // ExampleAPITests 4 | // 5 | // Created by Daniil Subbotin on 28.07.2020. 6 | // Copyright © 2020 RedMadRobot. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | func assertGET(_ urlRequest: URLRequest, file: StaticString = #file, line: UInt = #line) { 12 | guard let method = urlRequest.httpMethod else { 13 | return XCTFail("The request doesn't have HTTP method", file: file, line: line) 14 | } 15 | XCTAssertEqual(method, "GET", file: file, line: line) 16 | XCTAssertNil(urlRequest.httpBody, "GET request must not have body", file: file, line: line) 17 | } 18 | 19 | func assertPOST(_ urlRequest: URLRequest, file: StaticString = #file, line: UInt = #line) { 20 | guard let method = urlRequest.httpMethod else { 21 | return XCTFail("The request doesn't have HTTP method", file: file, line: line) 22 | } 23 | XCTAssertEqual(method, "POST", file: file, line: line) 24 | } 25 | 26 | func assertDELETE(_ urlRequest: URLRequest, file: StaticString = #file, line: UInt = #line) { 27 | guard let method = urlRequest.httpMethod else { 28 | return XCTFail("The request doesn't have HTTP method", file: file, line: line) 29 | } 30 | XCTAssertEqual(method, "DELETE", file: file, line: line) 31 | } 32 | 33 | func assertPATCH(_ urlRequest: URLRequest, file: StaticString = #file, line: UInt = #line) { 34 | guard let method = urlRequest.httpMethod else { 35 | return XCTFail("The request doesn't have HTTP method", file: file, line: line) 36 | } 37 | XCTAssertEqual(method, "PATCH", file: file, line: line) 38 | } 39 | 40 | func assertPath(_ urlRequest: URLRequest, _ path: String, file: StaticString = #file, line: UInt = #line) { 41 | guard let url = urlRequest.url else { 42 | return XCTFail("The request doesn't have URL", file: file, line: line) 43 | } 44 | XCTAssertEqual(url.path, path, "Request's path doesn't match", file: file, line: line) 45 | } 46 | 47 | func assertURL(_ urlRequest: URLRequest, _ urlString: String, file: StaticString = #file, line: UInt = #line) { 48 | guard let url = urlRequest.url else { 49 | return XCTFail("The request doesn't have URL", file: file, line: line) 50 | } 51 | XCTAssertEqual(url.absoluteString, urlString, "Request's URL doesn't match", file: file, line: line) 52 | } 53 | 54 | func assertHTTPHeaders(_ urlRequest: URLRequest, _ headers: [String: String], file: StaticString = #file) { 55 | XCTAssertEqual(urlRequest.allHTTPHeaderFields, headers) 56 | } 57 | 58 | func assertJsonBody(_ urlRequest: URLRequest, _ json: [String: Any], file: StaticString = #file, line: UInt = #line) { 59 | guard let body = urlRequest.httpBody else { 60 | return XCTFail("The request doesn't have body", file: file, line: line) 61 | } 62 | 63 | if let contentType = urlRequest.value(forHTTPHeaderField: "Content-Type") { 64 | XCTAssertTrue(contentType.contains("application/json"), "Content-Type запрос не json") 65 | } else { 66 | XCTFail("The request doesn't have HTTP Header Content-Type", file: file, line: line) 67 | } 68 | 69 | do { 70 | let json1 = try JSONSerialization.jsonObject(with: body) 71 | guard let dict = json1 as? NSDictionary else { 72 | return XCTFail("The body of the request isn't a JSON dictionary", file: file, line: line) 73 | } 74 | XCTAssertEqual(dict, json as NSDictionary, file: file, line: line) 75 | } catch { 76 | XCTFail("The body of the request isn't a JSON \(error)", file: file, line: line) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Example/ExampleAPITests/Endpoint/BookListEndpointTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BookListEndpointTests.swift 3 | // ExampleAPITests 4 | // 5 | // Created by Daniil Subbotin on 28.07.2020. 6 | // Copyright © 2020 RedMadRobot. All rights reserved. 7 | // 8 | 9 | import ExampleAPI 10 | import XCTest 11 | 12 | final class BookListEndpointTests: XCTestCase { 13 | 14 | func testMakeRequest() throws { 15 | let endpoint = BookListEndpoint() 16 | let urlRequest = try endpoint.makeRequest() 17 | 18 | assertGET(urlRequest) 19 | assertURL(urlRequest, "books") 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /Example/ExampleAPITests/Endpoint/FileUploadEndpointTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileUploadEndpointTests.swift 3 | // ExampleAPITests 4 | // 5 | // Created by Daniil Subbotin on 28.07.2020. 6 | // Copyright © 2020 RedMadRobot. All rights reserved. 7 | // 8 | 9 | import ExampleAPI 10 | import XCTest 11 | 12 | final class FileUploadEndpointTests: XCTestCase { 13 | 14 | func testMakeRequest() throws { 15 | let fileURL = URL(string: "path/to/file")! 16 | let endpoint = FileUploadEndpoint(fileURL: fileURL) 17 | 18 | let (request, body) = try endpoint.makeRequest() 19 | 20 | assertPOST(request) 21 | assertURL(request, "upload") 22 | assertHTTPHeaders(request, [ 23 | "Content-Type": "application/octet-stream" 24 | ]) 25 | 26 | switch body { 27 | case .file(let url): 28 | XCTAssertEqual(url, fileURL) 29 | default: 30 | XCTFail("urlRequest's UploadEndpointBody must be .file") 31 | } 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /Example/ExampleAPITests/Endpoint/StreamUploadEndpointTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StreamUploadEndpointTests.swift 3 | // ExampleAPITests 4 | // 5 | // Created by Daniil Subbotin on 28.07.2020. 6 | // Copyright © 2020 RedMadRobot. All rights reserved. 7 | // 8 | 9 | import ExampleAPI 10 | import XCTest 11 | 12 | final class StreamUploadEndpointTests: XCTestCase { 13 | 14 | func testMakeRequest() throws { 15 | let fileStream = InputStream(data: Data()) 16 | let fileSize = 1024 17 | let endpoint = StreamUploadEndpoint(stream: fileStream, size: fileSize) 18 | 19 | let (request, body) = try endpoint.makeRequest() 20 | 21 | assertPOST(request) 22 | assertURL(request, "upload") 23 | assertHTTPHeaders(request, [ 24 | "Content-Type": "application/octet-stream", 25 | "Content-Length": String(fileSize) 26 | ]) 27 | 28 | switch body { 29 | case .stream(let stream): 30 | XCTAssertEqual(stream, fileStream) 31 | default: 32 | XCTFail("urlRequest's UploadEndpointBody must be .stream") 33 | } 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /Example/ExampleAPITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Example/Podfile: -------------------------------------------------------------------------------- 1 | platform :ios, '11.0' 2 | 3 | target 'Example' do 4 | use_frameworks! 5 | end 6 | 7 | target 'ExampleAPI' do 8 | use_frameworks! 9 | 10 | pod 'Apexy', :path => "../" 11 | 12 | target 'ExampleAPITests' do 13 | inherit! :search_paths 14 | end 15 | end -------------------------------------------------------------------------------- /Example/Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - Alamofire (5.8.1) 3 | - Apexy (1.7.4): 4 | - Apexy/Alamofire (= 1.7.4) 5 | - Apexy/Alamofire (1.7.4): 6 | - Alamofire (~> 5.6) 7 | - Apexy/Core 8 | - Apexy/Core (1.7.4) 9 | 10 | DEPENDENCIES: 11 | - Apexy (from `../`) 12 | 13 | SPEC REPOS: 14 | trunk: 15 | - Alamofire 16 | 17 | EXTERNAL SOURCES: 18 | Apexy: 19 | :path: "../" 20 | 21 | SPEC CHECKSUMS: 22 | Alamofire: 3ca42e259043ee0dc5c0cdd76c4bc568b8e42af7 23 | Apexy: a3218097135e746fd7c9215da167521f9275df23 24 | 25 | PODFILE CHECKSUM: f86a90e7590ccb3aa7caeceaf315abe256650c66 26 | 27 | COCOAPODS: 1.12.1 28 | -------------------------------------------------------------------------------- /Images/apexy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RedMadRobot/apexy-ios/ad19a372695ef3191c5fb8e70624cb7439224f4d/Images/apexy.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Redmadrobot 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Alamofire", 6 | "repositoryURL": "https://github.com/Alamofire/Alamofire.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "8dd85aee02e39dd280c75eef88ffdb86eed4b07b", 10 | "version": "5.6.2" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Apexy", 8 | platforms: [ 9 | .macOS(.v10_15), 10 | .iOS(.v13), 11 | .tvOS(.v13), 12 | .watchOS(.v6) 13 | ], 14 | products: [ 15 | .library(name: "Apexy", targets: ["ApexyURLSession"]), 16 | .library(name: "ApexyAlamofire", targets: ["ApexyAlamofire"]), 17 | .library(name: "ApexyLoader", targets: ["ApexyLoader"]) 18 | ], 19 | dependencies: [ 20 | .package(url: "https://github.com/Alamofire/Alamofire.git", .upToNextMajor(from: "5.2.0")) 21 | ], 22 | targets: [ 23 | .target(name: "ApexyLoader", dependencies: ["Apexy"]), 24 | .target(name: "ApexyAlamofire", dependencies: ["Apexy", "Alamofire"]), 25 | .target(name: "ApexyURLSession", dependencies: ["Apexy"]), 26 | .target(name: "Apexy"), 27 | 28 | .testTarget(name: "ApexyLoaderTests", dependencies: ["ApexyLoader"]), 29 | .testTarget(name: "ApexyAlamofireTests", dependencies: ["ApexyAlamofire"]), 30 | .testTarget(name: "ApexyURLSessionTests", dependencies: ["ApexyURLSession"]), 31 | .testTarget(name: "ApexyTests", dependencies: ["Apexy"]) 32 | ] 33 | ) 34 | -------------------------------------------------------------------------------- /Sources/Apexy/APIResult.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIResult.swift 3 | // 4 | // 5 | // Created by Aleksei Tiurnin on 17.08.2022. 6 | // 7 | 8 | import Foundation 9 | 10 | public typealias APIResult = Swift.Result 11 | 12 | public extension APIResult { 13 | var error: Error? { 14 | switch self { 15 | case .failure(let error): 16 | return error 17 | default: 18 | return nil 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Apexy/Client.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol Client: AnyObject { 4 | 5 | /// Send request to specified endpoint. 6 | /// 7 | /// - Parameters: 8 | /// - endpoint: endpoint of remote content. 9 | /// - completionHandler: The completion closure to be executed when request is completed. 10 | /// - Returns: The progress of fetching the response data from the server for the request. 11 | func request( 12 | _ endpoint: T, 13 | completionHandler: @escaping (APIResult) -> Void 14 | ) -> Progress where T: Endpoint 15 | 16 | /// Upload data to specified endpoint. 17 | /// 18 | /// - Parameters: 19 | /// - endpoint: The remote endpoint and data to upload. 20 | /// - completionHandler: The completion closure to be executed when request is completed. 21 | /// - Returns: The progress of uploading data to the server. 22 | func upload( 23 | _ endpoint: T, 24 | completionHandler: @escaping (APIResult) -> Void 25 | ) -> Progress where T: UploadEndpoint 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Apexy/Clients/CombineClient.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Combine) 2 | import Combine 3 | 4 | @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) 5 | public protocol CombineClient: AnyObject { 6 | 7 | /// Send request to specified endpoint. 8 | /// - Parameters: 9 | /// - endpoint: endpoint of remote content. 10 | /// - Returns: Publisher which you can subscribe to 11 | func request(_ endpoint: T) -> AnyPublisher where T: Endpoint 12 | } 13 | 14 | @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) 15 | public extension Client where Self: CombineClient { 16 | func request(_ endpoint: T) -> AnyPublisher where T: Endpoint { 17 | Deferred> { 18 | let subject = PassthroughSubject() 19 | 20 | let progress = self.request(endpoint) { (result: Result) in 21 | switch result { 22 | case .success(let content): 23 | subject.send(content) 24 | subject.send(completion: .finished) 25 | case .failure(let error): 26 | subject.send(completion: .failure(error)) 27 | } 28 | } 29 | 30 | return subject.handleEvents(receiveCancel: { 31 | progress.cancel() 32 | subject.send(completion: .finished) 33 | }).eraseToAnyPublisher() 34 | } 35 | .eraseToAnyPublisher() 36 | } 37 | } 38 | 39 | #endif 40 | -------------------------------------------------------------------------------- /Sources/Apexy/Clients/ConcurrencyClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConcurrencyClient.swift 3 | // 4 | // 5 | // Created by Aleksei Tiurnin on 16.08.2022. 6 | // 7 | 8 | import Foundation 9 | 10 | @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) 11 | public protocol ConcurrencyClient: AnyObject { 12 | /// Send request to specified endpoint. 13 | /// - Parameters: 14 | /// - endpoint: endpoint of remote content. 15 | /// - Returns: response data from the server for the request. 16 | func request(_ endpoint: T) async throws -> T.Content where T: Endpoint 17 | 18 | /// Upload data to specified endpoint. 19 | /// - Parameters: 20 | /// - endpoint: endpoint of remote content. 21 | /// - Returns: response data from the server for the upload. 22 | func upload(_ endpoint: T) async throws -> T.Content where T: UploadEndpoint 23 | 24 | } 25 | -------------------------------------------------------------------------------- /Sources/Apexy/Endpoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Endpoint.swift 3 | // 4 | // Created by Alexander Ignatev on 08/02/2019. 5 | // Copyright © 2019 RedMadRobot. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | /// The endpoint to work with a remote content. 11 | public protocol Endpoint { 12 | 13 | /// Resource type. 14 | /// 15 | /// - Author: Nino 16 | associatedtype Content 17 | 18 | /// Create a new `URLRequest`. 19 | /// 20 | /// - Returns: Resource request. 21 | /// - Throws: Any error creating request. 22 | func makeRequest() throws -> URLRequest 23 | 24 | /// Obtain new content from response with body. 25 | /// 26 | /// - Parameters: 27 | /// - response: The metadata associated with the response. 28 | /// - body: The response body. 29 | /// - Returns: A new endpoint content. 30 | /// - Throws: Any error creating content. 31 | func content(from response: URLResponse?, with body: Data) throws -> Content 32 | 33 | /// Validate response. 34 | /// 35 | /// - Parameters: 36 | /// - request: The metadata associated with the request. 37 | /// - response: The metadata associated with the response. 38 | /// - data: The response body data. 39 | /// - Throws: Any response validation error. 40 | func validate(_ request: URLRequest?, response: HTTPURLResponse, data: Data?) throws 41 | } 42 | 43 | public extension Endpoint { 44 | func validate(_ request: URLRequest?, response: HTTPURLResponse, data: Data?) throws { } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/Apexy/HTTPBody.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HTTPBody.swift 3 | // 4 | // Created by z.samarskaya on 30/06/2020. 5 | // Copyright © 2020 RedMadRobot. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | /// The HTTP body for request 11 | public struct HTTPBody { 12 | public let data: Data 13 | public let contentType: String 14 | 15 | public init(data: Data, contentType: String) { 16 | self.data = data 17 | self.contentType = contentType 18 | } 19 | } 20 | 21 | public extension HTTPBody { 22 | /// Create HTTP body with json content type. 23 | /// 24 | /// - Parameters: 25 | /// - data: HTTP body data. 26 | /// - Returns: HTTPBody. 27 | static func json(_ data: Data) -> HTTPBody { 28 | return HTTPBody(data: data, contentType: "application/json") 29 | } 30 | 31 | /// Create HTTP body with form-urlencoded content type. 32 | /// 33 | /// - Parameters: 34 | /// - data: HTTP body data. 35 | /// - Returns: HTTPBody. 36 | static func form(_ data: Data) -> HTTPBody { 37 | return HTTPBody(data: data, contentType: "application/x-www-form-urlencoded") 38 | } 39 | 40 | /// Create HTTP body with text/plain content type. 41 | /// 42 | /// - Parameters: 43 | /// - data: HTTP body data. 44 | /// - Returns: HTTPBody. 45 | static func text(_ data: Data) -> HTTPBody { 46 | return HTTPBody(data: data, contentType: "text/plain") 47 | } 48 | 49 | /// Create HTTP body with text/plain content type. 50 | /// 51 | /// - Parameters: 52 | /// - data: HTTP body data. 53 | /// - Returns: HTTPBody. 54 | static func string(_ string: String) -> HTTPBody { 55 | return HTTPBody(data: Data(string.utf8), contentType: "text/plain") 56 | } 57 | 58 | /// Create HTTP body with octet-stream content type. 59 | /// 60 | /// - Parameters: 61 | /// - data: HTTP body data. 62 | /// - Returns: HTTPBody. 63 | static func binary(_ data: Data) -> HTTPBody { 64 | return HTTPBody(data: data, contentType: "application/octet-stream") 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/Apexy/ResponseObserver.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResponseObserver.swift 3 | // 4 | // 5 | // Created by Aleksei Tiurnin on 31.08.2022. 6 | // 7 | 8 | import Foundation 9 | 10 | public typealias ResponseObserver = (URLRequest?, HTTPURLResponse?, Data?, Error?) -> Void 11 | -------------------------------------------------------------------------------- /Sources/Apexy/URLRequestBuildable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLRequestBuildable.swift 3 | // 4 | // Created by z.samarskaya on 30/06/2020. 5 | // Copyright © 2020 RedMadRobot. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol URLRequestBuildable { 11 | func get(_ url: URL, queryItems: [URLQueryItem]?) -> URLRequest 12 | func post(_ url: URL, body: HTTPBody?) -> URLRequest 13 | func patch(_ url: URL, body: HTTPBody) -> URLRequest 14 | func put(_ url: URL, body: HTTPBody) -> URLRequest 15 | func delete(_ url: URL) -> URLRequest 16 | } 17 | 18 | public extension URLRequestBuildable { 19 | 20 | /// Create HTTP GET request. 21 | /// 22 | /// - Parameters: 23 | /// - url: Request URL. 24 | /// - queryItems: Request parameters. 25 | /// - Returns: HTTP GET Request. 26 | func get(_ url: URL, queryItems: [URLQueryItem]? = nil) -> URLRequest { 27 | guard let queryItems = queryItems, !queryItems.isEmpty else { 28 | return URLRequest(url: url) 29 | } 30 | 31 | var components = URLComponents(url: url, resolvingAgainstBaseURL: true) 32 | components?.queryItems = queryItems 33 | 34 | guard let queryURL = components?.url else { 35 | return URLRequest(url: url) 36 | } 37 | 38 | return URLRequest(url: queryURL) 39 | } 40 | 41 | /// Create HTTP POST request. 42 | /// 43 | /// - Parameters: 44 | /// - url: Request URL. 45 | /// - body: HTTP body. 46 | /// - Returns: HTTP POST request. 47 | func post(_ url: URL, body: HTTPBody?) -> URLRequest { 48 | var request = URLRequest(url: url) 49 | request.httpMethod = "POST" 50 | 51 | if let body = body { 52 | request.setValue(body.contentType, forHTTPHeaderField: "Content-Type") 53 | request.httpBody = body.data 54 | } 55 | return request 56 | } 57 | 58 | /// Create HTTP PATCH request. 59 | /// 60 | /// - Parameters: 61 | /// - url: Request URL. 62 | /// - body: HTTP body. 63 | /// - Returns: HTTP PATCH request. 64 | func patch(_ url: URL, body: HTTPBody) -> URLRequest { 65 | var request = post(url, body: body) 66 | request.httpMethod = "PATCH" 67 | return request 68 | } 69 | 70 | /// Create HTTP PUT request. 71 | /// 72 | /// - Parameters: 73 | /// - url: Request URL. 74 | /// - body: HTTP body. 75 | /// - Returns: HTTP PUT request. 76 | func put(_ url: URL, body: HTTPBody) -> URLRequest { 77 | var request = post(url, body: body) 78 | request.httpMethod = "PUT" 79 | return request 80 | } 81 | 82 | /// Create HTTP DELETE request. 83 | /// 84 | /// - Parameters: 85 | /// - url: Request URL. 86 | /// - Returns: HTTP DELETE request. 87 | func delete(_ url: URL) -> URLRequest { 88 | var request = URLRequest(url: url) 89 | request.httpMethod = "DELETE" 90 | return request 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Sources/Apexy/UploadEndpoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UploadEndpoint.swift 3 | // 4 | // Created by Anton Glezman on 17.06.2020. 5 | // 6 | 7 | import Foundation 8 | 9 | /// Type of uploadable content 10 | public enum UploadEndpointBody { 11 | case data(Data) 12 | case file(URL) 13 | case stream(InputStream) 14 | } 15 | 16 | /// The endpoint for upload data to the remote server. 17 | public protocol UploadEndpoint { 18 | 19 | /// Response type. 20 | associatedtype Content 21 | 22 | /// Create a new `URLRequest` and uploadable payload. 23 | /// 24 | /// - Returns: Resource request and uploadable data 25 | /// - Throws: Any error creating request. 26 | func makeRequest() throws -> (URLRequest, UploadEndpointBody) 27 | 28 | /// Obtain new content from response with body. 29 | /// 30 | /// - Parameters: 31 | /// - response: The metadata associated with the response. 32 | /// - body: The response body. 33 | /// - Returns: A new endpoint content. 34 | /// - Throws: Any error creating content. 35 | func content(from response: URLResponse?, with body: Data) throws -> Content 36 | 37 | } 38 | -------------------------------------------------------------------------------- /Sources/ApexyAlamofire/AlamofireClient+Concurrency.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlamofireClient+Concurrency.swift 3 | // 4 | // 5 | // Created by Aleksei Tiurnin on 15.08.2022. 6 | // 7 | 8 | import Alamofire 9 | import Apexy 10 | import Foundation 11 | 12 | @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) 13 | extension AlamofireClient: ConcurrencyClient { 14 | 15 | func observeResponse( 16 | dataResponse: DataResponse, 17 | error: Error?) { 18 | self.responseObserver?( 19 | dataResponse.request, 20 | dataResponse.response, 21 | dataResponse.data, 22 | error) 23 | } 24 | 25 | open func request(_ endpoint: T) async throws -> T.Content where T : Endpoint { 26 | 27 | let anyRequest = AnyRequest(create: endpoint.makeRequest) 28 | let request = sessionManager.request(anyRequest) 29 | .validate { request, response, data in 30 | Result(catching: { try endpoint.validate(request, response: response, data: data) }) 31 | } 32 | 33 | let dataResponse = await request.serializingData().response 34 | let result = APIResult(catching: { () throws -> T.Content in 35 | do { 36 | let data = try dataResponse.result.get() 37 | return try endpoint.content(from: dataResponse.response, with: data) 38 | } catch { 39 | throw error.unwrapAlamofireValidationError() 40 | } 41 | }) 42 | 43 | Task.detached { [weak self, dataResponse, result] in 44 | self?.observeResponse(dataResponse: dataResponse, error: result.error) 45 | } 46 | 47 | return try result.get() 48 | } 49 | 50 | open func upload(_ endpoint: T) async throws -> T.Content where T : UploadEndpoint { 51 | 52 | let urlRequest: URLRequest 53 | let body: UploadEndpointBody 54 | (urlRequest, body) = try endpoint.makeRequest() 55 | 56 | let request: UploadRequest 57 | switch body { 58 | case .data(let data): 59 | request = sessionManager.upload(data, with: urlRequest) 60 | case .file(let url): 61 | request = sessionManager.upload(url, with: urlRequest) 62 | case .stream(let stream): 63 | request = sessionManager.upload(stream, with: urlRequest) 64 | } 65 | 66 | let dataResponse = await request.serializingData().response 67 | let result = APIResult(catching: { () throws -> T.Content in 68 | do { 69 | let data = try dataResponse.result.get() 70 | return try endpoint.content(from: dataResponse.response, with: data) 71 | } catch { 72 | throw error.unwrapAlamofireValidationError() 73 | } 74 | }) 75 | 76 | Task.detached { [weak self, dataResponse, result] in 77 | self?.observeResponse(dataResponse: dataResponse, error: result.error) 78 | } 79 | 80 | return try result.get() 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/ApexyAlamofire/BaseRequestInterceptor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseRequestInterceptor.swift 3 | // 4 | // Created by Alexander Ignatev on 12/02/2019. 5 | // Copyright © 2019 RedMadRobot. All rights reserved. 6 | // 7 | 8 | import Alamofire 9 | import Foundation 10 | 11 | /// Implementation of Alamofire.RequestInterceptor. 12 | open class BaseRequestInterceptor: Alamofire.RequestInterceptor { 13 | 14 | /// Contains Base `URL`. 15 | /// 16 | /// Must end with a slash character `https://example.com/api/v1/` 17 | /// 18 | /// - Warning: declared as open variable for debug purposes only. 19 | open var baseURL: URL 20 | 21 | /// Creates a `BaseRequestInterceptor` instance with specified Base `URL`. 22 | /// 23 | /// - Parameter baseURL: Base `URL` for adapter. 24 | public init(baseURL: URL) { 25 | self.baseURL = baseURL 26 | } 27 | 28 | // MARK: - Alamofire.RequestInterceptor 29 | 30 | open func adapt( 31 | _ urlRequest: URLRequest, 32 | for session: Session, 33 | completion: @escaping (Result) -> Void) { 34 | 35 | guard let url = urlRequest.url else { 36 | completion(.failure(URLError(.badURL))) 37 | return 38 | } 39 | 40 | var request = urlRequest 41 | request.url = appendingBaseURL(to: url) 42 | 43 | completion(.success(request)) 44 | } 45 | 46 | open func retry( 47 | _ request: Request, 48 | for session: Session, 49 | dueTo error: Error, 50 | completion: @escaping (RetryResult) -> Void) { 51 | 52 | return completion(.doNotRetry) 53 | } 54 | 55 | // MARK: - Private 56 | 57 | private func appendingBaseURL(to url: URL) -> URL { 58 | URL(string: url.absoluteString, relativeTo: baseURL)! 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /Sources/ApexyLoader/ContentLoader.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Combine) 2 | import Combine 3 | #endif 4 | import Foundation 5 | 6 | private final class StateChangeHandler { 7 | let notify: () -> Void 8 | 9 | init(_ notify: @escaping () -> Void) { 10 | self.notify = notify 11 | } 12 | } 13 | 14 | public protocol ContentLoading: ObservableLoader { 15 | /// Starts loading data. 16 | func load() 17 | } 18 | 19 | /// A object that stores loaded content, loading state and allow to observing loading state. 20 | open class ContentLoader: ObservableLoader { 21 | 22 | /// An array of the loader state change handlers 23 | private var stateHandlers: [StateChangeHandler] = [] 24 | 25 | /// An array of the external loader observers. 26 | final public var observations: [LoaderObservation] = [] 27 | 28 | /// Content loading status. The default value is `.initial`. 29 | /// 30 | /// - Remark: To change state use `update(_:)`. 31 | public var state: LoadingState = .initial { 32 | didSet { 33 | stateHandlers.forEach { $0.notify() } 34 | 35 | if #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) { 36 | stateSubject.send(state) 37 | } 38 | } 39 | } 40 | 41 | // Can not use `@available` with lazy properties in Xcode 14. This is a workaround. 42 | // https://stackoverflow.com/a/55534141/7453375 43 | private var storedStateSubject: Any? 44 | @available(iOS 13.0, macOS 10.15, watchOS 6.0, tvOS 13.0, *) 45 | private var stateSubject: CurrentValueSubject, Never> { 46 | if let subject = storedStateSubject as? CurrentValueSubject, Never> { 47 | return subject 48 | } 49 | let subject = CurrentValueSubject, Never>(.initial) 50 | storedStateSubject = subject 51 | return subject 52 | } 53 | 54 | /// Content loading status. The default value is `.initial`. 55 | /// 56 | /// - Remark: To change state use `update(_:)`. 57 | private var storedStatePublisher: Any? 58 | @available(iOS 13.0, macOS 10.15, watchOS 6.0, tvOS 13.0, *) 59 | public var statePublisher: AnyPublisher, Never> { 60 | if let publisher = storedStatePublisher as? AnyPublisher, Never> { 61 | return publisher 62 | } 63 | let publisher = stateSubject.eraseToAnyPublisher() 64 | storedStatePublisher = publisher 65 | return publisher 66 | } 67 | 68 | public init() {} 69 | 70 | // MARK: - ObservableLoader 71 | 72 | /// Starts state observing. 73 | /// 74 | /// - Parameter changeHandler: A closure to execute when the loader state changes. 75 | /// - Returns: An instance of the `LoaderObservation`. 76 | final public func observe(_ changeHandler: @escaping () -> Void) -> LoaderObservation { 77 | let handler = StateChangeHandler(changeHandler) 78 | stateHandlers.append(handler) 79 | return LoaderObservation { [weak self] in 80 | if let index = self?.stateHandlers.firstIndex(where: { $0 === handler }) { 81 | self?.stateHandlers.remove(at: index) 82 | } 83 | } 84 | } 85 | 86 | // MARK: - Loading 87 | 88 | /// Updates the loader state to `.loading`. 89 | /// 90 | /// Call this method before loading data to update the loader state. 91 | /// - Returns: A boolean value indicating the possibility to start loading data. The method return `false` if the current state is `loading`. 92 | @discardableResult 93 | final public func startLoading() -> Bool { 94 | if state.isLoading { 95 | return false 96 | } 97 | state = .loading(cache: state.content) 98 | return true 99 | } 100 | 101 | /// Updates the loader state using result. 102 | /// 103 | /// Call this method at the end of data loading to update the loader state. 104 | /// - Parameter result: Data loading result. 105 | final public func finishLoading(_ result: Result) { 106 | switch result { 107 | case .success(let content): 108 | state = .success(content: content) 109 | case .failure(let error): 110 | state = .failure(error: error, cache: state.content) 111 | } 112 | } 113 | } 114 | 115 | // MARK: - Content + Equatable 116 | 117 | public extension ContentLoader where Content: Equatable { 118 | 119 | /// Updates state of the loader. 120 | /// - Parameter state: New state. 121 | func update(_ state: LoadingState) { 122 | if self.state != state { 123 | self.state = state 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Sources/ApexyLoader/LoaderObservation.swift: -------------------------------------------------------------------------------- 1 | /// Cancels observation for changes to `ContentLoader` on deinitialization. 2 | /// 3 | /// - Remark: Works like `NSKeyValueObservation`, `AnyCancellable` and `DisposeBag`. 4 | public final class LoaderObservation { 5 | typealias Cancel = () -> Void 6 | 7 | private let cancel: Cancel 8 | 9 | init(_ cancel: @escaping Cancel) { 10 | self.cancel = cancel 11 | } 12 | 13 | deinit { 14 | cancel() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/ApexyLoader/LoadingState.swift: -------------------------------------------------------------------------------- 1 | /// Represents content loading state. 2 | public enum LoadingState { 3 | 4 | /// Initial empty state. 5 | case initial 6 | 7 | /// Content is loading. 8 | /// 9 | /// - `cache`: Cached content that was previously loaded. 10 | case loading(cache: Content?) 11 | 12 | /// Content successfull loaded. 13 | /// 14 | /// - `content`: Actual loaded content. 15 | case success(content: Content) 16 | 17 | /// Content failed to load. 18 | /// 19 | /// - `error`: An error that occurs while loading content. 20 | /// - `cache`: Cached content that was previously loaded. 21 | case failure(error: Error, cache: Content?) 22 | } 23 | 24 | // MARK: - Properties 25 | 26 | extension LoadingState { 27 | 28 | public var content: Content? { 29 | switch self { 30 | case .loading(let content?), 31 | .success(let content), 32 | .failure(_, let content?): 33 | return content 34 | default: 35 | return nil 36 | } 37 | } 38 | 39 | public var isLoading: Bool { 40 | switch self { 41 | case .loading: 42 | return true 43 | default: 44 | return false 45 | } 46 | } 47 | 48 | public var error: Error? { 49 | switch self { 50 | case .failure(let error, _): 51 | return error 52 | default: 53 | return nil 54 | } 55 | } 56 | } 57 | 58 | // MARK: - Methods 59 | 60 | public extension LoadingState { 61 | 62 | /// Merges two states. 63 | func merge(_ state: LoadingState, transform: (Content, C2) -> C3) -> LoadingState { 64 | 65 | switch (self, state) { 66 | case (.loading(let cache1?), _): 67 | let cache3 = state.content.map { transform(cache1, $0) } 68 | return LoadingState.loading(cache: cache3) 69 | case (_, .loading(let cache2?)): 70 | let cache3 = content.map { transform($0, cache2) } 71 | return LoadingState.loading(cache: cache3) 72 | case (.loading, _), 73 | (_, .loading): 74 | return LoadingState.loading(cache: nil) 75 | case (.failure(let error, let cache1?), _): 76 | let cache3 = state.content.map { transform(cache1, $0) } 77 | return LoadingState.failure(error: error, cache: cache3) 78 | case (_, .failure(let error, let cache2?)): 79 | let cache3 = content.map { transform($0, cache2) } 80 | return LoadingState.failure(error: error, cache: cache3) 81 | case (.failure(let error, _), _), 82 | (_, .failure(let error, _)): 83 | return LoadingState.failure(error: error, cache: nil) 84 | case (.success(let lhs), .success(let rhs)): 85 | return LoadingState.success(content: transform(lhs, rhs)) 86 | case (.initial, .initial), 87 | (.initial, .success), 88 | (.success, .initial): 89 | return LoadingState.initial 90 | } 91 | } 92 | } 93 | 94 | // MARK: - Equatable 95 | 96 | extension LoadingState: Equatable where Content: Equatable { 97 | static public func == (lhs: LoadingState, rhs: LoadingState) -> Bool { 98 | switch (lhs, rhs) { 99 | case (.initial, .initial): 100 | return true 101 | case (.failure(_, let cache1), .failure(_, let cache2)), 102 | (.loading(let cache1), .loading(let cache2)): 103 | return cache1 == cache2 104 | case (.success(let content1), .success(let content2)): 105 | return content1 == content2 106 | default: 107 | return false 108 | } 109 | } 110 | } 111 | 112 | // MARK: - CustomStringConvertible 113 | 114 | extension LoadingState: CustomStringConvertible { 115 | public var description: String { 116 | switch self { 117 | case .initial: 118 | return "Initial" 119 | case .loading(let cache): 120 | return "Loading: cache \(String(describing: cache))" 121 | case .success(let content): 122 | return "Success: \(content)" 123 | case .failure(let error, let cache): 124 | return "Failure: \(error), cache \(String(describing: cache))" 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Sources/ApexyLoader/ObservableLoader.swift: -------------------------------------------------------------------------------- 1 | // Loader, which can be observed. 2 | public protocol ObservableLoader: AnyObject { 3 | 4 | /// Starts observing the loader state change. 5 | /// 6 | /// - Parameter changeHandler: State change handler. 7 | /// - Returns: An instance of `LoaderObservation` to cancel observation. 8 | func observe(_ changeHandler: @escaping () -> Void) -> LoaderObservation 9 | } 10 | -------------------------------------------------------------------------------- /Sources/ApexyLoader/WebLoader.swift: -------------------------------------------------------------------------------- 1 | import Apexy 2 | import Foundation 3 | 4 | /// Loads content by network. 5 | open class WebLoader: ContentLoader { 6 | private let apiClient: Client 7 | public private(set) var progress: Progress? 8 | 9 | /// Creates an instance of `WebLoader` to load content by network using specified `Client`. 10 | /// - Parameter apiClient: An instance of the `Client` protocol. Use `AlamofireClient` or `URLSessionClient`. 11 | public init(apiClient: Client) { 12 | self.apiClient = apiClient 13 | } 14 | 15 | deinit { 16 | progress?.cancel() 17 | } 18 | 19 | /// Sends requests to the network. 20 | /// 21 | /// - Warning: You must call `startLoading` before calling this method! 22 | /// - Parameter endpoint: An object representing request. 23 | public func request(_ endpoint: T) where T: Endpoint, T.Content == Content { 24 | progress = apiClient.request(endpoint) { [weak self] result in 25 | self?.progress = nil 26 | self?.finishLoading(result) 27 | } 28 | } 29 | 30 | /// Sends requests to the network and transform successfull result 31 | /// 32 | /// - Parameters: 33 | /// - endpoint: An object representing request. 34 | /// - transform: A closure that transforms successfull result. 35 | public func request(_ endpoint: T, transform: @escaping (T.Content) -> Content) where T: Endpoint { 36 | progress = apiClient.request(endpoint) { [weak self] result in 37 | self?.progress = nil 38 | self?.finishLoading(result.map(transform)) 39 | } 40 | } 41 | 42 | /// Sends requests to the network and calls completion handler. 43 | /// - Parameters: 44 | /// - endpoint: An object representing request. 45 | /// - completion: A completion handler. 46 | public func request(_ endpoint: T, completion: @escaping (Result) -> Void) where T: Endpoint { 47 | progress = apiClient.request(endpoint) { [weak self] result in 48 | self?.progress = nil 49 | completion(result) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/ApexyURLSession/BaseRequestAdapter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A type that can inspect and optionally adapt a `URLRequest` in some manner if necessary. 4 | public protocol RequestAdapter { 5 | func adapt(_ urlRequest: URLRequest) throws -> URLRequest 6 | } 7 | 8 | /// A type that adapt a `URLRequest` by appending base URL. 9 | open class BaseRequestAdapter: RequestAdapter { 10 | 11 | /// Contains Base `URL`. 12 | /// 13 | /// Must end with a slash character `https://example.com/api/v1/` 14 | /// 15 | /// - Warning: declared as open variable for debug purposes only. 16 | open var baseURL: URL 17 | 18 | /// Creates a `BaseRequestAdapter` instance with specified Base `URL`. 19 | /// 20 | /// - Parameter baseURL: Base `URL` for adapter. 21 | public init(baseURL: URL) { 22 | self.baseURL = baseURL 23 | } 24 | 25 | // MARK: - RequestAdapter 26 | 27 | open func adapt(_ urlRequest: URLRequest) throws -> URLRequest { 28 | guard let url = urlRequest.url else { 29 | throw URLError(.badURL) 30 | } 31 | 32 | var request = urlRequest 33 | request.url = appendingBaseURL(to: url) 34 | return request 35 | } 36 | 37 | // MARK: - Private 38 | 39 | private func appendingBaseURL(to url: URL) -> URL { 40 | URL(string: url.absoluteString, relativeTo: baseURL)! 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/ApexyURLSession/URLSessionClient+Concurrency.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLSessionClient+Concurrency.swift 3 | // 4 | // 5 | // Created by Aleksei Tiurnin on 15.08.2022. 6 | // 7 | 8 | import Apexy 9 | import Foundation 10 | 11 | @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) 12 | extension URLSessionClient: ConcurrencyClient { 13 | 14 | func observeResponse( 15 | request: URLRequest?, 16 | responseResult: Result<(data: Data, response: URLResponse), Error>) { 17 | let tuple = try? responseResult.get() 18 | self.responseObserver?( 19 | request, 20 | tuple?.response as? HTTPURLResponse, 21 | tuple?.data, 22 | responseResult.error) 23 | } 24 | 25 | open func request(_ endpoint: T) async throws -> T.Content where T : Endpoint { 26 | 27 | var request = try endpoint.makeRequest() 28 | request = try requestAdapter.adapt(request) 29 | var responseResult: Result<(data: Data, response: URLResponse), Error> 30 | 31 | do { 32 | let response: (data: Data, response: URLResponse) = try await session.data(for: request) 33 | 34 | if let httpResponse = response.response as? HTTPURLResponse { 35 | try endpoint.validate(request, response: httpResponse, data: response.data) 36 | } 37 | 38 | responseResult = .success(response) 39 | } catch let someError { 40 | responseResult = .failure(someError) 41 | } 42 | 43 | Task.detached { [weak self, request, responseResult] in 44 | self?.observeResponse(request: request, responseResult: responseResult) 45 | } 46 | 47 | return try responseResult.flatMap { tuple in 48 | do { 49 | return .success(try endpoint.content(from: tuple.response, with: tuple.data)) 50 | } catch { 51 | return .failure(error) 52 | } 53 | }.get() 54 | } 55 | 56 | open func upload(_ endpoint: T) async throws -> T.Content where T : UploadEndpoint { 57 | 58 | var request: (request: URLRequest, body: UploadEndpointBody) = try endpoint.makeRequest() 59 | request.request = try requestAdapter.adapt(request.request) 60 | var responseResult: Result<(data: Data, response: URLResponse), Error> 61 | 62 | do { 63 | let response: (data: Data, response: URLResponse) 64 | switch request { 65 | case (_, .data(let data)): 66 | response = try await session.upload(for: request.request, from: data) 67 | case (_, .file(let url)): 68 | response = try await session.upload(for: request.request, fromFile: url) 69 | case (_, .stream): 70 | throw URLSessionClientError.uploadStreamUnimplemented 71 | } 72 | 73 | responseResult = .success(response) 74 | } catch let someError { 75 | responseResult = .failure(someError) 76 | } 77 | 78 | Task.detached { [weak self, request, responseResult] in 79 | self?.observeResponse(request: request.request, responseResult: responseResult) 80 | } 81 | 82 | return try responseResult.flatMap { tuple in 83 | do { 84 | return .success(try endpoint.content(from: tuple.response, with: tuple.data)) 85 | } catch { 86 | return .failure(error) 87 | } 88 | }.get() 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Sources/ApexyURLSession/URLSessionClient.swift: -------------------------------------------------------------------------------- 1 | import Apexy 2 | import Foundation 3 | 4 | open class URLSessionClient: Client, CombineClient { 5 | 6 | let session: URLSession 7 | 8 | let requestAdapter: RequestAdapter 9 | 10 | /// The queue on which the completion handler is dispatched. 11 | let completionQueue: DispatchQueue 12 | 13 | /// This closure to be called after each response from the server for the request. 14 | let responseObserver: ResponseObserver? 15 | 16 | /// Creates new 'URLSessionClient' instance. 17 | /// 18 | /// - Parameters: 19 | /// - baseURL: Base `URL`. 20 | /// - configuration: The configuration used to construct the managed session. 21 | /// - delegate: The delegate of URLSession. 22 | /// - completionQueue: The serial operation queue used to dispatch all completion handlers. `.main` by default. 23 | /// - responseObserver: The closure to be called after each response. 24 | public convenience init( 25 | baseURL: URL, 26 | configuration: URLSessionConfiguration = .default, 27 | delegate: URLSessionDelegate? = nil, 28 | completionQueue: DispatchQueue = .main, 29 | responseObserver: ResponseObserver? = nil) { 30 | 31 | self.init( 32 | requestAdapter: BaseRequestAdapter(baseURL: baseURL), 33 | configuration: configuration, 34 | delegate: delegate, 35 | completionQueue: completionQueue, 36 | responseObserver: responseObserver) 37 | } 38 | 39 | /// Creates new 'URLSessionClient' instance. 40 | /// 41 | /// - Parameters: 42 | /// - requestAdapter: RequestAdapter used to adapt a `URLRequest`. 43 | /// - configuration: The configuration used to construct the managed session. 44 | /// - delegate: The delegate of URLSession. 45 | /// - completionQueue: The serial operation queue used to dispatch all completion handlers. `.main` by default. 46 | /// - responseObserver: The closure to be called after each response. 47 | public init( 48 | requestAdapter: RequestAdapter, 49 | configuration: URLSessionConfiguration = .default, 50 | delegate: URLSessionDelegate? = nil, 51 | completionQueue: DispatchQueue = .main, 52 | responseObserver: ResponseObserver? = nil) { 53 | 54 | self.requestAdapter = requestAdapter 55 | self.session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil) 56 | self.completionQueue = completionQueue 57 | self.responseObserver = responseObserver 58 | } 59 | 60 | open func request( 61 | _ endpoint: T, 62 | completionHandler: @escaping (APIResult) -> Void) -> Progress where T : Endpoint { 63 | 64 | var request: URLRequest 65 | do { 66 | request = try endpoint.makeRequest() 67 | request = try requestAdapter.adapt(request) 68 | } catch { 69 | completionHandler(.failure(error)) 70 | return Progress() 71 | } 72 | 73 | let task = session.dataTask(with: request) { (data, response, error) in 74 | let result = APIResult(catching: { () throws -> T.Content in 75 | if let httpResponse = response as? HTTPURLResponse { 76 | try endpoint.validate(request, response: httpResponse, data: data) 77 | } 78 | let data = data ?? Data() 79 | if let error = error { 80 | throw error 81 | } 82 | return try endpoint.content(from: response, with: data) 83 | }) 84 | self.completionQueue.async { 85 | self.responseObserver?(request, response as? HTTPURLResponse, data, error) 86 | completionHandler(result) 87 | } 88 | } 89 | task.resume() 90 | 91 | return task.progress 92 | } 93 | 94 | open func upload(_ endpoint: T, completionHandler: @escaping (APIResult) -> Void) -> Progress where T : UploadEndpoint { 95 | var request: (URLRequest, UploadEndpointBody) 96 | do { 97 | request = try endpoint.makeRequest() 98 | request.0 = try requestAdapter.adapt(request.0) 99 | } catch { 100 | completionHandler(.failure(error)) 101 | return Progress() 102 | } 103 | 104 | let handler: (Data?, URLResponse?, Error?) -> Void = { (data, response, error) in 105 | let result = APIResult(catching: { () throws -> T.Content in 106 | let data = data ?? Data() 107 | if let error = error { 108 | throw error 109 | } 110 | return try endpoint.content(from: response, with: data) 111 | }) 112 | self.completionQueue.async { 113 | self.responseObserver?(request.0, response as? HTTPURLResponse, data, error) 114 | completionHandler(result) 115 | } 116 | } 117 | 118 | let task: URLSessionUploadTask 119 | switch request { 120 | case (let request, .data(let data)): 121 | task = session.uploadTask(with: request, from: data, completionHandler: handler) 122 | case (let request, .file(let url)): 123 | task = session.uploadTask(with: request, fromFile: url, completionHandler: handler) 124 | case (_, .stream): 125 | completionHandler(.failure(URLSessionClientError.uploadStreamUnimplemented)) 126 | return Progress() 127 | } 128 | task.resume() 129 | 130 | return task.progress 131 | } 132 | } 133 | 134 | enum URLSessionClientError: LocalizedError { 135 | case uploadStreamUnimplemented 136 | 137 | var errorDescription: String? { 138 | switch self { 139 | case .uploadStreamUnimplemented: 140 | return """ 141 | UploadEndpointBody.stream is unimplemented. If you need it feel free to create an issue \ 142 | on GitHub https://github.com/RedMadRobot/apexy-ios/issues/new 143 | """ 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /Tests/ApexyAlamofireTests/AlamofireClientCombineTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Combine) 2 | import Combine 3 | import Apexy 4 | import ApexyAlamofire 5 | import XCTest 6 | 7 | final class AlamofireClientCombineTests: XCTestCase { 8 | 9 | private var client: AlamofireClient! 10 | private var cancellables = Set() 11 | 12 | override func setUp() { 13 | let url = URL(string: "https://booklibrary.com")! 14 | 15 | let config = URLSessionConfiguration.ephemeral 16 | config.protocolClasses = [MockURLProtocol.self] 17 | 18 | client = AlamofireClient(baseURL: url, configuration: config) 19 | } 20 | 21 | func testClientRequestWithCombineMultipleTimes() { 22 | let endpoint = EmptyEndpoint() 23 | MockURLProtocol.requestHandler = { request in 24 | let data = UUID().uuidString.data(using: .utf8)! 25 | let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "2.0", headerFields: nil)! 26 | return (response, data) 27 | } 28 | 29 | let exp = expectation(description: "wait for response") 30 | exp.expectedFulfillmentCount = 2 31 | let request = client.request(endpoint) 32 | 33 | // First subscription 34 | var firstRequestContent: Data? 35 | request 36 | .sink( 37 | receiveCompletion: { _ in }, 38 | receiveValue: { content in 39 | firstRequestContent = content 40 | exp.fulfill() 41 | } 42 | ) 43 | .store(in: &cancellables) 44 | 45 | // Second subscription 46 | var secondRequestContent: Data? 47 | request 48 | .sink( 49 | receiveCompletion: { _ in }, 50 | receiveValue: { content in 51 | secondRequestContent = content 52 | exp.fulfill() 53 | } 54 | ) 55 | .store(in: &cancellables) 56 | 57 | // Third subscription which will be cancelled at once 58 | request 59 | .sink( 60 | receiveCompletion: { _ in }, 61 | receiveValue: { _ in } 62 | ) 63 | .cancel() 64 | 65 | wait(for: [exp], timeout: 1) 66 | 67 | XCTAssertNotNil(firstRequestContent) 68 | XCTAssertNotNil(secondRequestContent) 69 | XCTAssertNotEqual(firstRequestContent, secondRequestContent) 70 | } 71 | } 72 | #endif 73 | -------------------------------------------------------------------------------- /Tests/ApexyAlamofireTests/AlamofireClientTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlamofireClientTests.swift 3 | // 4 | // Created by Daniil Subbotin on 07.09.2020. 5 | // Copyright © 2020 RedMadRobot. All rights reserved. 6 | // 7 | 8 | import Apexy 9 | import ApexyAlamofire 10 | import XCTest 11 | 12 | final class AlamofireClientTests: XCTestCase { 13 | 14 | private var client: AlamofireClient! 15 | 16 | override func setUp() { 17 | let url = URL(string: "https://booklibrary.com")! 18 | 19 | let config = URLSessionConfiguration.ephemeral 20 | config.protocolClasses = [MockURLProtocol.self] 21 | 22 | client = AlamofireClient(baseURL: url, configuration: config) 23 | } 24 | 25 | func testClientRequest() { 26 | let endpoint = EmptyEndpoint() 27 | let data = "Test".data(using: .utf8)! 28 | MockURLProtocol.requestHandler = { request in 29 | let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "2.0", headerFields: nil)! 30 | return (response, data) 31 | } 32 | 33 | let exp = expectation(description: "wait for response") 34 | _ = client.request(endpoint) { result in 35 | switch result { 36 | case .success(let content): 37 | XCTAssertEqual(content, data) 38 | case .failure: 39 | XCTFail("Expected result: .success, actual result: .failure") 40 | } 41 | exp.fulfill() 42 | } 43 | wait(for: [exp], timeout: 1) 44 | } 45 | 46 | func testClientUpload() { 47 | let data = "apple".data(using: .utf8)! 48 | let endpoint = SimpleUploadEndpoint(data: data) 49 | MockURLProtocol.requestHandler = { request in 50 | let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "2.0", headerFields: nil)! 51 | return (response, data) 52 | } 53 | 54 | let exp = expectation(description: "wait for response") 55 | _ = client.upload(endpoint, completionHandler: { result in 56 | switch result { 57 | case .success(let content): 58 | XCTAssertEqual(content, data) 59 | case .failure: 60 | XCTFail("Expected result: .success, actual result: .failure") 61 | } 62 | exp.fulfill() 63 | }) 64 | wait(for: [exp], timeout: 1) 65 | } 66 | } 67 | 68 | -------------------------------------------------------------------------------- /Tests/ApexyAlamofireTests/BaseRequestInterceptorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseRequestInterceptorTests.swift 3 | // 4 | // Created by Daniil Subbotin on 07.09.2020. 5 | // Copyright © 2020 RedMadRobot. All rights reserved. 6 | // 7 | 8 | import Apexy 9 | import ApexyAlamofire 10 | import Alamofire 11 | import XCTest 12 | 13 | final class BaseRequestInterceptorTests: XCTestCase { 14 | 15 | private let url = URL(string: "https://booklibrary.com")! 16 | 17 | private var interceptor: RequestInterceptor { 18 | BaseRequestInterceptor(baseURL: url) 19 | } 20 | 21 | func testAdaptWhenURLNotContainsTrailingSlash() { 22 | let request = URLRequest(url: URL(string: "books/10")!) 23 | 24 | let expectation = XCTestExpectation(description: "Wait for completion") 25 | interceptor.adapt(request, for: .default) { result in 26 | switch result { 27 | case .success(let req): 28 | XCTAssertEqual(req.url?.absoluteString, "https://booklibrary.com/books/10") 29 | case .failure: 30 | XCTFail("Expected result: .success, actual result: .failure") 31 | } 32 | expectation.fulfill() 33 | } 34 | wait(for: [expectation], timeout: 1) 35 | } 36 | 37 | func testAdaptWhenURLContainsTrailingSlash() { 38 | let request = URLRequest(url: URL(string: "path/")!) 39 | let exp = expectation(description: "Adapting url request") 40 | interceptor.adapt(request, for: .default) { result in 41 | let request = try! result.get() 42 | XCTAssertEqual(request.url?.absoluteString, "https://booklibrary.com/path/") 43 | exp.fulfill() 44 | } 45 | wait(for: [exp], timeout: 1) 46 | } 47 | 48 | func testAdaptWhenURLContainsQueryItems() { 49 | let url = URL(string: "api/path/")! 50 | var components = URLComponents(url: url, resolvingAgainstBaseURL: false)! 51 | components.queryItems = [URLQueryItem(name: "param", value: "value")] 52 | 53 | let request = URLRequest(url: components.url!) 54 | let exp = expectation(description: "Adapting url request") 55 | interceptor.adapt(request, for: .default) { result in 56 | let request = try! result.get() 57 | XCTAssertEqual(request.url?.absoluteString, "https://booklibrary.com/api/path/?param=value") 58 | exp.fulfill() 59 | } 60 | wait(for: [exp], timeout: 1) 61 | } 62 | 63 | func testAdaptWhenRequestContainsHeaders() { 64 | var request = URLRequest(url: URL(string: "books")!) 65 | request.addValue("application/octet-stream", forHTTPHeaderField: "Content-Type") 66 | 67 | let expectation = XCTestExpectation(description: "Wait for completion") 68 | interceptor.adapt(request, for: .default) { result in 69 | switch result { 70 | case .success(let req): 71 | XCTAssertEqual(req.value(forHTTPHeaderField: "Content-Type"), "application/octet-stream") 72 | case .failure: 73 | XCTFail("Expected result: .success, actual result: .failure") 74 | } 75 | expectation.fulfill() 76 | } 77 | wait(for: [expectation], timeout: 1) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Tests/ApexyAlamofireTests/Helpers/EmptyEndpoint.swift: -------------------------------------------------------------------------------- 1 | import Apexy 2 | import Foundation 3 | 4 | struct EmptyEndpoint: Endpoint { 5 | 6 | typealias Content = Data 7 | 8 | func makeRequest() throws -> URLRequest { 9 | URLRequest(url: URL(string: "empty")!) 10 | } 11 | 12 | func content(from response: URLResponse?, with body: Data) throws -> Data { 13 | return body 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Tests/ApexyAlamofireTests/Helpers/MockURLProtocol.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class MockURLProtocol: URLProtocol { 4 | 5 | static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data) )? 6 | 7 | override class func canInit(with request: URLRequest) -> Bool { 8 | true 9 | } 10 | 11 | override class func canonicalRequest(for request: URLRequest) -> URLRequest { 12 | request 13 | } 14 | 15 | override func stopLoading() {} 16 | 17 | override func startLoading() { 18 | guard let handler = MockURLProtocol.requestHandler else { return } 19 | do { 20 | let (response, data) = try handler(request) 21 | client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) 22 | client?.urlProtocol(self, didLoad: data) 23 | client?.urlProtocolDidFinishLoading(self) 24 | } catch { 25 | client?.urlProtocol(self, didFailWithError: error) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Tests/ApexyAlamofireTests/Helpers/SimpleUploadEndpoint.swift: -------------------------------------------------------------------------------- 1 | import Apexy 2 | import Foundation 3 | 4 | struct SimpleUploadEndpoint: UploadEndpoint { 5 | 6 | typealias Content = Data 7 | 8 | private let data: Data 9 | 10 | init(data: Data) { 11 | self.data = data 12 | } 13 | 14 | func makeRequest() throws -> (URLRequest, UploadEndpointBody) { 15 | var req = URLRequest(url: URL(string: "upload")!) 16 | req.httpMethod = "POST" 17 | 18 | let body = UploadEndpointBody.data(data) 19 | return (req, body) 20 | } 21 | 22 | func content(from response: URLResponse?, with body: Data) throws -> Data { 23 | body 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Tests/ApexyLoaderTests/ContentLoaderTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ApexyLoader 2 | import Combine 3 | import XCTest 4 | 5 | final class ContentLoaderTests: XCTestCase { 6 | 7 | private var contentLoader: ContentLoader! 8 | private var numberOfChanges = 0 9 | private var observation: LoaderObservation! 10 | 11 | private var bag = Set() 12 | private var receivedValues = [LoadingState]() 13 | 14 | override func setUp() { 15 | super.setUp() 16 | 17 | numberOfChanges = 0 18 | contentLoader = ContentLoader() 19 | observation = contentLoader.observe { [weak self] in 20 | self?.numberOfChanges += 1 21 | } 22 | 23 | receivedValues.removeAll() 24 | 25 | contentLoader.statePublisher.sink(receiveCompletion: { _ in }) { loadingState in 26 | self.receivedValues.append(loadingState) 27 | }.store(in: &bag) 28 | 29 | XCTAssertTrue( 30 | contentLoader.observations.isEmpty, 31 | "No observation of other loaders") 32 | XCTAssertEqual( 33 | contentLoader.state, 34 | .initial, 35 | "Initial loader state") 36 | } 37 | 38 | func testCancelObservation() { 39 | observation = nil 40 | contentLoader.state = .success(content: 10) 41 | XCTAssertEqual( 42 | numberOfChanges, 0, 43 | "The change handler didn‘t triggered because the observation was canceled") 44 | } 45 | 46 | func testCancelObservationCombine() { 47 | bag.removeAll() 48 | contentLoader.state = .success(content: 10) 49 | XCTAssertEqual( 50 | receivedValues, 51 | [.initial], 52 | "The change handler didn‘t triggered because the observation was canceled") 53 | } 54 | 55 | func testStartLoading() { 56 | XCTAssertTrue( 57 | contentLoader.startLoading(), 58 | "Loading has begun") 59 | XCTAssertTrue( 60 | contentLoader.state == .loading(cache: nil), 61 | "State of the loader must be loading") 62 | XCTAssertEqual( 63 | numberOfChanges, 1, 64 | "Change handler triggered") 65 | 66 | XCTAssertFalse( 67 | contentLoader.startLoading(), 68 | "The second loading didn‘t start before the end of the first one.") 69 | XCTAssertTrue( 70 | contentLoader.state == .loading(cache: nil), 71 | "The load status has NOT changed") 72 | XCTAssertEqual( 73 | numberOfChanges, 1, 74 | "The change handler did NOT triggered") 75 | } 76 | 77 | func testStartLoadingCombine() { 78 | XCTAssertTrue( 79 | contentLoader.startLoading(), 80 | "Loading has begun") 81 | XCTAssertEqual( 82 | receivedValues, 83 | [.initial, .loading(cache: nil)], 84 | "State of the loader must be loading") 85 | XCTAssertFalse( 86 | contentLoader.startLoading(), 87 | "The second loading didn‘t start before the end of the first one.") 88 | XCTAssertEqual( 89 | receivedValues, 90 | [.initial, .loading(cache: nil)], 91 | "The load status has NOT changed") 92 | } 93 | 94 | func testFinishLoading() { 95 | contentLoader.finishLoading(.success(12)) 96 | XCTAssertTrue( 97 | contentLoader.state == .success(content: 12), 98 | "Successfully loading state") 99 | XCTAssertEqual( 100 | numberOfChanges, 1, 101 | "The change handler triggered") 102 | 103 | let error = URLError(.networkConnectionLost) 104 | contentLoader.finishLoading(.failure(error)) 105 | XCTAssertTrue( 106 | contentLoader.state == .failure(error: error, cache: 12), 107 | "The state must me failure with cache") 108 | XCTAssertEqual( 109 | numberOfChanges, 2, 110 | "The handler triggered") 111 | } 112 | 113 | func testFinishLoadingCombine() { 114 | contentLoader.finishLoading(.success(12)) 115 | XCTAssertEqual( 116 | receivedValues, 117 | [.initial, .success(content: 12)], 118 | "Successfully loading state") 119 | 120 | receivedValues.removeAll() 121 | 122 | let error = URLError(.networkConnectionLost) 123 | contentLoader.finishLoading(.failure(error)) 124 | 125 | XCTAssertEqual( 126 | receivedValues, 127 | [.failure(error: error, cache: 12)], 128 | "The state must me failure with cache") 129 | } 130 | 131 | func testUpdate() { 132 | contentLoader.update(.initial) 133 | XCTAssertEqual( 134 | numberOfChanges, 0, 135 | "The state didn't change and the handler didn't triggered") 136 | 137 | contentLoader.update(.success(content: 1)) 138 | XCTAssertEqual( 139 | numberOfChanges, 1, 140 | "The state changed and the handler triggered") 141 | 142 | contentLoader.update(.success(content: 1)) 143 | XCTAssertEqual( 144 | numberOfChanges, 1, 145 | "The state didn't changed and the handler didn't triggered") 146 | } 147 | 148 | func testUpdateCombine() { 149 | contentLoader.update(.initial) 150 | XCTAssertEqual( 151 | receivedValues, 152 | [.initial], 153 | "The state didn't change and the handler didn't triggered") 154 | 155 | contentLoader.update(.success(content: 1)) 156 | XCTAssertEqual( 157 | receivedValues, 158 | [.initial, .success(content: 1)], 159 | "The state changed and the handler triggered") 160 | 161 | contentLoader.update(.success(content: 1)) 162 | XCTAssertEqual( 163 | receivedValues, 164 | [.initial, .success(content: 1)], 165 | "The state didn't changed and the handler didn't triggered") 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /Tests/ApexyLoaderTests/LoaderObservationTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ApexyLoader 2 | import XCTest 3 | 4 | final class LoaderObservationTests: XCTestCase { 5 | 6 | private var observation: LoaderObservation! 7 | 8 | func testDeinit() { 9 | var numberOfTriggers = 0 10 | observation = LoaderObservation { 11 | numberOfTriggers += 1 12 | } 13 | 14 | observation = nil 15 | 16 | XCTAssertEqual(numberOfTriggers, 1, "The handler triggered once") 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Tests/ApexyLoaderTests/LoadingStateTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ApexyLoader 2 | import XCTest 3 | 4 | final class LoadingStateTests: XCTestCase { 5 | 6 | private let error = URLError(.badURL) 7 | 8 | func testContent() { 9 | XCTAssertNil(LoadingState.initial.content) 10 | XCTAssertNil(LoadingState.loading(cache: nil).content) 11 | XCTAssertNil(LoadingState.failure(error: error, cache: nil).content) 12 | 13 | XCTAssertEqual(LoadingState.loading(cache: 1).content, 1) 14 | XCTAssertEqual(LoadingState.success(content: 2).content, 2) 15 | XCTAssertEqual(LoadingState.failure(error: error, cache: 3).content, 3) 16 | } 17 | 18 | func testIsLoading() { 19 | XCTAssertTrue( 20 | LoadingState.loading(cache: nil).isLoading) 21 | XCTAssertFalse( 22 | LoadingState.initial.isLoading) 23 | XCTAssertFalse( 24 | LoadingState.success(content: 0).isLoading) 25 | XCTAssertFalse( 26 | LoadingState.failure(error: error, cache: 6).isLoading) 27 | } 28 | 29 | func testError() throws { 30 | XCTAssertNil( 31 | LoadingState.loading(cache: nil).error) 32 | XCTAssertNil( 33 | LoadingState.initial.error) 34 | XCTAssertNil( 35 | LoadingState.success(content: 0).error) 36 | 37 | let error = try XCTUnwrap(LoadingState.failure(error: self.error, cache: 6).error as? URLError) 38 | XCTAssertEqual(error, self.error) 39 | } 40 | 41 | func testMerge() { 42 | XCTAssertEqual( 43 | LoadingState.loading(cache: 2).merge(.success(content: 3), transform: +), 44 | LoadingState.loading(cache: 5)) 45 | XCTAssertEqual( 46 | LoadingState.success(content: 2).merge(.loading(cache: 3), transform: +), 47 | LoadingState.loading(cache: 5)) 48 | XCTAssertEqual( 49 | LoadingState.loading(cache: nil).merge(.success(content: 3), transform: +), 50 | LoadingState.loading(cache: nil)) 51 | XCTAssertEqual( 52 | LoadingState.success(content: 2).merge(.loading(cache: nil), transform: +), 53 | LoadingState.loading(cache: nil)) 54 | 55 | XCTAssertEqual( 56 | LoadingState.failure(error: error, cache: 7).merge(.failure(error: error, cache: 7), transform: +), 57 | LoadingState.failure(error: error, cache: 14)) 58 | XCTAssertEqual( 59 | LoadingState.failure(error: error, cache: 7).merge(.success(content: 8), transform: +), 60 | LoadingState.failure(error: error, cache: 15)) 61 | XCTAssertEqual( 62 | LoadingState.success(content: 9).merge(.failure(error: error, cache: 7), transform: +), 63 | LoadingState.failure(error: error, cache: 16)) 64 | XCTAssertEqual( 65 | LoadingState.success(content: 9).merge(.failure(error: error, cache: nil), transform: +), 66 | LoadingState.failure(error: error, cache: nil)) 67 | 68 | XCTAssertEqual( 69 | LoadingState.success(content: 5).merge(.success(content: 5), transform: +), 70 | LoadingState.success(content: 10)) 71 | XCTAssertEqual( 72 | LoadingState.initial.merge(.initial, transform: +), 73 | LoadingState.initial) 74 | XCTAssertEqual( 75 | LoadingState.initial.merge(.success(content: 1), transform: +), 76 | LoadingState.initial) 77 | XCTAssertEqual( 78 | LoadingState.success(content: 2).merge(.initial, transform: +), 79 | LoadingState.initial) 80 | } 81 | 82 | func testInitialEquatable() { 83 | XCTAssertEqual( 84 | LoadingState.initial, 85 | LoadingState.initial) 86 | XCTAssertNotEqual( 87 | LoadingState.initial, 88 | LoadingState.loading(cache: nil)) 89 | XCTAssertNotEqual( 90 | LoadingState.initial, 91 | LoadingState.loading(cache: 76)) 92 | XCTAssertNotEqual( 93 | LoadingState.initial, 94 | LoadingState.success(content: 23)) 95 | XCTAssertNotEqual( 96 | LoadingState.initial, 97 | LoadingState.failure(error: error, cache: nil)) 98 | XCTAssertNotEqual( 99 | LoadingState.initial, 100 | LoadingState.failure(error: error, cache: 100)) 101 | } 102 | 103 | func testLoadingEquatable() { 104 | XCTAssertEqual( 105 | LoadingState.loading(cache: 1), 106 | LoadingState.loading(cache: 1)) 107 | XCTAssertNotEqual( 108 | LoadingState.loading(cache: 2), 109 | LoadingState.loading(cache: 3)) 110 | XCTAssertNotEqual( 111 | LoadingState.loading(cache: 4), 112 | LoadingState.initial) 113 | XCTAssertNotEqual( 114 | LoadingState.loading(cache: 6), 115 | LoadingState.success(content: 6)) 116 | XCTAssertNotEqual( 117 | LoadingState.loading(cache: 6), 118 | LoadingState.success(content: 7)) 119 | XCTAssertNotEqual( 120 | LoadingState.loading(cache: 8), 121 | LoadingState.failure(error: error, cache: nil)) 122 | } 123 | 124 | func testSuccessEquatable() { 125 | XCTAssertEqual( 126 | LoadingState.success(content: 43), 127 | LoadingState.success(content: 43)) 128 | XCTAssertNotEqual( 129 | LoadingState.success(content: 43), 130 | LoadingState.success(content: 47)) 131 | XCTAssertNotEqual( 132 | LoadingState.success(content: 43), 133 | LoadingState.initial) 134 | XCTAssertNotEqual( 135 | LoadingState.success(content: 43), 136 | LoadingState.loading(cache: nil)) 137 | XCTAssertNotEqual( 138 | LoadingState.success(content: 43), 139 | LoadingState.failure(error: error, cache: nil)) 140 | } 141 | 142 | func testFailureEquatable() { 143 | XCTAssertEqual( 144 | LoadingState.failure(error: error, cache: 3), 145 | LoadingState.failure(error: error, cache: 3)) 146 | XCTAssertNotEqual( 147 | LoadingState.failure(error: error, cache: nil), 148 | LoadingState.failure(error: error, cache: 3)) 149 | XCTAssertNotEqual( 150 | LoadingState.failure(error: error, cache: nil), 151 | LoadingState.initial) 152 | XCTAssertNotEqual( 153 | LoadingState.failure(error: error, cache: nil), 154 | LoadingState.loading(cache: nil)) 155 | XCTAssertNotEqual( 156 | LoadingState.failure(error: error, cache: nil), 157 | LoadingState.success(content: 4)) 158 | } 159 | 160 | } 161 | -------------------------------------------------------------------------------- /Tests/ApexyTests/HTTPBodyTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HTTPBodyTests.swift 3 | // 4 | // Created by Daniil Subbotin on 07.09.2020. 5 | // Copyright © 2020 RedMadRobot. All rights reserved. 6 | // 7 | 8 | import Apexy 9 | import XCTest 10 | 11 | final class HTTPBodyTests: XCTestCase { 12 | 13 | func testJsonHttpBody() { 14 | let emptyData = Data() 15 | 16 | let json = HTTPBody.json(emptyData) 17 | 18 | XCTAssertEqual(json.data, emptyData) 19 | XCTAssertEqual(json.contentType, "application/json") 20 | } 21 | 22 | func testFormHttpBody() { 23 | let emptyData = Data() 24 | 25 | let json = HTTPBody.form(emptyData) 26 | 27 | XCTAssertEqual(json.data, emptyData) 28 | XCTAssertEqual(json.contentType, "application/x-www-form-urlencoded") 29 | } 30 | 31 | func testBinaryHttpBody() { 32 | let emptyData = Data() 33 | 34 | let json = HTTPBody.binary(emptyData) 35 | 36 | XCTAssertEqual(json.data, emptyData) 37 | XCTAssertEqual(json.contentType, "application/octet-stream") 38 | } 39 | 40 | func testStringHttpBody() { 41 | let json = HTTPBody.string("Test") 42 | 43 | let testData = "Test".data(using: .utf8) 44 | XCTAssertEqual(json.data, testData) 45 | XCTAssertEqual(json.contentType, "text/plain") 46 | } 47 | 48 | func testTextHttpBody() { 49 | let testData = "Test".data(using: .utf8)! 50 | 51 | let json = HTTPBody.text(testData) 52 | 53 | XCTAssertEqual(json.data, testData) 54 | XCTAssertEqual(json.contentType, "text/plain") 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Tests/ApexyTests/URLRequestBuildableTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLRequestBuildableTests.swift 3 | // 4 | // Created by Daniil Subbotin on 07.09.2020. 5 | // Copyright © 2020 RedMadRobot. All rights reserved. 6 | // 7 | 8 | import Apexy 9 | import XCTest 10 | 11 | private struct URLRequestBuilder: URLRequestBuildable {} 12 | 13 | final class URLRequestBuildableTests: XCTestCase { 14 | 15 | private let url = URL(string: "https://apple.com")! 16 | 17 | func testGet() { 18 | let queryItems: [URLQueryItem] = [ URLQueryItem(name: "name", value: "John") ] 19 | 20 | let urlRequest = URLRequestBuilder().get(url, queryItems: queryItems) 21 | 22 | XCTAssertEqual(urlRequest.httpMethod, "GET") 23 | XCTAssertEqual(urlRequest.url?.absoluteString, "https://apple.com?name=John") 24 | XCTAssertNil(urlRequest.httpBody) 25 | XCTAssertNil(urlRequest.allHTTPHeaderFields) 26 | } 27 | 28 | func testPost() { 29 | let bodyData = "Test".data(using: .utf8)! 30 | let httpBody = HTTPBody(data: bodyData, contentType: "text/plain") 31 | 32 | let urlRequest = URLRequestBuilder().post(url, body: httpBody) 33 | 34 | XCTAssertEqual(urlRequest.httpMethod, "POST") 35 | XCTAssertEqual(urlRequest.url?.absoluteString, "https://apple.com") 36 | XCTAssertEqual(urlRequest.httpBody, bodyData) 37 | XCTAssertEqual(urlRequest.allHTTPHeaderFields, ["Content-Type": "text/plain"]) 38 | } 39 | 40 | func testPatch() { 41 | let bodyData = "Test".data(using: .utf8)! 42 | let httpBody = HTTPBody(data: bodyData, contentType: "text/plain") 43 | 44 | let urlRequest = URLRequestBuilder().patch(url, body: httpBody) 45 | 46 | XCTAssertEqual(urlRequest.httpMethod, "PATCH") 47 | XCTAssertEqual(urlRequest.url?.absoluteString, "https://apple.com") 48 | XCTAssertEqual(urlRequest.httpBody, bodyData) 49 | XCTAssertEqual(urlRequest.allHTTPHeaderFields, ["Content-Type": "text/plain"]) 50 | } 51 | 52 | func testPut() { 53 | let bodyData = "Test".data(using: .utf8)! 54 | let httpBody = HTTPBody(data: bodyData, contentType: "text/plain") 55 | 56 | let urlRequest = URLRequestBuilder().put(url, body: httpBody) 57 | 58 | XCTAssertEqual(urlRequest.httpMethod, "PUT") 59 | XCTAssertEqual(urlRequest.url?.absoluteString, "https://apple.com") 60 | XCTAssertEqual(urlRequest.httpBody, bodyData) 61 | XCTAssertEqual(urlRequest.allHTTPHeaderFields, ["Content-Type": "text/plain"]) 62 | } 63 | 64 | func testDelete() { 65 | let urlRequest = URLRequestBuilder().delete(url) 66 | 67 | XCTAssertEqual(urlRequest.httpMethod, "DELETE") 68 | XCTAssertEqual(urlRequest.url?.absoluteString, "https://apple.com") 69 | XCTAssertNil(urlRequest.httpBody) 70 | XCTAssertEqual(urlRequest.allHTTPHeaderFields, [:]) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Tests/ApexyURLSessionTests/BaseRequestAdapterTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ApexyURLSession 2 | import XCTest 3 | 4 | final class BaseRequestAdapterTests: XCTestCase { 5 | 6 | private let url = URL(string: "https://booklibrary.com")! 7 | 8 | private var adapter: RequestAdapter { 9 | BaseRequestAdapter(baseURL: url) 10 | } 11 | 12 | func testAdaptWhenURLNotContainsTrailingSlash() throws { 13 | let request = URLRequest(url: URL(string: "books/10")!) 14 | 15 | let adaptedRequest = try adapter.adapt(request) 16 | 17 | XCTAssertEqual(adaptedRequest.url?.absoluteString, "https://booklibrary.com/books/10") 18 | } 19 | 20 | func testAdaptWhenURLContainsTrailingSlash() throws { 21 | let request = URLRequest(url: URL(string: "path/")!) 22 | 23 | let adaptedRequest = try adapter.adapt(request) 24 | 25 | XCTAssertEqual(adaptedRequest.url?.absoluteString, "https://booklibrary.com/path/") 26 | } 27 | 28 | func testAdaptWhenURLContainsQueryItems() throws { 29 | let url = URL(string: "api/path/")! 30 | var components = URLComponents(url: url, resolvingAgainstBaseURL: false)! 31 | components.queryItems = [URLQueryItem(name: "param", value: "value")] 32 | let request = URLRequest(url: components.url!) 33 | 34 | let adaptedRequest = try adapter.adapt(request) 35 | 36 | XCTAssertEqual(adaptedRequest.url?.absoluteString, "https://booklibrary.com/api/path/?param=value") 37 | } 38 | 39 | func testAdaptWhenRequestContainsHeaders() throws { 40 | var request = URLRequest(url: URL(string: "books")!) 41 | request.addValue("application/octet-stream", forHTTPHeaderField: "Content-Type") 42 | 43 | let adaptedRequest = try adapter.adapt(request) 44 | 45 | XCTAssertEqual(adaptedRequest.value(forHTTPHeaderField: "Content-Type"), "application/octet-stream") 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /Tests/ApexyURLSessionTests/URLSessionClientTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLSessionClientTests.swift 3 | // 4 | // Created by Daniil Subbotin on 07.09.2020. 5 | // Copyright © 2020 RedMadRobot. All rights reserved. 6 | // 7 | 8 | import Apexy 9 | import ApexyURLSession 10 | import Combine 11 | import XCTest 12 | 13 | final class URLSessionClientTests: XCTestCase { 14 | 15 | private var client: URLSessionClient! 16 | private var bag = Set() 17 | 18 | override func setUp() { 19 | let url = URL(string: "https://booklibrary.com")! 20 | 21 | let config = URLSessionConfiguration.ephemeral 22 | config.protocolClasses = [MockURLProtocol.self] 23 | 24 | client = URLSessionClient(baseURL: url, configuration: config) 25 | } 26 | 27 | func testClientRequest() { 28 | let endpoint = EmptyEndpoint() 29 | let data = "Test".data(using: .utf8)! 30 | MockURLProtocol.requestHandler = { request in 31 | let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "2.0", headerFields: nil)! 32 | return (response, data) 33 | } 34 | 35 | let exp = expectation(description: "wait for response") 36 | _ = client.request(endpoint) { result in 37 | switch result { 38 | case .success(let content): 39 | XCTAssertEqual(content, data) 40 | case .failure: 41 | XCTFail("Expected result: .success, actual result: .failure") 42 | } 43 | exp.fulfill() 44 | } 45 | wait(for: [exp], timeout: 1) 46 | } 47 | 48 | func testEndpointValidate() { 49 | var endpoint = EmptyEndpoint() 50 | endpoint.validateError = EndpointValidationError.validationFailed 51 | 52 | let data = "Test".data(using: .utf8)! 53 | MockURLProtocol.requestHandler = { request in 54 | let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "2.0", headerFields: nil)! 55 | return (response, data) 56 | } 57 | 58 | let exp = expectation(description: "wait for response") 59 | 60 | _ = client.request(endpoint) { result in 61 | switch result { 62 | case .success: 63 | XCTFail("Expected result: .failure, actual result: .success") 64 | case .failure(let error as EndpointValidationError): 65 | XCTAssertEqual(error, endpoint.validateError) 66 | case .failure(let error): 67 | XCTFail("Expected result: .failure(EndpointValidationError), actual result: .failure(\(error))") 68 | } 69 | exp.fulfill() 70 | } 71 | wait(for: [exp], timeout: 1) 72 | } 73 | 74 | func testClientUpload() { 75 | let data = "apple".data(using: .utf8)! 76 | let endpoint = SimpleUploadEndpoint(data: data) 77 | MockURLProtocol.requestHandler = { request in 78 | let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "2.0", headerFields: nil)! 79 | return (response, data) 80 | } 81 | 82 | let exp = expectation(description: "wait for response") 83 | _ = client.upload(endpoint, completionHandler: { result in 84 | switch result { 85 | case .success(let content): 86 | XCTAssertEqual(content, data) 87 | case .failure: 88 | XCTFail("Expected result: .success, actual result: .failure") 89 | } 90 | exp.fulfill() 91 | }) 92 | wait(for: [exp], timeout: 1) 93 | } 94 | 95 | @available(iOS 13.0, *) 96 | @available(OSX 10.15, *) 97 | func testClientRequestUsingCombine() throws { 98 | let endpoint = EmptyEndpoint() 99 | let data = "Test".data(using: .utf8)! 100 | MockURLProtocol.requestHandler = { request in 101 | let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "2.0", headerFields: nil)! 102 | return (response, data) 103 | } 104 | 105 | let exp = expectation(description: "wait for response") 106 | 107 | let publisher = client.request(endpoint) 108 | publisher.sink(receiveCompletion: { result in 109 | switch result { 110 | case .finished: 111 | break 112 | case .failure: 113 | XCTFail("Expected result: .finished, actual result: .failure") 114 | } 115 | }) { content in 116 | XCTAssertEqual(content, data) 117 | exp.fulfill() 118 | }.store(in: &bag) 119 | 120 | wait(for: [exp], timeout: 0.1) 121 | } 122 | 123 | @available(iOS 13.0, *) 124 | @available(macOS 10.15, *) 125 | func testClientDataRequestUsingAsyncAwait() async throws { 126 | let endpoint = EmptyEndpoint() 127 | let data = "Test".data(using: .utf8)! 128 | MockURLProtocol.requestHandler = { request in 129 | let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "2.0", headerFields: nil)! 130 | return (response, data) 131 | } 132 | 133 | do { 134 | let content = try await client.request(endpoint) 135 | XCTAssertEqual(content, data) 136 | } catch { 137 | XCTFail("Expected result: .success, actual result: .failure") 138 | } 139 | } 140 | 141 | @available(iOS 13.0, *) 142 | @available(macOS 10.15, *) 143 | func testClientUploadUsingAsyncAwait() async throws { 144 | let data = "apple".data(using: .utf8)! 145 | let endpoint = SimpleUploadEndpoint(data: data) 146 | MockURLProtocol.requestHandler = { request in 147 | let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "2.0", headerFields: nil)! 148 | return (response, data) 149 | } 150 | 151 | do { 152 | let content = try await client.upload(endpoint) 153 | XCTAssertEqual(content, data) 154 | } catch { 155 | XCTFail("Expected result: .success, actual result: .failure") 156 | } 157 | } 158 | } 159 | 160 | private final class MockURLProtocol: URLProtocol { 161 | 162 | static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data) )? 163 | 164 | override class func canInit(with request: URLRequest) -> Bool { 165 | true 166 | } 167 | 168 | override class func canonicalRequest(for request: URLRequest) -> URLRequest { 169 | request 170 | } 171 | 172 | override func stopLoading() {} 173 | 174 | override func startLoading() { 175 | guard let handler = MockURLProtocol.requestHandler else { return } 176 | do { 177 | let (response, data) = try handler(request) 178 | client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) 179 | client?.urlProtocol(self, didLoad: data) 180 | client?.urlProtocolDidFinishLoading(self) 181 | } catch { 182 | client?.urlProtocol(self, didFailWithError: error) 183 | } 184 | } 185 | } 186 | 187 | private struct EmptyEndpoint: Endpoint { 188 | 189 | typealias Content = Data 190 | 191 | var validateError: EndpointValidationError? = nil 192 | 193 | func makeRequest() throws -> URLRequest { 194 | URLRequest(url: URL(string: "empty")!) 195 | } 196 | 197 | func content(from response: URLResponse?, with body: Data) throws -> Data { 198 | return body 199 | } 200 | 201 | func validate(_ request: URLRequest?, response: HTTPURLResponse, data: Data?) throws { 202 | if let error = validateError { 203 | throw error 204 | } 205 | } 206 | } 207 | 208 | private struct SimpleUploadEndpoint: UploadEndpoint { 209 | 210 | typealias Content = Data 211 | 212 | private let data: Data 213 | 214 | init(data: Data) { 215 | self.data = data 216 | } 217 | 218 | func makeRequest() throws -> (URLRequest, UploadEndpointBody) { 219 | var req = URLRequest(url: URL(string: "upload")!) 220 | req.httpMethod = "POST" 221 | 222 | let body = UploadEndpointBody.data(data) 223 | return (req, body) 224 | } 225 | 226 | func content(from response: URLResponse?, with body: Data) throws -> Data { 227 | body 228 | } 229 | } 230 | 231 | private enum EndpointValidationError: String, Error, Equatable { 232 | case validationFailed 233 | } 234 | --------------------------------------------------------------------------------