├── .gitignore ├── .swiftlint.yml ├── .swiftpm └── xcode │ └── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Examples ├── .swiftpm │ └── xcode │ │ └── package.xcworkspace │ │ └── contents.xcworkspacedata ├── CombineExample │ ├── CombineExample.xcodeproj │ │ ├── project.pbxproj │ │ └── project.xcworkspace │ │ │ ├── contents.xcworkspacedata │ │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── swiftpm │ │ │ └── Package.resolved │ ├── CombineExample │ │ ├── AppDelegate.swift │ │ ├── Assets │ │ │ ├── Assets.xcassets │ │ │ │ ├── AppIcon.appiconset │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ └── Preview Content │ │ │ │ └── Preview Assets.xcassets │ │ │ │ └── Contents.json │ │ ├── Base.lproj │ │ │ └── LaunchScreen.storyboard │ │ ├── ContentView.swift │ │ ├── Networking │ │ │ └── GithubAPI.swift │ │ ├── OrganizationListViewModel.swift │ │ ├── SceneDelegate.swift │ │ └── SupportingFiles │ │ │ └── Info.plist │ └── CombineExampleTests │ │ ├── CombineExampleTests.swift │ │ └── Info.plist ├── GithubExample │ ├── GithubExample.xcodeproj │ │ ├── project.pbxproj │ │ └── project.xcworkspace │ │ │ ├── contents.xcworkspacedata │ │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── swiftpm │ │ │ └── Package.resolved │ └── GithubExample │ │ ├── Assets │ │ └── Assets.xcassets │ │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ ├── Code │ │ ├── AppDelegate.swift │ │ ├── Models │ │ │ ├── Organization.swift │ │ │ ├── Repository.swift │ │ │ └── User.swift │ │ ├── Networking │ │ │ └── GithubAPI.swift │ │ ├── SceneDelegate.swift │ │ ├── Storyboards │ │ │ └── Base.lproj │ │ │ │ ├── LaunchScreen.storyboard │ │ │ │ └── Main.storyboard │ │ ├── Utilities │ │ │ └── Paging │ │ │ │ ├── PageProtocol.swift │ │ │ │ └── PagedResource.swift │ │ ├── ViewControllers │ │ │ ├── OrganizationViewController.swift │ │ │ ├── RepositoryListViewController.swift │ │ │ └── UserListViewController.swift │ │ └── Views │ │ │ ├── RepositoryCell.swift │ │ │ └── UserCell.swift │ │ └── SupportingFiles │ │ └── Info.plist ├── Package.swift └── ReactiveExample │ ├── ReactiveExample.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcshareddata │ │ └── xcschemes │ │ └── ReactiveExample.xcscheme │ └── ReactiveExample │ ├── API.swift │ ├── AppDelegate.swift │ ├── Auth │ ├── AuthHandler.swift │ └── Credentials.swift │ ├── Config.swift │ ├── Extensions │ └── Resource+Reactive.swift │ ├── Models │ ├── BlogPost.swift │ └── Stubs │ │ ├── authresponse.json │ │ ├── nested-post.json │ │ └── posts.json │ ├── Storyboards │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ └── Base.lproj │ │ ├── LaunchScreen.storyboard │ │ └── Main.storyboard │ ├── SupportingFiles │ └── Info.plist │ └── ViewController.swift ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── Fetch │ ├── Cache │ ├── Cache.swift │ ├── Cacheable.swift │ ├── DiskCache.swift │ ├── Expiration.swift │ ├── HybridCache.swift │ └── MemoryCache.swift │ ├── Extensions │ ├── Fetch+Async.swift │ └── Fetch+Combine.swift │ ├── Network │ ├── APIClient.swift │ ├── APILogger.swift │ ├── FetchError.swift │ ├── Resource+Fetch.swift │ └── Resource.swift │ ├── Stub │ ├── AlternatingStub.swift │ ├── ClosureStub.swift │ ├── RandomStub.swift │ ├── Stub.swift │ └── StubbedURL.swift │ ├── StubProvider │ └── StubProvider.swift │ └── Utilities │ ├── AnyEncodable.swift │ ├── Crypto.swift │ ├── Decoder+Keys.swift │ ├── HTTPContentType.swift │ ├── IgnoreBody.swift │ ├── JSONEncoder+ResourceEncoder.swift │ ├── RequestToken.swift │ ├── ResourceDecoderProtocol.swift │ └── ResourceEncoderProtocol.swift └── Tests └── FetchTests ├── Async Await ├── AsyncCacheTests.swift └── AsyncTests.swift ├── Cache ├── CacheTests.swift ├── DiskCacheTests.swift ├── HybridCacheTests.swift └── MemoryCacheTests.swift ├── CancelTests.swift ├── CustomValidationTests.swift ├── DispatchQueueTests.swift ├── FullPathTests.swift ├── IgnoreBodyTests.swift ├── MultipleStubsTests.swift ├── NestingTests.swift ├── ShouldStubTests.swift ├── StubProviderTests.swift ├── StubTests.swift ├── TestAPI.swift ├── TestFiles └── copyModelA.json ├── URLRequestTests.swift └── modela.json /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | ## Build generated 4 | #build/ 5 | DerivedData 6 | 7 | ## Various settings 8 | *.pbxuser 9 | !default.pbxuser 10 | *.mode1v3 11 | !default.mode1v3 12 | *.mode2v3 13 | !default.mode2v3 14 | *.perspectivev3 15 | !default.perspectivev3 16 | xcuserdata 17 | 18 | ## Other 19 | *.xccheckout 20 | *.moved-aside 21 | *.xcuserstate 22 | *.xcscmblueprint 23 | 24 | ## Obj-C/Swift specific 25 | *.hmap 26 | *.ipa 27 | 28 | .idea/ 29 | 30 | # Carthage 31 | # 32 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 33 | **/Carthage/Checkouts 34 | **/Carthage/Build 35 | 36 | # SPM 37 | .build -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | # Find all the available rules by running: 2 | # swiftlint rules 3 | # 4 | 5 | # rule identifiers to exclude from running 6 | disabled_rules: 7 | - valid_docs 8 | - force_try 9 | - todo 10 | - trailing_whitespace 11 | - type_name 12 | - large_tuple 13 | - redundant_optional_initialization 14 | - force_cast 15 | - superfluous_disable_command 16 | - variable_name 17 | - nesting 18 | - cyclomatic_complexity 19 | #- operator_whitespace 20 | #- vertical_whitespace 21 | #- function_parameter_count 22 | #- cyclomatic_complexity 23 | #- type_body_length 24 | #- operator_whitespace 25 | #- colon 26 | #- comma 27 | #- control_statement 28 | 29 | # paths to ignore during linting. Takes precedence over `included`. 30 | excluded: 31 | - Carthage 32 | - ReactiveExample 33 | 34 | # configurable rules can be customized from this configuration file 35 | line_length: 36 | warning: 300 37 | error: 500 38 | file_length: 39 | warning: 800 40 | error: 1200 41 | type_body_length: 42 | warning: 600 43 | error: 1000 44 | function_body_length: 45 | warning: 100 46 | error: 160 47 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Examples/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Examples/CombineExample/CombineExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Examples/CombineExample/CombineExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Examples/CombineExample/CombineExample.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": "f82c23a8a7ef8dc1a49a8bfc6a96883e79121864", 10 | "version": "5.5.0" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Examples/CombineExample/CombineExample/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // CombineExample 4 | // 5 | // Created by Stefan Wieland on 02.08.19. 6 | // Copyright © 2019 allaboutapps GmbH. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Fetch 11 | 12 | @UIApplicationMain 13 | class AppDelegate: UIResponder, UIApplicationDelegate { 14 | 15 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 16 | 17 | let config = Config(baseURL: URL(string: "https://api.github.com")!, 18 | cache: MemoryCache(defaultExpiration: .seconds(3600)), 19 | cachePolicy: .cacheFirstNetworkAlways) 20 | APIClient.shared.setup(with: config) 21 | 22 | return true 23 | } 24 | 25 | // MARK: UISceneSession Lifecycle 26 | 27 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 28 | // Called when a new scene session is being created. 29 | // Use this method to select a configuration to create the new scene with. 30 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 31 | } 32 | 33 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 34 | // Called when the user discards a scene session. 35 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 36 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Examples/CombineExample/CombineExample/Assets/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 | } -------------------------------------------------------------------------------- /Examples/CombineExample/CombineExample/Assets/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Examples/CombineExample/CombineExample/Assets/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Examples/CombineExample/CombineExample/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 | -------------------------------------------------------------------------------- /Examples/CombineExample/CombineExample/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // CombineExample 4 | // 5 | // Created by Stefan Wieland on 02.08.19. 6 | // Copyright © 2019 allaboutapps GmbH. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Combine 11 | 12 | struct ContentView: View { 13 | 14 | @ObservedObject 15 | var viewModel: OrganizationListViewModel 16 | 17 | var body: some View { 18 | 19 | Group { 20 | if viewModel.organisation == nil { 21 | Text("Loading") 22 | } else { 23 | Text(viewModel.organisation!.name) 24 | } 25 | }.onAppear { 26 | self.viewModel.loadData() 27 | } 28 | 29 | } 30 | } 31 | 32 | #if DEBUG 33 | struct ContentView_Previews: PreviewProvider { 34 | static var previews: some View { 35 | ContentView(viewModel: OrganizationListViewModel()) 36 | } 37 | } 38 | #endif 39 | -------------------------------------------------------------------------------- /Examples/CombineExample/CombineExample/Networking/GithubAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GithubAPI.swift 3 | // CombineExample 4 | // 5 | // Created by Stefan Wieland on 02.08.19. 6 | // Copyright © 2019 allaboutapps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Fetch 11 | 12 | struct GithubAPI { 13 | 14 | struct Org { 15 | 16 | static func organization(with username: String) -> Resource { 17 | return Resource(path: "orgs/\(username)") 18 | } 19 | 20 | static func members(for organizationName: String) -> Resource<[User]> { 21 | return Resource(path: "orgs/\(organizationName)/members") 22 | } 23 | 24 | static func repositories(for organizationName: String) -> Resource<[Repository]> { 25 | return Resource(path: "orgs/\(organizationName)/repos") 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Examples/CombineExample/CombineExample/OrganizationListViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OrganizationListViewModel.swift 3 | // CombineExample 4 | // 5 | // Created by Stefan Wieland on 02.08.19. 6 | // Copyright © 2019 allaboutapps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Fetch 11 | import Combine 12 | 13 | class OrganizationListViewModel: ObservableObject { 14 | 15 | private let organizationName = "allaboutapps" 16 | 17 | @Published 18 | var organisation: Organization? = nil 19 | 20 | var cancellable: Cancellable? 21 | 22 | func loadData() { 23 | cancellable = GithubAPI.Org 24 | .organization(with: organizationName) 25 | .requestModel() 26 | .sink(receiveCompletion: { result in 27 | switch result { 28 | case .finished: 29 | print("finished") 30 | case .failure(let error): 31 | print("error", error) 32 | } 33 | }, receiveValue: { [weak self] (organization) in 34 | self?.organisation = organization 35 | print("Received value") 36 | }) 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /Examples/CombineExample/CombineExample/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // CombineExample 4 | // 5 | // Created by Stefan Wieland on 02.08.19. 6 | // Copyright © 2019 allaboutapps GmbH. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SwiftUI 11 | 12 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 13 | 14 | var window: UIWindow? 15 | 16 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 17 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 18 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 19 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 20 | 21 | // Use a UIHostingController as window root view controller 22 | if let windowScene = scene as? UIWindowScene { 23 | let window = UIWindow(windowScene: windowScene) 24 | window.rootViewController = UIHostingController(rootView: ContentView(viewModel: OrganizationListViewModel())) 25 | self.window = window 26 | window.makeKeyAndVisible() 27 | } 28 | } 29 | 30 | func sceneDidDisconnect(_ scene: UIScene) { 31 | // Called as the scene is being released by the system. 32 | // This occurs shortly after the scene enters the background, or when its session is discarded. 33 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 34 | // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). 35 | } 36 | 37 | func sceneDidBecomeActive(_ scene: UIScene) { 38 | // Called when the scene has moved from an inactive state to an active state. 39 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 40 | } 41 | 42 | func sceneWillResignActive(_ scene: UIScene) { 43 | // Called when the scene will move from an active state to an inactive state. 44 | // This may occur due to temporary interruptions (ex. an incoming phone call). 45 | } 46 | 47 | func sceneWillEnterForeground(_ scene: UIScene) { 48 | // Called as the scene transitions from the background to the foreground. 49 | // Use this method to undo the changes made on entering the background. 50 | } 51 | 52 | func sceneDidEnterBackground(_ scene: UIScene) { 53 | // Called as the scene transitions from the foreground to the background. 54 | // Use this method to save data, release shared resources, and store enough scene-specific state information 55 | // to restore the scene back to its current state. 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Examples/CombineExample/CombineExample/SupportingFiles/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 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UISceneConfigurationName 33 | Default Configuration 34 | UISceneDelegateClassName 35 | $(PRODUCT_MODULE_NAME).SceneDelegate 36 | 37 | 38 | 39 | 40 | UILaunchStoryboardName 41 | LaunchScreen 42 | UIRequiredDeviceCapabilities 43 | 44 | armv7 45 | 46 | UISupportedInterfaceOrientations 47 | 48 | UIInterfaceOrientationPortrait 49 | UIInterfaceOrientationLandscapeLeft 50 | UIInterfaceOrientationLandscapeRight 51 | 52 | UISupportedInterfaceOrientations~ipad 53 | 54 | UIInterfaceOrientationPortrait 55 | UIInterfaceOrientationPortraitUpsideDown 56 | UIInterfaceOrientationLandscapeLeft 57 | UIInterfaceOrientationLandscapeRight 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /Examples/CombineExample/CombineExampleTests/CombineExampleTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CombineExampleTests.swift 3 | // CombineExampleTests 4 | // 5 | // Created by Stefan Wieland on 02.08.19. 6 | // Copyright © 2019 allaboutapps GmbH. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import CombineExample 11 | 12 | class CombineExampleTests: XCTestCase { 13 | 14 | override func setUp() { 15 | // Put setup code here. This method is called before the invocation of each test method in the class. 16 | } 17 | 18 | override func tearDown() { 19 | // Put teardown code here. This method is called after the invocation of each test method in the class. 20 | } 21 | 22 | func testExample() { 23 | // This is an example of a functional test case. 24 | // Use XCTAssert and related functions to verify your tests produce the correct results. 25 | } 26 | 27 | func testPerformanceExample() { 28 | // This is an example of a performance test case. 29 | self.measure { 30 | // Put the code you want to measure the time of here. 31 | } 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /Examples/CombineExample/CombineExampleTests/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 | -------------------------------------------------------------------------------- /Examples/GithubExample/GithubExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Examples/GithubExample/GithubExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Examples/GithubExample/GithubExample.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": "f82c23a8a7ef8dc1a49a8bfc6a96883e79121864", 10 | "version": "5.5.0" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Examples/GithubExample/GithubExample/Assets/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 | -------------------------------------------------------------------------------- /Examples/GithubExample/GithubExample/Assets/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 | -------------------------------------------------------------------------------- /Examples/GithubExample/GithubExample/Assets/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/GithubExample/GithubExample/Code/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // GithubExample 4 | // 5 | // Created by Stefan Wieland on 05.01.22. 6 | // 7 | 8 | import UIKit 9 | import Fetch 10 | 11 | @main 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 15 | let config = Config(baseURL: URL(string: "https://api.github.com")!, 16 | cache: MemoryCache(defaultExpiration: .seconds(3600)), 17 | cachePolicy: .cacheFirstNetworkAlways) 18 | APIClient.shared.setup(with: config) 19 | return true 20 | } 21 | 22 | // MARK: UISceneSession Lifecycle 23 | 24 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 25 | // Called when a new scene session is being created. 26 | // Use this method to select a configuration to create the new scene with. 27 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 28 | } 29 | 30 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 31 | // Called when the user discards a scene session. 32 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 33 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 34 | } 35 | 36 | 37 | } 38 | 39 | -------------------------------------------------------------------------------- /Examples/GithubExample/GithubExample/Code/Models/Organization.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Organization.swift 3 | // Example 4 | // 5 | // Created by Oliver Krakora on 12.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Fetch 11 | 12 | struct Organization: Equatable, Cacheable { 13 | 14 | private enum CodingKeys: String, CodingKey { 15 | case id 16 | case url 17 | case avatarURL = "avatar_url" 18 | case description 19 | case name 20 | case location 21 | case email 22 | case blogURL = "blog" 23 | case publicRepositoryCount = "public_repos" 24 | } 25 | 26 | let id: Int 27 | let url: URL 28 | let avatarURL: URL? 29 | let name: String 30 | let location: String? 31 | let description: String? 32 | let blogURL: URL? 33 | let email: String? 34 | let publicRepositoryCount: Int 35 | } 36 | -------------------------------------------------------------------------------- /Examples/GithubExample/GithubExample/Code/Models/Repository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Repository.swift 3 | // GithubExample 4 | // 5 | // Created by Oliver Krakora on 12.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Fetch 11 | 12 | struct Repository: Equatable, Cacheable { 13 | 14 | private enum CodingKeys: String, CodingKey { 15 | case id 16 | case name 17 | case fullName = "full_name" 18 | case url = "html_url" 19 | case description 20 | case language 21 | case forkCount = "forks_count" 22 | case stars = "stargazers_count" 23 | case watchers = "watchers" 24 | } 25 | 26 | let id: Int 27 | let name: String 28 | let fullName: String 29 | let url: URL 30 | let description: String? 31 | let language: String? 32 | let forkCount: Int 33 | let stars: Int 34 | let watchers: Int 35 | } 36 | -------------------------------------------------------------------------------- /Examples/GithubExample/GithubExample/Code/Models/User.swift: -------------------------------------------------------------------------------- 1 | // 2 | // User.swift 3 | // GithubExample 4 | // 5 | // Created by Oliver Krakora on 12.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Fetch 11 | 12 | struct User: Equatable, Cacheable { 13 | 14 | private enum CodingKeys: String, CodingKey { 15 | case username = "login" 16 | case id 17 | case avatarURL = "avatar_url" 18 | case url 19 | 20 | } 21 | 22 | let username: String 23 | let id: Int 24 | let avatarURL: URL? 25 | let url: URL 26 | } 27 | -------------------------------------------------------------------------------- /Examples/GithubExample/GithubExample/Code/Networking/GithubAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GithubAPI.swift 3 | // Example 4 | // 5 | // Created by Oliver Krakora on 12.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Fetch 11 | 12 | struct GithubAPI { 13 | 14 | struct Org { 15 | 16 | static func organization(with username: String) -> Resource { 17 | return Resource(path: "orgs/\(username)") 18 | } 19 | 20 | static func members(for organizationName: String) -> Resource<[User]> { 21 | return Resource(path: "orgs/\(organizationName)/members") 22 | } 23 | 24 | static func repositories(for organizationName: String) -> Resource<[Repository]> { 25 | return Resource(path: "orgs/\(organizationName)/repos") 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Examples/GithubExample/GithubExample/Code/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // GithubExample 4 | // 5 | // Created by Stefan Wieland on 05.01.22. 6 | // 7 | 8 | import UIKit 9 | 10 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 11 | 12 | var window: UIWindow? 13 | 14 | 15 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 16 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 17 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 18 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 19 | guard let _ = (scene as? UIWindowScene) else { return } 20 | } 21 | 22 | func sceneDidDisconnect(_ scene: UIScene) { 23 | // Called as the scene is being released by the system. 24 | // This occurs shortly after the scene enters the background, or when its session is discarded. 25 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 26 | // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). 27 | } 28 | 29 | func sceneDidBecomeActive(_ scene: UIScene) { 30 | // Called when the scene has moved from an inactive state to an active state. 31 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 32 | } 33 | 34 | func sceneWillResignActive(_ scene: UIScene) { 35 | // Called when the scene will move from an active state to an inactive state. 36 | // This may occur due to temporary interruptions (ex. an incoming phone call). 37 | } 38 | 39 | func sceneWillEnterForeground(_ scene: UIScene) { 40 | // Called as the scene transitions from the background to the foreground. 41 | // Use this method to undo the changes made on entering the background. 42 | } 43 | 44 | func sceneDidEnterBackground(_ scene: UIScene) { 45 | // Called as the scene transitions from the foreground to the background. 46 | // Use this method to save data, release shared resources, and store enough scene-specific state information 47 | // to restore the scene back to its current state. 48 | } 49 | 50 | 51 | } 52 | 53 | -------------------------------------------------------------------------------- /Examples/GithubExample/GithubExample/Code/Storyboards/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 | -------------------------------------------------------------------------------- /Examples/GithubExample/GithubExample/Code/Utilities/Paging/PageProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PageableProtocol.swift 3 | // Fetch 4 | // 5 | // Created by Oliver Krakora on 12.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol PageProtocol: Decodable { 12 | associatedtype Item: Decodable 13 | 14 | var offset: Int { get } 15 | var limit: Int { get } 16 | var total: Int { get } 17 | var items: [Item] { get } 18 | } 19 | -------------------------------------------------------------------------------- /Examples/GithubExample/GithubExample/Code/Utilities/Paging/PagedResource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PagedResource.swift 3 | // Fetch 4 | // 5 | // Created by Oliver Krakora on 12.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Fetch 11 | 12 | class PagedResource { 13 | 14 | typealias PageResourceConstructor = ((Resource, Page) -> Resource) 15 | 16 | private let initialPage: Resource 17 | 18 | private var currentPage: Resource 19 | 20 | private(set) var pages: [Page] = [] 21 | 22 | private(set) var pageItems: [Page.Item] = [] 23 | 24 | private let constructPageResource: PageResourceConstructor 25 | 26 | init(initalPage: Resource, resourceConstructor: PageResourceConstructor?) { 27 | self.initialPage = initalPage 28 | self.currentPage = initalPage 29 | 30 | if let constructor = resourceConstructor { 31 | self.constructPageResource = constructor 32 | } else { 33 | self.constructPageResource = { latestResource, latestPage in 34 | return latestResource 35 | } 36 | } 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /Examples/GithubExample/GithubExample/Code/ViewControllers/OrganizationViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GithubProfileViewController.swift 3 | // Example 4 | // 5 | // Created by Oliver Krakora on 12.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Fetch 11 | 12 | class OrganizationViewController: UIViewController { 13 | 14 | @IBOutlet private var avatarImageView: UIImageView! 15 | 16 | @IBOutlet private var organizationLabel: UILabel! 17 | 18 | @IBOutlet private var locationLabel: UILabel! 19 | 20 | @IBOutlet private var websiteLabel: UILabel! 21 | 22 | @IBOutlet private var segmentedControl: UISegmentedControl! 23 | 24 | @IBOutlet private var containerView: UIView! 25 | 26 | private lazy var childs: [UIViewController] = { 27 | let storyboard = UIStoryboard(name: "Main", bundle: nil) 28 | 29 | let repositoryVC = storyboard.instantiateViewController(withIdentifier: String(describing: RepositoryListViewController.self)) as! RepositoryListViewController 30 | repositoryVC.resource = GithubAPI.Org.repositories(for: organizationName) 31 | let usersVC = storyboard.instantiateViewController(withIdentifier: String(describing: UserListViewController.self)) as! UserListViewController 32 | usersVC.resource = GithubAPI.Org.members(for: organizationName) 33 | 34 | return [repositoryVC, usersVC] 35 | }() 36 | 37 | private let organizationName = "allaboutapps" 38 | 39 | private lazy var resource = GithubAPI.Org.organization(with: organizationName) 40 | 41 | private var organization: Organization? 42 | 43 | private var disposable: RequestToken? 44 | 45 | override func viewDidLoad() { 46 | super.viewDidLoad() 47 | setupChilds() 48 | } 49 | 50 | override func viewWillAppear(_ animated: Bool) { 51 | super.viewWillAppear(animated) 52 | loadData() 53 | } 54 | 55 | deinit { 56 | disposable?.cancel() 57 | } 58 | 59 | private func setupChilds() { 60 | for child in childs.reversed() { 61 | child.view.isHidden = true 62 | child.willMove(toParent: self) 63 | child.view.translatesAutoresizingMaskIntoConstraints = false 64 | containerView.addSubview(child.view) 65 | child.didMove(toParent: self) 66 | NSLayoutConstraint.activate([ 67 | child.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), 68 | child.view.topAnchor.constraint(equalTo: containerView.topAnchor), 69 | child.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), 70 | child.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor) 71 | ]) 72 | } 73 | childs.first?.view.isHidden = false 74 | } 75 | 76 | private func loadData() { 77 | disposable = resource.fetch { [weak self] result, _ in 78 | if case let .success(value) = result { 79 | self?.organization = value.model 80 | } 81 | self?.setupView() 82 | } 83 | } 84 | 85 | private func setupView() { 86 | guard let organization = organization else { return } 87 | 88 | if let avatarURL = organization.avatarURL, let data = try? Data(contentsOf: avatarURL) { 89 | avatarImageView.image = UIImage(data: data) 90 | } 91 | 92 | organizationLabel.text = organization.name 93 | locationLabel.text = organization.location 94 | if let url = organization.blogURL { 95 | websiteLabel.attributedText = NSAttributedString(string: url.absoluteString, attributes: [NSAttributedString.Key.link: url]) 96 | } 97 | websiteLabel.text = organization.blogURL?.absoluteString 98 | } 99 | 100 | @IBAction private func updateChildViewController(_ sender: Any) { 101 | var allChilds = childs 102 | let currentVC = allChilds.remove(at: segmentedControl.selectedSegmentIndex) 103 | allChilds.forEach { $0.view.isHidden = true } 104 | containerView.bringSubviewToFront(currentVC.view) 105 | currentVC.view.isHidden = false 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Examples/GithubExample/GithubExample/Code/ViewControllers/RepositoryListViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RepositoryListViewController.swift 3 | // GithubExample 4 | // 5 | // Created by Oliver Krakora on 12.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Fetch 11 | 12 | class RepositoryListViewController: UIViewController { 13 | 14 | @IBOutlet private var tableView: UITableView! 15 | 16 | var resource: Resource<[Repository]>! 17 | 18 | private var repositories: [Repository]? 19 | 20 | private var disposable: RequestToken? 21 | 22 | override func viewDidLoad() { 23 | super.viewDidLoad() 24 | tableView.dataSource = self 25 | tableView.delegate = self 26 | tableView.refreshControl = { 27 | let refreshControl = UIRefreshControl() 28 | refreshControl.addTarget(self, action: #selector(forceReloadData), for: .valueChanged) 29 | return refreshControl 30 | }() 31 | tableView.tableFooterView = UIView() 32 | } 33 | 34 | override func viewWillAppear(_ animated: Bool) { 35 | super.viewWillAppear(animated) 36 | loadData() 37 | } 38 | 39 | @objc private func forceReloadData() { 40 | loadData(force: true) 41 | } 42 | 43 | private func loadData(force: Bool = false) { 44 | let cachePolicy: CachePolicy? = force ? CachePolicy.networkOnlyUpdateCache : nil 45 | disposable = resource.fetch(cachePolicy: cachePolicy) { [weak self] result, _ in 46 | if case let .success(value) = result { 47 | self?.repositories = value.model 48 | } 49 | self?.tableView.refreshControl?.endRefreshing() 50 | self?.tableView.reloadData() 51 | } 52 | } 53 | } 54 | 55 | extension RepositoryListViewController: UITableViewDataSource { 56 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 57 | return repositories?.count ?? 0 58 | } 59 | 60 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 61 | let repository = repositories![indexPath.row] 62 | let cell = tableView.dequeueReusableCell(withIdentifier: RepositoryCell.reuseIdentifier, for: indexPath) as! RepositoryCell 63 | cell.configure(with: repository) 64 | return cell 65 | } 66 | } 67 | 68 | extension RepositoryListViewController: UITableViewDelegate { 69 | func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { 70 | return false 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Examples/GithubExample/GithubExample/Code/ViewControllers/UserListViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserListViewController.swift 3 | // GithubExample 4 | // 5 | // Created by Oliver Krakora on 12.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Fetch 11 | 12 | class UserListViewController: UIViewController { 13 | 14 | @IBOutlet private var tableView: UITableView! 15 | 16 | var resource: Resource<[User]>! 17 | 18 | private var users: [User]? 19 | 20 | private var disposable: RequestToken? 21 | 22 | override func viewDidLoad() { 23 | super.viewDidLoad() 24 | tableView.dataSource = self 25 | tableView.delegate = self 26 | tableView.refreshControl = { 27 | let refreshControl = UIRefreshControl() 28 | refreshControl.addTarget(self, action: #selector(forceReloadData), for: .valueChanged) 29 | return refreshControl 30 | }() 31 | tableView.tableFooterView = UIView() 32 | } 33 | 34 | override func viewWillAppear(_ animated: Bool) { 35 | super.viewWillAppear(animated) 36 | loadData() 37 | } 38 | 39 | deinit { 40 | disposable?.cancel() 41 | } 42 | 43 | @objc private func forceReloadData() { 44 | loadData(force: true) 45 | } 46 | 47 | private func loadData(force: Bool = false) { 48 | let cachePolicy: CachePolicy? = force ? CachePolicy.networkOnlyUpdateCache : nil 49 | 50 | disposable = resource.fetch(cachePolicy: cachePolicy) { [weak self] result, _ in 51 | if case let .success(value) = result { 52 | self?.users = value.model 53 | } 54 | self?.tableView.reloadData() 55 | self?.tableView.refreshControl?.endRefreshing() 56 | } 57 | } 58 | } 59 | 60 | extension UserListViewController: UITableViewDataSource { 61 | 62 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 63 | return users?.count ?? 0 64 | } 65 | 66 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 67 | let user = users![indexPath.row] 68 | let cell = tableView.dequeueReusableCell(withIdentifier: UserCell.reuseIdentifier) as! UserCell 69 | cell.configure(with: user) 70 | return cell 71 | } 72 | } 73 | 74 | extension UserListViewController: UITableViewDelegate { 75 | func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { 76 | return false 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Examples/GithubExample/GithubExample/Code/Views/RepositoryCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RepositoryCell.swift 3 | // GithubExample 4 | // 5 | // Created by Oliver Krakora on 12.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class RepositoryCell: UITableViewCell { 12 | 13 | @IBOutlet private var nameLabel: UILabel! 14 | 15 | func configure(with repository: Repository) { 16 | nameLabel.text = repository.name 17 | } 18 | } 19 | 20 | extension RepositoryCell { 21 | static let reuseIdentifier = "\(RepositoryCell.self)" 22 | } 23 | -------------------------------------------------------------------------------- /Examples/GithubExample/GithubExample/Code/Views/UserCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserCell.swift 3 | // GithubExample 4 | // 5 | // Created by Oliver Krakora on 12.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class UserCell: UITableViewCell { 12 | 13 | private static let avatarDownloadQueue = DispatchQueue(label: "at.allaboutapps.avatars") 14 | 15 | @IBOutlet private var avatarImageView: UIImageView! 16 | 17 | @IBOutlet private var usernameLabel: UILabel! 18 | 19 | private var user: User! 20 | 21 | private var loadImageWorkItem: DispatchWorkItem? 22 | 23 | override func prepareForReuse() { 24 | super.prepareForReuse() 25 | loadImageWorkItem?.cancel() 26 | avatarImageView.image = nil 27 | } 28 | 29 | func configure(with user: User) { 30 | self.user = user 31 | usernameLabel.text = user.username 32 | loadImage() 33 | } 34 | 35 | private func loadImage() { 36 | guard let url = user.avatarURL else { return } 37 | 38 | loadImageWorkItem = DispatchWorkItem { [weak self] in 39 | guard let data = try? Data(contentsOf: url) else { return } 40 | DispatchQueue.main.async { 41 | self?.avatarImageView.image = UIImage(data: data) 42 | } 43 | } 44 | UserCell.avatarDownloadQueue.async(execute: loadImageWorkItem!) 45 | } 46 | } 47 | 48 | extension UserCell { 49 | static let reuseIdentifier = "\(UserCell.self)" 50 | } 51 | -------------------------------------------------------------------------------- /Examples/GithubExample/GithubExample/SupportingFiles/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIApplicationSceneManifest 6 | 7 | UIApplicationSupportsMultipleScenes 8 | 9 | UISceneConfigurations 10 | 11 | UIWindowSceneSessionRoleApplication 12 | 13 | 14 | UISceneConfigurationName 15 | Default Configuration 16 | UISceneDelegateClassName 17 | $(PRODUCT_MODULE_NAME).SceneDelegate 18 | UISceneStoryboardFile 19 | Main 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Examples/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | 3 | import PackageDescription 4 | 5 | // Empty manifest to hide example folder from xcode 6 | let package = Package( 7 | name: "", 8 | platforms: [], 9 | products: [], 10 | dependencies: [], 11 | targets: [], 12 | swiftLanguageVersions: [.v5] 13 | ) 14 | -------------------------------------------------------------------------------- /Examples/ReactiveExample/ReactiveExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Examples/ReactiveExample/ReactiveExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Examples/ReactiveExample/ReactiveExample.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": "f82c23a8a7ef8dc1a49a8bfc6a96883e79121864", 10 | "version": "5.5.0" 11 | } 12 | }, 13 | { 14 | "package": "KeychainAccess", 15 | "repositoryURL": "https://github.com/kishikawakatsumi/KeychainAccess", 16 | "state": { 17 | "branch": "master", 18 | "revision": "6299daec1d74be12164fec090faf9ed14d0da9d6", 19 | "version": null 20 | } 21 | }, 22 | { 23 | "package": "ReactiveSwift", 24 | "repositoryURL": "https://github.com/ReactiveCocoa/ReactiveSwift", 25 | "state": { 26 | "branch": null, 27 | "revision": "efb2f0a6f6c8739cce8fb14148a5bd3c83f2f91d", 28 | "version": "7.0.0" 29 | } 30 | } 31 | ] 32 | }, 33 | "version": 1 34 | } 35 | -------------------------------------------------------------------------------- /Examples/ReactiveExample/ReactiveExample.xcodeproj/xcshareddata/xcschemes/ReactiveExample.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 39 | 40 | 41 | 42 | 43 | 44 | 54 | 56 | 62 | 63 | 64 | 65 | 66 | 67 | 73 | 75 | 81 | 82 | 83 | 84 | 86 | 87 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /Examples/ReactiveExample/ReactiveExample/API.swift: -------------------------------------------------------------------------------- 1 | // 2 | // API.swift 3 | // Fetch 4 | // 5 | // Created by Michael Heinzl on 02.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Alamofire 11 | import Fetch 12 | 13 | public struct API { 14 | 15 | public struct StubbedAuth { 16 | 17 | static let baseURL = URL(string: "") 18 | 19 | public static func login(username: String, password: String) -> Resource { 20 | return Resource( 21 | method: .post, 22 | baseURL: baseURL, 23 | path: "/api/v1/auth/login", 24 | body: .encodable([ 25 | "username": username, 26 | "password": password 27 | ])) 28 | // , shouldStub: true, 29 | // stub: StubResponse(statusCode: 200, fileName: "authresponse.json", delay: 3)) 30 | } 31 | 32 | public static func tokenRefresh(_ refreshToken: String) -> Resource { 33 | return Resource( 34 | method: .post, 35 | baseURL: baseURL, 36 | path: "/api/v1/auth/refresh", 37 | body: .encodable([ 38 | "refreshToken": refreshToken 39 | ])) 40 | // , shouldStub: true, 41 | // stub: StubResponse(statusCode: 200, fileName: "authresponse.json", delay: 4)) 42 | } 43 | 44 | public static func authorizedRequest() -> Resource { 45 | let conditionalStub = ClosureStub { () -> Stub in 46 | let unauthorizedStub = StubResponse(statusCode: 401, data: Data(), delay: 2) 47 | let okStub = StubResponse(statusCode: 200, data: Data(), delay: 2) 48 | return CredentialsController.shared.currentCredentials == nil ? unauthorizedStub : okStub 49 | } 50 | 51 | return Resource( 52 | path: "/auth/secret") 53 | // , 54 | // shouldStub: true, 55 | // stub: conditionalStub 56 | // ) 57 | } 58 | 59 | public static func unauthorizedErrorRequest() -> Resource { 60 | let failingStub = StubResponse(statusCode: 401, data: Data(), delay: 2) 61 | let okStub = StubResponse(statusCode: 200, data: Data(), delay: 2) 62 | 63 | return Resource( 64 | path: "/fail") 65 | // , 66 | // shouldStub: true, 67 | // stub: AlternatingStub(stubs: [failingStub, okStub]) 68 | // ) 69 | } 70 | } 71 | 72 | public struct BlogPosts { 73 | 74 | public static func list() -> Resource<[BlogPost]> { 75 | return Resource( 76 | method: .get, 77 | path: "/posts") 78 | // , 79 | // shouldStub: true, 80 | // stub: StubResponse(statusCode: 200, fileName: "posts.json", delay: 1)) 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Examples/ReactiveExample/ReactiveExample/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // ReactiveExample 4 | // 5 | // Created by Matthias Buchetics on 17.04.19. 6 | // Copyright © 2019 all about apps Gmbh. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Fetch 11 | 12 | @UIApplicationMain 13 | class AppDelegate: UIResponder, UIApplicationDelegate { 14 | 15 | var window: UIWindow? 16 | 17 | func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 18 | let url = URL(string: "https://f4a4ddde.ngrok.io")! 19 | 20 | APIClient.shared.setup(with: Fetch.Config( 21 | baseURL: url, 22 | interceptor: AuthHandler(), 23 | cache: MemoryCache(defaultExpiration: .seconds(60)), 24 | shouldStub: true)) 25 | 26 | registerStubs() 27 | 28 | CredentialsController.shared.resetOnNewInstallations() 29 | 30 | return true 31 | } 32 | 33 | } 34 | 35 | private extension AppDelegate { 36 | 37 | func registerStubs() { 38 | let stubProvider = APIClient.shared.stubProvider 39 | 40 | let stubAuthResponse = StubResponse(statusCode: 200, fileName: "authresponse.json", delay: 3) 41 | let stubConditional = ClosureStub { () -> Stub in 42 | let unauthorizedStub = StubResponse(statusCode: 401, data: Data(), delay: 2) 43 | let okStub = StubResponse(statusCode: 200, data: Data(), delay: 2) 44 | return CredentialsController.shared.currentCredentials == nil ? unauthorizedStub : okStub 45 | } 46 | let stubAlternating = AlternatingStub(stubs: [ 47 | StubResponse(statusCode: 401, data: Data(), delay: 2), 48 | StubResponse(statusCode: 200, data: Data(), delay: 2) 49 | ]) 50 | let stubBlog = StubResponse(statusCode: 200, fileName: "posts.json", delay: 1) 51 | 52 | stubProvider.register(stub: stubAuthResponse, for: API.StubbedAuth.login(username: "", password: "")) 53 | stubProvider.register(stub: stubAuthResponse, for: API.StubbedAuth.tokenRefresh("")) 54 | stubProvider.register(stub: stubConditional, for: API.StubbedAuth.authorizedRequest()) 55 | stubProvider.register(stub: stubAlternating, for: API.StubbedAuth.unauthorizedErrorRequest()) 56 | 57 | stubProvider.register(stub: stubBlog, for: API.BlogPosts.list()) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Examples/ReactiveExample/ReactiveExample/Auth/AuthHandler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Alamofire 3 | import Fetch 4 | 5 | public class AuthHandler: RequestInterceptor { 6 | 7 | private typealias RefreshCompletion = (_ succeeded: Bool, _ credentials: Credentials?) -> Void 8 | private typealias RequestRetryCompletion = (Alamofire.RetryResult) -> Void 9 | 10 | private let lock = NSLock() 11 | private let queue = DispatchQueue(label: "network.auth.queue") 12 | private var isRefreshing = false 13 | private var requestsToRetry: [RequestRetryCompletion] = [] 14 | 15 | // MARK: - RequestAdapter 16 | 17 | public func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (AFResult) -> Void) { 18 | var urlRequest = urlRequest 19 | if let accessToken = CredentialsController.shared.currentCredentials?.accessToken { 20 | urlRequest.setValue("Bearer " + accessToken, forHTTPHeaderField: "Authorization") 21 | } 22 | completion(.success(urlRequest)) 23 | } 24 | 25 | // MARK: - RequestRetrier 26 | 27 | public func retry(_ request: Alamofire.Request, for session: Session, dueTo error: Error, completion: @escaping (Alamofire.RetryResult) -> Void) { 28 | lock.lock() ; defer { lock.unlock() } 29 | 30 | guard let response = request.task?.response as? HTTPURLResponse, 31 | let refreshToken = CredentialsController.shared.currentCredentials?.refreshToken, 32 | response.statusCode == 401 else { 33 | completion(.doNotRetry) 34 | return 35 | } 36 | 37 | requestsToRetry.append(completion) 38 | 39 | if !isRefreshing { 40 | refreshCredentials(refreshToken, session: session) { [weak self] (succeeded, credentials) in 41 | guard let self = self else { return } 42 | 43 | self.lock.lock() ; defer { self.lock.unlock() } 44 | 45 | if let credentials = credentials { 46 | CredentialsController.shared.currentCredentials = credentials 47 | } 48 | 49 | if succeeded { 50 | self.requestsToRetry.forEach { $0(.retry) } 51 | } else { 52 | self.requestsToRetry.forEach { $0(.doNotRetry) } 53 | } 54 | 55 | self.requestsToRetry.removeAll() 56 | } 57 | } 58 | } 59 | 60 | // MARK: - Private - Refresh Tokens 61 | 62 | private func refreshCredentials(_ refreshToken: String, session: Session, completion: @escaping RefreshCompletion) { 63 | guard !isRefreshing else { return } 64 | 65 | isRefreshing = true 66 | 67 | API.StubbedAuth.tokenRefresh(refreshToken).request { result in 68 | switch result { 69 | case .success(let credentials): 70 | completion(true, credentials.model) 71 | case .failure: 72 | completion(false, nil) 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Examples/ReactiveExample/ReactiveExample/Auth/Credentials.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import KeychainAccess 3 | import ReactiveSwift 4 | import Fetch 5 | 6 | public class CredentialsController { 7 | 8 | private init() {} 9 | public static let shared = CredentialsController() 10 | 11 | private let keychain = Keychain(service: Config.keyPrefix) 12 | private let credentialStorageKey = Config.Keychain.credentialStorageKey 13 | private var cachedCredentials: Credentials? 14 | 15 | private var currentCredentialsChangedSignalObserver = Signal<(), Never>.pipe() 16 | public var currentCredentialsChangedSignal: Signal<(), Never> { 17 | return currentCredentialsChangedSignalObserver.output 18 | } 19 | 20 | private let jsonDecoder = JSONDecoder() 21 | private let jsonEncoder = JSONEncoder() 22 | 23 | public var currentCredentials: Credentials? { 24 | get { 25 | if let credentialsData = keychain[data: credentialStorageKey], 26 | let credentials = try? jsonDecoder.decode(Credentials.self, from: credentialsData), cachedCredentials == nil { 27 | cachedCredentials = credentials 28 | return credentials 29 | } else { 30 | return cachedCredentials 31 | } 32 | } 33 | set { 34 | if let credentials = newValue { 35 | keychain[data: credentialStorageKey] = try? jsonEncoder.encode(credentials) 36 | cachedCredentials = credentials 37 | } else { 38 | cachedCredentials = nil 39 | _ = try? keychain.remove(credentialStorageKey) 40 | } 41 | currentCredentialsChangedSignalObserver.input.send(value: ()) 42 | } 43 | } 44 | 45 | public func resetOnNewInstallations() { 46 | if let installationDate = UserDefaults.standard.value(forKey: "installationDate") as? Date { 47 | print("existing installation, app installed: \(installationDate)") 48 | } else { 49 | print("new installation, resetting credentials in keychain") 50 | currentCredentials = nil 51 | UserDefaults.standard.set(Date(), forKey: "installationDate") 52 | } 53 | } 54 | 55 | } 56 | 57 | public struct Credentials: Codable { 58 | public let accessToken: String 59 | public let refreshToken: String 60 | public let expiresIn: TimeInterval 61 | 62 | public init(accessToken: String, refreshToken: String?, expiresIn: TimeInterval?) { 63 | self.accessToken = accessToken 64 | self.refreshToken = refreshToken ?? "" 65 | self.expiresIn = expiresIn ?? NSDate.distantFuture.timeIntervalSinceReferenceDate 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Examples/ReactiveExample/ReactiveExample/Config.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Global set of configuration values for this application. 4 | public struct Config { 5 | static let keyPrefix = "at.allaboutapps" 6 | 7 | // MARK: User Defaults 8 | 9 | public struct UserDefaultsKey { 10 | static let lastUpdate = Config.keyPrefix + ".lastUpdate" 11 | } 12 | 13 | // MARK: Keychain 14 | 15 | public struct Keychain { 16 | static let credentialStorageKey = "CredentialsStorage" 17 | static let credentialsKey = "credentials" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Examples/ReactiveExample/ReactiveExample/Extensions/Resource+Reactive.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReactiveFetch.swift 3 | // Example 4 | // 5 | // Created by Michael Heinzl on 09.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import ReactiveSwift 10 | import Fetch 11 | 12 | // MARK: - Request 13 | 14 | public extension Resource { 15 | 16 | func request() -> SignalProducer, FetchError> { 17 | return SignalProducer { (observer, lifetime) in 18 | let strongSelf = self 19 | let token = strongSelf.request { (result) in 20 | switch result { 21 | case .success(let response): 22 | observer.send(value: response) 23 | observer.sendCompleted() 24 | case .failure(let error): 25 | observer.send(error: error) 26 | } 27 | } 28 | 29 | lifetime.observeEnded { 30 | token?.cancel() 31 | } 32 | } 33 | } 34 | 35 | func requestModel() -> SignalProducer { 36 | return request().map { $0.model } 37 | } 38 | } 39 | 40 | // MARK: - Fetch 41 | 42 | public extension Resource where T: Cacheable { 43 | 44 | func fetch(cachePolicy: CachePolicy? = nil) -> SignalProducer, FetchError> { 45 | return SignalProducer { (observer, lifetime) in 46 | let strongSelf = self 47 | let token = strongSelf.fetch(cachePolicy: cachePolicy) { (result, isFinished) in 48 | switch result { 49 | case .success(let response): 50 | observer.send(value: response) 51 | if isFinished { 52 | observer.sendCompleted() 53 | } 54 | case .failure(let error): 55 | observer.send(error: error) 56 | } 57 | } 58 | 59 | lifetime.observeEnded { 60 | token.cancel() 61 | } 62 | } 63 | } 64 | 65 | func fetchModel(cachePolicy: CachePolicy? = nil) -> SignalProducer { 66 | return fetch(cachePolicy: cachePolicy).map { $0.model } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Examples/ReactiveExample/ReactiveExample/Models/BlogPost.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Post.swift 3 | // Fetch 4 | // 5 | // Created by Michael Heinzl on 02.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Fetch 11 | 12 | public struct BlogPost: Codable, Equatable { 13 | public let id: Int 14 | public let title: String 15 | public let author: String 16 | 17 | public init(id: Int, title: String, author: String) { 18 | self.id = id 19 | self.title = title 20 | self.author = author 21 | } 22 | } 23 | 24 | extension BlogPost: Cacheable { } 25 | -------------------------------------------------------------------------------- /Examples/ReactiveExample/ReactiveExample/Models/Stubs/authresponse.json: -------------------------------------------------------------------------------- 1 | { 2 | "accessToken": "asdj023dmas9da9sdams0dasdasdas90", 3 | "refreshToken": "sf90f345dsfdf923iekü0239e,21u3e", 4 | "expiresIn": 3600 5 | } 6 | -------------------------------------------------------------------------------- /Examples/ReactiveExample/ReactiveExample/Models/Stubs/nested-post.json: -------------------------------------------------------------------------------- 1 | { 2 | "super": { 3 | "deep": { 4 | "nesting": { 5 | "id": 1, 6 | "title": "json-server", 7 | "author": "typicode" 8 | } 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Examples/ReactiveExample/ReactiveExample/Models/Stubs/posts.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "title": "json-server", 5 | "author": "typicode" 6 | }, 7 | { 8 | "id": 2, 9 | "author": "mheinzl", 10 | "title": "new post" 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /Examples/ReactiveExample/ReactiveExample/Storyboards/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 | } -------------------------------------------------------------------------------- /Examples/ReactiveExample/ReactiveExample/Storyboards/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Examples/ReactiveExample/ReactiveExample/Storyboards/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 | -------------------------------------------------------------------------------- /Examples/ReactiveExample/ReactiveExample/Storyboards/Base.lproj/Main.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 | 31 | 38 | 45 | 52 | 59 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /Examples/ReactiveExample/ReactiveExample/SupportingFiles/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 | -------------------------------------------------------------------------------- /Examples/ReactiveExample/ReactiveExample/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // ReactiveExample 4 | // 5 | // Created by Matthias Buchetics on 17.04.19. 6 | // Copyright © 2019 all about apps Gmbh. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Fetch 11 | 12 | class ViewController: UIViewController { 13 | 14 | @IBOutlet private var logTextView: UITextView! 15 | 16 | private let failingResource: Resource = API.StubbedAuth.unauthorizedErrorRequest() 17 | 18 | override func viewDidLoad() { 19 | super.viewDidLoad() 20 | let logger = APIClient.shared.config.eventMonitors.first(where: { $0 is APILogger }) as? APILogger 21 | logger?.customOutputClosure = { [weak self] message in 22 | self?.logTextView.text += "\n" + message 23 | } 24 | } 25 | 26 | @IBAction private func authorize(_ sender: Any) { 27 | API.StubbedAuth.login(username: "TEST", password: "TEST").request().startWithResult { result in 28 | let credentials: Credentials? 29 | switch result { 30 | case .success(let value): 31 | credentials = value.model 32 | case .failure: 33 | credentials = nil 34 | } 35 | CredentialsController.shared.currentCredentials = credentials 36 | } 37 | 38 | } 39 | 40 | @IBAction private func refreshToken(_ sender: Any) { 41 | failingResource.request().start() 42 | } 43 | 44 | @IBAction private func authenticatedRequest(_ sender: Any) { 45 | API.StubbedAuth.authorizedRequest().request().start() 46 | } 47 | 48 | @IBAction private func randomRequest(_ sender: Any) { 49 | API.BlogPosts.list().request().start() 50 | } 51 | 52 | @IBAction private func clearCredentials(_ sender: Any) { 53 | CredentialsController.shared.currentCredentials = nil 54 | } 55 | 56 | @IBAction private func clearLog(_ sender: Any) { 57 | logTextView.text.removeAll() 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 all about apps GmbH 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 13 | all 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 21 | THE 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": "bc268c28fb170f494de9e9927c371b8342979ece", 10 | "version": "5.7.1" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Fetch", 7 | platforms: [ 8 | .macOS(.v10_12), 9 | .iOS(.v11), 10 | .tvOS(.v11), 11 | .watchOS(.v3), 12 | ], 13 | products: [ 14 | .library(name: "Fetch", targets: ["Fetch"]), 15 | ], 16 | dependencies: [ 17 | .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.7.0"), 18 | ], 19 | targets: [ 20 | .target(name: "Fetch", 21 | dependencies: ["Alamofire"]), 22 | .testTarget(name: "FetchTests", 23 | dependencies: ["Fetch"], 24 | resources: [ 25 | .process("modela.json"), 26 | .copy("TestFiles") 27 | ]), 28 | ], 29 | swiftLanguageVersions: [.v5] 30 | ) 31 | -------------------------------------------------------------------------------- /Sources/Fetch/Cache/Cache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cache.swift 3 | // Fetch 4 | // 5 | // Created by Matthias Buchetics on 08.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public enum CachePolicy { 12 | case networkOnlyUpdateCache 13 | case networkOnlyNoCache 14 | case cacheOnly 15 | case cacheFirstNetworkIfNotFoundOrExpired 16 | case cacheFirstNetworkAlways 17 | case cacheFirstNetworkRefresh 18 | case networkFirstCacheIfFailed 19 | } 20 | 21 | public struct CacheEntry { 22 | public let data: T 23 | public let expirationDate: Date 24 | 25 | public var isExpired: Bool { 26 | return expirationDate.timeIntervalSinceNow < 0 27 | } 28 | } 29 | 30 | public protocol Cache { 31 | func set(_ data: T, for resource: CacheableResource) throws 32 | func set(_ data: T, expirationDate: Date, for resource: CacheableResource) throws 33 | func get(for resource: CacheableResource) throws -> CacheEntry? 34 | 35 | func remove(for resource: CacheableResource) throws 36 | func remove(group: String) throws 37 | func removeExpired() throws 38 | func removeExpired(olderThan date: Date) throws 39 | func removeAll() throws 40 | func cleanup() throws 41 | } 42 | 43 | public extension Cache { 44 | 45 | func value(for resource: CacheableResource) -> T? { 46 | return try? get(for: resource)?.data 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/Fetch/Cache/Cacheable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cacheable.swift 3 | // Fetch 4 | // 5 | // Created by Matthias Buchetics on 15.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public protocol Cacheable: Codable { 12 | func isEqualTo(_ other: Cacheable?) -> Bool 13 | } 14 | 15 | public protocol CacheableResource { 16 | var cacheKey: String { get } 17 | var cacheGroup: String? { get } 18 | var cacheExpiration: Expiration? { get } 19 | } 20 | 21 | // MARK: Extensions 22 | 23 | public extension Cacheable where Self: Equatable { 24 | 25 | func isEqualTo(_ other: Cacheable?) -> Bool { 26 | guard let other = other as? Self else { return false } 27 | return self == other 28 | } 29 | } 30 | 31 | extension Array: Cacheable where Element: Cacheable { 32 | 33 | public func isEqualTo(_ other: Cacheable?) -> Bool { 34 | guard let other = other as? [Element] else { return false } 35 | 36 | let isDifferent = zip(self, other).contains { 37 | !$0.0.isEqualTo($0.1) 38 | } 39 | 40 | return !isDifferent 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/Fetch/Cache/DiskCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DiskCache.swift 3 | // Fetch 4 | // 5 | // Created by Matthias Buchetics on 15.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public enum DiskCacheError: Error { 12 | case createFileFailed 13 | case missingModificationDate 14 | case fileEnumerationFailed 15 | } 16 | 17 | public class DiskCache: Cache { 18 | 19 | public enum Directory { 20 | case caches 21 | case document 22 | case url(URL) 23 | } 24 | 25 | struct File { 26 | let url: URL 27 | let resourceValues: URLResourceValues 28 | } 29 | 30 | public let url: URL 31 | 32 | private let maxSize: Int // in bytes 33 | private let defaultExpiration: Expiration 34 | private let returnIfExpired: Bool 35 | 36 | let fileManager = FileManager.default 37 | let jsonDecoder: ResourceDecoderProtocol 38 | let jsonEncoder: ResourceEncoderProtocol 39 | 40 | public init(name: String = "at.allaboutapps.DiskCache", 41 | directory: Directory = .caches, 42 | maxSize: Int = 0, 43 | jsonDecoder: ResourceDecoderProtocol = JSONDecoder(), 44 | jsonEncoder: ResourceEncoderProtocol = JSONEncoder(), 45 | defaultExpiration: Expiration = .never, 46 | returnIfExpired: Bool = true) throws { 47 | let directoryUrl: URL 48 | 49 | switch directory { 50 | case .caches: 51 | directoryUrl = try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true) 52 | case .document: 53 | directoryUrl = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) 54 | case .url(let customURL): 55 | directoryUrl = customURL 56 | } 57 | 58 | self.url = directoryUrl.appendingPathComponent(name, isDirectory: true) 59 | self.maxSize = maxSize 60 | self.jsonDecoder = jsonDecoder 61 | self.jsonEncoder = jsonEncoder 62 | self.defaultExpiration = defaultExpiration 63 | self.returnIfExpired = returnIfExpired 64 | 65 | try createDirectory(at: url) 66 | } 67 | 68 | func createDirectory(at url: URL) throws { 69 | guard !fileManager.fileExists(atPath: url.path) else { 70 | return 71 | } 72 | 73 | try fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) 74 | } 75 | 76 | func directory(for resource: CacheableResource) -> URL { 77 | var resourceURL = url 78 | 79 | if let group = resource.cacheGroup { 80 | resourceURL.appendPathComponent(group, isDirectory: true) 81 | } 82 | 83 | return resourceURL 84 | } 85 | 86 | func path(for resource: CacheableResource) -> String { 87 | var resourceURL = directory(for: resource) 88 | resourceURL.appendPathComponent(resource.cacheKey) 89 | resourceURL.appendPathExtension("json") 90 | return resourceURL.path 91 | } 92 | 93 | private func allFiles() throws -> [File] { 94 | let resourceKeys: [URLResourceKey] = [ 95 | .isDirectoryKey, 96 | .contentModificationDateKey, 97 | .fileSizeKey 98 | ] 99 | 100 | let fileEnumerator = fileManager.enumerator(at: self.url, includingPropertiesForKeys: resourceKeys) 101 | 102 | guard let files = fileEnumerator?.allObjects as? [URL] else { 103 | throw DiskCacheError.fileEnumerationFailed 104 | } 105 | 106 | var result = [File]() 107 | 108 | for url in files { 109 | guard let resourceValues = try? url.resourceValues(forKeys: Set(resourceKeys)), resourceValues.isDirectory == false else { 110 | continue 111 | } 112 | 113 | result.append(File(url: url, resourceValues: resourceValues)) 114 | } 115 | 116 | return result 117 | } 118 | 119 | // MARK: Cache 120 | 121 | public func set(_ data: T, for resource: CacheableResource) throws where T: Cacheable { 122 | let expiration = resource.cacheExpiration ?? defaultExpiration 123 | try set(data, expirationDate: expiration.date, for: resource) 124 | } 125 | 126 | public func set(_ data: T, expirationDate: Date, for resource: CacheableResource) throws { 127 | let encodedData = try jsonEncoder.encode(data) 128 | let directoryURL = directory(for: resource) 129 | let filePath = path(for: resource) 130 | 131 | try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) 132 | 133 | if fileManager.createFile(atPath: filePath, contents: encodedData, attributes: [.modificationDate: expirationDate]) == false { 134 | throw DiskCacheError.createFileFailed 135 | } 136 | 137 | print("[DiskCache] set \(resource.cacheKey)") 138 | } 139 | 140 | public func get(for resource: CacheableResource) throws -> CacheEntry? where T: Cacheable { 141 | let filePath = path(for: resource) 142 | let attributes = try fileManager.attributesOfItem(atPath: filePath) 143 | let data = try Data(contentsOf: URL(fileURLWithPath: filePath)) 144 | let decodedData = try jsonDecoder.decode(T.self, from: data) 145 | 146 | guard let date = attributes[.modificationDate] as? Date else { 147 | throw DiskCacheError.missingModificationDate 148 | } 149 | 150 | guard returnIfExpired || date.timeIntervalSinceNow >= 0 else { 151 | print("[MemoryCache] get \(resource.cacheKey): is expired") 152 | try? remove(for: resource) 153 | return nil 154 | } 155 | 156 | print("[DiskCache] get \(resource.cacheKey): found") 157 | return CacheEntry(data: decodedData, expirationDate: date) 158 | } 159 | 160 | public func remove(for resource: CacheableResource) throws { 161 | print("[DiskCache] remove \(resource.cacheKey)") 162 | 163 | let filePath = path(for: resource) 164 | try fileManager.removeItem(atPath: filePath) 165 | } 166 | 167 | public func remove(group: String) throws { 168 | print("[DiskCache] remove group \(group)") 169 | 170 | let path = url.appendingPathComponent(group).path 171 | try fileManager.removeItem(atPath: path) 172 | } 173 | 174 | public func removeExpired() throws { 175 | try removeExpired(olderThan: Date()) 176 | } 177 | 178 | public func removeExpired(olderThan date: Date) throws { 179 | print("[DiskCache] remove expired older than \(date)") 180 | 181 | var filesToDelete = [URL]() 182 | 183 | for file in try allFiles() { 184 | if let expirationDate = file.resourceValues.contentModificationDate, expirationDate < date { 185 | filesToDelete.append(file.url) 186 | continue 187 | } 188 | } 189 | 190 | for url in filesToDelete { 191 | try fileManager.removeItem(at: url) 192 | } 193 | } 194 | 195 | public func removeAll() throws { 196 | print("[DiskCache] remove all") 197 | try fileManager.removeItem(atPath: url.path) 198 | try createDirectory(at: url) 199 | } 200 | 201 | // MARK: Cleanup 202 | 203 | public func computeTotalSize() throws -> Int { 204 | var size: Int = 0 205 | let contents = try fileManager.contentsOfDirectory(atPath: url.path) 206 | 207 | for pathComponent in contents { 208 | let filePath = url.appendingPathComponent(pathComponent).path 209 | let attributes = try fileManager.attributesOfItem(atPath: filePath) 210 | if let fileSize = attributes[.size] as? Int { 211 | size += fileSize 212 | } 213 | } 214 | 215 | return size 216 | } 217 | 218 | public func cleanup() throws { 219 | guard maxSize > 0 else { return } 220 | 221 | var totalSize = try computeTotalSize() 222 | let targetSize = maxSize / 2 223 | 224 | if totalSize < maxSize { 225 | return 226 | } 227 | 228 | let sortedFiles = try allFiles().sorted(by: { (f1, f2) -> Bool in 229 | if let date1 = f1.resourceValues.contentModificationDate, let date2 = f2.resourceValues.contentModificationDate { 230 | return date1 > date2 231 | } else { 232 | return false 233 | } 234 | }) 235 | 236 | var filesToDelete = [URL]() 237 | 238 | for file in sortedFiles { 239 | filesToDelete.append(file.url) 240 | 241 | if let fileSize = file.resourceValues.fileSize { 242 | totalSize -= Int(fileSize) 243 | } 244 | 245 | if totalSize < targetSize { 246 | break 247 | } 248 | } 249 | 250 | for url in filesToDelete { 251 | try fileManager.removeItem(at: url) 252 | } 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /Sources/Fetch/Cache/Expiration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Expiration.swift 3 | // Fetch 4 | // 5 | // Created by Matthias Buchetics on 15.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public enum Expiration { 12 | 13 | case never 14 | case seconds(TimeInterval) 15 | case date(Date) 16 | 17 | public var date: Date { 18 | switch self { 19 | case .never: 20 | return Date.distantFuture 21 | case .seconds(let seconds): 22 | return Date().addingTimeInterval(seconds) 23 | case .date(let date): 24 | return date 25 | } 26 | } 27 | 28 | public var isExpired: Bool { 29 | return date.timeIntervalSinceNow < 0 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Fetch/Cache/HybridCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HybridCache.swift 3 | // Fetch 4 | // 5 | // Created by Matthias Buchetics on 16.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public class HybridCache: Cache { 12 | 13 | public let primaryCache: Cache 14 | public let secondaryCache: Cache 15 | 16 | public init(primaryCache: Cache, secondaryCache: Cache) { 17 | self.primaryCache = primaryCache 18 | self.secondaryCache = secondaryCache 19 | } 20 | 21 | // MARK: Cache 22 | 23 | public func set(_ data: T, for resource: CacheableResource) throws where T: Cacheable { 24 | try primaryCache.set(data, for: resource) 25 | try secondaryCache.set(data, for: resource) 26 | } 27 | 28 | public func set(_ data: T, expirationDate: Date, for resource: CacheableResource) throws where T: Cacheable { 29 | try primaryCache.set(data, expirationDate: expirationDate, for: resource) 30 | try secondaryCache.set(data, expirationDate: expirationDate, for: resource) 31 | } 32 | 33 | public func get(for resource: CacheableResource) throws -> CacheEntry? where T: Cacheable { 34 | if let entry: CacheEntry = try? primaryCache.get(for: resource) { 35 | return entry 36 | } else if let entry: CacheEntry = try? secondaryCache.get(for: resource) { 37 | try primaryCache.set(entry.data, expirationDate: entry.expirationDate, for: resource) 38 | return entry 39 | } else { 40 | return nil 41 | } 42 | } 43 | 44 | public func remove(for resource: CacheableResource) throws { 45 | try primaryCache.remove(for: resource) 46 | try secondaryCache.remove(for: resource) 47 | } 48 | 49 | public func remove(group: String) throws { 50 | try primaryCache.remove(group: group) 51 | try secondaryCache.remove(group: group) 52 | } 53 | 54 | public func removeExpired() throws { 55 | try primaryCache.removeExpired() 56 | try secondaryCache.removeExpired() 57 | } 58 | 59 | public func removeExpired(olderThan date: Date) throws { 60 | try primaryCache.removeExpired(olderThan: date) 61 | try secondaryCache.removeExpired(olderThan: date) 62 | } 63 | 64 | public func removeAll() throws { 65 | try primaryCache.removeAll() 66 | try secondaryCache.removeAll() 67 | } 68 | 69 | public func cleanup() throws { 70 | try primaryCache.cleanup() 71 | try secondaryCache.cleanup() 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/Fetch/Cache/MemoryCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MemoryCache.swift 3 | // Fetch 4 | // 5 | // Created by Matthias Buchetics on 09.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public class MemoryCache: Cache { 12 | 13 | public struct Entry { 14 | let data: Cacheable 15 | let group: String? 16 | let expirationDate: Date 17 | 18 | var isExpired: Bool { 19 | return expirationDate.timeIntervalSinceNow < 0 20 | } 21 | 22 | init(_ data: Cacheable, group: String? = nil, expirationDate: Date) { 23 | self.data = data 24 | self.group = group 25 | self.expirationDate = expirationDate 26 | } 27 | } 28 | 29 | var cache = [String: Entry]() 30 | 31 | private let defaultExpiration: Expiration 32 | private let returnIfExpired: Bool 33 | 34 | // MARK: Init 35 | 36 | public init(defaultExpiration: Expiration = .never, returnIfExpired: Bool = true) { 37 | self.defaultExpiration = defaultExpiration 38 | self.returnIfExpired = returnIfExpired 39 | } 40 | 41 | // MARK: Cache 42 | 43 | public func set(_ data: T, for resource: CacheableResource) throws { 44 | let expiration = resource.cacheExpiration ?? defaultExpiration 45 | try set(data, expirationDate: expiration.date, for: resource) 46 | } 47 | 48 | public func set(_ data: T, expirationDate: Date, for resource: CacheableResource) throws { 49 | print("[MemoryCache] set \(resource.cacheKey): \(data)") 50 | cache[resource.cacheKey] = Entry(data, group: resource.cacheGroup, expirationDate: expirationDate) 51 | } 52 | 53 | public func get(for resource: CacheableResource) throws -> CacheEntry? { 54 | guard let entry = cache[resource.cacheKey] else { 55 | print("[MemoryCache] get \(resource.cacheKey): not found") 56 | return nil 57 | } 58 | 59 | guard let data = entry.data as? T else { 60 | print("[MemoryCache] get \(resource.cacheKey): invalid type") 61 | return nil 62 | } 63 | 64 | guard returnIfExpired || entry.isExpired == false else { 65 | print("[MemoryCache] get \(resource.cacheKey): is expired") 66 | try? remove(for: resource) 67 | return nil 68 | } 69 | 70 | print("[MemoryCache] get \(resource.cacheKey): \(data)") 71 | return CacheEntry(data: data, expirationDate: entry.expirationDate) 72 | } 73 | 74 | public func remove(for resource: CacheableResource) throws { 75 | print("[MemoryCache] remove \(resource.cacheKey)") 76 | 77 | cache.removeValue(forKey: resource.cacheKey) 78 | } 79 | 80 | public func remove(group: String) throws { 81 | cache 82 | .filter { $0.value.group == group } 83 | .forEach { (key, _) in 84 | print("[MemoryCache] remove \(key)") 85 | self.cache.removeValue(forKey: key) 86 | } 87 | } 88 | 89 | public func removeExpired() throws { 90 | cache 91 | .filter { $0.value.isExpired } 92 | .forEach { (key, _) in 93 | print("[MemoryCache] remove \(key)") 94 | self.cache.removeValue(forKey: key) 95 | } 96 | } 97 | 98 | public func removeExpired(olderThan date: Date) throws { 99 | cache 100 | .filter { $0.value.expirationDate < date } 101 | .forEach { (key, _) in 102 | print("[MemoryCache] remove \(key)") 103 | self.cache.removeValue(forKey: key) 104 | } 105 | } 106 | 107 | public func removeAll() { 108 | cache.removeAll() 109 | } 110 | 111 | public func cleanup() throws { 112 | try removeExpired() 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Sources/Fetch/Extensions/Fetch+Async.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Fetch+Async.swift 3 | // Fetch+Async 4 | // 5 | // Created by Matthias Buchetics on 01.09.21. 6 | // Copyright © 2021 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | #if swift(>=5.5.2) 10 | 11 | import Foundation 12 | 13 | @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) 14 | public extension Resource { 15 | 16 | enum ForwardBehaviour { 17 | case firstValue 18 | case waitForFinishedValue 19 | } 20 | 21 | func requestAsync() async throws -> NetworkResponse { 22 | var requestToken: RequestToken? 23 | 24 | return try await withTaskCancellationHandler { 25 | try Task.checkCancellation() 26 | 27 | return try await withCheckedThrowingContinuation { (continuation) in 28 | requestToken = self.request(queue: .asyncCompletionQueue) { (result) in 29 | switch result { 30 | case let .success(response): 31 | continuation.resume(returning: response) 32 | case let .failure(error): 33 | continuation.resume(throwing: error) 34 | } 35 | } 36 | } 37 | } onCancel: { [requestToken] in 38 | requestToken?.cancel() // runs immediately when cancelled 39 | } 40 | } 41 | 42 | func fetchAsync(cachePolicy: CachePolicy? = nil, behaviour: ForwardBehaviour = .firstValue) async throws -> (FetchResponse, Bool) where T: Cacheable { 43 | var requestToken: RequestToken? 44 | 45 | return try await withTaskCancellationHandler { 46 | try Task.checkCancellation() 47 | 48 | return try await withCheckedThrowingContinuation { (continuation) in 49 | var hasSendOneValue = false 50 | requestToken = self.fetch(cachePolicy: cachePolicy, queue: .asyncCompletionQueue) { (result, isFinished) in 51 | guard !hasSendOneValue else { return } 52 | 53 | switch result { 54 | case let .success(response): 55 | 56 | let sendValue = { 57 | continuation.resume(returning: (response, isFinished)) 58 | hasSendOneValue = true 59 | } 60 | 61 | switch (behaviour, isFinished) { 62 | case (.firstValue, _): 63 | sendValue() 64 | case (.waitForFinishedValue, true): 65 | sendValue() 66 | default: 67 | break 68 | } 69 | 70 | case let .failure(error): 71 | continuation.resume(throwing: error) 72 | } 73 | } 74 | } 75 | } onCancel: { [requestToken] in 76 | requestToken?.cancel() // runs immediately when cancelled 77 | } 78 | } 79 | 80 | func fetchAsyncSequence(cachePolicy: CachePolicy? = nil) -> AsyncThrowingStream, Error> where T: Cacheable { 81 | return AsyncThrowingStream, Error> { continuation in 82 | 83 | let requestToken = self.fetch(cachePolicy: cachePolicy, queue: .main) { (result, isFinished) in 84 | switch result { 85 | case let .success(response): 86 | continuation.yield(response) 87 | if isFinished { 88 | continuation.finish(throwing: nil) 89 | } 90 | case let .failure(error): 91 | continuation.finish(throwing: error) 92 | } 93 | } 94 | 95 | continuation.onTermination = { @Sendable termination in 96 | switch termination { 97 | case .cancelled: 98 | requestToken.cancel() 99 | default: 100 | break 101 | } 102 | } 103 | } 104 | } 105 | } 106 | 107 | #endif 108 | -------------------------------------------------------------------------------- /Sources/Fetch/Extensions/Fetch+Combine.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Fetch+Combine.swift 3 | // Fetch 4 | // 5 | // Created by Matthias Buchetics on 18.12.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | #if canImport(Combine) 10 | 11 | import Foundation 12 | import Combine 13 | 14 | // MARK: - FetchPublisher 15 | 16 | @available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) 17 | class FetchPublisher: Publisher { 18 | 19 | internal typealias Failure = FetchError 20 | 21 | private class Subscription: Combine.Subscription { 22 | 23 | private let cancellable: Cancellable? 24 | 25 | init(subscriber: AnySubscriber, callback: @escaping (AnySubscriber) -> Cancellable?) { 26 | self.cancellable = callback(subscriber) 27 | } 28 | 29 | func request(_ demand: Subscribers.Demand) { 30 | // We don't care for the demand right now 31 | } 32 | 33 | func cancel() { 34 | cancellable?.cancel() 35 | } 36 | } 37 | 38 | private let callback: (AnySubscriber) -> Cancellable? 39 | 40 | init(callback: @escaping (AnySubscriber) -> Cancellable?) { 41 | self.callback = callback 42 | } 43 | 44 | internal func receive(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input { 45 | let subscription = Subscription(subscriber: AnySubscriber(subscriber), callback: callback) 46 | subscriber.receive(subscription: subscription) 47 | } 48 | } 49 | 50 | @available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) 51 | extension RequestToken: Cancellable { } 52 | 53 | // MARK: - Resource+Request 54 | 55 | @available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) 56 | public extension Resource { 57 | 58 | func requestPublisher(callbackQueue: DispatchQueue = .main) -> AnyPublisher, FetchError> { 59 | return FetchPublisher { (subscriber) in 60 | return self.request(queue: callbackQueue) { (result) in 61 | switch result { 62 | case let .success(response): 63 | _ = subscriber.receive(response) 64 | subscriber.receive(completion: .finished) 65 | case let .failure(error): 66 | subscriber.receive(completion: .failure(error)) 67 | } 68 | } 69 | }.eraseToAnyPublisher() 70 | } 71 | 72 | func requestModel(callbackQueue: DispatchQueue = .main) -> AnyPublisher { 73 | return requestPublisher(callbackQueue: callbackQueue) 74 | .map { $0.model } 75 | .eraseToAnyPublisher() 76 | } 77 | } 78 | 79 | // MARK: - Resource+Fetch 80 | 81 | @available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) 82 | public extension Resource where T: Cacheable { 83 | 84 | func fetchPublisher(cachePolicy: CachePolicy? = nil, callbackQueue: DispatchQueue = .main) -> AnyPublisher, FetchError> { 85 | return FetchPublisher { (subscriber) in 86 | return self.fetch(cachePolicy: cachePolicy, queue: callbackQueue) { (result, isFinished) in 87 | switch result { 88 | case let .success(response): 89 | _ = subscriber.receive(response) 90 | if isFinished { 91 | subscriber.receive(completion: .finished) 92 | } 93 | case let .failure(error): 94 | subscriber.receive(completion: .failure(error)) 95 | } 96 | } 97 | }.eraseToAnyPublisher() 98 | } 99 | 100 | func fetchModel(callbackQueue: DispatchQueue = .main) -> AnyPublisher { 101 | return fetchPublisher(callbackQueue: callbackQueue) 102 | .map { $0.model } 103 | .eraseToAnyPublisher() 104 | } 105 | } 106 | 107 | #endif 108 | -------------------------------------------------------------------------------- /Sources/Fetch/Network/APIClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIClient.swift 3 | // Fetch 4 | // 5 | // Created by Michael Heinzl on 02.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Alamofire 11 | 12 | extension DispatchQueue { 13 | static let asyncCompletionQueue = DispatchQueue(label: "at.allaboutapps.fetch.asyncCompletionQueue", attributes: .concurrent) 14 | static let decodingQueue = DispatchQueue(label: "at.allaboutapps.fetch.decodingQueue") 15 | } 16 | 17 | /// A configuration object used to setup an `APIClient` 18 | public struct Config { 19 | 20 | public var baseURL: URL 21 | public var defaultHeaders: HTTPHeaders 22 | public var timeout: TimeInterval 23 | public var urlSession: URLSessionConfiguration 24 | public var eventMonitors: [EventMonitor] 25 | public var interceptor: RequestInterceptor? 26 | public var decoder: ResourceDecoderProtocol 27 | public var encoder: ResourceEncoderProtocol 28 | public var cache: Cache? 29 | public var cachePolicy: CachePolicy 30 | public var protocolClasses: [AnyClass] 31 | public var stubProvider: StubProvider 32 | public var shouldStub: Bool? 33 | public var serverTrustManager: ServerTrustManager? 34 | public var sessionDelegate: SessionDelegate? 35 | 36 | /// Initializes a new `Config` 37 | /// 38 | /// - Parameters: 39 | /// - baseURL: The base `URL` used for all request, if not specified by the `Resource` 40 | /// - defaultHeaders: The `HTTPHeaders` used for all request, if not specified by the `Resource` 41 | /// - timeout: The request timeout interval controls how long (in seconds) a task should wait for additional data to arrive before giving up 42 | /// - urlSession: The `URLSessionConfiguration` passed to the Alamofire `Session` 43 | /// - eventMonitors: The `EventMonitor` array passed to the Alamofire `Session` 44 | /// - adapter: The `RequestAdapter` passed to the Alamofire `Session` 45 | /// - retrier: The `RequestRetrier` passed to the Alamofire `Session` 46 | /// - jsonDecoder: The `JSONDecoder` used for all `Resources`, if not specified by the `Resource` 47 | /// - jsonEncoder: The `JSONEncoder` used for all `Resources`, if not specified by the `Resource` 48 | /// - cache: The `Cache` used for all `Resources` 49 | /// - cachePolicy: The `CachePolicy` used for all `Resources` 50 | /// - protocolClasses: Custom protocolClasses for URLSessionConfiguration 51 | /// - shouldStub: Indicates if requests should be stubbed, can be overwritten by the resource 52 | public init(baseURL: URL, 53 | defaultHeaders: HTTPHeaders = HTTPHeaders.default, 54 | timeout: TimeInterval = 60 * 2, 55 | urlSession: URLSessionConfiguration = URLSessionConfiguration.default, 56 | eventMonitors: [EventMonitor] = [APILogger(verbose: true)], 57 | interceptor: RequestInterceptor? = nil, 58 | jsonDecoder: ResourceDecoderProtocol = JSONDecoder(), 59 | jsonEncoder: ResourceEncoderProtocol = JSONEncoder(), 60 | cache: Cache? = nil, 61 | cachePolicy: CachePolicy = .networkOnlyUpdateCache, 62 | protocolClasses: [AnyClass] = [], 63 | serverTrustManager: ServerTrustManager? = nil, 64 | sessionDelegate: SessionDelegate? = nil, 65 | stubProvider: StubProvider = DefaultStubProvider(), 66 | shouldStub: Bool? = nil) { 67 | self.baseURL = baseURL 68 | self.defaultHeaders = defaultHeaders 69 | self.timeout = timeout 70 | self.urlSession = urlSession 71 | self.eventMonitors = eventMonitors 72 | self.interceptor = interceptor 73 | self.decoder = jsonDecoder 74 | self.encoder = jsonEncoder 75 | self.cache = cache 76 | self.cachePolicy = cachePolicy 77 | self.protocolClasses = protocolClasses 78 | self.stubProvider = stubProvider 79 | self.shouldStub = shouldStub 80 | self.serverTrustManager = serverTrustManager 81 | self.sessionDelegate = sessionDelegate 82 | } 83 | } 84 | 85 | /// The `NetworkResponse` represents an parsed response from the network. 86 | public struct NetworkResponse { 87 | 88 | /// The parsed model decoded from the response body 89 | public let model: T 90 | 91 | /// The associated `HTTPURLResponse` 92 | public let urlResponse: HTTPURLResponse 93 | 94 | internal init(model: T, urlResponse: HTTPURLResponse) { 95 | self.model = model 96 | self.urlResponse = urlResponse 97 | } 98 | } 99 | 100 | /// The `APIClient` is the interface to the network and it is used by a `Resource` to send http requests. 101 | open class APIClient { 102 | 103 | typealias CompletionCallback = ((Swift.Result, FetchError>) -> Void) 104 | 105 | /// Initializes a new `APIClient` 106 | /// 107 | /// - Parameter config: The config which is used for the setup 108 | public init(config: Config) { 109 | setup(with: config) 110 | } 111 | 112 | private init() {} 113 | 114 | /// The default `APIClient` 115 | public static let shared = APIClient() 116 | 117 | private var _config: Config? 118 | 119 | /// The `Config` passed from the `setup` function 120 | public var config: Config { 121 | assert(_config != nil, "Setup of APIClient was not called!") 122 | return _config! 123 | } 124 | 125 | public var session: Session! 126 | 127 | public var stubProvider: StubProvider { 128 | config.stubProvider 129 | } 130 | 131 | public func setStubProvider(_ stubProvider: StubProvider) { 132 | _config?.stubProvider = stubProvider 133 | } 134 | 135 | /// Configures an `APIClient` with the given `config` 136 | /// 137 | /// - Parameter config: used to setup the `APIClient` 138 | /// 139 | /// - Important: setup has to be called once before using the `APIClient` 140 | public func setup(with config: Config) { 141 | self._config = config 142 | 143 | let configuration = config.urlSession 144 | configuration.protocolClasses = config.protocolClasses + [StubbedURL.self] 145 | configuration.timeoutIntervalForRequest = config.timeout 146 | 147 | session = Session( 148 | configuration: configuration, 149 | delegate: config.sessionDelegate ?? SessionDelegate(), 150 | interceptor: config.interceptor, 151 | serverTrustManager: config.serverTrustManager, 152 | eventMonitors: config.eventMonitors 153 | ) 154 | } 155 | 156 | public func registerURLProtocolClass(_ someClass: AnyClass) { 157 | URLProtocol.registerClass(someClass) 158 | } 159 | 160 | public func unregisterClassURLProtocolClass(_ someClass: AnyClass) { 161 | URLProtocol.unregisterClass(someClass) 162 | } 163 | 164 | // MARK: - Resource 165 | 166 | @discardableResult internal func request(_ resource: Resource, queue: DispatchQueue, completion: @escaping CompletionCallback) -> RequestToken { 167 | precondition(_config != nil, "Setup of APIClient was not called!") 168 | 169 | var urlRequest: URLRequest 170 | do { 171 | urlRequest = try resource.asURLRequest() 172 | 173 | // register stub if needed 174 | if config.shouldStub ?? false && stubProvider.stub(for: resource) != nil { 175 | StubbedURL.stubProvider = config.stubProvider 176 | urlRequest.headers.add(name: StubbedURL.stubIdHeader, value: resource.stubKey) 177 | } 178 | 179 | } catch { 180 | queue.async { 181 | completion(.failure(.other(error: error))) 182 | } 183 | return RequestToken({}) 184 | } 185 | 186 | var dataRequest: DataRequest 187 | if let multipartFormData = resource.multipartFormData { 188 | dataRequest = session.upload(multipartFormData: multipartFormData, with: urlRequest) 189 | } else { 190 | dataRequest = session.request(urlRequest) 191 | } 192 | 193 | if let customValidation = resource.customValidation { 194 | dataRequest = dataRequest.validate(customValidation) 195 | } 196 | 197 | dataRequest 198 | .validate() // Validate response (status codes + content types) 199 | .responseData(queue: DispatchQueue.decodingQueue, completionHandler: { (dataResponse) in 200 | // Map and decode Data to Object 201 | let decodedResponse = dataResponse.tryMap { (data) throws -> T in 202 | if T.self == IgnoreBody.self { 203 | return IgnoreBody() as! T 204 | } else { 205 | return try resource.decode(data) 206 | } 207 | } 208 | 209 | switch decodedResponse.result { 210 | case .success(let model): 211 | if let urlResponse = dataResponse.response { 212 | queue.async { 213 | completion(.success(NetworkResponse(model: model, urlResponse: urlResponse))) 214 | } 215 | } else { 216 | queue.async { 217 | completion(.failure(.invalidResponse)) 218 | } 219 | } 220 | case .failure(let error): 221 | let fetchError: FetchError 222 | switch error { 223 | case let afError as AFError: 224 | if afError.isExplicitlyCancelledError { 225 | return 226 | } else { 227 | fetchError = .network(error: afError, responseData: decodedResponse.data) 228 | } 229 | case let decodingError as DecodingError: 230 | // TODO: log decoding error 231 | fetchError = .decoding(error: decodingError) 232 | default: 233 | // TODO: log other error 234 | fetchError = .other(error: error) 235 | } 236 | queue.async { 237 | completion(.failure(fetchError)) 238 | } 239 | } 240 | }) 241 | 242 | return RequestToken { 243 | dataRequest.cancel() 244 | } 245 | } 246 | 247 | } 248 | -------------------------------------------------------------------------------- /Sources/Fetch/Network/APILogger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APILogger.swift 3 | // Fetch 4 | // 5 | // Created by Michael Heinzl on 02.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Alamofire 11 | 12 | /// The `APILogger` is used as an `EventMonitor` to log network requests and responds 13 | public class APILogger: EventMonitor { 14 | 15 | public static let apiLogDateFormatter: DateFormatter = { 16 | let formatter = DateFormatter() 17 | formatter.dateFormat = "HH:mm:ss" 18 | formatter.locale = Locale(identifier: "en_US_POSIX") 19 | return formatter 20 | }() 21 | 22 | public typealias CustomLogClosure = ((_ timeStamp: Date, _ message: String, _ requestId: UUID) -> Void) 23 | 24 | public typealias CustomOutputClosure = ((String) -> Void) 25 | 26 | /// If `customLogClosure` is set every log entry is passed into `customLogClosure` 27 | public var customLogClosure: CustomLogClosure? 28 | 29 | public var customOutputClosure: CustomOutputClosure? 30 | 31 | /// Enables verbose logging if `true` 32 | public let verbose: Bool 33 | 34 | /// Initializes a new APILogger 35 | /// 36 | /// - Parameter verbose: Enables verbose logging if `true` 37 | public init(verbose: Bool) { 38 | self.verbose = verbose 39 | } 40 | 41 | // MARK: - Event Monitor 42 | 43 | public func requestDidResume(_ request: Request) { 44 | guard let urlRequest = request.request else { 45 | printMessage("🔸 No Request", with: request.id) 46 | return 47 | } 48 | 49 | var output = [String]() 50 | let isStubbed = urlRequest.headers.dictionary.keys.contains(StubbedURL.stubIdHeader) 51 | output.append("↗️\(isStubbed ? " [STUB]" : "") \(urlRequest.httpMethod ?? "") - \(urlRequest.url?.absoluteString ?? "")") 52 | 53 | let spacing = " " 54 | if verbose, let headers = urlRequest.allHTTPHeaderFields { 55 | output.append("\(spacing)Headers:") 56 | let formattedHeaders = headers 57 | .map { "\(spacing) \($0.0): \($0.1)" } 58 | .joined(separator: "\n") 59 | output.append(formattedHeaders) 60 | } 61 | 62 | if verbose, let body = prettyJSON(data: urlRequest.httpBody) { 63 | output.append("\(spacing)Request Body:") 64 | output.append(body) 65 | } 66 | 67 | printMessage(output.joined(separator: "\n"), with: request.id) 68 | } 69 | 70 | public func request(_ request: Alamofire.Request, didCompleteTask task: URLSessionTask, with error: AFError?) { 71 | guard let response = task.response as? HTTPURLResponse else { 72 | printMessage("🔸 No HTTPURLResponse", with: request.id) 73 | return 74 | } 75 | 76 | guard let urlRequest = request.request else { 77 | printMessage("🔸 Response has no request", with: request.id) 78 | return 79 | } 80 | 81 | var output = [String]() 82 | 83 | let range = 200...399 84 | let icon = range.contains(response.statusCode) ? "✅" : "❌" 85 | let isStubbed = urlRequest.headers.dictionary.keys.contains(StubbedURL.stubIdHeader) 86 | 87 | output.append("\(icon)\(isStubbed ? " [STUB]" : "") \(response.statusCode) \(urlRequest.httpMethod ?? "") - \(urlRequest.url?.absoluteString ?? "")") 88 | 89 | if verbose, 90 | let data = (request as? DataRequest)?.data, 91 | let body = prettyJSON(data: data) ?? String(data: data, encoding: .utf8) { 92 | output.append(body) 93 | } 94 | 95 | if let error = error { 96 | output.append("💢 Error: \(error)") 97 | } 98 | 99 | printMessage(output.joined(separator: "\n"), with: request.id) 100 | } 101 | 102 | // MARK: - Helper 103 | 104 | private func prettyJSON(data: Data?) -> String? { 105 | if let data = data, 106 | let json = try? JSONSerialization.jsonObject(with: data, options: []), 107 | let prettyJson = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted]), 108 | let string = NSString(data: prettyJson, encoding: String.Encoding.utf8.rawValue) { 109 | return string as String 110 | } 111 | 112 | return nil 113 | } 114 | 115 | private func printMessage(_ message: String, with requestId: UUID) { 116 | let now = Date() 117 | 118 | if let customLogClosure = customLogClosure { 119 | customLogClosure(now, message, requestId) 120 | } else { 121 | var output = "[\(APILogger.apiLogDateFormatter.string(from: now))] " 122 | if verbose { 123 | output += "Request ID: \(requestId.uuidString)\n" 124 | } 125 | output += message 126 | if let out = customOutputClosure { 127 | out(output) 128 | } else { 129 | print(output) 130 | } 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Sources/Fetch/Network/FetchError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchError.swift 3 | // Fetch 4 | // 5 | // Created by Matthias Buchetics on 17.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Alamofire 11 | 12 | /// The `FetchError` represents a possible error during a network request, the decoding of the response body and the caching 13 | public enum FetchError: Error { 14 | 15 | /// Indicating an error during the network request 16 | /// 17 | /// - error: The `AFError` provided by Alamofire 18 | /// - responseData: The `Data` representing the http response body 19 | case network(error: AFError, responseData: Data?) 20 | 21 | /// Indicating an error during the decoding of the http response body 22 | /// 23 | /// - error: The resulting `DecodingError` 24 | case decoding(error: DecodingError) 25 | 26 | /// Indicating that the `HTTPURLResponse` is `nil` or invalid 27 | case invalidResponse 28 | 29 | /// Indicating that no cache was found for the resource (only for the `cacheOnly` cache policy) 30 | case cacheNotFound 31 | 32 | /// Indicating any other error during the process 33 | /// 34 | /// - error: The other error 35 | case other(error: Error) 36 | } 37 | -------------------------------------------------------------------------------- /Sources/Fetch/Network/Resource+Fetch.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Resource+Fetch.swift 3 | // Fetch 4 | // 5 | // Created by Matthias Buchetics on 09.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Alamofire 11 | 12 | // MARK: - Fetch 13 | 14 | public enum FetchResponse { 15 | case cache(T, isExpired: Bool) 16 | case network(response: NetworkResponse, updated: Bool) 17 | 18 | public var model: T { 19 | switch self { 20 | case .cache(let model, _): 21 | return model 22 | case .network(let networkResponse, _): 23 | return networkResponse.model 24 | } 25 | } 26 | } 27 | 28 | public extension Resource where T: Cacheable { 29 | 30 | // var cachedEntry: (data: T, isExpired: Bool)? { 31 | // if let entry: CacheEntry = cache?.get(for: self) { 32 | // return (data: entry.data, isExpired: entry.isExpired) 33 | // } else { 34 | // return nil 35 | // } 36 | // } 37 | 38 | var cachedValue: T? { 39 | return cache?.value(for: self) 40 | } 41 | 42 | // MARK: Fetch 43 | 44 | @discardableResult func fetch(cachePolicy: CachePolicy? = nil, queue: DispatchQueue = .main, onResponse: @escaping (Swift.Result, FetchError>, Bool) -> Void) -> RequestToken { 45 | guard let cache = cache else { 46 | return requestAndUpdateCache(cache: nil, queue: queue, completion: onResponse) 47 | } 48 | 49 | switch method { 50 | case .get: 51 | // use provided cache policy OR resource cache policy OR default client cache policy 52 | return fetchUsingCache(cache, cachePolicy: cachePolicy ?? self.cachePolicy ?? apiClient.config.cachePolicy, onResponse: onResponse) 53 | case .post, .patch, .put, .delete: 54 | return requestAndUpdateCache(cache: nil, queue: queue) { (result, isFinished) in 55 | // remove all cache entries that belong to the same groups 56 | let strongSelf = self 57 | if let group = strongSelf.cacheGroup { 58 | do { 59 | try strongSelf.cache?.remove(group: group) 60 | } catch { 61 | print("cache error: \(error)") 62 | } 63 | } 64 | 65 | onResponse(result, isFinished) 66 | } 67 | default: 68 | return requestAndUpdateCache(cache: nil, queue: queue, completion: onResponse) 69 | } 70 | } 71 | 72 | @discardableResult private func fetchUsingCache(_ cache: Cache, cachePolicy: CachePolicy, queue: DispatchQueue = .main, onResponse: @escaping (Swift.Result, FetchError>, Bool) -> Void) -> RequestToken { 73 | 74 | let token = RequestToken() 75 | 76 | switch cachePolicy { 77 | 78 | case .networkOnlyNoCache: 79 | token += requestAndUpdateCache(cache: nil, queue: queue, completion: onResponse) 80 | 81 | case .networkOnlyUpdateCache: 82 | token += requestAndUpdateCache(cache: cache, queue: queue, completion: onResponse) 83 | 84 | case .cacheOnly: 85 | token += readCacheAsync(queue: queue) { (entry) in 86 | if let entry = entry { 87 | onResponse(.success(.cache(entry.data, isExpired: entry.isExpired)), true) 88 | } else { 89 | onResponse(.failure(.cacheNotFound), true) 90 | } 91 | } 92 | 93 | case .cacheFirstNetworkIfNotFoundOrExpired: 94 | token += readCacheAsync(queue: queue) { (entry) in 95 | if let entry = entry { 96 | if entry.isExpired { 97 | onResponse(.success(.cache(entry.data, isExpired: true)), false) 98 | token += self.requestAndUpdateCache(cache: cache, compareWith: entry.data, queue: queue, completion: onResponse) 99 | } else { 100 | onResponse(.success(.cache(entry.data, isExpired: false)), true) 101 | } 102 | } else { 103 | token += self.requestAndUpdateCache(cache: cache, queue: queue, completion: onResponse) 104 | } 105 | } 106 | 107 | case .cacheFirstNetworkAlways: 108 | token += readCacheAsync(queue: queue) { (entry) in 109 | if let entry = entry { 110 | onResponse(.success(.cache(entry.data, isExpired: entry.isExpired)), false) 111 | token += self.requestAndUpdateCache(cache: cache, compareWith: entry.data, queue: queue, completion: onResponse) 112 | } else { 113 | token += self.requestAndUpdateCache(cache: cache, queue: queue, completion: onResponse) 114 | } 115 | } 116 | 117 | case .cacheFirstNetworkRefresh: 118 | token += readCacheAsync(queue: queue) { (entry) in 119 | if let entry = entry { 120 | onResponse(.success(.cache(entry.data, isExpired: entry.isExpired)), true) 121 | token += self.requestAndUpdateCache(cache: cache, queue: queue, completion: nil) 122 | } else { 123 | token += self.requestAndUpdateCache(cache: cache, queue: queue, completion: onResponse) 124 | } 125 | } 126 | 127 | case .networkFirstCacheIfFailed: 128 | token += requestAndUpdateCache(cache: cache, queue: queue) { (networkResult, isFinished) in 129 | switch networkResult { 130 | case .success: 131 | onResponse(networkResult, isFinished) 132 | case .failure: 133 | token += self.readCacheAsync(queue: queue) { (entry) in 134 | if let entry = entry { 135 | // return the cached entry, ignoring the network error 136 | onResponse(.success(.cache(entry.data, isExpired: entry.isExpired)), true) 137 | } else { 138 | // return the network error 139 | onResponse(networkResult, true) 140 | } 141 | } 142 | } 143 | } 144 | } 145 | 146 | return token 147 | } 148 | 149 | private func requestAndUpdateCache(cache: Cache?, compareWith cached: T? = nil, queue: DispatchQueue, completion: ((Swift.Result, FetchError>, Bool) -> Void)?) -> RequestToken { 150 | return apiClient.request(self, queue: DispatchQueue.decodingQueue) { (result) in 151 | if let cache = cache, let data = try? result.get().model { 152 | do { 153 | try cache.set(data, for: self) 154 | } catch { 155 | print("cache set failed: \(error)") 156 | } 157 | } 158 | 159 | if let completion = completion { 160 | let fetchResult: Swift.Result, FetchError> = result.map { (networkResponse) in 161 | let isEqual = networkResponse.model.isEqualTo(cached) 162 | return .network(response: networkResponse, updated: !isEqual) 163 | } 164 | 165 | queue.async { 166 | completion(fetchResult, true) 167 | } 168 | } 169 | } 170 | } 171 | 172 | private func readCacheAsync(queue: DispatchQueue, completion: @escaping (CacheEntry?) -> Void) -> RequestToken { 173 | let token = RequestToken() 174 | 175 | DispatchQueue.decodingQueue.async { 176 | queue.async { 177 | if !token.isCancelled { 178 | if let entry: CacheEntry = try? self.cache?.get(for: self) { 179 | completion(entry) 180 | } else { 181 | completion(nil) 182 | } 183 | } 184 | } 185 | } 186 | 187 | return token 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /Sources/Fetch/Stub/AlternatingStub.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlternatingStub.swift 3 | // Fetch 4 | // 5 | // Created by Michael Heinzl on 10.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Alamofire 11 | 12 | /// A `Stub` representing a list of `Stubs`. 13 | /// The `AlternatingStub` cycles through the list. 14 | /// The index is increased after `result` is called. 15 | public class AlternatingStub: Stub { 16 | 17 | let stubs: [Stub] 18 | 19 | /// Initializes a new `AlternatingStub` 20 | /// 21 | /// - Parameter stubs: The list of `Stubs` which is cycled through 22 | public init(stubs: [Stub]) { 23 | self.stubs = stubs 24 | } 25 | 26 | /// The id of the current stub 27 | public var id: UUID { 28 | return currentStub.id 29 | } 30 | 31 | /// The delay of the current stub 32 | public var delay: TimeInterval { 33 | return currentStub.delay 34 | } 35 | 36 | public internal(set) var index: Int = 0 37 | 38 | internal func setNextIndex() { 39 | index = (index + 1) % stubs.count 40 | } 41 | 42 | /// The `Result` of the current stub 43 | public var result: Result<(StatusCode, Data, HTTPHeaders), Error> { 44 | let stub = currentStub.result 45 | setNextIndex() 46 | return stub 47 | } 48 | 49 | /// The `Result` of the current stub 50 | var currentStub: Stub { 51 | return stubs[index] 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /Sources/Fetch/Stub/ClosureStub.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClosureStub.swift 3 | // Fetch 4 | // 5 | // Created by Oliver Krakora on 24.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Alamofire 11 | 12 | public struct ClosureStub: Stub { 13 | 14 | public typealias StubClosure = (() -> Stub) 15 | 16 | public let stubClosure: StubClosure 17 | 18 | public var result: Result<(StatusCode, Data, HTTPHeaders), Error> { 19 | return stubClosure().result 20 | } 21 | 22 | public let id: UUID = UUID() 23 | 24 | public var delay: TimeInterval { 25 | return stubClosure().delay 26 | } 27 | 28 | public init(stubClosure: @escaping StubClosure) { 29 | self.stubClosure = stubClosure 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Fetch/Stub/RandomStub.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RandomStub.swift 3 | // Fetch 4 | // 5 | // Created by Michael Heinzl on 10.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// A `Stub` representing a list of `Stubs`. 12 | /// It returnes a random stub from the list each time `result` is called. 13 | public class RandomStub: AlternatingStub { 14 | 15 | override internal func setNextIndex() { 16 | index = Int.random(in: 0.. { get } 21 | 22 | /// The id to identify a `Stub`. It is used to match the stub to the response 23 | var id: UUID { get } 24 | 25 | /// The `TimeInterval` after which the stub is returned to simulate a network delay 26 | var delay: TimeInterval { get } 27 | } 28 | 29 | /// A simple `Stub` representing a successful response 30 | public struct StubResponse: Stub { 31 | public let id = UUID() 32 | public let result: Result<(StatusCode, Data, HTTPHeaders), Error> 33 | public let delay: TimeInterval 34 | 35 | /// Initializes a new `StubResponse` using a data object 36 | /// 37 | /// - Parameters: 38 | /// - statusCode: HTTP status code 39 | /// - data: HTTP body data 40 | /// - delay: Simulated network delay 41 | public init(statusCode: StatusCode, data: Data, headers: HTTPHeaders = HTTPHeaders(), delay: TimeInterval) { 42 | self.result = .success((statusCode, data, headers)) 43 | self.delay = delay 44 | } 45 | 46 | /// Initializes a new `StubResponse` using a file 47 | /// 48 | /// - Parameters: 49 | /// - statusCode: HTTP status code 50 | /// - fileName: The name of the file (e.g. test.json). The content of the file is used as HTTP body 51 | /// - delay: Simulated network delay 52 | /// - bundle: The `Bundle` containing the file, default Bundle.main 53 | public init(statusCode: StatusCode, fileName: String, headers: HTTPHeaders = HTTPHeaders(), delay: TimeInterval, bundle: Bundle = Bundle.main) { 54 | let fileExtension = fileName.fileExtension 55 | let name = fileName.fileName 56 | 57 | let path = bundle.path(forResource: name, ofType: fileExtension)! 58 | var headersToUse = headers 59 | 60 | if fileExtension == "json" { 61 | headersToUse.add(HTTPHeader.contentType("application/json")) 62 | } 63 | 64 | self.init(statusCode: statusCode, data: try! Data(contentsOf: URL(fileURLWithPath: path)), headers: headersToUse, delay: delay) 65 | } 66 | 67 | public init(statusCode: StatusCode, fileName: String, directory: String, headers: HTTPHeaders = .init(), delay: TimeInterval, bundle: Bundle = .main) { 68 | let fileExtension = fileName.fileExtension 69 | let name = fileName.fileName 70 | 71 | let path = bundle.path(forResource: name, ofType: fileExtension, inDirectory: directory)! 72 | 73 | var headersToUse = headers 74 | 75 | if fileExtension == "json" { 76 | headersToUse.add(HTTPHeader.contentType("application/json")) 77 | } 78 | 79 | self.init(statusCode: statusCode, data: try! Data(contentsOf: URL(fileURLWithPath: path)), headers: headersToUse, delay: delay) 80 | } 81 | 82 | /// Initializes a new `StubResponse` using a `Encodable` and a `JSONEncoder` 83 | /// 84 | /// - Parameters: 85 | /// - statusCode: HTTP status code 86 | /// - encodable: The object which will be encoded 87 | /// - encoder: The `JSONEncoder` used to encode the `Encodable` 88 | /// - delay: Simulated network delay 89 | public init(statusCode: StatusCode, encodable: Encodable, encoder: ResourceEncoderProtocol = JSONEncoder(), headers: HTTPHeaders = HTTPHeaders(), delay: TimeInterval) { 90 | self.init(statusCode: statusCode, data: try! encoder.encode(AnyEncodable(encodable)), headers: headers, delay: delay) 91 | } 92 | 93 | } 94 | 95 | /// A simple `Stub` representing a unsuccessful response 96 | public struct StubError: Stub { 97 | public let id = UUID() 98 | public let result: Result<(StatusCode, Data, HTTPHeaders), Error> 99 | public let delay: TimeInterval 100 | 101 | /// Initializes a new `StubError` 102 | /// 103 | /// - Parameters: 104 | /// - error: The resulting error 105 | /// - delay: Simulated network delay 106 | public init(error: Error, delay: TimeInterval) { 107 | self.result = .failure(error) 108 | self.delay = delay 109 | } 110 | } 111 | 112 | private extension String { 113 | var fileName: String { 114 | URL(fileURLWithPath: self).deletingPathExtension().lastPathComponent 115 | } 116 | 117 | var fileExtension: String { 118 | URL(fileURLWithPath: self).pathExtension 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Sources/Fetch/Stub/StubbedURL.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockURLProtocol.swift 3 | // Fetch 4 | // 5 | // Created by Michael Heinzl on 05.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class StubbedURL: URLProtocol { 12 | 13 | static var stubProvider: StubProvider? 14 | 15 | private let queue = DispatchQueue(label: "at.allaboutapps.fetch.stubQueue") 16 | 17 | override func startLoading() { 18 | // Get the corresponding stub from the registry using the stubId set the header 19 | // The stubId is set in the resource if necessary 20 | guard 21 | let stubKey = request.headers[StubbedURL.stubIdHeader], 22 | let stub = Self.stubProvider?.stub(for: stubKey) 23 | else { 24 | preconditionFailure("Stubbed request was not set correctly") 25 | } 26 | 27 | if stub.delay <= 0.0 { 28 | handleStub(stub) 29 | } else { 30 | queue.asyncAfter(deadline: .now() + stub.delay) { [weak self] in 31 | self?.handleStub(stub) 32 | } 33 | } 34 | } 35 | 36 | private func handleStub(_ stub: Stub) { 37 | guard let client = client else { return } 38 | 39 | switch stub.result { 40 | case .success(let (statusCode, data, headers)): 41 | let urlResponse = HTTPURLResponse( 42 | url: URL(string: "https://mocked.com")!, 43 | statusCode: statusCode, 44 | httpVersion: nil, 45 | headerFields: headers.dictionary)! 46 | 47 | client.urlProtocol(self, didReceive: urlResponse, cacheStoragePolicy: .notAllowed) 48 | client.urlProtocol(self, didLoad: data) 49 | client.urlProtocolDidFinishLoading(self) 50 | 51 | case .failure(let error): 52 | client.urlProtocol(self, didFailWithError: error) 53 | } 54 | } 55 | 56 | static let stubIdHeader = "StubbedURLProtocol.stubId" 57 | 58 | // MARK: - Helper 59 | 60 | override class func canInit(with request: URLRequest) -> Bool { 61 | return request.headers[stubIdHeader] != nil 62 | } 63 | 64 | override class func canonicalRequest(for request: URLRequest) -> URLRequest { 65 | return request 66 | } 67 | 68 | override class func requestIsCacheEquivalent(_ a: URLRequest, to b: URLRequest) -> Bool { 69 | return false 70 | } 71 | 72 | override func stopLoading() {} 73 | } 74 | -------------------------------------------------------------------------------- /Sources/Fetch/StubProvider/StubProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StubProvider.swift 3 | // Fetch 4 | // 5 | // Created by Stefan Wieland on 09.12.21. 6 | // Copyright © 2021 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public typealias ResourceStubKey = String 12 | 13 | /// StubProvider does hold all registered stubs and provide it if needed by resource 14 | public protocol StubProvider { 15 | 16 | /// Register a stub for a given resource 17 | /// Existing stub for given resource will be replaced 18 | /// 19 | /// - Parameters: 20 | /// - stub: A stub confirming to Stub protocol 21 | /// - resource: Stub will be registered for this resource 22 | func register(stub: Stub, for resource: Resource) 23 | 24 | /// Register a stub for a custom stubKey 25 | /// Existing stub for given stubKey will be replaced 26 | /// 27 | /// - Parameters: 28 | /// - stub: A stub confirming to Stub protocol 29 | /// - stubKey: Stub will be registered for this stubKey 30 | /// 31 | /// Also set the same stubKey on `Resource` 32 | func register(stub: Stub, forStubKey stubKey: ResourceStubKey) 33 | 34 | /// Remove all registered stubs in provider 35 | func removeAll() 36 | 37 | /// Remove registered stub for given resource 38 | /// 39 | /// - Parameter resource: Resource 40 | func removeStub(for resource: Resource) 41 | 42 | /// Remove registered stub for given stubKey 43 | /// 44 | /// - Parameter resource: Resource 45 | func removeStub(forStubKey stubKey: ResourceStubKey) 46 | 47 | /// Return stub for given resource 48 | /// 49 | /// - Parameter resource: Resource 50 | /// - returns: Stub if registered 51 | func stub(for resource: Resource) -> Stub? 52 | 53 | /// Return stub for given stubKey 54 | /// 55 | /// - Parameter stubKey: A key to retrieve a stub 56 | /// - returns: Stub if registered 57 | func stub(for stubKey: ResourceStubKey) -> Stub? 58 | 59 | } 60 | 61 | // MARK: - DefaultStubProvider 62 | 63 | public class DefaultStubProvider: StubProvider { 64 | 65 | private var store = [ResourceStubKey: Stub]() 66 | 67 | public init() { } 68 | 69 | public func register(stub: Stub, for resource: Resource) where T: Decodable { 70 | store[resource.stubKey] = stub 71 | } 72 | 73 | public func register(stub: Stub, forStubKey stubKey: ResourceStubKey) { 74 | store[stubKey] = stub 75 | } 76 | 77 | public func removeStub(for resource: Resource) { 78 | removeStub(forStubKey: resource.stubKey) 79 | } 80 | 81 | public func removeStub(forStubKey stubKey: ResourceStubKey) { 82 | store[stubKey] = nil 83 | } 84 | 85 | public func removeAll() { 86 | store.removeAll() 87 | } 88 | 89 | public func stub(for resource: Resource) -> Stub? { 90 | stub(for: resource.stubKey) 91 | } 92 | 93 | public func stub(for stubKey: ResourceStubKey) -> Stub? { 94 | store[stubKey] 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /Sources/Fetch/Utilities/AnyEncodable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnyEncodable.swift 3 | // ExampleKit 4 | // 5 | // Created by Matthias Buchetics on 24.09.18. 6 | // Copyright © 2018 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct AnyEncodable: Encodable { 12 | 13 | private let encodable: Encodable 14 | 15 | public init(_ encodable: Encodable) { 16 | self.encodable = encodable 17 | } 18 | 19 | public func encode(to encoder: Encoder) throws { 20 | try encodable.encode(to: encoder) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Fetch/Utilities/Crypto.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Crypto.swift 3 | // Fetch 4 | // 5 | // Created by Matthias Buchetics on 03.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CommonCrypto 11 | 12 | struct Crypto { 13 | 14 | static func md5(_ data: Data) -> Data { 15 | var md5 = Data(count: Int(CC_MD5_DIGEST_LENGTH)) 16 | 17 | md5.withUnsafeMutableBytes { md5Buffer in 18 | data.withUnsafeBytes { buffer in 19 | _ = CC_MD5(buffer.baseAddress!, CC_LONG(buffer.count), md5Buffer.bindMemory(to: UInt8.self).baseAddress) 20 | } 21 | } 22 | 23 | return md5 24 | } 25 | 26 | static func sha1(_ data: Data) -> Data { 27 | var sha1 = Data(count: Int(CC_SHA1_DIGEST_LENGTH)) 28 | 29 | sha1.withUnsafeMutableBytes { sha1Buffer in 30 | data.withUnsafeBytes { buffer in 31 | _ = CC_SHA1(buffer.baseAddress!, CC_LONG(buffer.count), sha1Buffer.bindMemory(to: UInt8.self).baseAddress) 32 | } 33 | } 34 | 35 | return sha1 36 | } 37 | } 38 | 39 | extension String { 40 | 41 | var md5: String? { 42 | guard let data = self.data(using: String.Encoding.utf8) else { return nil } 43 | return Crypto.md5(data).map { String(format: "%02x", $0) }.joined() 44 | } 45 | 46 | var sha1: String? { 47 | guard let data = self.data(using: String.Encoding.utf8) else { return nil } 48 | return Crypto.sha1(data).map { String(format: "%02x", $0) }.joined() 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/Fetch/Utilities/Decoder+Keys.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Decoder+Keys.swift 3 | // Fetch 4 | // 5 | // Created by Michael Heinzl on 04.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension JSONDecoder: ResourceRootKeyDecoderProtocol { 12 | 13 | /// Returns a value of the type you specify, decoded from a JSON object using a list of keys 14 | /// to remove wrapped container objects. The last key contains the actual object. 15 | /// 16 | /// - Parameters: 17 | /// - type: The type of the value to decode. 18 | /// - data: The data to decode from. 19 | /// - key: The keys of the container objects. 20 | /// - Returns: The decoded value of the requested type. 21 | /// - Throws: `DecodingError.dataCorrupted` if values requested from the payload are corrupted, or if the given data is not valid JSON. 22 | /// - throws: An error if any value throws an error during decoding. 23 | public func decode(_ type: T.Type, from data: Data, keyedBy key: [String]) throws -> T { 24 | userInfo[.jsonDecoderRootKeyArrayName] = key 25 | let root = try decode(Envelop.self, from: data) 26 | userInfo[.jsonDecoderRootKeyArrayName] = nil 27 | return root.value 28 | } 29 | } 30 | 31 | extension CodingUserInfoKey { 32 | static let jsonDecoderRootKeyArrayName = CodingUserInfoKey(rawValue: "rootKeyArray")! 33 | } 34 | 35 | struct Envelop: Decodable where T: Decodable { 36 | 37 | /// Wrapper to use arbitrary String/Int as CodingKey 38 | private struct CodingKeys: CodingKey { 39 | var stringValue: String 40 | var intValue: Int? 41 | 42 | init?(stringValue: String) { 43 | self.stringValue = stringValue 44 | } 45 | 46 | init?(intValue: Int) { 47 | self.intValue = intValue 48 | stringValue = "\(intValue)" 49 | } 50 | } 51 | 52 | let value: T 53 | 54 | init(from decoder: Decoder) throws { 55 | let container = try decoder.container(keyedBy: CodingKeys.self) 56 | 57 | // Check if userInfo was properly set and check if rootKeys contain elements 58 | guard let rootKeys = decoder.userInfo[.jsonDecoderRootKeyArrayName] as? [String], rootKeys.count >= 1 else { 59 | throw DecodingError.dataCorrupted(DecodingError.Context( 60 | codingPath: [], 61 | debugDescription: "No root keys found at \(CodingUserInfoKey.jsonDecoderRootKeyArrayName)")) 62 | } 63 | 64 | // Map rootKeys to CodingKeys 65 | let codingKeys = rootKeys.compactMap { CodingKeys(stringValue: $0) } 66 | let valueKey = codingKeys.last! 67 | 68 | // Unpack the nested containers until the last one 69 | let valueContainer = try codingKeys.dropLast().reduce(container) { (currentContainer, key) in 70 | return try currentContainer.nestedContainer(keyedBy: CodingKeys.self, forKey: key) 71 | } 72 | // Decode the actual value from the last container 73 | value = try valueContainer.decode(T.self, forKey: valueKey) 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /Sources/Fetch/Utilities/HTTPContentType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HTTPContentType.swift 3 | // Fetch 4 | // 5 | // Created by Michael Heinzl on 04.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// The enum represents common HTTP content types 12 | public enum HTTPContentType: CustomStringConvertible { 13 | case json 14 | case xml 15 | case yaml 16 | case imageJpeg 17 | case imagePng 18 | case custom(value: String) 19 | 20 | public var description: String { 21 | switch self { 22 | case .json: 23 | return "application/json" 24 | case .xml: 25 | return "application/xml" 26 | case .yaml: 27 | return "text/yaml" 28 | case .imageJpeg: 29 | return "image/jpeg" 30 | case .imagePng: 31 | return "image/png" 32 | case .custom(let value): 33 | return value 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/Fetch/Utilities/IgnoreBody.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IgnoreBody.swift 3 | // Fetch 4 | // 5 | // Created by Michael Heinzl on 17.05.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// IgnoreBody can be used as the generic type of a `Resource` to ignore the HTTP response body 12 | /// i.e. the body data is not decoded 13 | public struct IgnoreBody: Decodable { 14 | 15 | internal init() {} 16 | 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Fetch/Utilities/JSONEncoder+ResourceEncoder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JSONEncoder+ResourceEncoder.swift 3 | // Fetch 4 | // 5 | // Created by Oliver Krakora on 12.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension JSONEncoder: ResourceEncoderProtocol {} 12 | -------------------------------------------------------------------------------- /Sources/Fetch/Utilities/RequestToken.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestToken.swift 3 | // Fetch 4 | // 5 | // Created by Michael Heinzl on 09.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// A `RequestToken` can be used to cancel a running network request 12 | public class RequestToken { 13 | public let onCancel: (() -> Void) 14 | private var requestTokens = [RequestToken]() 15 | 16 | /// Indicates if the request is cancelled 17 | public private(set) var isCancelled: Bool = false 18 | 19 | public init(_ onCancel: @escaping (() -> Void) = {}) { 20 | self.onCancel = onCancel 21 | } 22 | 23 | /// Appends a `RequestToken` and cancels it if self is already cancelled 24 | public func append(_ requestToken: RequestToken) { 25 | requestTokens.append(requestToken) 26 | 27 | if isCancelled { 28 | requestToken.cancel() 29 | } 30 | } 31 | 32 | @discardableResult 33 | public static func += (lhs: RequestToken, rhs: RequestToken) -> RequestToken { 34 | lhs.append(rhs) 35 | return lhs 36 | } 37 | 38 | /// Cancels the corresponding request if not already cancelled 39 | public func cancel() { 40 | if !isCancelled { 41 | requestTokens.forEach { $0.cancel() } 42 | isCancelled = true 43 | onCancel() 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/Fetch/Utilities/ResourceDecoderProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResourceDecoderProtocol.swift 3 | // Fetch 4 | // 5 | // Created by Oliver Krakora on 12.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public protocol ResourceDecoderProtocol { 12 | func decode(_ type: T.Type, from data: Data) throws -> T 13 | } 14 | 15 | public protocol ResourceRootKeyDecoderProtocol: ResourceDecoderProtocol { 16 | func decode(_ type: T.Type, from data: Data, keyedBy key: [String]) throws -> T 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Fetch/Utilities/ResourceEncoderProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResourceEncoderProtocol.swift 3 | // Fetch 4 | // 5 | // Created by Oliver Krakora on 12.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public protocol ResourceEncoderProtocol { 12 | func encode(_ value: T) throws -> Data 13 | } 14 | -------------------------------------------------------------------------------- /Tests/FetchTests/Async Await/AsyncCacheTests.swift: -------------------------------------------------------------------------------- 1 | @testable 2 | import Fetch 3 | import XCTest 4 | 5 | #if swift(>=5.5.2) 6 | 7 | @available(macOS 12, iOS 13, tvOS 15, watchOS 8, *) 8 | class AsyncCacheTests: XCTestCase { 9 | 10 | private(set) var client: APIClient! 11 | private var cache: Cache! 12 | 13 | override func setUp() { 14 | super.setUp() 15 | cache = createCache() 16 | client = createAPIClient() 17 | } 18 | 19 | func createCache() -> Cache { 20 | return MemoryCache(defaultExpiration: .seconds(10.0)) 21 | } 22 | 23 | func createAPIClient() -> APIClient { 24 | let config = Config( 25 | baseURL: URL(string: "https://www.asdf.at")!, 26 | cache: cache, 27 | shouldStub: true 28 | ) 29 | 30 | return APIClient(config: config) 31 | } 32 | 33 | func testCacheWithFirstValue() { 34 | let resource = Resource( 35 | apiClient: client, 36 | method: .get, 37 | path: "/test/detail", 38 | cachePolicy: .cacheFirstNetworkAlways) 39 | 40 | let stub = StubResponse(statusCode: 200, encodable: ModelA(a: "123"), encoder: client.config.encoder, delay: 0.1) 41 | client.stubProvider.register(stub: stub, for: resource) 42 | 43 | try! resource.cache?.set(ModelA(a: "123"), for: resource) 44 | 45 | let expectation = self.expectation(description: "") 46 | Task { 47 | 48 | do { 49 | let (result, isFinished) = try await resource.fetchAsync(cachePolicy: nil) 50 | XCTAssertEqual(isFinished, false) 51 | 52 | switch result { 53 | case let .cache(value, _): 54 | XCTAssert(value == ModelA(a: "123"), "first value should be from cache") 55 | case .network: 56 | XCTFail("should never return network response") 57 | 58 | } 59 | expectation.fulfill() 60 | } catch { 61 | XCTFail("should suceed") 62 | } 63 | 64 | } 65 | waitForExpectations(timeout: 10, handler: nil) 66 | } 67 | 68 | func testCacheWithFinishedValue() { 69 | let resource = Resource( 70 | apiClient: client, 71 | method: .get, 72 | path: "/test/detail", 73 | cachePolicy: .cacheFirstNetworkAlways) 74 | 75 | let stub = StubResponse(statusCode: 200, encodable: ModelA(a: "123"), encoder: client.config.encoder, delay: 0.1) 76 | client.stubProvider.register(stub: stub, for: resource) 77 | 78 | try! resource.cache?.set(ModelA(a: "123"), for: resource) 79 | 80 | let expectation = self.expectation(description: "") 81 | Task { 82 | 83 | do { 84 | let (result, isFinished) = try await resource.fetchAsync(behaviour: .waitForFinishedValue) 85 | XCTAssertEqual(isFinished, true) 86 | 87 | switch result { 88 | case .cache: 89 | XCTFail("should wait for network") 90 | case let .network(_, updated): 91 | XCTAssertEqual(updated, false) 92 | 93 | } 94 | expectation.fulfill() 95 | } catch { 96 | XCTFail("should suceed") 97 | } 98 | 99 | } 100 | waitForExpectations(timeout: 10, handler: nil) 101 | } 102 | 103 | func testCacheWithAsyncSequence() { 104 | let resource = Resource( 105 | apiClient: client, 106 | method: .get, 107 | path: "/test/detail", 108 | cachePolicy: .cacheFirstNetworkAlways) 109 | 110 | let stub = StubResponse(statusCode: 200, encodable: ModelA(a: "123"), encoder: client.config.encoder, delay: 0.1) 111 | client.stubProvider.register(stub: stub, for: resource) 112 | 113 | try! resource.cache?.set(ModelA(a: "1234"), for: resource) 114 | 115 | let expectation = self.expectation(description: "") 116 | Task { 117 | 118 | do { 119 | var results = [ModelA]() 120 | for try await result in resource.fetchAsyncSequence() { 121 | results.append(result.model) 122 | } 123 | XCTAssert(results.count == 2, "Should send exactly 2 values") 124 | XCTAssertEqual(results[0].a, "1234", "first result should be from cache") 125 | XCTAssertEqual(results[1].a, "123", "first result should be from network") 126 | expectation.fulfill() 127 | } catch { 128 | XCTFail("should suceed") 129 | } 130 | 131 | } 132 | waitForExpectations(timeout: 10, handler: nil) 133 | } 134 | } 135 | 136 | #endif 137 | -------------------------------------------------------------------------------- /Tests/FetchTests/Async Await/AsyncTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Alamofire 3 | import Fetch 4 | 5 | #if swift(>=5.5.2) 6 | 7 | @available(macOS 12, iOS 13, tvOS 15, watchOS 8, *) 8 | class AsyncTests: XCTestCase { 9 | 10 | override func setUp() { 11 | APIClient.shared.setup(with: Config( 12 | baseURL: URL(string: "https://www.asdf.at")!, 13 | shouldStub: true 14 | )) 15 | } 16 | 17 | func testSuccessfulStubbingOfDecodable() { 18 | let expectation = self.expectation(description: "Fetch model") 19 | let resource = Resource( 20 | method: .get, 21 | path: "/test") 22 | 23 | let stub = StubResponse(statusCode: 200, encodable: ModelA(a: "a"), delay: 0.1) 24 | APIClient.shared.stubProvider.register(stub: stub, for: resource) 25 | 26 | Task { 27 | do { 28 | let result = try await resource.requestAsync() 29 | XCTAssertEqual(result.model.a, "a") 30 | expectation.fulfill() 31 | } catch { 32 | XCTFail("Request did not return value") 33 | } 34 | } 35 | waitForExpectations(timeout: 5, handler: nil) 36 | } 37 | 38 | func testFailingRequest() { 39 | let expectation = self.expectation(description: "Fetch model") 40 | let resource = Resource( 41 | method: .get, 42 | path: "/test") 43 | 44 | let stub = StubResponse(statusCode: 400, encodable: ModelA(a: "a"), delay: 0.1) 45 | APIClient.shared.stubProvider.register(stub: stub, for: resource) 46 | 47 | Task { 48 | do { 49 | let _ = try await resource.requestAsync() 50 | XCTFail("Request should not succeed") 51 | } catch { 52 | expectation.fulfill() 53 | } 54 | } 55 | waitForExpectations(timeout: 5, handler: nil) 56 | } 57 | 58 | func testRequestTokenCanCancelRequest() { 59 | let expectation = self.expectation(description: "T") 60 | let resource = Resource( 61 | method: .get, 62 | path: "/test") 63 | 64 | let stub = StubResponse(statusCode: 200, encodable: ModelA(a: "a"), delay: 0.2) 65 | APIClient.shared.stubProvider.register(stub: stub, for: resource) 66 | 67 | let task = Task { 68 | do { 69 | _ = try await resource.requestAsync() 70 | XCTFail("Request should be cancelled") 71 | } catch is CancellationError { 72 | print("cancelled") 73 | } catch { 74 | XCTFail("Request should be cancelled") 75 | } 76 | } 77 | task.cancel() 78 | 79 | DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + stub.delay + 0.1) { 80 | expectation.fulfill() 81 | } 82 | 83 | waitForExpectations(timeout: 10, handler: nil) 84 | } 85 | 86 | } 87 | 88 | #endif 89 | -------------------------------------------------------------------------------- /Tests/FetchTests/Cache/DiskCacheTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CacheTests.swift 3 | // FetchTests 4 | // 5 | // Created by Matthias Buchetics on 09.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Alamofire 11 | 12 | @testable 13 | import Fetch 14 | 15 | class DiskCacheTests: CacheTests { 16 | 17 | override func setUp() { 18 | super.setUp() 19 | } 20 | 21 | override func tearDown() { 22 | super.tearDown() 23 | } 24 | 25 | override func createCache() -> Cache { 26 | let cache = try! DiskCache(name: "at.allaboutapps.DiskCacheTest", defaultExpiration: .seconds(60.0)) 27 | try! cache.removeAll() 28 | return cache 29 | } 30 | 31 | func testCleanup() { 32 | let maxSize = 1000 33 | let cache = try! DiskCache(maxSize: 1000) 34 | 35 | struct Resource: CacheableResource { 36 | let cacheKey: String 37 | let cacheGroup: String? = nil 38 | let cacheExpiration: Expiration? = .never 39 | } 40 | 41 | for index in 0 ..< 1000 { 42 | let r = Resource(cacheKey: "\(index)") 43 | try! cache.set(ModelA(a: "\(index)"), for: r) 44 | } 45 | 46 | print(try! cache.computeTotalSize()) 47 | 48 | try! cache.cleanup() 49 | 50 | print(try! cache.computeTotalSize()) 51 | 52 | XCTAssert(try! cache.computeTotalSize() < maxSize, "cache size should be less than maximum") 53 | } 54 | 55 | func testFileExistence() { 56 | let resource = Resource( 57 | apiClient: client, 58 | path: "/test" 59 | ) 60 | 61 | let diskCache = resource.cache as? DiskCache 62 | 63 | XCTAssertNotNil(diskCache) 64 | 65 | let model = ModelA(a: "abcdefg") 66 | 67 | try? diskCache?.set(model, for: resource) 68 | 69 | let path = diskCache!.path(for: resource) 70 | 71 | let exists = FileManager.default.fileExists(atPath: path) 72 | 73 | XCTAssertTrue(exists) 74 | } 75 | 76 | func testFileExistenceAfterDelete() { 77 | let resource = Resource( 78 | apiClient: client, 79 | path: "/test" 80 | ) 81 | 82 | let diskCache = resource.cache as? DiskCache 83 | 84 | XCTAssertNotNil(diskCache) 85 | 86 | let model = ModelA(a: "abcdefg") 87 | 88 | try? diskCache?.set(model, for: resource) 89 | 90 | let path = diskCache!.path(for: resource) 91 | 92 | try? diskCache?.remove(for: resource) 93 | 94 | let exists = FileManager.default.fileExists(atPath: path) 95 | 96 | XCTAssertFalse(exists) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Tests/FetchTests/Cache/HybridCacheTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HybridCacheTests.swift 3 | // FetchTests 4 | // 5 | // Created by Matthias Buchetics on 09.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Alamofire 11 | 12 | @testable 13 | import Fetch 14 | 15 | class HybridCacheTests: CacheTests { 16 | 17 | override func setUp() { 18 | super.setUp() 19 | } 20 | 21 | override func tearDown() { 22 | super.tearDown() 23 | } 24 | 25 | override func createCache() -> Cache { 26 | let memoryCache = MemoryCache(defaultExpiration: .seconds(10.0)) 27 | let diskCache = try! DiskCache(name: "at.allaboutapps.HybridCacheTest", defaultExpiration: .seconds(60.0)) 28 | 29 | let cache = HybridCache(primaryCache: memoryCache, secondaryCache: diskCache) 30 | try! cache.removeAll() 31 | return cache 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Tests/FetchTests/Cache/MemoryCacheTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MemoryCacheTests.swift 3 | // FetchTests 4 | // 5 | // Created by Matthias Buchetics on 09.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Alamofire 11 | 12 | @testable 13 | import Fetch 14 | 15 | class MemoryCacheTests: CacheTests { 16 | 17 | override func setUp() { 18 | super.setUp() 19 | } 20 | 21 | override func tearDown() { 22 | super.tearDown() 23 | } 24 | 25 | override func createCache() -> Cache { 26 | return MemoryCache(defaultExpiration: .seconds(10.0)) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Tests/FetchTests/CancelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CancelTests.swift 3 | // FetchTests 4 | // 5 | // Created by Michael Heinzl on 11.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Alamofire 11 | import Fetch 12 | 13 | class CancelTests: XCTestCase { 14 | 15 | override func setUp() { 16 | APIClient.shared.setup(with: Config( 17 | baseURL: URL(string: "https://www.asdf.at")!, 18 | cache: MemoryCache(), 19 | shouldStub: true 20 | )) 21 | } 22 | 23 | func testRequestTokenCanCancelRequest() { 24 | let expectation = self.expectation(description: "T") 25 | let resource = Resource( 26 | method: .get, 27 | path: "/test") 28 | 29 | APIClient.shared.stubProvider.register(stub: StubResponse(statusCode: 200, encodable: ModelA(a: "a"), delay: 0.2), for: resource) 30 | 31 | guard let stub = APIClient.shared.stubProvider.stub(for: resource) else { 32 | XCTFail("Resource has no stub") 33 | return 34 | } 35 | var result: Swift.Result, FetchError>? 36 | let requestToken = resource.request { 37 | result = $0 38 | } 39 | 40 | guard let token = requestToken else { 41 | XCTFail("token is nil") 42 | return 43 | } 44 | 45 | DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + stub.delay - 0.1) { 46 | token.cancel() 47 | } 48 | DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + stub.delay + 0.1) { 49 | expectation.fulfill() 50 | } 51 | 52 | waitForExpectations(timeout: 10, handler: nil) 53 | XCTAssert((try? result?.get()) == nil, "Result value should be nil, becauce request was canceled") 54 | XCTAssert(token.isCancelled, "Token should be caneled") 55 | } 56 | 57 | func testRequestTokenCanCancelCacheRead() { 58 | let expectation = self.expectation(description: "T") 59 | let resource = Resource( 60 | method: .get, 61 | path: "/test", 62 | cachePolicy: .cacheFirstNetworkAlways) 63 | 64 | APIClient.shared.stubProvider.register(stub: StubResponse(statusCode: 200, encodable: ModelA(a: "a"), delay: 0.2), for: resource) 65 | 66 | guard let stub = APIClient.shared.stubProvider.stub(for: resource) else { 67 | XCTFail("Resource has no stub") 68 | return 69 | } 70 | 71 | guard let cache = resource.cache else { 72 | XCTFail("cache is nil") 73 | return 74 | } 75 | 76 | do { 77 | try cache.set(ModelA(a: "a"), for: resource) 78 | } catch { 79 | XCTFail("cache set failed") 80 | } 81 | 82 | let token = resource.fetch { (_, _) in 83 | XCTFail("callback should never be called") 84 | } 85 | 86 | token.cancel() 87 | 88 | DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + stub.delay + 0.1) { 89 | expectation.fulfill() 90 | } 91 | 92 | waitForExpectations(timeout: 10, handler: nil) 93 | XCTAssert(token.isCancelled, "Token should be caneled") 94 | } 95 | 96 | func testRequestTokenCanCancelDelayed() { 97 | let expectation = self.expectation(description: "T") 98 | let resource = Resource( 99 | method: .get, 100 | path: "/test", 101 | cachePolicy: .cacheFirstNetworkAlways) 102 | 103 | APIClient.shared.stubProvider.register(stub: StubResponse(statusCode: 200, encodable: ModelA(a: "a"), delay: 0.2), for: resource) 104 | 105 | guard let stub = APIClient.shared.stubProvider.stub(for: resource) else { 106 | XCTFail("Resource has no stub") 107 | return 108 | } 109 | 110 | guard let cache = resource.cache else { 111 | XCTFail("cache is nil") 112 | return 113 | } 114 | 115 | do { 116 | try cache.set(ModelA(a: "a"), for: resource) 117 | } catch { 118 | XCTFail("cache set failed") 119 | } 120 | 121 | let token = resource.fetch { (result, _) in 122 | switch result { 123 | case .success(.cache): 124 | break 125 | default: 126 | print("fail") 127 | XCTFail("callback should only be called once with the cached value, network request should be cancelled") 128 | } 129 | } 130 | 131 | DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + stub.delay - 0.1) { 132 | print("cancel") 133 | token.cancel() 134 | } 135 | DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + stub.delay + 0.1) { 136 | expectation.fulfill() 137 | } 138 | 139 | waitForExpectations(timeout: 10, handler: nil) 140 | XCTAssert(token.isCancelled, "Token should be caneled") 141 | } 142 | 143 | } 144 | -------------------------------------------------------------------------------- /Tests/FetchTests/CustomValidationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomValidationTests.swift 3 | // FetchTests 4 | // 5 | // Created by Michael Heinzl on 18.05.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Alamofire 11 | import Fetch 12 | 13 | class CustomValidationTests: XCTestCase { 14 | 15 | override func setUp() { 16 | APIClient.shared.setup(with: Config( 17 | baseURL: URL(string: "https://www.asdf.at")!, 18 | shouldStub: true 19 | )) 20 | } 21 | 22 | private enum ValidationError: Error { 23 | case wrongStatusCode 24 | } 25 | 26 | func testSuccessfulStubbingOfDecodable() { 27 | let testValidation: DataRequest.Validation = { (_, response, _) -> DataRequest.ValidationResult in 28 | if response.statusCode == 222 { 29 | return .failure(ValidationError.wrongStatusCode) 30 | } else { 31 | return .success(()) 32 | } 33 | } 34 | let expectation = self.expectation(description: "Fetch model") 35 | let resource = Resource( 36 | method: .get, 37 | path: "/test", 38 | customValidation: testValidation) 39 | 40 | APIClient.shared.stubProvider.register(stub: StubResponse(statusCode: 222, encodable: ModelA(a: "a"), delay: 0.1), for: resource) 41 | 42 | resource.request { (result) in 43 | switch result { 44 | case .failure(.network(let afError, _)): 45 | XCTAssertEqual((afError.underlyingError as? ValidationError), .wrongStatusCode) 46 | expectation.fulfill() 47 | default: 48 | XCTFail("Request did not return error") 49 | } 50 | } 51 | waitForExpectations(timeout: 5, handler: nil) 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /Tests/FetchTests/DispatchQueueTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DispatchQueueTests.swift 3 | // FetchTests 4 | // 5 | // Created by Michael Heinzl on 15.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Alamofire 11 | import Fetch 12 | 13 | class DispatchQueueTests: XCTestCase { 14 | 15 | override func setUp() { 16 | APIClient.shared.setup(with: Config( 17 | baseURL: URL(string: "https://www.asdf.at")!, 18 | shouldStub: true 19 | )) 20 | } 21 | 22 | func testResponseFailureOnCorrectQueue() { 23 | let expectation = self.expectation(description: "Fetch model") 24 | let resource = Resource( 25 | method: .get, 26 | path: "/test") 27 | 28 | APIClient.shared.stubProvider.register(stub: StubResponse(statusCode: 200, encodable: ModelA(a: "a"), delay: 0.1), for: resource) 29 | 30 | let testQueue = DispatchQueue(label: "test-queue") 31 | let testQueueKey = DispatchSpecificKey() 32 | testQueue.setSpecific(key: testQueueKey, value: ()) 33 | resource.request(queue: testQueue) { (result) in 34 | switch result { 35 | case .success: 36 | XCTAssertNotNil(DispatchQueue.getSpecific(key: testQueueKey), "callback should be called on specified queue") 37 | expectation.fulfill() 38 | default: 39 | XCTFail("Request did not return value") 40 | } 41 | } 42 | waitForExpectations(timeout: 5, handler: nil) 43 | } 44 | 45 | func testResponseSuccessOnCorrectQueue() { 46 | let expectation = self.expectation(description: "Fetch model") 47 | let resource = Resource( 48 | method: .get, 49 | path: "/test") 50 | APIClient.shared.stubProvider.register(stub: StubResponse(statusCode: 500, encodable: ModelA(a: "a"), delay: 0.1), for: resource) 51 | 52 | let testQueue = DispatchQueue(label: "test-queue") 53 | let testQueueKey = DispatchSpecificKey() 54 | testQueue.setSpecific(key: testQueueKey, value: ()) 55 | resource.request(queue: testQueue) { (result) in 56 | switch result { 57 | case .failure: 58 | XCTAssertNotNil(DispatchQueue.getSpecific(key: testQueueKey), "callback should be called on specified queue") 59 | expectation.fulfill() 60 | default: 61 | XCTFail("Request did return value") 62 | } 63 | } 64 | waitForExpectations(timeout: 5, handler: nil) 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /Tests/FetchTests/FullPathTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FullPathTests.swift 3 | // FetchTests 4 | // 5 | // Created by Michael Heinzl on 18.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Fetch 11 | 12 | class FullPathTests: XCTestCase { 13 | 14 | override func setUp() { 15 | APIClient.shared.setup(with: Config( 16 | baseURL: URL(string: "https://www.asdf.at")!, 17 | shouldStub: true 18 | )) 19 | } 20 | 21 | func testAbsoluteHTTPPathDoesNotUseBaseURL() { 22 | let path = "http://www.fullpath.at/rest" 23 | let resource = Resource(path: path) 24 | XCTAssertEqual(path, resource.url.absoluteString, "Path should be equal to url string") 25 | } 26 | 27 | func testAbsoluteHTTPSPathDoesNotUseBaseURL() { 28 | let path = "https://www.fullpath.at/rest" 29 | let resource = Resource(path: path) 30 | XCTAssertEqual(path, resource.url.absoluteString, "Path should be equal to url string") 31 | } 32 | 33 | func testRelativePathDoesUseBaseURL() { 34 | let path = "rest/test" 35 | let resource = Resource(path: path) 36 | XCTAssertNotEqual(path, resource.url.absoluteString, "Path should not be equal to url string") 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /Tests/FetchTests/IgnoreBodyTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IgnoreBodyTests.swift 3 | // FetchTests 4 | // 5 | // Created by Michael Heinzl on 17.05.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Alamofire 11 | @testable import Fetch 12 | 13 | class IgnoreBodyTests: XCTestCase { 14 | 15 | override func setUp() { 16 | APIClient.shared.setup(with: Config( 17 | baseURL: URL(string: "https://www.asdf.at")!, 18 | shouldStub: true 19 | )) 20 | } 21 | 22 | func testIgnoreBodyDoesNotDecode() { 23 | let expectation = self.expectation(description: "Fetch model") 24 | let resource = Resource( 25 | method: .get, 26 | path: "/test") 27 | 28 | APIClient.shared.stubProvider.register(stub: StubResponse(statusCode: 200, encodable: ModelA(a: "a"), delay: 0.0), for: resource) 29 | 30 | resource.request { (result) in 31 | switch result { 32 | case .success: 33 | expectation.fulfill() 34 | default: 35 | XCTFail("Request did not return value") 36 | } 37 | } 38 | waitForExpectations(timeout: 5, handler: nil) 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /Tests/FetchTests/MultipleStubsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MultipleStubsTests.swift 3 | // FetchTests 4 | // 5 | // Created by Michael Heinzl on 11.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Alamofire 11 | import Fetch 12 | 13 | class MultipleStubsTests: XCTestCase { 14 | 15 | override func setUp() { 16 | APIClient.shared.setup(with: Config( 17 | baseURL: URL(string: "https://www.asdf.at")!, 18 | shouldStub: true 19 | )) 20 | } 21 | func testAlternatingStub() { 22 | struct Foo: Codable { 23 | let a: Int 24 | } 25 | let stubs: [Stub] = [ 26 | StubResponse(statusCode: 200, encodable: Foo(a: 0), delay: 0.1), 27 | StubResponse(statusCode: 200, encodable: Foo(a: 1), delay: 0.2), 28 | StubError(error: NSError(domain: "TestDomain", code: 999, userInfo: nil), delay: 0.1), 29 | StubResponse(statusCode: 200, encodable: Foo(a: 3), delay: 0.1) 30 | ] 31 | let stub = AlternatingStub(stubs: stubs) 32 | let resource = Resource( 33 | method: .get, 34 | path: "/test") 35 | 36 | APIClient.shared.stubProvider.register(stub: stub, for: resource) 37 | 38 | for i in 0...9 { 39 | let expectation = self.expectation(description: "Fetch result") 40 | 41 | resource.request { (result) in 42 | let index = i % stubs.count 43 | switch result { 44 | case .success(let value): 45 | XCTAssertEqual(value.model.a, index, "Should return correct foo value") 46 | case .failure: 47 | XCTAssert(stubs[index] is StubError, "Should be an error") 48 | } 49 | expectation.fulfill() 50 | } 51 | waitForExpectations(timeout: 5, handler: nil) 52 | } 53 | } 54 | 55 | func testRandomStub() { 56 | struct Foo: Codable { 57 | let a: Int 58 | } 59 | let stubs: [Stub] = [ 60 | StubResponse(statusCode: 200, encodable: Foo(a: 0), delay: 0.1), 61 | StubResponse(statusCode: 200, encodable: Foo(a: 1), delay: 0.2), 62 | StubError(error: NSError(domain: "TestDomain", code: 999, userInfo: nil), delay: 0.1), 63 | StubResponse(statusCode: 200, encodable: Foo(a: 3), delay: 0.1) 64 | ] 65 | let stub = RandomStub(stubs: stubs) 66 | let resource = Resource( 67 | method: .get, 68 | path: "/test") 69 | 70 | APIClient.shared.stubProvider.register(stub: stub, for: resource) 71 | 72 | for _ in 0...9 { 73 | let expectation = self.expectation(description: "Fetch result") 74 | let index = stub.index 75 | resource.request { (result) in 76 | switch result { 77 | case .success(let value): 78 | XCTAssertEqual(value.model.a, index, "Should return correct foo value") 79 | case .failure: 80 | XCTAssert(stubs[index] is StubError, "Should be an error") 81 | } 82 | expectation.fulfill() 83 | } 84 | waitForExpectations(timeout: 5, handler: nil) 85 | } 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /Tests/FetchTests/NestingTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NestingTests.swift 3 | // FetchTests 4 | // 5 | // Created by Michael Heinzl on 11.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Alamofire 11 | import Fetch 12 | 13 | class NestingTests: XCTestCase { 14 | 15 | override func setUp() { 16 | APIClient.shared.setup(with: Config( 17 | baseURL: URL(string: "https://www.asdf.at")!, 18 | shouldStub: true 19 | )) 20 | } 21 | 22 | func testNestedDecodingSuccess() { 23 | let model = ModelA(a: "asdf") 24 | let nesting: Encodable = ["deep": [5: ["result": model]]] 25 | let resource = Resource( 26 | method: .get, 27 | path: "/test", 28 | rootKeys: ["deep", "5", "result"]) 29 | 30 | APIClient.shared.stubProvider.register(stub: StubResponse(statusCode: 200, encodable: nesting, delay: 0.1), for: resource) 31 | 32 | let expectation = self.expectation(description: "Fetch value") 33 | 34 | resource.request { (result) in 35 | switch result { 36 | case .success(let response): 37 | XCTAssertEqual(model, response.model) 38 | expectation.fulfill() 39 | case .failure: 40 | XCTFail("Request did return error") 41 | } 42 | } 43 | waitForExpectations(timeout: 5, handler: nil) 44 | } 45 | 46 | func testNestedDecodingTooManyKeys() { 47 | let model = ModelA(a: "asdf") 48 | let nesting: Encodable = ["deep": [5: ["result": model]]] 49 | let resource = Resource( 50 | method: .get, 51 | path: "/test", 52 | rootKeys: ["deep", "5", "result", "asdf"]) 53 | 54 | APIClient.shared.stubProvider.register(stub: StubResponse(statusCode: 200, encodable: nesting, delay: 0.1), for: resource) 55 | 56 | let expectation = self.expectation(description: "Fetch error") 57 | 58 | resource.request { (result) in 59 | switch result { 60 | case .failure(.decoding(.keyNotFound)): 61 | expectation.fulfill() 62 | default: 63 | XCTFail("Request did not return error") 64 | } 65 | } 66 | waitForExpectations(timeout: 5, handler: nil) 67 | } 68 | 69 | func testNestedDecodingTooFewKeys() { 70 | let model = ModelA(a: "asdf") 71 | let nesting: Encodable = ["deep": [5: ["result": model]]] 72 | let resource = Resource( 73 | method: .get, 74 | path: "/test", 75 | rootKeys: ["deep", "5"]) 76 | APIClient.shared.stubProvider.register(stub: StubResponse(statusCode: 200, encodable: nesting, delay: 0.1), for: resource) 77 | 78 | let expectation = self.expectation(description: "Fetch error") 79 | 80 | resource.request { (result) in 81 | switch result { 82 | case .failure(.decoding(.keyNotFound)): 83 | expectation.fulfill() 84 | default: 85 | XCTFail("Request did not return error") 86 | } 87 | } 88 | waitForExpectations(timeout: 5, handler: nil) 89 | } 90 | 91 | func testEmptyRootKeysShouldFail() { 92 | let model = ModelA(a: "asdf") 93 | let nesting: Encodable = ["deep": [5: ["result": model]]] 94 | let resource = Resource( 95 | method: .get, 96 | path: "/test", 97 | rootKeys: []) 98 | 99 | APIClient.shared.stubProvider.register(stub: StubResponse(statusCode: 200, encodable: nesting, delay: 0.1), for: resource) 100 | 101 | let expectation = self.expectation(description: "Fetch error") 102 | 103 | resource.request { (result) in 104 | switch result { 105 | case .failure(.decoding(.dataCorrupted)): 106 | expectation.fulfill() 107 | default: 108 | XCTFail("Request did not return error") 109 | } 110 | } 111 | waitForExpectations(timeout: 5, handler: nil) 112 | } 113 | 114 | } 115 | -------------------------------------------------------------------------------- /Tests/FetchTests/ShouldStubTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShouldStubTests.swift 3 | // FetchTests 4 | // 5 | // Created by Michael Heinzl on 15.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Alamofire 11 | @testable import Fetch 12 | 13 | class ShouldStubTests: XCTestCase { 14 | 15 | override func setUp() { 16 | APIClient.shared.setup(with: Config( 17 | baseURL: URL(string: "https://www.asdf.at")!, 18 | shouldStub: true 19 | )) 20 | } 21 | 22 | func testShouldStubSetGlobalStubs() { 23 | let customApiClient = APIClient(config: Config( 24 | baseURL: URL(string: "https://www.asdf.at")!, 25 | shouldStub: true 26 | )) 27 | let expectation = self.expectation(description: "Fetch model") 28 | let resource = Resource( 29 | apiClient: customApiClient, 30 | method: .get, 31 | path: "/test") 32 | 33 | customApiClient.stubProvider.register(stub: StubResponse(statusCode: 200, encodable: ModelA(a: "a"), delay: 0.1), for: resource) 34 | 35 | resource.request { (result) in 36 | switch result { 37 | case let .success(value): 38 | XCTAssertEqual(value.model.a, "a") 39 | expectation.fulfill() 40 | default: 41 | XCTFail("Request did not return value") 42 | } 43 | } 44 | waitForExpectations(timeout: 5, handler: nil) 45 | } 46 | 47 | func testShouldStubSetGlobalStubsDisabled() { 48 | let customApiClient = APIClient(config: Config( 49 | baseURL: URL(string: "https://www.asdf.at")!, 50 | timeout: 1, 51 | shouldStub: false 52 | )) 53 | let expectation = self.expectation(description: "Fetch model") 54 | let resource = Resource( 55 | apiClient: customApiClient, 56 | method: .get, 57 | path: "/test") 58 | 59 | customApiClient.stubProvider.register(stub: StubResponse(statusCode: 200, encodable: ModelA(a: "a"), delay: 0.1), for: resource) 60 | 61 | resource.request { (result) in 62 | switch result { 63 | case .success: 64 | XCTFail("Did not expect a value") 65 | case let .failure(error): 66 | XCTAssertNotNil(error) 67 | } 68 | expectation.fulfill() 69 | } 70 | waitForExpectations(timeout: 5, handler: nil) 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /Tests/FetchTests/StubProviderTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StubProviderTests.swift 3 | // FetchTests 4 | // 5 | // Created by Stefan Wieland on 09.12.21. 6 | // Copyright © 2021 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Fetch 11 | 12 | class StubProviderTests: XCTestCase { 13 | 14 | override func setUp() { 15 | APIClient.shared.setup(with: Config( 16 | baseURL: URL(string: "https://www.asdf.at")!, 17 | shouldStub: true 18 | )) 19 | APIClient.shared.stubProvider.removeAll() 20 | } 21 | 22 | func testChangeStubForSameResource() { 23 | let resource = Resource(method: .get, path: "/test") 24 | 25 | let expectationA = self.expectation(description: "Fetch model a") 26 | let stubA = StubResponse(statusCode: 200, encodable: ModelA(a: "a"), delay: 0.1) 27 | 28 | APIClient.shared.stubProvider.register(stub: stubA, for: resource) 29 | 30 | resource.request { (result) in 31 | switch result { 32 | case let .success(value): 33 | XCTAssertEqual(value.model.a, "a") 34 | expectationA.fulfill() 35 | default: 36 | XCTFail("Request did not return value") 37 | } 38 | 39 | } 40 | 41 | // update stub 42 | wait(for: [expectationA], timeout: 5) 43 | let expectationB = self.expectation(description: "Fetch model b") 44 | let stubB = StubResponse(statusCode: 200, encodable: ModelA(a: "b"), delay: 0.1) 45 | APIClient.shared.stubProvider.register(stub: stubB, for: resource) 46 | 47 | resource.request { (result) in 48 | switch result { 49 | case let .success(value): 50 | XCTAssertEqual(value.model.a, "b") 51 | expectationB.fulfill() 52 | default: 53 | XCTFail("Request did not return value") 54 | } 55 | } 56 | 57 | wait(for: [expectationB], timeout: 5) 58 | } 59 | 60 | func testRemoveStub() { 61 | let expectation = self.expectation(description: "Fetch model") 62 | 63 | let resource = Resource(method: .get, path: "/testRemove") 64 | 65 | let stub = StubResponse(statusCode: 200, encodable: ModelA(a: "a"), delay: 0.1) 66 | APIClient.shared.stubProvider.register(stub: stub, for: resource) 67 | APIClient.shared.stubProvider.removeStub(for: resource) 68 | 69 | resource.request { (result) in 70 | switch result { 71 | case .success: 72 | XCTFail("No stub data expected") 73 | case .failure(let error): 74 | XCTAssertNotNil(error, "Expected an error for no valid response") 75 | } 76 | expectation.fulfill() 77 | } 78 | waitForExpectations(timeout: 5, handler: nil) 79 | } 80 | 81 | func testRemoveStubFromResource() { 82 | let expectation = self.expectation(description: "Fetch model") 83 | 84 | let resource = Resource(method: .get, path: "/test", stubKey: "Foo") 85 | 86 | let stub = StubResponse(statusCode: 200, encodable: ModelA(a: "a"), delay: 0.1) 87 | APIClient.shared.stubProvider.register(stub: stub, forStubKey: "Foo") 88 | APIClient.shared.stubProvider.removeStub(for: resource) 89 | 90 | resource.request { (result) in 91 | switch result { 92 | case .success: 93 | XCTFail("No stub data expected") 94 | case .failure(let error): 95 | XCTAssertNotNil(error, "Expected an error for no valid response") 96 | } 97 | expectation.fulfill() 98 | } 99 | waitForExpectations(timeout: 5, handler: nil) 100 | } 101 | 102 | func testPOSTStub() { 103 | let expectationGET = self.expectation(description: "GET model") 104 | let expectationPOST = self.expectation(description: "POST model") 105 | 106 | let resourceGET = Resource(method: .get, path: "/test") 107 | let resourcePOST = Resource(method: .post, path: "/test") 108 | 109 | let stubGET = StubResponse(statusCode: 200, encodable: ModelA(a: "get"), delay: 0.1) 110 | APIClient.shared.stubProvider.register(stub: stubGET, for: resourceGET) 111 | 112 | let stubPOST = StubResponse(statusCode: 200, encodable: ModelA(a: "post"), delay: 0.1) 113 | APIClient.shared.stubProvider.register(stub: stubPOST, for: resourcePOST) 114 | 115 | resourceGET.request { (result) in 116 | switch result { 117 | case let .success(value): 118 | XCTAssertEqual(value.model.a, "get") 119 | default: 120 | XCTFail("Request did not return value") 121 | } 122 | expectationGET.fulfill() 123 | } 124 | 125 | resourcePOST.request { (result) in 126 | switch result { 127 | case let .success(value): 128 | XCTAssertEqual(value.model.a, "post") 129 | default: 130 | XCTFail("Request did not return value") 131 | } 132 | expectationPOST.fulfill() 133 | } 134 | 135 | waitForExpectations(timeout: 5, handler: nil) 136 | } 137 | 138 | func testCustomStubKey() { 139 | let expectation = self.expectation(description: "GET model") 140 | 141 | let resource = Resource(method: .get, path: "/test", stubKey: "key") 142 | let stub = StubResponse(statusCode: 200, encodable: ModelA(a: "a"), delay: 0.1) 143 | APIClient.shared.stubProvider.register(stub: stub, forStubKey: "key") 144 | 145 | resource.request { (result) in 146 | switch result { 147 | case let .success(value): 148 | XCTAssertEqual(value.model.a, "a") 149 | default: 150 | XCTFail("Request did not return value") 151 | } 152 | expectation.fulfill() 153 | } 154 | 155 | waitForExpectations(timeout: 5, handler: nil) 156 | } 157 | 158 | } 159 | -------------------------------------------------------------------------------- /Tests/FetchTests/StubTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StubTests.swift 3 | // FetchTests 4 | // 5 | // Created by Michael Heinzl on 11.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Alamofire 11 | import Fetch 12 | 13 | class StubTests: XCTestCase { 14 | 15 | override func setUp() { 16 | APIClient.shared.setup(with: Config( 17 | baseURL: URL(string: "https://www.asdf.at")!, 18 | shouldStub: true 19 | )) 20 | } 21 | 22 | func testSuccessfulStubbingOfDecodable() { 23 | let expectation = self.expectation(description: "Fetch model") 24 | let resource = Resource( 25 | method: .get, 26 | path: "/test") 27 | 28 | APIClient.shared.stubProvider.register(stub: StubResponse(statusCode: 200, encodable: ModelA(a: "a"), delay: 0.1), for: resource) 29 | 30 | resource.request { (result) in 31 | switch result { 32 | case let .success(value): 33 | XCTAssertEqual(value.model.a, "a") 34 | expectation.fulfill() 35 | default: 36 | XCTFail("Request did not return value") 37 | } 38 | } 39 | waitForExpectations(timeout: 5, handler: nil) 40 | } 41 | 42 | func testStubbedStatusCode() { 43 | let expectation = self.expectation(description: "Fetch model") 44 | let statusCode = 205 45 | let resource = Resource( 46 | method: .get, 47 | path: "/test") 48 | 49 | APIClient.shared.stubProvider.register(stub: StubResponse(statusCode: statusCode, encodable: ModelA(a: "a"), delay: 0.1), for: resource) 50 | 51 | resource.request { (result) in 52 | switch result { 53 | case let .success(response): 54 | XCTAssertEqual(response.urlResponse.statusCode, statusCode) 55 | expectation.fulfill() 56 | default: 57 | XCTFail("Request did not return value") 58 | } 59 | } 60 | waitForExpectations(timeout: 5, handler: nil) 61 | } 62 | 63 | func testStubbedModelFromFile() { 64 | let expectation = self.expectation(description: "Fetch model") 65 | let resource = Resource( 66 | method: .get, 67 | path: "/test") 68 | 69 | APIClient.shared.stubProvider.register(stub: StubResponse(statusCode: 200, fileName: "modela.json", delay: 0.1, bundle: Bundle.module), for: resource) 70 | 71 | resource.request { (result) in 72 | switch result { 73 | case .success(let response): 74 | XCTAssertEqual(response.model.a, "a") 75 | expectation.fulfill() 76 | case .failure: 77 | XCTFail("Request did not return value") 78 | } 79 | } 80 | waitForExpectations(timeout: 5, handler: nil) 81 | } 82 | 83 | func testStubbedModelFromFileInDirectory() { 84 | let expectation = self.expectation(description: "Fetch model") 85 | let resource = Resource( 86 | method: .get, 87 | path: "/test") 88 | 89 | APIClient.shared.stubProvider.register( 90 | stub: StubResponse( 91 | statusCode: 200, 92 | fileName: "copyModelA.json", 93 | directory: "TestFiles", 94 | delay: 0.1, 95 | bundle: Bundle.module 96 | ), 97 | for: resource 98 | ) 99 | 100 | resource.request { (result) in 101 | switch result { 102 | case .success(let response): 103 | XCTAssertEqual(response.model.a, "a") 104 | expectation.fulfill() 105 | case .failure: 106 | XCTFail("Request did not return value") 107 | } 108 | } 109 | waitForExpectations(timeout: 5, handler: nil) 110 | } 111 | 112 | func testStubbedHTTPHeaders() { 113 | let expectation = self.expectation(description: "Fetch model") 114 | let headers = HTTPHeaders([ 115 | "httpHeaderKey1": "httpHeaderValue1", 116 | "httpHeaderKey2": "httpHeaderValue2" 117 | ]) 118 | let resource = Resource( 119 | method: .get, 120 | path: "/test") 121 | 122 | APIClient.shared.stubProvider.register(stub: StubResponse(statusCode: 200, encodable: ModelA(a: "a"), headers: headers, delay: 0.1), for: resource) 123 | 124 | resource.request { (result) in 125 | switch result { 126 | case let .success(response): 127 | XCTAssertEqual(response.urlResponse.headers.dictionary, headers.dictionary) 128 | expectation.fulfill() 129 | default: 130 | XCTFail("Request did not return value") 131 | } 132 | } 133 | waitForExpectations(timeout: 5, handler: nil) 134 | } 135 | 136 | func testMultipleStubsReturnCorrectResult() { 137 | let requestCount = 5 138 | for i in 1...requestCount { 139 | let model = ModelA(a: String(i)) 140 | let stub = StubResponse(statusCode: 200, encodable: model, delay: 0.1) 141 | let stubKey = "MultipleStubsKey-\(i)" 142 | let resource = Resource(path: "/test", stubKey: stubKey) 143 | APIClient.shared.stubProvider.register(stub: stub, forStubKey: stubKey) 144 | 145 | let expectation = self.expectation(description: "Fetch model id: \(i)") 146 | 147 | resource.request { (result) in 148 | switch result { 149 | case let .success(value): 150 | XCTAssertEqual(value.model.a, String(i), "Model does not contain correct value") 151 | expectation.fulfill() 152 | default: 153 | XCTFail("Request did not return value") 154 | } 155 | } 156 | } 157 | waitForExpectations(timeout: 5, handler: nil) 158 | } 159 | 160 | func testErrorStub() { 161 | let inputError = NSError(domain: "TestDomain", code: 999, userInfo: nil) 162 | let resource = Resource( 163 | method: .get, 164 | path: "/test") 165 | APIClient.shared.stubProvider.register(stub: StubError(error: inputError, delay: 0.1), for: resource) 166 | 167 | let expectation = self.expectation(description: "Fetch error") 168 | 169 | resource.request { (result) in 170 | switch result { 171 | case .success: 172 | XCTFail("Request did not return error") 173 | case .failure(let error): 174 | if case .network(let afError, _) = error { 175 | guard let nsError = afError.underlyingError as NSError? else { 176 | XCTFail("Expect error is not set") 177 | return 178 | } 179 | 180 | XCTAssertEqual(nsError.domain, inputError.domain, "Same error domain as input") 181 | XCTAssertEqual(nsError.code, inputError.code, "Same error code as input") 182 | } else { 183 | XCTFail("Expect error is not set") 184 | } 185 | expectation.fulfill() 186 | } 187 | } 188 | waitForExpectations(timeout: 5, handler: nil) 189 | } 190 | 191 | } 192 | -------------------------------------------------------------------------------- /Tests/FetchTests/TestAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestAPI.swift 3 | // FetchTests 4 | // 5 | // Created by Michael Heinzl on 05.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Fetch 11 | 12 | struct ModelA: Equatable, Cacheable { 13 | let a: String 14 | } 15 | 16 | struct ModelB: Equatable, Cacheable { 17 | let b: String 18 | } 19 | -------------------------------------------------------------------------------- /Tests/FetchTests/TestFiles/copyModelA.json: -------------------------------------------------------------------------------- 1 | { 2 | "a": "a" 3 | } 4 | -------------------------------------------------------------------------------- /Tests/FetchTests/URLRequestTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLRequestTests.swift 3 | // FetchTests 4 | // 5 | // Created by Michael Heinzl on 11.04.19. 6 | // Copyright © 2019 aaa - all about apps GmbH. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Alamofire 11 | @testable import Fetch 12 | 13 | class URLRequestTests: XCTestCase { 14 | 15 | override func setUp() { 16 | APIClient.shared.setup(with: Config( 17 | baseURL: URL(string: "https://www.asdf.at")!, 18 | defaultHeaders: HTTPHeaders(["DefaultHeader": "default", "OtherHeader": "other"]), 19 | shouldStub: true 20 | )) 21 | } 22 | 23 | func testHttpDefaultContentTypeIsSetInHeader() { 24 | let resource = Resource( 25 | method: .post, 26 | path: "/test", 27 | body: .encodable(ModelA(a: "a"))) 28 | let urlRequest = try! resource.asURLRequest() 29 | let contains = urlRequest.allHTTPHeaderFields?.contains(where: { (arg) -> Bool in 30 | let (key, value) = arg 31 | return key == "Content-Type" && value == "application/json" 32 | }) ?? false 33 | XCTAssertTrue(contains) 34 | } 35 | 36 | func testCustomHeaderOverrideDefaultHeaderButKeepOther() { 37 | let resource = Resource( 38 | headers: HTTPHeaders(["DefaultHeader": "custom"]), 39 | path: "/test") 40 | let urlRequest = try! resource.asURLRequest() 41 | let containsCustomHeader = urlRequest.allHTTPHeaderFields?.contains(where: { (arg) -> Bool in 42 | let (key, value) = arg 43 | return key == "DefaultHeader" && value == "custom" 44 | }) ?? false 45 | XCTAssertTrue(containsCustomHeader) 46 | let containsOtherHeader = urlRequest.allHTTPHeaderFields?.contains(where: { (arg) -> Bool in 47 | let (key, value) = arg 48 | return key == "OtherHeader" && value == "other" 49 | }) ?? false 50 | XCTAssertTrue(containsOtherHeader) 51 | } 52 | 53 | func testCustomEncoding() { 54 | let inputModel = ModelA(a: "asdfasd") 55 | let resource = Resource( 56 | path: "/test", 57 | body: .encodable(inputModel), 58 | encode: { (encodable: Encodable) throws -> (Data, HTTPContentType?) in 59 | let data = try PropertyListEncoder().encode(AnyEncodable(encodable)) 60 | return (data, HTTPContentType.custom(value: "property-list")) 61 | }) 62 | let urlRequest = try! resource.asURLRequest() 63 | let body = (urlRequest.httpBody ?? Data()) 64 | let outputModel = try! PropertyListDecoder().decode(ModelA.self, from: body) 65 | XCTAssertEqual(inputModel, outputModel) 66 | let contains = urlRequest.allHTTPHeaderFields?.contains(where: { (arg) -> Bool in 67 | let (key, value) = arg 68 | return key == "Content-Type" && value == "property-list" 69 | }) ?? false 70 | XCTAssertTrue(contains) 71 | } 72 | 73 | func testCustomDeconding() { 74 | struct Foo: Codable { 75 | let seconds: TimeInterval 76 | } 77 | let date = Date() 78 | let seconds = date.timeIntervalSince1970 79 | let resource = Resource( 80 | path: "/test", 81 | decode: { (data: Data) throws -> Foo in 82 | let decoder = JSONDecoder() 83 | decoder.dateDecodingStrategy = .secondsSince1970 84 | return try decoder.decode(Foo.self, from: data) 85 | }) 86 | 87 | APIClient.shared 88 | .stubProvider 89 | .register(stub: StubResponse(statusCode: 200, encodable: Foo(seconds: seconds), delay: 0.1), 90 | for: resource) 91 | 92 | let expectation = self.expectation(description: "Fetch seconds") 93 | resource.request { (result) in 94 | switch result { 95 | case .success(let response): 96 | XCTAssertEqual(response.model.seconds, seconds) 97 | expectation.fulfill() 98 | default: 99 | XCTFail("Wrong response") 100 | } 101 | } 102 | waitForExpectations(timeout: 5, handler: nil) 103 | } 104 | 105 | func testUrlParameter() { 106 | let resource = Resource( 107 | path: "/test", 108 | urlParameters: ["url": "parameter"]) 109 | let urlRequest = try! resource.asURLRequest() 110 | XCTAssertTrue(urlRequest.url?.absoluteString.hasSuffix("?url=parameter") ?? false) 111 | } 112 | 113 | func testFetchResponse() { 114 | let fetchAExpectation = expectation(description: "FetchA") 115 | 116 | let resourceA = Resource( 117 | path: "/test", 118 | cachePolicy: .networkOnlyUpdateCache) 119 | 120 | APIClient.shared 121 | .stubProvider 122 | .register(stub: StubResponse(statusCode: 200, encodable: ModelA(a: "366"), delay: 0.1), 123 | for: resourceA) 124 | 125 | resourceA.fetch { (result, _) in 126 | switch result { 127 | case .success(let value): 128 | XCTAssertNotNil(value) 129 | fetchAExpectation.fulfill() 130 | default: 131 | break 132 | } 133 | } 134 | 135 | waitForExpectations(timeout: 10, handler: nil) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /Tests/FetchTests/modela.json: -------------------------------------------------------------------------------- 1 | { 2 | "a": "a" 3 | } 4 | --------------------------------------------------------------------------------