├── .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