diff --git a/.gitignore b/.gitignore index e35cb713..6a1ca7e5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ xcuserdata Carthage/ .build Siesta.xcodeproj/xcshareddata/xcbaselines +.idea/ diff --git a/Cartfile.private b/Cartfile.private index 017881f4..8441785d 100644 --- a/Cartfile.private +++ b/Cartfile.private @@ -2,6 +2,7 @@ github "Alamofire/Alamofire" # github "ReactiveCocoa/ReactiveCocoa" "master" # add to Cartfile if/when it has tests +github "ReactiveX/RxSwift" # Testing diff --git a/Cartfile.resolved b/Cartfile.resolved index 8ae66de9..c9cb6b34 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -1,3 +1,4 @@ github "Alamofire/Alamofire" "5.0.5" github "Quick/Nimble" "v8.0.5" +github "ReactiveX/RxSwift" "5.1.1" github "pcantrell/Quick" "d91676d00600c42f9b55349765b1f1099141bfba" diff --git a/Examples/GithubBrowser/GithubBrowser.xcodeproj/project.pbxproj b/Examples/GithubBrowser/GithubBrowser.xcodeproj/project.pbxproj index 5bade494..30f1c6ce 100644 --- a/Examples/GithubBrowser/GithubBrowser.xcodeproj/project.pbxproj +++ b/Examples/GithubBrowser/GithubBrowser.xcodeproj/project.pbxproj @@ -7,7 +7,43 @@ objects = { /* Begin PBXBuildFile section */ - 713AB3CBE73243473E91C834 /* Pods_GithubBrowser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 18CDC2D94A6B11796E5F8136 /* Pods_GithubBrowser.framework */; }; + 0FE8BF22249106E165566A5B /* Pods_GithubBrowser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 543BB0271968B0654B40F7EA /* Pods_GithubBrowser.framework */; }; + 3A4507672468AB8900FFB3A2 /* RepositoryListViewControllerCombine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A4507662468AB7700FFB3A2 /* RepositoryListViewControllerCombine.swift */; }; + 3A4507692468B38D00FFB3A2 /* UserViewControllerCombine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A4507682468B37E00FFB3A2 /* UserViewControllerCombine.swift */; }; + 3A605CAC246665CB00004033 /* SearchResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F17241F200E870038D78B /* SearchResults.swift */; }; + 3A605CAD246665CB00004033 /* Repository.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2B55631C7D76B500EB4D67 /* Repository.swift */; }; + 3A605CAF246665CB00004033 /* SiestaTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2B55671C7ED14700EB4D67 /* SiestaTheme.swift */; }; + 3A605CB2246665CB00004033 /* LoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2B55691C7EDE7B00EB4D67 /* LoginViewController.swift */; }; + 3A605CB3246665CB00004033 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2B55611C7D6F8700EB4D67 /* User.swift */; }; + 3A605CB4246665CB00004033 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE2F8471B94F10500D2AD96 /* AppDelegate.swift */; }; + 3A605CB5246665CB00004033 /* GitHubAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE2F8441B94F10500D2AD96 /* GitHubAPI.swift */; }; + 3A605CB6246665CB00004033 /* Siesta+RxSwift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A0058362447B8DD00FC421F /* Siesta+RxSwift.swift */; }; + 3A605CB7246665CB00004033 /* Optional+GithubBrowser.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE27A7F1D3F1EF400D757F9 /* Optional+GithubBrowser.swift */; }; + 3A605CB8246665CB00004033 /* CommentaryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6752561F6B8FCE009BE918 /* CommentaryViewController.swift */; }; + 3A605CBD246665CB00004033 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DA7462451B4C768B00406D67 /* LaunchScreen.storyboard */; }; + 3A605CBE246665CB00004033 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DA7462431B4C768B00406D67 /* Assets.xcassets */; }; + 3A605CBF246665CB00004033 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DA7462401B4C768B00406D67 /* Main.storyboard */; }; + 3A605CC9246665E400004033 /* SearchResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F17241F200E870038D78B /* SearchResults.swift */; }; + 3A605CCA246665E400004033 /* Repository.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2B55631C7D76B500EB4D67 /* Repository.swift */; }; + 3A605CCC246665E400004033 /* SiestaTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2B55671C7ED14700EB4D67 /* SiestaTheme.swift */; }; + 3A605CCF246665E400004033 /* LoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2B55691C7EDE7B00EB4D67 /* LoginViewController.swift */; }; + 3A605CD0246665E400004033 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2B55611C7D6F8700EB4D67 /* User.swift */; }; + 3A605CD1246665E400004033 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE2F8471B94F10500D2AD96 /* AppDelegate.swift */; }; + 3A605CD2246665E400004033 /* GitHubAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE2F8441B94F10500D2AD96 /* GitHubAPI.swift */; }; + 3A605CD4246665E400004033 /* Optional+GithubBrowser.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE27A7F1D3F1EF400D757F9 /* Optional+GithubBrowser.swift */; }; + 3A605CD5246665E400004033 /* CommentaryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6752561F6B8FCE009BE918 /* CommentaryViewController.swift */; }; + 3A605CDA246665E400004033 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DA7462451B4C768B00406D67 /* LaunchScreen.storyboard */; }; + 3A605CDB246665E400004033 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DA7462431B4C768B00406D67 /* Assets.xcassets */; }; + 3A605CDC246665E400004033 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DA7462401B4C768B00406D67 /* Main.storyboard */; }; + 3A605CE62466787900004033 /* UserViewControllerRx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A605CE52466786400004033 /* UserViewControllerRx.swift */; }; + 3A605CE8246678BA00004033 /* RepositoryListViewControllerRx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A605CE7246678AC00004033 /* RepositoryListViewControllerRx.swift */; }; + 3A605CEA246678DA00004033 /* RepositoryViewControllerRx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A605CE9246678CB00004033 /* RepositoryViewControllerRx.swift */; }; + 3A605CED246688AB00004033 /* RepositoryViewControllerCombine.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3CA54519C18B1994363BC1 /* RepositoryViewControllerCombine.swift */; }; + 3A605CEF246689CB00004033 /* SiestaUI+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3CA50176A39AE184D4BB0C /* SiestaUI+Combine.swift */; }; + 3A605CF024669C9E00004033 /* SiestaUI+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A605CEE246689BF00004033 /* SiestaUI+Rx.swift */; }; + 3F4223768CB3A003CE3DD76C /* Pods_GithubBrowserCombine.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0AE2E58566ED4EA14159C996 /* Pods_GithubBrowserCombine.framework */; }; + AB3CA091A147D8B56A94583B /* GitHubAPI+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3CABF5CF2BAE423E1173EF /* GitHubAPI+Combine.swift */; }; + AB3CA92CA71718DC4185DB62 /* GitHubAPI+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3CAEFB1E514B85FC9B5D20 /* GitHubAPI+Rx.swift */; }; DA2B55621C7D6F8700EB4D67 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2B55611C7D6F8700EB4D67 /* User.swift */; }; DA2B55641C7D76B500EB4D67 /* Repository.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2B55631C7D76B500EB4D67 /* Repository.swift */; }; DA2B55681C7ED14800EB4D67 /* SiestaTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2B55671C7ED14700EB4D67 /* SiestaTheme.swift */; }; @@ -23,12 +59,35 @@ DAE2F84C1B94F10500D2AD96 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE2F8471B94F10500D2AD96 /* AppDelegate.swift */; }; DAE2F84D1B94F10500D2AD96 /* RepositoryListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE2F8481B94F10500D2AD96 /* RepositoryListViewController.swift */; }; DAE2F84E1B94F10500D2AD96 /* UserViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE2F8491B94F10500D2AD96 /* UserViewController.swift */; }; + E3B5ACFD5B206C5C69576246 /* Pods_GithubBrowserRx.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C0CC06B89137A3E2842910B /* Pods_GithubBrowserRx.framework */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 18CDC2D94A6B11796E5F8136 /* Pods_GithubBrowser.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GithubBrowser.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 0AE2E58566ED4EA14159C996 /* Pods_GithubBrowserCombine.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GithubBrowserCombine.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 2EC46971D8CE872A81E0C927 /* Pods-GithubBrowserRx.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GithubBrowserRx.release.xcconfig"; path = "Pods/Target Support Files/Pods-GithubBrowserRx/Pods-GithubBrowserRx.release.xcconfig"; sourceTree = ""; }; + 3A0058362447B8DD00FC421F /* Siesta+RxSwift.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "Siesta+RxSwift.swift"; path = "../../../../Extensions/RxSwift/Siesta+RxSwift.swift"; sourceTree = ""; }; + 3A4507662468AB7700FFB3A2 /* RepositoryListViewControllerCombine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryListViewControllerCombine.swift; sourceTree = ""; }; + 3A4507682468B37E00FFB3A2 /* UserViewControllerCombine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserViewControllerCombine.swift; sourceTree = ""; }; + 3A605CC4246665CB00004033 /* GithubBrowserRx.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GithubBrowserRx.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 3A605CE1246665E400004033 /* GithubBrowserCombine.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GithubBrowserCombine.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 3A605CE32466782300004033 /* Info-Combine.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Info-Combine.plist"; sourceTree = ""; }; + 3A605CE42466782300004033 /* Info-Rx.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Info-Rx.plist"; sourceTree = ""; }; + 3A605CE52466786400004033 /* UserViewControllerRx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserViewControllerRx.swift; sourceTree = ""; }; + 3A605CE7246678AC00004033 /* RepositoryListViewControllerRx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryListViewControllerRx.swift; sourceTree = ""; }; + 3A605CE9246678CB00004033 /* RepositoryViewControllerRx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryViewControllerRx.swift; sourceTree = ""; }; + 3A605CEE246689BF00004033 /* SiestaUI+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SiestaUI+Rx.swift"; sourceTree = ""; }; + 4C0CC06B89137A3E2842910B /* Pods_GithubBrowserRx.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GithubBrowserRx.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 543BB0271968B0654B40F7EA /* Pods_GithubBrowser.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GithubBrowser.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 6228ADAEF589FF02F7DBE209 /* Pods-GithubBrowserCombine.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GithubBrowserCombine.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GithubBrowserCombine/Pods-GithubBrowserCombine.debug.xcconfig"; sourceTree = ""; }; + 82355EF7923E93FFDD663264 /* Pods-GithubBrowserCombine.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GithubBrowserCombine.release.xcconfig"; path = "Pods/Target Support Files/Pods-GithubBrowserCombine/Pods-GithubBrowserCombine.release.xcconfig"; sourceTree = ""; }; + 89BED802E740A6AF6589378B /* Pods-GithubBrowser-GithubBrowserRx.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GithubBrowser-GithubBrowserRx.release.xcconfig"; path = "Pods/Target Support Files/Pods-GithubBrowser-GithubBrowserRx/Pods-GithubBrowser-GithubBrowserRx.release.xcconfig"; sourceTree = ""; }; A30D9B88A43E86FECB6FED59 /* Pods-GithubBrowser.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GithubBrowser.release.xcconfig"; path = "Pods/Target Support Files/Pods-GithubBrowser/Pods-GithubBrowser.release.xcconfig"; sourceTree = ""; }; + AB3CA50176A39AE184D4BB0C /* SiestaUI+Combine.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SiestaUI+Combine.swift"; sourceTree = ""; }; + AB3CA54519C18B1994363BC1 /* RepositoryViewControllerCombine.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RepositoryViewControllerCombine.swift; sourceTree = ""; }; + AB3CABF5CF2BAE423E1173EF /* GitHubAPI+Combine.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "GitHubAPI+Combine.swift"; sourceTree = ""; }; + AB3CAEFB1E514B85FC9B5D20 /* GitHubAPI+Rx.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "GitHubAPI+Rx.swift"; sourceTree = ""; }; CC0AB1A69A9C04B0879CFD4C /* Pods-GithubBrowser.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GithubBrowser.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GithubBrowser/Pods-GithubBrowser.debug.xcconfig"; sourceTree = ""; }; + D9B0EAD2563BA1E464107F2B /* Pods-GithubBrowserRx.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GithubBrowserRx.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GithubBrowserRx/Pods-GithubBrowserRx.debug.xcconfig"; sourceTree = ""; }; DA2B55611C7D6F8700EB4D67 /* User.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; DA2B55631C7D76B500EB4D67 /* Repository.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Repository.swift; sourceTree = ""; }; DA2B55671C7ED14700EB4D67 /* SiestaTheme.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SiestaTheme.swift; sourceTree = ""; }; @@ -46,14 +105,31 @@ DAE2F8471B94F10500D2AD96 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; DAE2F8481B94F10500D2AD96 /* RepositoryListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RepositoryListViewController.swift; sourceTree = ""; }; DAE2F8491B94F10500D2AD96 /* UserViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserViewController.swift; sourceTree = ""; }; + F968E16DBBF61C1DDCBBE9CF /* Pods-GithubBrowser-GithubBrowserRx.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GithubBrowser-GithubBrowserRx.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GithubBrowser-GithubBrowserRx/Pods-GithubBrowser-GithubBrowserRx.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 3A605CBA246665CB00004033 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E3B5ACFD5B206C5C69576246 /* Pods_GithubBrowserRx.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3A605CD7246665E400004033 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 3F4223768CB3A003CE3DD76C /* Pods_GithubBrowserCombine.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DA7462361B4C768B00406D67 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 713AB3CBE73243473E91C834 /* Pods_GithubBrowser.framework in Frameworks */, + 0FE8BF22249106E165566A5B /* Pods_GithubBrowser.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -63,7 +139,9 @@ 8AED95D5D78D84DA71FAF1F6 /* Frameworks */ = { isa = PBXGroup; children = ( - 18CDC2D94A6B11796E5F8136 /* Pods_GithubBrowser.framework */, + 0AE2E58566ED4EA14159C996 /* Pods_GithubBrowserCombine.framework */, + 543BB0271968B0654B40F7EA /* Pods_GithubBrowser.framework */, + 4C0CC06B89137A3E2842910B /* Pods_GithubBrowserRx.framework */, ); name = Frameworks; sourceTree = ""; @@ -73,6 +151,12 @@ children = ( CC0AB1A69A9C04B0879CFD4C /* Pods-GithubBrowser.debug.xcconfig */, A30D9B88A43E86FECB6FED59 /* Pods-GithubBrowser.release.xcconfig */, + 6228ADAEF589FF02F7DBE209 /* Pods-GithubBrowserCombine.debug.xcconfig */, + 82355EF7923E93FFDD663264 /* Pods-GithubBrowserCombine.release.xcconfig */, + D9B0EAD2563BA1E464107F2B /* Pods-GithubBrowserRx.debug.xcconfig */, + 2EC46971D8CE872A81E0C927 /* Pods-GithubBrowserRx.release.xcconfig */, + F968E16DBBF61C1DDCBBE9CF /* Pods-GithubBrowser-GithubBrowserRx.debug.xcconfig */, + 89BED802E740A6AF6589378B /* Pods-GithubBrowser-GithubBrowserRx.release.xcconfig */, ); name = Pods; sourceTree = ""; @@ -94,6 +178,8 @@ DA7462451B4C768B00406D67 /* LaunchScreen.storyboard */, DA7462431B4C768B00406D67 /* Assets.xcassets */, DA7462481B4C768B00406D67 /* Info.plist */, + 3A605CE32466782300004033 /* Info-Combine.plist */, + 3A605CE42466782300004033 /* Info-Rx.plist */, ); name = Resources; sourceTree = ""; @@ -112,6 +198,8 @@ isa = PBXGroup; children = ( DA7462391B4C768B00406D67 /* GithubBrowser.app */, + 3A605CC4246665CB00004033 /* GithubBrowserRx.app */, + 3A605CE1246665E400004033 /* GithubBrowserCombine.app */, ); name = Products; sourceTree = ""; @@ -132,6 +220,7 @@ isa = PBXGroup; children = ( DAE27A7F1D3F1EF400D757F9 /* Optional+GithubBrowser.swift */, + 3A0058362447B8DD00FC421F /* Siesta+RxSwift.swift */, ); path = Util; sourceTree = ""; @@ -140,6 +229,8 @@ isa = PBXGroup; children = ( DAE2F8441B94F10500D2AD96 /* GitHubAPI.swift */, + AB3CAEFB1E514B85FC9B5D20 /* GitHubAPI+Rx.swift */, + AB3CABF5CF2BAE423E1173EF /* GitHubAPI+Combine.swift */, ); path = API; sourceTree = ""; @@ -148,12 +239,20 @@ isa = PBXGroup; children = ( DAE2F8491B94F10500D2AD96 /* UserViewController.swift */, + 3A4507682468B37E00FFB3A2 /* UserViewControllerCombine.swift */, + 3A605CE52466786400004033 /* UserViewControllerRx.swift */, DAE2F8481B94F10500D2AD96 /* RepositoryListViewController.swift */, + 3A4507662468AB7700FFB3A2 /* RepositoryListViewControllerCombine.swift */, + 3A605CE7246678AC00004033 /* RepositoryListViewControllerRx.swift */, DAAC4E451D3AAD1B00FB3CE2 /* RepositoryViewController.swift */, + AB3CA54519C18B1994363BC1 /* RepositoryViewControllerCombine.swift */, + 3A605CE9246678CB00004033 /* RepositoryViewControllerRx.swift */, DA2B55691C7EDE7B00EB4D67 /* LoginViewController.swift */, DAE2F8471B94F10500D2AD96 /* AppDelegate.swift */, DA2B55671C7ED14700EB4D67 /* SiestaTheme.swift */, DA6752561F6B8FCE009BE918 /* CommentaryViewController.swift */, + AB3CA50176A39AE184D4BB0C /* SiestaUI+Combine.swift */, + 3A605CEE246689BF00004033 /* SiestaUI+Rx.swift */, ); path = UI; sourceTree = ""; @@ -161,6 +260,44 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 3A605CA9246665CB00004033 /* GithubBrowserRx */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3A605CC1246665CB00004033 /* Build configuration list for PBXNativeTarget "GithubBrowserRx" */; + buildPhases = ( + 3A605CAA246665CB00004033 /* [CP] Check Pods Manifest.lock */, + 3A605CAB246665CB00004033 /* Sources */, + 3A605CBA246665CB00004033 /* Frameworks */, + 3A605CBC246665CB00004033 /* Resources */, + 3A605CC0246665CB00004033 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = GithubBrowserRx; + productName = GithubBrowser; + productReference = 3A605CC4246665CB00004033 /* GithubBrowserRx.app */; + productType = "com.apple.product-type.application"; + }; + 3A605CC6246665E400004033 /* GithubBrowserCombine */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3A605CDE246665E400004033 /* Build configuration list for PBXNativeTarget "GithubBrowserCombine" */; + buildPhases = ( + 3A605CC7246665E400004033 /* [CP] Check Pods Manifest.lock */, + 3A605CC8246665E400004033 /* Sources */, + 3A605CD7246665E400004033 /* Frameworks */, + 3A605CD9246665E400004033 /* Resources */, + 3A605CDD246665E400004033 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = GithubBrowserCombine; + productName = GithubBrowser; + productReference = 3A605CE1246665E400004033 /* GithubBrowserCombine.app */; + productType = "com.apple.product-type.application"; + }; DA7462381B4C768B00406D67 /* GithubBrowser */ = { isa = PBXNativeTarget; buildConfigurationList = DA74624B1B4C768B00406D67 /* Build configuration list for PBXNativeTarget "GithubBrowser" */; @@ -200,6 +337,7 @@ developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( + English, en, Base, ); @@ -209,11 +347,33 @@ projectRoot = ""; targets = ( DA7462381B4C768B00406D67 /* GithubBrowser */, + 3A605CC6246665E400004033 /* GithubBrowserCombine */, + 3A605CA9246665CB00004033 /* GithubBrowserRx */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 3A605CBC246665CB00004033 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3A605CBD246665CB00004033 /* LaunchScreen.storyboard in Resources */, + 3A605CBE246665CB00004033 /* Assets.xcassets in Resources */, + 3A605CBF246665CB00004033 /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3A605CD9246665E400004033 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3A605CDA246665E400004033 /* LaunchScreen.storyboard in Resources */, + 3A605CDB246665E400004033 /* Assets.xcassets in Resources */, + 3A605CDC246665E400004033 /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DA7462371B4C768B00406D67 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -227,6 +387,92 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 3A605CAA246665CB00004033 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-GithubBrowserRx-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3A605CC0246665CB00004033 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-GithubBrowserRx/Pods-GithubBrowserRx-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/Siesta-iOS10.0/Siesta.framework", + "${BUILT_PRODUCTS_DIR}/RxCocoa/RxCocoa.framework", + "${BUILT_PRODUCTS_DIR}/RxOptional/RxOptional.framework", + "${BUILT_PRODUCTS_DIR}/RxRelay/RxRelay.framework", + "${BUILT_PRODUCTS_DIR}/RxSwift/RxSwift.framework", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Siesta.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RxCocoa.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RxOptional.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RxRelay.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RxSwift.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-GithubBrowserRx/Pods-GithubBrowserRx-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 3A605CC7246665E400004033 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-GithubBrowserCombine-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3A605CDD246665E400004033 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-GithubBrowserCombine/Pods-GithubBrowserCombine-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/Siesta-iOS13.0/Siesta.framework", + "${BUILT_PRODUCTS_DIR}/CombineCocoa/CombineCocoa.framework", + "${BUILT_PRODUCTS_DIR}/CombineDataSources/CombineDataSources.framework", + "${BUILT_PRODUCTS_DIR}/CombineExt/CombineExt.framework", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Siesta.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/CombineCocoa.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/CombineDataSources.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/CombineExt.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-GithubBrowserCombine/Pods-GithubBrowserCombine-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; 589B08AB8210E6BCC8924F81 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -252,7 +498,7 @@ ); inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-GithubBrowser/Pods-GithubBrowser-frameworks.sh", - "${BUILT_PRODUCTS_DIR}/Siesta/Siesta.framework", + "${BUILT_PRODUCTS_DIR}/Siesta-iOS10.0/Siesta.framework", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( @@ -266,6 +512,49 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 3A605CAB246665CB00004033 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3A605CAC246665CB00004033 /* SearchResults.swift in Sources */, + 3A605CAD246665CB00004033 /* Repository.swift in Sources */, + 3A605CAF246665CB00004033 /* SiestaTheme.swift in Sources */, + 3A605CE62466787900004033 /* UserViewControllerRx.swift in Sources */, + 3A605CB2246665CB00004033 /* LoginViewController.swift in Sources */, + 3A605CB3246665CB00004033 /* User.swift in Sources */, + 3A605CB4246665CB00004033 /* AppDelegate.swift in Sources */, + 3A605CB5246665CB00004033 /* GitHubAPI.swift in Sources */, + 3A605CB6246665CB00004033 /* Siesta+RxSwift.swift in Sources */, + 3A605CB7246665CB00004033 /* Optional+GithubBrowser.swift in Sources */, + 3A605CEA246678DA00004033 /* RepositoryViewControllerRx.swift in Sources */, + 3A605CF024669C9E00004033 /* SiestaUI+Rx.swift in Sources */, + 3A605CB8246665CB00004033 /* CommentaryViewController.swift in Sources */, + 3A605CE8246678BA00004033 /* RepositoryListViewControllerRx.swift in Sources */, + AB3CA92CA71718DC4185DB62 /* GitHubAPI+Rx.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3A605CC8246665E400004033 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3A605CED246688AB00004033 /* RepositoryViewControllerCombine.swift in Sources */, + 3A605CC9246665E400004033 /* SearchResults.swift in Sources */, + 3A605CEF246689CB00004033 /* SiestaUI+Combine.swift in Sources */, + 3A605CCA246665E400004033 /* Repository.swift in Sources */, + 3A4507672468AB8900FFB3A2 /* RepositoryListViewControllerCombine.swift in Sources */, + 3A605CCC246665E400004033 /* SiestaTheme.swift in Sources */, + 3A4507692468B38D00FFB3A2 /* UserViewControllerCombine.swift in Sources */, + 3A605CCF246665E400004033 /* LoginViewController.swift in Sources */, + 3A605CD0246665E400004033 /* User.swift in Sources */, + 3A605CD1246665E400004033 /* AppDelegate.swift in Sources */, + 3A605CD2246665E400004033 /* GitHubAPI.swift in Sources */, + 3A605CD4246665E400004033 /* Optional+GithubBrowser.swift in Sources */, + 3A605CD5246665E400004033 /* CommentaryViewController.swift in Sources */, + AB3CA091A147D8B56A94583B /* GitHubAPI+Combine.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DA7462351B4C768B00406D67 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -307,6 +596,60 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 3A605CC2246665CB00004033 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D9B0EAD2563BA1E464107F2B /* Pods-GithubBrowserRx.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + INFOPLIST_FILE = "Source/Info-Rx.plist"; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.bustoutsolutions.GithubBrowser; + PRODUCT_NAME = GithubBrowserRx; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 3A605CC3246665CB00004033 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 2EC46971D8CE872A81E0C927 /* Pods-GithubBrowserRx.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + INFOPLIST_FILE = "Source/Info-Rx.plist"; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.bustoutsolutions.GithubBrowser; + PRODUCT_NAME = GithubBrowserRx; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 3A605CDF246665E400004033 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6228ADAEF589FF02F7DBE209 /* Pods-GithubBrowserCombine.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + INFOPLIST_FILE = "Source/Info-Combine.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.bustoutsolutions.GithubBrowser; + PRODUCT_NAME = GithubBrowserCombine; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 3A605CE0246665E400004033 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 82355EF7923E93FFDD663264 /* Pods-GithubBrowserCombine.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + INFOPLIST_FILE = "Source/Info-Combine.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.bustoutsolutions.GithubBrowser; + PRODUCT_NAME = GithubBrowserCombine; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; DA7462491B4C768B00406D67 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -444,6 +787,24 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 3A605CC1246665CB00004033 /* Build configuration list for PBXNativeTarget "GithubBrowserRx" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3A605CC2246665CB00004033 /* Debug */, + 3A605CC3246665CB00004033 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 3A605CDE246665E400004033 /* Build configuration list for PBXNativeTarget "GithubBrowserCombine" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3A605CDF246665E400004033 /* Debug */, + 3A605CE0246665E400004033 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; DA7462341B4C768B00406D67 /* Build configuration list for PBXProject "GithubBrowser" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Examples/GithubBrowser/Podfile b/Examples/GithubBrowser/Podfile index e82ee473..a85bf528 100644 --- a/Examples/GithubBrowser/Podfile +++ b/Examples/GithubBrowser/Podfile @@ -5,3 +5,20 @@ target 'GithubBrowser' do pod 'Siesta/UI', path: '../..' end + +target "GithubBrowserCombine" do + use_frameworks! + platform :ios, '13.0' + pod 'Siesta/UI', path: '../..' + pod 'CombineCocoa' + pod 'CombineExt' + pod 'CombineDataSources' +end + +target "GithubBrowserRx" do + use_frameworks! + pod 'Siesta/UI', path: '../..' + pod 'RxSwift' + pod 'RxCocoa' + pod 'RxOptional' +end diff --git a/Examples/GithubBrowser/Podfile.lock b/Examples/GithubBrowser/Podfile.lock index 4fff65f6..2c0bb4da 100644 --- a/Examples/GithubBrowser/Podfile.lock +++ b/Examples/GithubBrowser/Podfile.lock @@ -1,18 +1,53 @@ PODS: + - CombineCocoa (0.2.0) + - CombineDataSources (0.2.5) + - CombineExt (1.2.0) + - RxCocoa (5.1.1): + - RxRelay (~> 5) + - RxSwift (~> 5) + - RxOptional (4.1.0): + - RxCocoa (~> 5) + - RxSwift (~> 5) + - RxRelay (5.1.1): + - RxSwift (~> 5) + - RxSwift (5.1.1) - Siesta/Core (1.5.2) - Siesta/UI (1.5.2): - Siesta/Core DEPENDENCIES: + - CombineCocoa + - CombineDataSources + - CombineExt + - RxCocoa + - RxOptional + - RxSwift - Siesta/UI (from `../..`) +SPEC REPOS: + trunk: + - CombineCocoa + - CombineDataSources + - CombineExt + - RxCocoa + - RxOptional + - RxRelay + - RxSwift + EXTERNAL SOURCES: Siesta: :path: "../.." SPEC CHECKSUMS: + CombineCocoa: 92b2579a9e4a364f8ba94be7fc68335bd3d73d92 + CombineDataSources: dcbc3b06a038ffdf620b3b4533d3ecaf76ca6a2e + CombineExt: f6ad3c8e9a0c85f06e5c66ddd85eb32875d906d2 + RxCocoa: 32065309a38d29b5b0db858819b5bf9ef038b601 + RxOptional: b1fcd60856807a564c0215c2184b8d33e7826dc2 + RxRelay: d77f7d771495f43c556cbc43eebd1bb54d01e8e9 + RxSwift: 81470a2074fa8780320ea5fe4102807cb7118178 Siesta: d1e1966af43ffca170f658ad6d987228a5b40873 -PODFILE CHECKSUM: 974001388daa9ecbfa915ea0bc4093a33242099c +PODFILE CHECKSUM: 71f3353904f6db031a60fe5063ce84284832882f COCOAPODS: 1.10.0 diff --git a/Examples/GithubBrowser/README.md b/Examples/GithubBrowser/README.md index e88eb5f2..f4ebbe57 100644 --- a/Examples/GithubBrowser/README.md +++ b/Examples/GithubBrowser/README.md @@ -51,3 +51,9 @@ Siesta solves all these problems transparently, with minimal code. If you hit the GitHub API’s rate limit while running the demo, press the “Log In” button. If you’re experimenting with the demo a lot, you can set `GITHUB_USER` and `GITHUB_PASS` environment variables in the “Run” build scheme to make the app automatically log you in on launch. You can use a [personal access token](https://github.com/settings/tokens) in place of your password. You don’t need to grant any permissions to your token for this app; just the public access will do. + +## Combine and RxSwift + +The main view controllers have *Combine and *Rx variants to demonstrate the use of Siesta's reactive extensions, and the project has accompanying Combine and Rx targets. + +You might find it instructive to compare the reactive controllers with their non-reactive versions, particularly if you're an existing Siesta user thinking of adopting the reactive extensions. diff --git a/Examples/GithubBrowser/Source/API/GitHubAPI+Combine.swift b/Examples/GithubBrowser/Source/API/GitHubAPI+Combine.swift new file mode 100644 index 00000000..8611d53b --- /dev/null +++ b/Examples/GithubBrowser/Source/API/GitHubAPI+Combine.swift @@ -0,0 +1,15 @@ +import Combine + +extension _GitHubAPI { + + /* + An unintrusive way to publish isAuthenticated - _GitHubAPI is shared with the other non-Combine examples. + In reality if we were using a stored property we'd annotate with @Published. + */ + var isAuthenticatedPublisher: AnyPublisher { + publisher(for: \.basicAuthHeader) + .map { $0 != nil } + .eraseToAnyPublisher() + } + +} \ No newline at end of file diff --git a/Examples/GithubBrowser/Source/API/GitHubAPI+Rx.swift b/Examples/GithubBrowser/Source/API/GitHubAPI+Rx.swift new file mode 100644 index 00000000..0a5138dc --- /dev/null +++ b/Examples/GithubBrowser/Source/API/GitHubAPI+Rx.swift @@ -0,0 +1,14 @@ +import RxSwift +import RxCocoa + +extension _GitHubAPI { + + /* + An unintrusive way to publish isAuthenticated - _GitHubAPI is shared with the other non-rx examples. + In reality we'd do this differently. + */ + var isAuthenticatedObservable: Observable { + rx.observe(String?.self, "basicAuthHeader").map { $0.map { $0 != nil} ?? false }.distinctUntilChanged() + } + +} \ No newline at end of file diff --git a/Examples/GithubBrowser/Source/API/GithubAPI.swift b/Examples/GithubBrowser/Source/API/GithubAPI.swift index 44ae643b..9ffa2d4d 100644 --- a/Examples/GithubBrowser/Source/API/GithubAPI.swift +++ b/Examples/GithubBrowser/Source/API/GithubAPI.swift @@ -5,7 +5,7 @@ import Siesta let GitHubAPI = _GitHubAPI() -class _GitHubAPI { +class _GitHubAPI: NSObject { /* NSObject just for reactive extensions */ // MARK: - Configuration @@ -13,7 +13,9 @@ class _GitHubAPI { baseURL: "https://api.github.com", standardTransformers: [.text, .image]) // No .json because we use Swift 4 JSONDecoder instead of older JSONSerialization - fileprivate init() { + fileprivate override init() { + super.init() + #if DEBUG // Bare-bones logging of which network calls Siesta makes: SiestaLog.Category.enabled = [.network] @@ -128,7 +130,8 @@ class _GitHubAPI { return basicAuthHeader != nil } - private var basicAuthHeader: String? { + /* "@objc dynamic" just for reactive extensions */ + @objc dynamic var basicAuthHeader: String? { didSet { // These two calls are almost always necessary when you have changing auth for your API: @@ -181,6 +184,16 @@ class _GitHubAPI { named: repositoryModel.name) } + func contributors(_ repositoryModel: Repository) -> Resource? { + return repository(repositoryModel) + .optionalRelative(repositoryModel.contributorsURL) + } + + func languages(_ repositoryModel: Repository) -> Resource? { + return repository(repositoryModel) + .optionalRelative(repositoryModel.languagesURL) + } + func currentUserStarred(_ repositoryModel: Repository) -> Resource { return service .resource("/user/starred") diff --git a/Examples/GithubBrowser/Source/Info-Combine.plist b/Examples/GithubBrowser/Source/Info-Combine.plist new file mode 100644 index 00000000..6905cc67 --- /dev/null +++ b/Examples/GithubBrowser/Source/Info-Combine.plist @@ -0,0 +1,40 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Examples/GithubBrowser/Source/Info-Rx.plist b/Examples/GithubBrowser/Source/Info-Rx.plist new file mode 100644 index 00000000..6905cc67 --- /dev/null +++ b/Examples/GithubBrowser/Source/Info-Rx.plist @@ -0,0 +1,40 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Examples/GithubBrowser/Source/UI/RepositoryListViewControllerCombine.swift b/Examples/GithubBrowser/Source/UI/RepositoryListViewControllerCombine.swift new file mode 100644 index 00000000..7b4dc69c --- /dev/null +++ b/Examples/GithubBrowser/Source/UI/RepositoryListViewControllerCombine.swift @@ -0,0 +1,114 @@ +import UIKit +import Siesta +import Combine +import CombineExt +import CombineDataSources + +class RepositoryListViewController: UITableViewController { + + private var statusOverlay = ResourceStatusOverlay() + private var subs = [AnyCancellable]() + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = SiestaTheme.darkColor + statusOverlay.embed(in: self) + + // Standard table view stuff we don't want here (we're sharing the storyboard with the other examples) + tableView.dataSource = nil + tableView.delegate = nil + } + + override func viewDidLayoutSubviews() { + statusOverlay.positionToCoverParent() + } + + /** + The input to this class - the repositories to display. + + Whether it's better to pass in a resource or a publisher here is much the same argument as whether to define + APIs in terms of resources or publishers. See UserViewController for a discussion about that. + */ + func configure(repositories: AnyPublisher) { + /* + Oh hey, in the next small handful of lines, let's: + - make an api call if necessary to fetch the latest repo list we're to show + - display progress and errors while doing that, and + - populate the table. + */ + repositories + // In this project we have an extension to tell Siesta's status overlay to be + // interested in the latest Resource output by a Resource publisher. The following + // line gives us a progress spinner, error display and retry functionality. + .watchedBy(statusOverlay: statusOverlay) + + // Transform the sequence of Resources into a sequence of their content: [Repository]. + .flatMapLatest { resource -> AnyPublisher<[Repository], Never> in + resource?.contentPublisher() ?? Just([]).eraseToAnyPublisher() + } + + // This is everything we need to populate the table with the list of repos, + // courtesy of CombineDataSources. + .bind(subscriber: tableView.rowsSubscriber(cellIdentifier: "repo", cellType: RepositoryTableViewCell.self, cellConfig: { cell, indexPath, repo in + cell.repository = repo + })) + .store(in: &subs) + } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "repoDetail" { + if let repositoryVC = segue.destination as? RepositoryViewController, + let cell = sender as? RepositoryTableViewCell { + + if let repo = cell.repository { + repositoryVC.repositoryResource = Just(GitHubAPI.repository(repo)).eraseToAnyPublisher() + } + } + } + } +} + +class RepositoryTableViewCell: UITableViewCell { + @IBOutlet weak var icon: RemoteImageView! + @IBOutlet weak var userLabel: UILabel! + @IBOutlet weak var repoLabel: UILabel! + @IBOutlet weak var starCountLabel: UILabel! + + var repository: Repository? { + didSet { + userLabel.text = repository?.owner.login + repoLabel.text = repository?.name + starCountLabel.text = repository?.starCount?.description + + // Note how powerful this next line is: + // + // • RemoteImageView calls loadIfNeeded() when we set imageURL, so this automatically triggers a network + // request for the image. However... + // + // • loadIfNeeded() won’t make redundant requests, so no need to worry about whether this avatar is used in + // other table cells, or whether we’ve already requested it! Many cells sharing one image spawn one + // request. One response updates _all_ the cells that image. + // + // • If imageURL was already set, RemoteImageView calls cancelLoadIfUnobserved() on the old image resource. + // This means that if the user is scrolling fast and table cells are being reused: + // + // - a request in progress gets cancelled + // - unless other cells are also waiting on the same image, in which case the request continues, and + // - an image that we’ve already fetch stay available in memory, fully parsed & ready for instant resuse. + // + // Finally, note that all of this nice behavior is not special magic that’s specific to images. These are + // basic Siesta behaviors you can use for resources of any kind. Look at the RemoteImageView source code + // and study how it uses the core Siesta API. + + icon.imageURL = repository?.owner.avatarURL + } + } +} + +// Required by CombineDataSources for binding the repositories to the table +extension Repository: Hashable { + public func hash(into hasher: inout Hasher) { url.hash(into: &hasher) } + + public static func ==(lhs: Repository, rhs: Repository) -> Bool { lhs.url == rhs.url } +} \ No newline at end of file diff --git a/Examples/GithubBrowser/Source/UI/RepositoryListViewControllerRx.swift b/Examples/GithubBrowser/Source/UI/RepositoryListViewControllerRx.swift new file mode 100644 index 00000000..1fa3930f --- /dev/null +++ b/Examples/GithubBrowser/Source/UI/RepositoryListViewControllerRx.swift @@ -0,0 +1,105 @@ +import UIKit +import Siesta +import RxSwift + +class RepositoryListViewController: UITableViewController { + + private var statusOverlay = ResourceStatusOverlay() + private var disposeBag = DisposeBag() + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = SiestaTheme.darkColor + statusOverlay.embed(in: self) + + // Standard table view stuff we don't want here (we're sharing the storyboard with the other examples) + tableView.dataSource = nil + tableView.delegate = nil + } + + override func viewDidLayoutSubviews() { + statusOverlay.positionToCoverParent() + } + + /** + The input to this class - the repositories to display. + + Whether it's better to pass in a resource or an observable here is much the same argument as whether to define + APIs in terms of resources or observables. See UserViewController for a discussion about that. + */ +func configure(repositories: Observable) { + /* + Oh hey, in the next small handful of lines, let's: + - make an api call if necessary to fetch the latest repo list we're to show + - display progress and errors while doing that, and + - populate the table. + */ + repositories + // In this project we have an extension to tell Siesta's status overlay to be + // interested in the latest Resource output by a Resource sequence. The following + // line gives us a progress spinner, error display and retry functionality. + .watchedBy(statusOverlay: statusOverlay) + + // Transform the sequence of Resources into a sequence of their content: [Repository]. + .flatMapLatest { resource -> Observable<[Repository]> in + resource?.rx.content() ?? .just([]) + } + + // This is everything we need to populate the table with the list of repos. + .bind(to: tableView.rx.items(cellIdentifier: "repo", cellType: RepositoryTableViewCell.self)) { + (row, repo, cell) in + cell.repository = repo + } + .disposed(by: disposeBag) +} + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "repoDetail" { + if let repositoryVC = segue.destination as? RepositoryViewController, + let cell = sender as? RepositoryTableViewCell { + + if let repo = cell.repository { + repositoryVC.repositoryResource = .just(GitHubAPI.repository(repo)) + } + } + } + } +} + +class RepositoryTableViewCell: UITableViewCell { + @IBOutlet weak var icon: RemoteImageView! + @IBOutlet weak var userLabel: UILabel! + @IBOutlet weak var repoLabel: UILabel! + @IBOutlet weak var starCountLabel: UILabel! + + var repository: Repository? { + didSet { + userLabel.text = repository?.owner.login + repoLabel.text = repository?.name + starCountLabel.text = repository?.starCount?.description + + // Note how powerful this next line is: + // + // • RemoteImageView calls loadIfNeeded() when we set imageURL, so this automatically triggers a network + // request for the image. However... + // + // • loadIfNeeded() won’t make redundant requests, so no need to worry about whether this avatar is used in + // other table cells, or whether we’ve already requested it! Many cells sharing one image spawn one + // request. One response updates _all_ the cells that image. + // + // • If imageURL was already set, RemoteImageView calls cancelLoadIfUnobserved() on the old image resource. + // This means that if the user is scrolling fast and table cells are being reused: + // + // - a request in progress gets cancelled + // - unless other cells are also waiting on the same image, in which case the request continues, and + // - an image that we’ve already fetch stay available in memory, fully parsed & ready for instant resuse. + // + // Finally, note that all of this nice behavior is not special magic that’s specific to images. These are + // basic Siesta behaviors you can use for resources of any kind. Look at the RemoteImageView source code + // and study how it uses the core Siesta API. + + icon.imageURL = repository?.owner.avatarURL + } + } +} diff --git a/Examples/GithubBrowser/Source/UI/RepositoryViewControllerCombine.swift b/Examples/GithubBrowser/Source/UI/RepositoryViewControllerCombine.swift new file mode 100644 index 00000000..b7d689ae --- /dev/null +++ b/Examples/GithubBrowser/Source/UI/RepositoryViewControllerCombine.swift @@ -0,0 +1,220 @@ +// +// RepositoryViewController.swift +// GithubBrowser +// +// Created by Paul on 2016/7/16. +// Copyright © 2016 Bust Out Solutions. All rights reserved. +// + +import UIKit +import Siesta +import Combine +import CombineCocoa +import CombineExt + +class RepositoryViewController: UIViewController { + + // MARK: UI Elements + + @IBOutlet weak var starIcon: UILabel! + @IBOutlet weak var starButton: UIButton! + @IBOutlet weak var starCountLabel: UILabel! + @IBOutlet weak var descriptionLabel: UILabel! + @IBOutlet weak var homepageButton: UIButton! + @IBOutlet weak var languagesLabel: UILabel! + @IBOutlet weak var contributorsLabel: UILabel! + + /** + The input to this class - the repository to display. We allow it to change, although as it happens it's only + called with a single value. + Whether it's better to pass in a resource or a publisher here is much the same argument as whether to define + APIs in terms of resources or publishers. See UserViewController for a discussion about that. + */ + var repositoryResource: AnyPublisher? + + private var statusOverlay = ResourceStatusOverlay() + private var subs = [AnyCancellable]() + + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = SiestaTheme.darkColor + statusOverlay.embed(in: self) + statusOverlay.displayPriority = [.anyData, .loading, .error] // Prioritize partial data over loading indicator + + configure() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + CommentaryViewController.publishCommentary( + """ + Go back and then go forward to this screen again. Note how everything reappears instantly for a + previously viewed repository, even though the data spans multiple API requests. This behavior emerges + naturally from Siesta’s approach. + + The app says “load if needed” for all the data on this screen every time you visit it, but this is not + expensive. Why not? Siesta (1) has a configurable notion of staleness to prevent excessive network + requests, and (2) it transparently handles ETags with no additional code or configuration. + + Siesta fights the dreaded Massive View Controller by allowing separation of concerns without + requiring excessive layers of abstraction. For example, on this screen… + + …when you star/unstar the repository, the spin-while-requesting animation is completely decoupled + from the logic that asks for an updated star count. Though tightly grouped in the UI, they live in entirely + different sections of code. Study that code, and you’ll understand the power of Siesta. + """) + } + + private func configure() { + guard let repositoryResource = repositoryResource else { + // Yikes - didn't expect that + fatalError("where's my repositoryResource?") + } + + + // -- Resources -- + + let repository: AnyPublisher = repositoryResource + .watchedBy(statusOverlay: statusOverlay) + .flatMapLatest { $0.optionalContentPublisher() } + .eraseToAnyPublisher() + + let contributors: AnyPublisher<[User]?, Never> = repository.map { + $0.flatMap(GitHubAPI.contributors) + } + .watchedBy(statusOverlay: statusOverlay) + .flatMapLatest { $0?.optionalContentPublisher().eraseToAnyPublisher() ?? Just(nil).eraseToAnyPublisher() } + .eraseToAnyPublisher() + + let languages: AnyPublisher<[String: Int]?, Never> = repository.map { + $0.flatMap(GitHubAPI.languages) + } + .watchedBy(statusOverlay: statusOverlay) + .flatMapLatest { $0?.optionalContentPublisher().eraseToAnyPublisher() ?? Just(nil).eraseToAnyPublisher() } + .eraseToAnyPublisher() + + let isStarred: AnyPublisher = repository.map { + $0.flatMap(GitHubAPI.currentUserStarred) + } + .watchedBy(statusOverlay: statusOverlay) + .flatMapLatest { $0?.optionalContentPublisher().map { $0 ?? false }.eraseToAnyPublisher() ?? Just(false).eraseToAnyPublisher() } + .eraseToAnyPublisher() + + + // -- Display -- + + repository.sink { [unowned self] repository in + self.navigationItem.title = repository?.name + self.descriptionLabel?.text = repository?.description + self.homepageButton?.setTitle(repository?.homepage, for: .normal) + } + .store(in: &subs) + + contributors + .map { + $0?.map { $0.login }.joined(separator: "\n") + ?? "-" + } + .assign(to: \.text, on: contributorsLabel) + .store(in: &subs) + + languages + .map { $0?.keys.joined(separator: " • ") ?? "-" } + .assign(to: \.text, on: languagesLabel) + .store(in: &subs) + + isStarred.combineLatest(repository) + .sink { [unowned self] isStarred, repository in + self.starCountLabel?.text = repository?.starCount?.description + self.starIcon?.text = isStarred ? "★" : "☆" + self.starButton?.setTitle(isStarred ? "Unstar" : "Star", for: .normal) + self.starButton?.isEnabled = (repository != nil) + } + .store(in: &subs) + + + // -- Actions -- + + homepageButton?.tapPublisher + .withLatestFrom(repository) + .sink { + if let homepage = $0?.homepage, + let homepageURL = URL(string: homepage) { + UIApplication.shared.open(homepageURL) + } + } + .store(in: &subs) + + starButton?.tapPublisher + .withLatestFrom(isStarred.combineLatest(repository)) + .flatMap { [unowned self] args -> AnyPublisher in + let isStarred = args.0 + guard let repository = args.1 else { return Empty().eraseToAnyPublisher() } + + // Two things of note here: + // + // 1. Siesta guarantees onCompletion will be called exactly once, no matter what the error condition, so + // it’s safe to rely on it to stop the animation and reenable the button. No error recovery gymnastics! + // + // 2. Who changes the button title between “Star” and “Unstar?” Who updates the star count? + // + // Answer: the setStarred(…) method itself updates both the starred resource and the repository resource, + // if the call succeeds. And why don’t we have to take any special action to deal with that here in + // toggleStar(…)? Because RepositoryViewController is already observing those resources, and will thus + // pick up the changes made by setStarred(…) without any futher intervention. + // + // This is exactly what chainable callbacks are for: we add our onCompletion callback, somebody else adds + // their onSuccess callback, and neither knows about the other. Decoupling is lovely! And because Siesta + // parses responses only once, no matter how many callback there are, the performance cost is negligible. + + self.startStarRequestAnimation() + + // Creating a Request and using its publisher methods is at times a bad idea - you might be better off + // with Resource.*requestPublisher instead. Read the method comments to find out why. + // + // However, it works here, and we're sticking with the version of the api that gives us a Request, rather + // than modifying it to be reactive. + // + // In fact in this case we could just as easily do away with the flatMap and use an onCompletion block on + // GitHubAPI.setStarred, but this is a Combine demo! + return GitHubAPI.setStarred(!isStarred, repository: repository).publisher() + .catch { _ in Just(()) }.eraseToAnyPublisher() + } + .sink { [unowned self] _ in + self.stopStarRequestAnimation() + } + .store(in: &subs) + } + + private func startStarRequestAnimation() { + starButton?.isEnabled = false + let rotation = CABasicAnimation(keyPath: "transform.rotation.z") + rotation.fromValue = 0 + rotation.toValue = 2 * Double.pi + rotation.duration = 1.6 + rotation.repeatCount = Float.infinity + starIcon?.layer.add(rotation, forKey: "loadingIndicator") + } + + @objc private func stopStarRequestAnimation() { + starButton?.isEnabled = true + let stopRotation = CASpringAnimation(keyPath: "transform.rotation.z") + stopRotation.toValue = -Double.pi * 2 / 5 + stopRotation.damping = 6 + stopRotation.duration = stopRotation.settlingDuration + starIcon?.layer.add(stopRotation, forKey: "loadingIndicator") + } + + + // Dummy actions just here for compatibility with the storyboard, which we share with the other implementations + // of this controller. + + @IBAction func toggleStar(_ sender: Any) { + } + + @IBAction func openHomepage(_ sender: Any) { + } +} diff --git a/Examples/GithubBrowser/Source/UI/RepositoryViewControllerRx.swift b/Examples/GithubBrowser/Source/UI/RepositoryViewControllerRx.swift new file mode 100644 index 00000000..1a44e9f7 --- /dev/null +++ b/Examples/GithubBrowser/Source/UI/RepositoryViewControllerRx.swift @@ -0,0 +1,215 @@ +// +// RepositoryViewController.swift +// GithubBrowser +// +// Created by Paul on 2016/7/16. +// Copyright © 2016 Bust Out Solutions. All rights reserved. +// + +import UIKit +import Siesta +import RxSwift + +class RepositoryViewController: UIViewController { + + // MARK: UI Elements + + @IBOutlet weak var starIcon: UILabel! + @IBOutlet weak var starButton: UIButton! + @IBOutlet weak var starCountLabel: UILabel! + @IBOutlet weak var descriptionLabel: UILabel! + @IBOutlet weak var homepageButton: UIButton! + @IBOutlet weak var languagesLabel: UILabel! + @IBOutlet weak var contributorsLabel: UILabel! + + /** + The input to this class - the repository to display. We allow it to change, although as it happens it's only + called with a single value. + Whether it's better to pass in a resource or an observable here is much the same argument as whether to define + APIs in terms of resources or observables. See UserViewController for a discussion about that. + */ + var repositoryResource: Observable? + + private var statusOverlay = ResourceStatusOverlay() + private var disposeBag = DisposeBag() + + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = SiestaTheme.darkColor + statusOverlay.embed(in: self) + statusOverlay.displayPriority = [.anyData, .loading, .error] // Prioritize partial data over loading indicator + + configure() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + CommentaryViewController.publishCommentary( + """ + Go back and then go forward to this screen again. Note how everything reappears instantly for a + previously viewed repository, even though the data spans multiple API requests. This behavior emerges + naturally from Siesta’s approach. + + The app says “load if needed” for all the data on this screen every time you visit it, but this is not + expensive. Why not? Siesta (1) has a configurable notion of staleness to prevent excessive network + requests, and (2) it transparently handles ETags with no additional code or configuration. + + Siesta fights the dreaded Massive View Controller by allowing separation of concerns without + requiring excessive layers of abstraction. For example, on this screen… + + …when you star/unstar the repository, the spin-while-requesting animation is completely decoupled + from the logic that asks for an updated star count. Though tightly grouped in the UI, they live in entirely + different sections of code. Study that code, and you’ll understand the power of Siesta. + """) + } + + private func configure() { + guard let repositoryResource = repositoryResource else { + // Yikes - didn't expect that + fatalError("where's my repositoryResource?") + } + + + // -- Resources -- + + let repository: Observable = repositoryResource + .watchedBy(statusOverlay: statusOverlay) + .flatMapLatest { $0.rx.optionalContent() } + + let contributors: Observable<[User]?> = repository.map { + $0.flatMap(GitHubAPI.contributors) + } + .watchedBy(statusOverlay: statusOverlay) + .flatMapLatest { $0?.rx.optionalContent() ?? .just(nil) } + + let languages: Observable<[String: Int]?> = repository.map { + $0.flatMap(GitHubAPI.languages) + } + .watchedBy(statusOverlay: statusOverlay) + .flatMapLatest { $0?.rx.optionalContent() ?? .just(nil) } + + let isStarred: Observable = repository.map { + $0.flatMap(GitHubAPI.currentUserStarred) + } + .watchedBy(statusOverlay: statusOverlay) + .flatMapLatest { $0?.rx.optionalContent().map { $0 ?? false } ?? .just(false) } + + + // -- Display -- + + repository.bind { [unowned self] repository in + self.navigationItem.title = repository?.name + self.descriptionLabel?.text = repository?.description + self.homepageButton?.setTitle(repository?.homepage, for: .normal) + } + .disposed(by: disposeBag) + + contributors + .map { + $0?.map { $0.login }.joined(separator: "\n") + ?? "-" + } + .bind(to: contributorsLabel.rx.text) + .disposed(by: disposeBag) + + languages + .map { $0?.keys.joined(separator: " • ") ?? "-" } + .bind(to: languagesLabel.rx.text) + .disposed(by: disposeBag) + + Observable.combineLatest(isStarred, repository) + .bind { [unowned self] in + let (isStarred, repository) = $0 + + self.starCountLabel?.text = repository?.starCount?.description + self.starIcon?.text = isStarred ? "★" : "☆" + self.starButton?.setTitle(isStarred ? "Unstar" : "Star", for: .normal) + self.starButton?.isEnabled = (repository != nil) + } + .disposed(by: disposeBag) + + + // -- Actions -- + + homepageButton?.rx.tap + .withLatestFrom(repository) + .bind { + if let homepage = $0?.homepage, + let homepageURL = URL(string: homepage) { + UIApplication.shared.open(homepageURL) + } + } + .disposed(by: disposeBag) + + starButton?.rx.tap + .withLatestFrom(Observable.combineLatest(isStarred, repository)) + .flatMap { [unowned self] args -> Observable in + let isStarred = args.0 + guard let repository = args.1 else { return .empty() } + + // Two things of note here: + // + // 1. Siesta guarantees onCompletion will be called exactly once, no matter what the error condition, so + // it’s safe to rely on it to stop the animation and reenable the button. No error recovery gymnastics! + // + // 2. Who changes the button title between “Star” and “Unstar?” Who updates the star count? + // + // Answer: the setStarred(…) method itself updates both the starred resource and the repository resource, + // if the call succeeds. And why don’t we have to take any special action to deal with that here in + // toggleStar(…)? Because RepositoryViewController is already observing those resources, and will thus + // pick up the changes made by setStarred(…) without any futher intervention. + // + // This is exactly what chainable callbacks are for: we add our onCompletion callback, somebody else adds + // their onSuccess callback, and neither knows about the other. Decoupling is lovely! And because Siesta + // parses responses only once, no matter how many callback there are, the performance cost is negligible. + + self.startStarRequestAnimation() + + // Creating a Request and using its rx methods is at times a bad idea - you might be better off + // with Resource.rx.request* instead. Read the method comments to find out why. + // + // However, it works here, and we're sticking with the version of the api that gives us a Request, rather + // than modifying it to be reactive. + // + // In fact in this case we could just as easily do away with the flatMap and use an onCompletion block on + // GitHubAPI.setStarred, but this is an rx demo! + return GitHubAPI.setStarred(!isStarred, repository: repository).rx.observable() + } + .bind { [unowned self] in + self.stopStarRequestAnimation() + } + .disposed(by: disposeBag) + } + + private func startStarRequestAnimation() { + starButton?.isEnabled = false + let rotation = CABasicAnimation(keyPath: "transform.rotation.z") + rotation.fromValue = 0 + rotation.toValue = 2 * Double.pi + rotation.duration = 1.6 + rotation.repeatCount = Float.infinity + starIcon?.layer.add(rotation, forKey: "loadingIndicator") + } + + @objc private func stopStarRequestAnimation() { + starButton?.isEnabled = true + let stopRotation = CASpringAnimation(keyPath: "transform.rotation.z") + stopRotation.toValue = -Double.pi * 2 / 5 + stopRotation.damping = 6 + stopRotation.duration = stopRotation.settlingDuration + starIcon?.layer.add(stopRotation, forKey: "loadingIndicator") + } + + + // Dummy actions just here for compatibility with the storyboard, which we share with the other implementations + // of this controller. + + @IBAction func toggleStar(_ sender: Any) { + } + + @IBAction func openHomepage(_ sender: Any) { + } +} diff --git a/Examples/GithubBrowser/Source/UI/SiestaUI+Combine.swift b/Examples/GithubBrowser/Source/UI/SiestaUI+Combine.swift new file mode 100644 index 00000000..ab6de63e --- /dev/null +++ b/Examples/GithubBrowser/Source/UI/SiestaUI+Combine.swift @@ -0,0 +1,40 @@ +import Siesta +import Combine + +/* +A quick way to get Combine compatibility for ResourceStatusOverlay. A more elaborate alternative would +be to write a version of ResourceStatusOverlay that understands ResourceState publishers. +*/ +extension Publisher where Output == Resource { + + func watchedBy(statusOverlay: ResourceStatusOverlay) -> AnyPublisher { + withPrevious() + .handleEvents(receiveOutput: { + $0.previous?.removeObservers(ownedBy: statusOverlay) + $0.current.addObserver(statusOverlay) + }) + .map { $0.current } + .eraseToAnyPublisher() + } +} + +extension Publisher where Output == Resource? { + + func watchedBy(statusOverlay: ResourceStatusOverlay) -> AnyPublisher { + withPrevious() + .handleEvents(receiveOutput: { + $0.previous??.removeObservers(ownedBy: statusOverlay) + $0.current?.addObserver(statusOverlay) + }) + .map { $0.current } + .eraseToAnyPublisher() + } +} + +fileprivate extension Publisher { + + func withPrevious() -> AnyPublisher<(previous: Output?, current: Output), Failure> { + scan(nil) { (previous: $0?.current, current: $1) }.compactMap { $0 }.eraseToAnyPublisher() + } +} + diff --git a/Examples/GithubBrowser/Source/UI/SiestaUI+Rx.swift b/Examples/GithubBrowser/Source/UI/SiestaUI+Rx.swift new file mode 100644 index 00000000..bd76ae18 --- /dev/null +++ b/Examples/GithubBrowser/Source/UI/SiestaUI+Rx.swift @@ -0,0 +1,38 @@ +import Siesta +import RxSwift + +/* +A quick way to get RxSwift compatibility for ResourceStatusOverlay. A more elaborate alternative would +be to write a version of ResourceStatusOverlay that understands ResourceState observables. +*/ +extension ObservableType where Element == Resource { + + func watchedBy(statusOverlay: ResourceStatusOverlay) -> Observable { + withPrevious() + .do(onNext: { + $0.previous?.removeObservers(ownedBy: statusOverlay) + $0.current.addObserver(statusOverlay) + }) + .map { $0.current } + } +} + +extension ObservableType where Element == Resource? { + + func watchedBy(statusOverlay: ResourceStatusOverlay) -> Observable { + withPrevious() + .do(onNext: { + $0.previous??.removeObservers(ownedBy: statusOverlay) + $0.current?.addObserver(statusOverlay) + }) + .map { $0.current } + } +} + +fileprivate extension ObservableType { + + func withPrevious() -> Observable<(previous: Element?, current: Element)> { + scan(nil) { (previous: $0?.current, current: $1) }.compactMap { $0 } + } +} + diff --git a/Examples/GithubBrowser/Source/UI/UserViewControllerCombine.swift b/Examples/GithubBrowser/Source/UI/UserViewControllerCombine.swift new file mode 100644 index 00000000..fe713114 --- /dev/null +++ b/Examples/GithubBrowser/Source/UI/UserViewControllerCombine.swift @@ -0,0 +1,181 @@ +import UIKit +import Siesta +import Combine + +class UserViewController: UIViewController, UISearchBarDelegate { + + @IBOutlet weak var loginButton: UIButton! + @IBOutlet weak var searchBar: UISearchBar! + @IBOutlet weak var userInfoView: UIView! + @IBOutlet weak var usernameLabel, fullNameLabel: UILabel! + @IBOutlet weak var avatar: RemoteImageView! + + var statusOverlay = ResourceStatusOverlay() + + var repoListVC: RepositoryListViewController? + + private let searchBarText = PassthroughSubject() + private var subs = [AnyCancellable]() + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = SiestaTheme.darkColor + + statusOverlay.embed(in: self) + + searchBar.becomeFirstResponder() + + GitHubAPI.isAuthenticatedPublisher + .map { $0 ? "Log Out" : "Log In" } + .sink { [unowned self] in self.loginButton.setTitle($0, for: .normal) } + .store(in: &subs) + + loginButton.tapPublisher + .withLatestFrom(GitHubAPI.isAuthenticatedPublisher) + .sink { [unowned self] in + if $0 { + GitHubAPI.logOut() + } + else { + self.performSegue(withIdentifier: "login", sender: self.loginButton) + } + } + .store(in: &subs) + + let searchString = searchBarText + .prepend("") + .map { $0 == "" ? nil : $0 } + .removeDuplicates() + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + + /* + About API classes: + + There are a couple of possibilities for your API methods: + (1) as with non-reactive Siesta, return a Resource (or a Request) + (2) return publishers - the result of Resource.statePublisher() or requestPublisher() + + (2) has the advantage of being strong typed, so you get to know more about your API from your method + definitions. But you are stepping out of Siesta-world by doing this, and you lose the ability to do + anything else Resource-related. + + For those with non-pedantic taste, you could use (2), but drop down to (1) for some methods as required. + + In this sample project we stick with (1). This is mainly to avoid rewriting GitHubAPI for every variant, + but also because we want to use ResourceStatusOverlay. (It would be possible to write a ResourceStatusOverlay + that understands streams of ResourceState, but here we just use a simple adapter that works with streams of Resource.) + */ + let user: AnyPublisher = + // Look up the user by name, either when the search string changes or the login state changes. (We + // want the latter in case the previous lookup failed because of api rate limits when not logged in.) + // + // Of course, this being Siesta, "look up" might just consist of getting the already-loaded resource + // content. We trigger repeated lookups without fear. + // + // It works like this: combineLatest outputs another item, causing a new subscription to contentPublisher() + // below, which in turn calls loadIfNeeded(). + // + searchString.combineLatest(GitHubAPI.isAuthenticatedPublisher) + .map { searchString, _ -> Resource? in + if let searchString = searchString { + return GitHubAPI.user(searchString) + } + else { + return nil + } + } + + // We have a stream of Resource? at the moment - the status overlay observes the Resource, + // displaying spinner, errors and the retry option... + .watchedBy(statusOverlay: statusOverlay) + + // ... then we get the typed content (User - it's part of the `let user` declaration above). + // Finally, a Siesta+Combine operation! + // + // If we weren't using statusOverlay we might have called statePublisher() here instead of + // contentPublisher(), and done something with that to display progress and errors as well as the + // content. + .flatMapLatest { $0?.contentPublisher() ?? Just(nil).eraseToAnyPublisher() } + .eraseToAnyPublisher() + + searchString.combineLatest(user) + .sink { [unowned self] searchString, user in + self.fullNameLabel.text = user?.name + self.avatar.imageURL = user?.avatarURL + self.usernameLabel.text = searchString == nil ? "Active Repositories" : user?.login + } + .store(in: &subs) + + // Configure the repo list with the repositories to display. It accepts Observable, but could have + // accepted Observable<[Repository]> - see the notes in RepositoryListViewController. + repoListVC?.configure(repositories: + searchString.combineLatest(user) + .map { (searchString, user) -> Resource? in + if let user = user { + return GitHubAPI.user(user.login) + .optionalRelative(user.repositoriesURL)? + .withParam("sort", "updated") + } + else if searchString != nil { + return nil + } + else { + return GitHubAPI.activeRepositories + } + } + .eraseToAnyPublisher() + ) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + setNeedsStatusBarAppearanceUpdate() + + CommentaryViewController.publishCommentary( + """ + Stress test the live search above by rapidly deleting and retyping the same characters. + Note how fast previously fetched data reappears. Why so fast? + + Unlike other networking libraries, Siesta can cache responses in their final, fully parsed, app-specific + form. And it lets the app use stale cached data while simultaneously requesting an update. + + This example app has no special logic for caching, throttling, or preventing redundant network requests. + That’s all handled by Siesta. + + On this screen, the user profile, avatars, and repo list come from separate cascading API calls. + With traditional callback-based networking, this would be a state nightmare. Why? Well… + + How do you prevent all these views from getting out of sync as the user types? + Even if responses for different repos come back late? Out of order? + + Siesta’s architecture provides an elegant solution to this problem. + Its abstractions produce app code that is simpler and less brittle. + """) + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + .lightContent + } + + override func viewDidLayoutSubviews() { + statusOverlay.positionToCover(userInfoView) + } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "repos" { + repoListVC = segue.destination as? RepositoryListViewController + } + } + + // CombineCocoa doesn't support UISearchBar (or any other delegate-based components) yet + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + searchBarText.send(searchText) + } + + // Dummy actions just here for compatibility with the storyboard, which we share with the other implementations + // of this controller. + + @IBAction func logInOrOut() { } +} \ No newline at end of file diff --git a/Examples/GithubBrowser/Source/UI/UserViewControllerRx.swift b/Examples/GithubBrowser/Source/UI/UserViewControllerRx.swift new file mode 100644 index 00000000..e78b598b --- /dev/null +++ b/Examples/GithubBrowser/Source/UI/UserViewControllerRx.swift @@ -0,0 +1,173 @@ +import UIKit +import Siesta +import RxSwift +import RxCocoa +import RxOptional + +class UserViewController: UIViewController, UISearchBarDelegate /* the latter only for storyboard compatibility */ { + + @IBOutlet weak var loginButton: UIButton! + @IBOutlet weak var searchBar: UISearchBar! + @IBOutlet weak var userInfoView: UIView! + @IBOutlet weak var usernameLabel, fullNameLabel: UILabel! + @IBOutlet weak var avatar: RemoteImageView! + + var statusOverlay = ResourceStatusOverlay() + + var repoListVC: RepositoryListViewController? + + private var disposeBag = DisposeBag() + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = SiestaTheme.darkColor + + statusOverlay.embed(in: self) + + searchBar.becomeFirstResponder() + + GitHubAPI.isAuthenticatedObservable + .map { $0 ? "Log Out" : "Log In" } + .bind(to: loginButton.rx.title(for: .normal)) + .disposed(by: disposeBag) + + loginButton.rx.tap + .withLatestFrom(GitHubAPI.isAuthenticatedObservable) + .bind { [unowned self] in + if $0 { + GitHubAPI.logOut() + } + else { + self.performSegue(withIdentifier: "login", sender: self.loginButton) + } + } + .disposed(by: disposeBag) + + let searchString = searchBar.rx.text + .map { $0 == "" ? nil : $0 } + .distinctUntilChanged() + .debounce(.milliseconds(300), scheduler: MainScheduler.instance) + + + /* + About API classes: + + There are a couple of possibilities for your API methods: + (1) as with non-reactive Siesta, return a Resource (or a Request) + (2) return observables - the result of Resource.rx.state() or rx.request() + + (2) has the advantage of being strong typed, so you get to know more about your API from your method + definitions. But you are stepping out of Siesta-world by doing this, and you lose the ability to do + anything else Resource-related. + + For those with non-pedantic taste, you could use (2), but drop down to (1) for some methods as required. + + In this sample project we stick with (1). This is mainly to avoid rewriting GitHubAPI for every variant, + but also because we want to use ResourceStatusOverlay. (It would be possible to write a ResourceStatusOverlay + that understands streams of ResourceState, but here we just use a simple adapter that works with streams of Resource.) + */ + let user: Observable = + // Look up the user by name, either when the search string changes or the login state changes. (We + // want the latter in case the previous lookup failed because of api rate limits when not logged in.) + // + // Of course, this being Siesta, "look up" might just consist of getting the already-loaded resource + // content. We trigger repeated lookups without fear. + // + // It works like this: combineLatest outputs another item, causing a new subscription to rx.content() + // below, which in turn calls loadIfNeeded(). + // + Observable.combineLatest(searchString, GitHubAPI.isAuthenticatedObservable) + .map { searchString, _ -> Resource? in + if let searchString = searchString { + return GitHubAPI.user(searchString) + } + else { + return nil + } + } + + // We have a stream of Resource? at the moment - the status overlay observes the Resource, + // displaying spinner, errors and the retry option... + .watchedBy(statusOverlay: statusOverlay) + + // ... then we get the typed content (User - it's part of the `let user` declaration above). + // Finally, a Siesta+Rx operation! + // If we weren't using statusOverlay we might have called state() instead of content() and done + // something with that to display progress and errors as well as the content. + .flatMapLatest { $0?.rx.content() ?? .just(nil) } + + Observable.combineLatest(searchString, user) + .bind { [unowned self] searchString, user in + self.fullNameLabel.text = user?.name + self.avatar.imageURL = user?.avatarURL + self.usernameLabel.text = searchString == nil ? "Active Repositories" : user?.login + } + .disposed(by: disposeBag) + + // Configure the repo list with the repositories to display. It accepts Observable, but could have + // accepted Observable<[Repository]> - see the notes in RepositoryListViewController. + repoListVC?.configure(repositories: + Observable.combineLatest(searchString, user) + .map { (searchString, user) -> Resource? in + if let user = user { + return GitHubAPI.user(user.login) + .optionalRelative(user.repositoriesURL)? + .withParam("sort", "updated") + } + else if searchString != nil { + return nil + } + else { + return GitHubAPI.activeRepositories + } + } + ) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + setNeedsStatusBarAppearanceUpdate() + + CommentaryViewController.publishCommentary( + """ + Stress test the live search above by rapidly deleting and retyping the same characters. + Note how fast previously fetched data reappears. Why so fast? + + Unlike other networking libraries, Siesta can cache responses in their final, fully parsed, app-specific + form. And it lets the app use stale cached data while simultaneously requesting an update. + + This example app has no special logic for caching, throttling, or preventing redundant network requests. + That’s all handled by Siesta. + + On this screen, the user profile, avatars, and repo list come from separate cascading API calls. + With traditional callback-based networking, this would be a state nightmare. Why? Well… + + How do you prevent all these views from getting out of sync as the user types? + Even if responses for different repos come back late? Out of order? + + Siesta’s architecture provides an elegant solution to this problem. + Its abstractions produce app code that is simpler and less brittle. + """) + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + .lightContent + } + + override func viewDidLayoutSubviews() { + statusOverlay.positionToCover(userInfoView) + } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "repos" { + repoListVC = segue.destination as? RepositoryListViewController + } + } + + // Dummy actions just here for compatibility with the storyboard, which we share with the other implementations + // of this controller. + + @IBAction func logInOrOut() { } +} \ No newline at end of file diff --git a/Extensions/RxSwift/Siesta+RxSwift.swift b/Extensions/RxSwift/Siesta+RxSwift.swift new file mode 100644 index 00000000..b5f646f8 --- /dev/null +++ b/Extensions/RxSwift/Siesta+RxSwift.swift @@ -0,0 +1,259 @@ +// +// Siesta+RxSwift.swift +// Siesta +// +// Created by Adrian on 2020/4/15. +// Copyright © 2020 Bust Out Solutions. All rights reserved. +// + +import Siesta +import RxSwift + +// MARK: - Resources + +/** +RxSwift extensions for Resource. + +For usage examples see the following method comments, `RxSwiftSpec.swift` and the GitHubBrowser example project. + +Following RxSwift's convention, we add methods to `myResource.rx`, not to `myResource` directly. +*/ +extension Reactive where Base: Resource + { + /** + The changing state of the resource, corresponding to the resource's events. + + Note that content is typed; you'll get an error (in latestError) if your resource doesn't produce + the type you imply. + + Subscribing to this sequence triggers a call to `loadIfNeeded()`, which is probably what you want. + For example, it lets you do things like this, refreshing the resource whenever the view appears: + + ``` + func viewDidLoad() { + ... + rx.viewWillAppear // theoretical reactive extension to UIViewController implemented using rx.methodInvoked + .flatMapLatest { + api.interestingThing.rx.state() + } + .subscribe { [weak self] (state: ResourceState<InterestingThing>) in + ... + } + .disposed(by: disposeBag) + } + ``` + + As with non-rx, you'll immediately get an event (`observerAdded`) describing the current state of the resource. + + The sequence will never error out, or in fact complete at all. Please dispose of your subscriptions + appropriately, otherwise you'll have a permanent reference to the resource. + + Events are published on the main thread. + + Why doesn't this return `Driver`, especially since the request methods use traits rather than plain + observables? Mainly because Driver is in RxCocoa, and I didn't want to import that here. (There's probably + an argument to be made that Driver ought to be in RxSwift - although it's useful when writing UI code, + it's not *only* useful for that.) With reference to driver's characteristics: + - events are published on the main thread, like Driver + - doesn't error out, like Driver + - you get the resource state as soon as you subscribe (via the observerAdded event), so you effectively + have replay(1) here too + */ + public func state() -> Observable> + { + events().map { resource, event in resource.snapshot(latestEvent: event) } + } + + /** + Just the content, when present. Note this doesn't error out either - by using this, you're saying you + don't care about errors at all. + + Otherwise, see comments for `state()` + */ + public func content() -> Observable + { + state().content() + } + + /// The content, if it's present, otherwise nil. You'll get output from this for every event. + public func optionalContent() -> Observable + { + state().map { $0.content } + } + + private func events() -> Observable<(Resource, ResourceEvent)> + { + Observable<(Resource, ResourceEvent)>.create + { + observer in + let owner = SyntheticOwner() + + self.base.addObserver(owner: owner) { observer.onNext(($0, $1)) } + + self.base.loadIfNeeded() + + return Disposables.create { self.base.removeObservers(ownedBy: owner) } + } + .observeOn(MainScheduler.instance) + } + + private class SyntheticOwner {} + } + +extension ObservableType + { + /// See comments on `Resource.rx.content()` + public func content() -> Observable where Element == ResourceState + { + compactMap { $0.content } + } + } + + +extension Resource + { + /// A direct copy from Siesta's Combine implementation. + fileprivate func snapshot(latestEvent: ResourceEvent) + -> ResourceState + { + let content: T? = latestData?.typedContent() + let contentTypeError: RequestError? = + (latestData != nil && content == nil) + ? RequestError( + userMessage: "The server return an unexpected response type", + cause: RequestError.Cause.WrongContentType()) + : nil + + return ResourceState( + content: content, + latestError: latestError ?? contentTypeError, + isLoading: isLoading, + isRequesting: isRequesting, + latestEvent: latestEvent + ) + } + } + + +// MARK: - Requests + +extension Reactive where Base: Resource + { + /** + These methods produce cold observables - the request isn't started until subscription time. This will often be what + you want, and you should at least consider preferring these methods over the Request.rx ones. This is particularly + true when chaining requests using `Completable` - see `requestCompletable()`. + + If your request doesn't return data, you can ask for `Void`, in which case you'll get an element on completion, or + `Never`, in which case you won't. Or you might prefer `requestCompletable()`. + */ + public func request(createRequest: @escaping (Resource) -> Request) -> Observable + { + Observable.deferred { createRequest(self.base).rx.observable() } + } + + /** + (See also comments on `request()`.) + + If your request doesn't return data you can ask for `Void` - or you might prefer `requestCompletable()`. + */ + public func requestSingle(createRequest: @escaping (Resource) -> Request) -> Single + { + Single.deferred { createRequest(self.base).rx.single() } + } + + /** + (See also comments on `request()`.) + + If you want to chain Completables to perform sequential requests, you're in the right place. `Request.rx.completable()` + won't work for that (see its comments for an example). + + ``` + doSomethingResource.rx.requestCompletable { $0.request(.post) } + .andThen(doSomethingNextResource.requestCompletable { $0.request(.post) } + .subscribe { ... } + ``` + */ + public func requestCompletable(createRequest: @escaping (Resource) -> Request) -> Completable + { + Completable.deferred { createRequest(self.base).rx.completable() } + } +} + +extension Request + { + /// Let's keep with the .rx convention. This can't be an extension of Reactive though because Request is a protocol. + public var rx: RequestReactive { RequestReactive(request: self) } + } + +public struct RequestReactive + { + let request: Request + + /** + Be cautious with these methods - Requests are started when they're created, so we're effectively creating hot observables here. + Consider using the `Resource.rx.request*()` methods, which produce cold observables - requests won't start until subscription time. + + However, if you've been handed a Request and you want to make it reactive, these methods are here for you. + + If your request doesn't return data, you can ask for `Void`, in which case you'll get an element on completion, or + `Never`, in which case you won't. + */ + public func observable() -> Observable + { + Observable.create + { + observer in + self.request.onSuccess + { + if T.self == Never.self + { /* no output */ } + else if let result = () as? T + { observer.onNext(result) } + else + { + guard let result: T = $0.typedContent() else + { + observer.onError(RequestError(userMessage: "Wrong content type", + cause: RequestError.Cause.WrongContentType())) + return + } + observer.onNext(result) + } + observer.onCompleted() + } + + self.request.onFailure { observer.onError($0) } + + return Disposables.create() + } + .observeOn(MainScheduler.instance) + } + + /** + Caution - see comments on `observable()`. + + You can get yourself into trouble trying to chain requests with this method (see `Resource.rx.requestCompletable()` + for a working version of this code): + + ``` + doSomethingResource.request(.post).rxCompletable + .andThen(api.doSomethingNextResource.request(.post).rxCompletable) // Nooooo. Started immediately, not after doSomething. + .subscribe { ... } + ``` + */ + public func completable() -> Completable + { + (observable() as Observable).asCompletable() + } + + /** + Caution - see comments on `observable()`. + + If your request doesn't return data, you can ask for `Void`. + */ + public func single() -> Single + { + observable().asSingle() + } + } diff --git a/Package.resolved b/Package.resolved index 17857d05..53e95b05 100644 --- a/Package.resolved +++ b/Package.resolved @@ -45,6 +45,15 @@ "revision": "39b75c1cd536acb95f643bde93de45e272ccaf86", "version": "0.0.0" } + }, + { + "package": "RxSwift", + "repositoryURL": "https://github.com/ReactiveX/RxSwift", + "state": { + "branch": null, + "revision": "002d325b0bdee94e7882e1114af5ff4fe1e96afa", + "version": "5.1.1" + } } ] }, diff --git a/Package.swift b/Package.swift index f5af997d..b62e940b 100644 --- a/Package.swift +++ b/Package.swift @@ -12,6 +12,7 @@ let package = Package( .library(name: "Siesta", targets: ["Siesta"]), .library(name: "SiestaUI", targets: ["SiestaUI"]), .library(name: "Siesta_Alamofire", targets: ["Siesta_Alamofire"]), + .library(name: "Siesta_RxSwift", targets: ["Siesta_RxSwift"]), ], dependencies: [ // Siesta has no required third-party dependencies for use in downstream projects. @@ -22,6 +23,7 @@ let package = Package( // For tests: .package(url: "https://github.com/pcantrell/Quick", .exact("0.0.0")), .package(url: "https://github.com/Quick/Nimble", from: "8.0.1"), + .package(url: "https://github.com/ReactiveX/RxSwift", .exact("5.1.1")), ], targets: [ .target( @@ -36,9 +38,14 @@ let package = Package( dependencies: ["Siesta", "Alamofire"], path: "Extensions/Alamofire" ), + .target( + name: "Siesta_RxSwift", + dependencies: ["Siesta", "RxSwift"], + path: "Extensions/RxSwift" + ), .testTarget( name: "SiestaTests", - dependencies: ["SiestaUI", "Siesta_Alamofire", "Quick", "Nimble"], + dependencies: ["SiestaUI", "Siesta_Alamofire", "Siesta_RxSwift", "Quick", "Nimble"], path: "Tests/Functional", exclude: ["ObjcCompatibilitySpec.m"] // SwiftPM currently only supports Swift ), diff --git a/Siesta.xcodeproj/project.pbxproj b/Siesta.xcodeproj/project.pbxproj index 188cbfdf..bab31cc5 100644 --- a/Siesta.xcodeproj/project.pbxproj +++ b/Siesta.xcodeproj/project.pbxproj @@ -8,6 +8,15 @@ /* Begin PBXBuildFile section */ 00B394491DC5FD67002CD5B7 /* NetworkActivityIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00B394481DC5FD67002CD5B7 /* NetworkActivityIndicator.swift */; }; + 3A0058442447FA9000FC421F /* Siesta+RxSwift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A0058432447FA9000FC421F /* Siesta+RxSwift.swift */; }; + 3A0058452447FA9000FC421F /* Siesta+RxSwift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A0058432447FA9000FC421F /* Siesta+RxSwift.swift */; }; + 3A0058462447FA9000FC421F /* Siesta+RxSwift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A0058432447FA9000FC421F /* Siesta+RxSwift.swift */; }; + 3A00584E2447FB7700FC421F /* RxSwift.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 3A00584B2447FAE700FC421F /* RxSwift.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 3A00584F2447FB7F00FC421F /* RxSwift.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 3A00584C2447FAFA00FC421F /* RxSwift.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 3A0058502447FB8800FC421F /* RxSwift.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 3A00584D2447FB0D00FC421F /* RxSwift.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 3A0958D724563F9E00DDB82E /* CombineSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A0958D624563F5600DDB82E /* CombineSpec.swift */; }; + 3A0958D824563F9F00DDB82E /* CombineSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A0958D624563F5600DDB82E /* CombineSpec.swift */; }; + 3A0958D924563FA000DDB82E /* CombineSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A0958D624563F5600DDB82E /* CombineSpec.swift */; }; 9F0FAF901D033FCD00CE1B61 /* ResourceStateSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC0CE6A1B4A3A9D004FBB4B /* ResourceStateSpec.swift */; }; 9F0FAF911D033FCD00CE1B61 /* Networking-Alamofire.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2FF1051C0F97F600C98FF1 /* Networking-Alamofire.swift */; }; 9F0FAF921D033FCD00CE1B61 /* ProgressSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8EF6CF1BC1A917002175EB /* ProgressSpec.swift */; }; @@ -55,6 +64,10 @@ 9F2FB53F1C2864CB0068DFFA /* RequestError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFB3C4E1B3DEFE8009CF96C /* RequestError.swift */; }; 9F2FB5401C2864CB0068DFFA /* ResourceObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9F4BDF1B41C9CC00E8966F /* ResourceObserver.swift */; }; 9F9628841CF1D7AF008092B7 /* ConfigurationPatternConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9AA8141CCAFDD20016DB18 /* ConfigurationPatternConvertible.swift */; }; + AB3CA570428CEAE75F9C47EF /* Resource+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3CA3CDD179E88421760132 /* Resource+Combine.swift */; }; + AB3CA65684E47AD975943FE8 /* Resource+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3CA3CDD179E88421760132 /* Resource+Combine.swift */; }; + AB3CA7FF357E47C772C963DF /* Resource+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3CA3CDD179E88421760132 /* Resource+Combine.swift */; }; + AB3CAD917273D60CFF29E840 /* Resource+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3CA3CDD179E88421760132 /* Resource+Combine.swift */; }; CD5651F01E6F306B009F224A /* ServiceSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA49EA7B1B35FE5F00AE1B8F /* ServiceSpec.swift */; }; CD5651F11E6F306B009F224A /* ResourceSpecBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA49EA7D1B35FE7E00AE1B8F /* ResourceSpecBase.swift */; }; CD5651F21E6F306B009F224A /* ResourcePathsSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC0CE681B4A39B0004FBB4B /* ResourcePathsSpec.swift */; }; @@ -342,6 +355,7 @@ 9F0FB0571D04184F00CE1B61 /* Alamofire.framework in CopyFiles */, 9F0FB0581D04184F00CE1B61 /* Nimble.framework in CopyFiles */, 9F0FB05A1D04184F00CE1B61 /* Quick.framework in CopyFiles */, + 3A00584F2447FB7F00FC421F /* RxSwift.framework in CopyFiles */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -363,6 +377,7 @@ DA5E4B6222DCF48D0059ED10 /* Alamofire.framework in CopyFiles */, DA5E4B6322DCF48D0059ED10 /* Nimble.framework in CopyFiles */, DA5E4B6522DCF48D0059ED10 /* Quick.framework in CopyFiles */, + 3A0058502447FB8800FC421F /* RxSwift.framework in CopyFiles */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -375,6 +390,7 @@ DA60F5F91B2F729300D76DC6 /* Nimble.framework in CopyFiles */, DA60F5FA1B2F729300D76DC6 /* Quick.framework in CopyFiles */, DA612E471B38AD6400DE9A9F /* Alamofire.framework in CopyFiles */, + 3A00584E2447FB7700FC421F /* RxSwift.framework in CopyFiles */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -382,6 +398,12 @@ /* Begin PBXFileReference section */ 00B394481DC5FD67002CD5B7 /* NetworkActivityIndicator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkActivityIndicator.swift; sourceTree = ""; }; + 3A0058432447FA9000FC421F /* Siesta+RxSwift.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Siesta+RxSwift.swift"; sourceTree = ""; }; + 3A0058472447FAA900FC421F /* RxSwiftSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RxSwiftSpec.swift; sourceTree = ""; }; + 3A00584B2447FAE700FC421F /* RxSwift.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = RxSwift.framework; path = Carthage/Build/iOS/RxSwift.framework; sourceTree = ""; }; + 3A00584C2447FAFA00FC421F /* RxSwift.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = RxSwift.framework; path = Carthage/Build/Mac/RxSwift.framework; sourceTree = ""; }; + 3A00584D2447FB0D00FC421F /* RxSwift.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = RxSwift.framework; path = Carthage/Build/tvOS/RxSwift.framework; sourceTree = ""; }; + 3A0958D624563F5600DDB82E /* CombineSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineSpec.swift; sourceTree = ""; }; 570C77E01D38463200226F92 /* ReactiveCocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ReactiveCocoa.framework; path = Carthage/Build/iOS/ReactiveCocoa.framework; sourceTree = ""; }; 570C77E31D38466900226F92 /* ReactiveCocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ReactiveCocoa.framework; path = Carthage/Build/Mac/ReactiveCocoa.framework; sourceTree = ""; }; 570C77E51D38468A00226F92 /* Result.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Result.framework; path = Carthage/Build/Mac/Result.framework; sourceTree = ""; }; @@ -394,6 +416,7 @@ 9F0FB0561D04184F00CE1B61 /* Quick.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Quick.framework; path = Carthage/Build/Mac/Quick.framework; sourceTree = ""; }; 9F2FB51E1C28645E0068DFFA /* Siesta.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Siesta.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9F2FB5221C28645E0068DFFA /* Info-macOS.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "Info-macOS.plist"; path = "Source/Info-macOS.plist"; sourceTree = SOURCE_ROOT; }; + AB3CA3CDD179E88421760132 /* Resource+Combine.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Resource+Combine.swift"; sourceTree = ""; }; CDCCAF651E6A313800860D18 /* Siesta.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Siesta.framework; sourceTree = BUILT_PRODUCTS_DIR; }; CDCCAF681E6A313800860D18 /* Info-watchOS.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "Info-watchOS.plist"; path = "../../Info-watchOS.plist"; sourceTree = ""; }; CDCCAF721E6A31D900860D18 /* Siesta.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Siesta.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -578,6 +601,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 3A0058422447FA9000FC421F /* RxSwift */ = { + isa = PBXGroup; + children = ( + 3A0058432447FA9000FC421F /* Siesta+RxSwift.swift */, + ); + path = RxSwift; + sourceTree = ""; + }; 574156961D393AF8009A2EC8 /* ReactiveCocoa */ = { isa = PBXGroup; children = ( @@ -592,6 +623,7 @@ 9F0FB0531D04184F00CE1B61 /* Alamofire.framework */, 9F0FB0541D04184F00CE1B61 /* Nimble.framework */, 9F0FB0561D04184F00CE1B61 /* Quick.framework */, + 3A00584C2447FAFA00FC421F /* RxSwift.framework */, 570C77E31D38466900226F92 /* ReactiveCocoa.framework */, 570C77E51D38468A00226F92 /* Result.framework */, ); @@ -621,6 +653,7 @@ DA2FF1031C0F97F600C98FF1 /* Extensions */ = { isa = PBXGroup; children = ( + 3A0058422447FA9000FC421F /* RxSwift */, 574156961D393AF8009A2EC8 /* ReactiveCocoa */, DA2FF1041C0F97F600C98FF1 /* Alamofire */, ); @@ -688,6 +721,8 @@ DA8EF6CF1BC1A917002175EB /* ProgressSpec.swift */, DA9E515320DD7A45000923BA /* RemoteImageViewSpec.swift */, DA9F4BDD1B3F8E2E00E8966F /* WeakCacheSpec.swift */, + 3A0058472447FAA900FC421F /* RxSwiftSpec.swift */, + 3A0958D624563F5600DDB82E /* CombineSpec.swift */, DA4D61971B751FEE00F6BB9C /* ObjcCompatibilitySpec.m */, DA99B5C71B38C36A009C6937 /* Support */, ); @@ -710,6 +745,7 @@ DA612E421B38AD1000DE9A9F /* Alamofire.framework */, DA0B49961B2F68DC00BFBF38 /* Nimble.framework */, DA0B49971B2F68DC00BFBF38 /* Quick.framework */, + 3A00584B2447FAE700FC421F /* RxSwift.framework */, 570C77E01D38463200226F92 /* ReactiveCocoa.framework */, 570C77E71D3846A800226F92 /* Result.framework */, ); @@ -722,6 +758,7 @@ DA5E4B5222DCEFE40059ED10 /* Alamofire.framework */, DA5E4B5322DCEFE40059ED10 /* Nimble.framework */, DA5E4B5422DCEFE40059ED10 /* Quick.framework */, + 3A00584D2447FB0D00FC421F /* RxSwift.framework */, ); name = tvOS; sourceTree = ""; @@ -771,6 +808,7 @@ DA60F5FB1B30B2F800D76DC6 /* Resource.swift */, DAC0B3721D63880600D25C44 /* ResourceNavigation.swift */, DA9F4BDF1B41C9CC00E8966F /* ResourceObserver.swift */, + AB3CA3CDD179E88421760132 /* Resource+Combine.swift */, ); path = Resource; sourceTree = ""; @@ -1340,6 +1378,7 @@ 9F0FAF911D033FCD00CE1B61 /* Networking-Alamofire.swift in Sources */, 9F0FAF921D033FCD00CE1B61 /* ProgressSpec.swift in Sources */, 9F0FAF931D033FCD00CE1B61 /* ResourceObserversSpec.swift in Sources */, + 3A0058452447FA9000FC421F /* Siesta+RxSwift.swift in Sources */, 9F0FAF941D033FCD00CE1B61 /* SpecHelpers.swift in Sources */, 9F0FAF961D033FCD00CE1B61 /* WeakCacheSpec.swift in Sources */, 9F0FAF971D033FCD00CE1B61 /* ResponseDataHandlingSpec.swift in Sources */, @@ -1350,6 +1389,7 @@ DA5A90312054E0C500309D8B /* EntityCacheSpec.swift in Sources */, 9F0FAF9B1D033FCD00CE1B61 /* ResourcePathsSpec.swift in Sources */, DA7285E71D0DF46600132CD9 /* PipelineSpec.swift in Sources */, + 3A0958D824563F9F00DDB82E /* CombineSpec.swift in Sources */, DA62C9852428670500398674 /* NetworkStub.swift in Sources */, 9F0FAF9C1D033FCD00CE1B61 /* ResourceSpecBase.swift in Sources */, ); @@ -1396,6 +1436,7 @@ 9F2FB52B1C2864970068DFFA /* String+Siesta.swift in Sources */, 9F2FB5331C2864BD0068DFFA /* Networking.swift in Sources */, DACD23471D618CE300848C04 /* Ω_Deprecations.swift in Sources */, + AB3CA65684E47AD975943FE8 /* Resource+Combine.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1440,6 +1481,7 @@ CDCCAFCB1E6A389700860D18 /* PipelineConfiguration.swift in Sources */, CDCCAFC31E6A389200860D18 /* NetworkRequest.swift in Sources */, CDCCAFCD1E6A389700860D18 /* ResponseTransformer.swift in Sources */, + AB3CA7FF357E47C772C963DF /* Resource+Combine.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1484,6 +1526,7 @@ CDCCAFCE1E6A389800860D18 /* PipelineConfiguration.swift in Sources */, CDCCAFC91E6A389300860D18 /* NetworkRequest.swift in Sources */, CDCCAFD01E6A389800860D18 /* ResponseTransformer.swift in Sources */, + AB3CA570428CEAE75F9C47EF /* Resource+Combine.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1493,6 +1536,7 @@ files = ( CD5651F01E6F306B009F224A /* ServiceSpec.swift in Sources */, CD5651F51E6F306B009F224A /* RequestSpec.swift in Sources */, + 3A0058462447FA9000FC421F /* Siesta+RxSwift.swift in Sources */, CD5651F71E6F306B009F224A /* ResponseDataHandlingSpec.swift in Sources */, CD5651F21E6F306B009F224A /* ResourcePathsSpec.swift in Sources */, CD5651F81E6F306B009F224A /* ProgressSpec.swift in Sources */, @@ -1502,6 +1546,7 @@ CD5651F41E6F306B009F224A /* ResourceObserversSpec.swift in Sources */, CD5651F91E6F306B009F224A /* WeakCacheSpec.swift in Sources */, DA5A90322054E0C500309D8B /* EntityCacheSpec.swift in Sources */, + 3A0958D924563FA000DDB82E /* CombineSpec.swift in Sources */, CD5651FA1E6F306D009F224A /* ObjcCompatibilitySpec.m in Sources */, CD5651F61E6F306B009F224A /* PipelineSpec.swift in Sources */, DA5E4B5E22DCF12E0059ED10 /* SpecHelpers.swift in Sources */, @@ -1562,6 +1607,7 @@ DA9F7B1F1B8BC3B000A604C3 /* EntityCache.swift in Sources */, DA3FD66D1D0DF65B00C75742 /* PipelineConfiguration.swift in Sources */, DACD23461D618CE300848C04 /* Ω_Deprecations.swift in Sources */, + AB3CAD917273D60CFF29E840 /* Resource+Combine.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1571,6 +1617,7 @@ files = ( DACD23401D60D0FE00848C04 /* RequestSpec.swift in Sources */, DAC0CE6B1B4A3A9D004FBB4B /* ResourceStateSpec.swift in Sources */, + 3A0058442447FA9000FC421F /* Siesta+RxSwift.swift in Sources */, DA2FF1061C0F97F600C98FF1 /* Networking-Alamofire.swift in Sources */, DA8EF6D11BC20AFE002175EB /* ProgressSpec.swift in Sources */, DAC0CE6D1B4A3ADA004FBB4B /* ResourceObserversSpec.swift in Sources */, @@ -1580,6 +1627,7 @@ DA3314311B4DC11900A7A175 /* ResponseDataHandlingSpec.swift in Sources */, DA9E515420DD7A45000923BA /* RemoteImageViewSpec.swift in Sources */, DA49EA7C1B35FE5F00AE1B8F /* ServiceSpec.swift in Sources */, + 3A0958D724563F9E00DDB82E /* CombineSpec.swift in Sources */, DA788A8E1D6AC1580085C820 /* ObjcCompatibilitySpec.m in Sources */, DA4D619B1B7521EE00F6BB9C /* TestService.swift in Sources */, DAFA89281B8172920090D283 /* SiestaSpec.swift in Sources */, diff --git a/Source/Siesta/Resource/Resource+Combine.swift b/Source/Siesta/Resource/Resource+Combine.swift new file mode 100644 index 00000000..03534189 --- /dev/null +++ b/Source/Siesta/Resource/Resource+Combine.swift @@ -0,0 +1,300 @@ +// +// Resource+Combine.swift +// Siesta +// +// Created by Adrian on 2020/04/27. +// Copyright © 2020 Bust Out Solutions. All rights reserved. +// + +#if canImport(Combine) + +import Foundation +import Combine + +/** +Combine extensions for Resource. + +For usage examples see `CombineSpec.swift` and the GitHubBrowser example project. +*/ +@available(iOS 13, tvOS 13, OSX 10.15, watchOS 6.0, *) +extension Resource + { + /** + The changing state of the resource, corresponding to the resource's events. + + Note that content is typed; you'll get an error (in `latestError`) if your resource doesn't produce + the type you specify. + + Subscribing to this publisher triggers a call to `loadIfNeeded()`, which is probably what you want. + + As with non-reactive Siesta, you'll immediately get an event (`observerAdded`) describing the current + state of the resource. + + The publisher will never complete. Please dispose of your subscriptions appropriately otherwise you'll have + a permanent reference to the resource. + + Events are published on the main thread. + */ + public func statePublisher() -> AnyPublisher,Never> + { + EventPublisher(resource: self) + .receive(on: DispatchQueue.main) + .map { resource, event in resource.snapshot(latestEvent: event) } + .eraseToAnyPublisher() + } + + /** + Just the content, when present. See also `statePublisher()`. + */ + public func contentPublisher() -> AnyPublisher + { + statePublisher().content() + } + + /// The content, if it's present, otherwise nil. You'll get output from this for every event. + /// See also `statePublisher()`. + public func optionalContentPublisher() -> AnyPublisher + { + statePublisher().optionalContent() + } + + fileprivate struct EventPublisher: Publisher + { + typealias Output = (Resource, ResourceEvent) + typealias Failure = Never + + let resource: Resource + + init(resource: Resource) + { self.resource = resource } + + func receive(subscriber: S) where S: Subscriber, Output == S.Input + { + let subscription = EventSubscription(subscriber: subscriber, resource: resource) + subscriber.receive(subscription: subscription) + resource.loadIfNeeded() + } + } + + fileprivate class EventSubscription: Subscription, ResourceObserver where SubscriberType.Input == (Resource,ResourceEvent) + { + private var subscriber: SubscriberType? + private weak var resource: Resource? + private var demand: Subscribers.Demand = .none + private var observing = false + + init(subscriber: SubscriberType, resource: Resource) + { + self.subscriber = subscriber + self.resource = resource + } + + func request(_ demand: Subscribers.Demand) + { + self.demand += demand + if !observing + { + observing = true + resource?.addObserver(self) + } + } + + func resourceChanged(_ resource: Resource, event: ResourceEvent) + { + guard demand > 0, let subscriber = subscriber else + { return } + demand -= 1 + demand += subscriber.receive((resource, event)) + } + + func cancel() + { subscriber = nil } + } + } + +@available(iOS 13, tvOS 13, OSX 10.15, watchOS 6.0, *) +extension AnyPublisher + { + /// See comments on `Resource.contentPublisher()` + public func content() -> AnyPublisher where Output == ResourceState + { compactMap { $0.content }.eraseToAnyPublisher() } + + /// See comments on `Resource.optionalContentPublisher()` + public func optionalContent() -> AnyPublisher where Output == ResourceState + { compactMap { $0.content }.eraseToAnyPublisher() } + } + +#endif + + +// MARK: - ResourceState + +/** +Immutable state of a resource at a point in time - used for Combine publishers, but also suitable for other reactive +frameworks such as RxSwift, for which there is an optional Siesta extension. + +Note the strong typing. If there is content but it's not of the type specified, `latestError` is populated +with a cause of `RequestError.Cause.WrongContentType`. +*/ +public struct ResourceState + { + /// Resource.latestData?.typedContent(). If the resource produces content of a different type you get an error. + public let content: T? + /// Resource.latestError + public let latestError: RequestError? + /// Resource.isLoading + public let isLoading: Bool + /// Resource.isRequesting + public let isRequesting: Bool + + /** + Usually the other fields of this struct are sufficient, but sometimes it's useful to have access to + actual resource events, for example if you're recording network errors somewhere. + */ + public let latestEvent: ResourceEvent + + /// Create + public init(content: T?, latestError: RequestError?, isLoading: Bool, isRequesting: Bool, latestEvent: ResourceEvent) + { + self.content = content + self.latestError = latestError + self.isLoading = isLoading + self.isRequesting = isRequesting + self.latestEvent = latestEvent + } + + /// Transform state into a different content type + public func map(transform: (T) -> Other) -> ResourceState + { + ResourceState( + content: content.map(transform), + latestError: latestError, + isLoading: isLoading, + isRequesting: isRequesting, + latestEvent: latestEvent) + } + } + +extension Resource + { + /// The current state of the resource. Note the RxSwift extension also has a copy of this method as it's + /// not really suitable for adding to the public API. + fileprivate func snapshot(latestEvent: ResourceEvent) + -> ResourceState + { + let content: T? = latestData?.typedContent() + let contentTypeError: RequestError? = + (latestData != nil && content == nil) + ? RequestError( + userMessage: "The server return an unexpected response type", + cause: RequestError.Cause.WrongContentType()) + : nil + + return ResourceState( + content: content, + latestError: latestError ?? contentTypeError, + isLoading: isLoading, + isRequesting: isRequesting, + latestEvent: latestEvent + ) + } + } + +extension RequestError.Cause + { + public struct WrongContentType: Error + { + public init() {} + } + } + + +// MARK: - Requests + +#if canImport(Combine) + +@available(iOS 13, tvOS 13, OSX 10.15, watchOS 6.0, *) +extension Resource + { + /** + These methods produce cold observables - the request isn't started until subscription time. This will often be what + you want, and you should at least consider preferring these methods over the Request publishers. + + Publisher for a request that doesn't return data. + */ + public func requestPublisher(createRequest: @escaping (Resource) -> Request) -> AnyPublisher + { + Deferred { + Just(()) + .receive(on: DispatchQueue.main) + .setFailureType(to: RequestError.self) + .flatMap { _ in createRequest(self).publisher() } + }.eraseToAnyPublisher() + } + + /** + Publisher for a request that returns data. Strongly typed, like the Resource publishers. + + See also `requestPublisher()` + */ + public func dataRequestPublisher(createRequest: @escaping (Resource) -> Request) -> AnyPublisher + { + Deferred { + Just(()) + .receive(on: DispatchQueue.main) + .setFailureType(to: RequestError.self) + .flatMap { _ in createRequest(self).dataPublisher() } + }.eraseToAnyPublisher() + } + } + +@available(iOS 13, tvOS 13, OSX 10.15, watchOS 6.0, *) +extension Request + { + /** + Be cautious with these methods - Requests are started when they're created, so we're effectively creating hot observables here. + Consider using the `Resource.*requestPublisher()` methods, which produce cold observables - requests won't start until + subscription time. + + However, if you've been handed a Request and you want to make it reactive, these methods are here for you. + + Publisher for a request that doesn't return data. + */ + public func publisher() -> AnyPublisher + { + dataPublisher() + } + + /** + Publisher for a request that returns data. Strongly typed, like the Resource publishers. + + See also `publisher()` + */ + public func dataPublisher() -> AnyPublisher + { + Future + { + promise in + self.onSuccess + { + if let result = () as? T + { promise(.success(result)) } + else + { + guard let result: T = $0.typedContent() else + { + promise(.failure(RequestError(userMessage: "Wrong content type", + cause: RequestError.Cause.WrongContentType()))) + return + } + promise(.success(result)) + } + } + + self.onFailure { promise(.failure($0)) } + } + .eraseToAnyPublisher() + } + } +#endif + diff --git a/Source/Siesta/Service.swift b/Source/Siesta/Service.swift index c9382f02..70d0fcec 100644 --- a/Source/Siesta/Service.swift +++ b/Source/Siesta/Service.swift @@ -478,4 +478,20 @@ open class Service: NSObject { resourceCache.flushUnused() } + + + // MARK: Reloading + + /** + Call reloadIfNeeded() on all resources. + */ + @objc + public final func reloadAllResourcesIfNeeded() + { + DispatchQueue.mainThreadPrecondition() + + resourceCache.flushUnused() // Little point in keeping Resource instance if we’re discarding its content + for resource in resourceCache.values + { resource.loadIfNeeded() } + } } diff --git a/Tests/Functional/CombineSpec.swift b/Tests/Functional/CombineSpec.swift new file mode 100644 index 00000000..0b917d23 --- /dev/null +++ b/Tests/Functional/CombineSpec.swift @@ -0,0 +1,438 @@ +// +// CombineSpec.swift +// Siesta +// +// Created by Adrian on 2020/4/27. +// Copyright © 2020 Bust Out Solutions. All rights reserved. +// + +import Foundation +import XCTest +import Siesta +import Quick +import Nimble +import Combine + +@available(iOS 13, tvOS 13, OSX 10.15, *) +class ResourceCombineSpec: ResourceSpecBase + { + private var subs = [AnyCancellable]() + + override func resourceSpec(_ service: @escaping () -> Service, _ resource: @escaping () -> Resource) + { + describe("statePublisher()") + { + it("outputs content when the request succeeds") + { + NetworkStub.add( + .get, resource, + returning: HTTPResponse(headers: ["Content-type": "text/plain"], body: "pee hoo")) + + var loadingExpectation: XCTestExpectation? = QuickSpec.current.expectation(description: "isLoading set") + var requestingExpectation: XCTestExpectation? = QuickSpec.current.expectation(description: "isRequesting set") + let newDataEventExpectation = QuickSpec.current.expectation(description: "awaiting newData event") + + resource().statePublisher() + .sinkUntilTested + { + (state: ResourceState) in + expect(state.latestError).to(beNil()) + if state.isLoading { + loadingExpectation?.fulfill() + loadingExpectation = nil + } + if state.isRequesting { + requestingExpectation?.fulfill() + requestingExpectation = nil + } + if case .newData = state.latestEvent { newDataEventExpectation.fulfill() } + + if let content = state.content + { + expect(content) == "pee hoo" + return true + } + return false + } + } + + it("outputs content when the content is already cached") + { + NetworkStub.add( + .get, resource, + returning: HTTPResponse(headers: ["Content-type": "text/plain"], body: "pee hoo")) + + let observableExpectation = QuickSpec.current.expectation(description: "awaiting observable") + resource().statePublisher() + .compactMap + { (state: ResourceState) in + state.content != nil ? resource().statePublisher() : nil + } + .prefix(1) + .switchToLatest() + .sink + { (state: ResourceState) in + if let s = state.content + { + expect(s) == "pee hoo" + observableExpectation.fulfill() + } + } + .store(in: &self.subs) + + QuickSpec.current.waitForExpectations(timeout: 1) + self.subs.removeAll() + } + + + it("outputs content again when it changes") + { + NetworkStub.add( + .get, resource, + returning: HTTPResponse(headers: ["Content-type": "text/plain"], body: "pee hoo")) + + let observableExpectation = QuickSpec.current.expectation(description: "awaiting observable") + + resource().statePublisher() + .compactMap { $0.content as String? } + .handleEvents(receiveOutput: + { + _ in + NetworkStub.add( + .get, resource, + returning: HTTPResponse(headers: ["Content-type": "text/plain"], body: "whoo baa")) + + resource().load() + }) + .removeDuplicates() + .prefix(2) + .collect() + .sink + { + expect($0) == ["pee hoo", "whoo baa"] + observableExpectation.fulfill() + } + .store(in: &self.subs) + + QuickSpec.current.waitForExpectations(timeout: 1) + self.subs.removeAll() + } + + it("outputs an error when the request fails") + { + NetworkStub.add(.get, resource, status: 404) + + resource().statePublisher() + .sinkUntilTested + { + (state: ResourceState) in + if state.latestError != nil + { return true } + return false + } + } + + it("outputs an error if the content type is wrong") + { + NetworkStub.add( + .get, resource, + returning: HTTPResponse(headers: ["Content-Type": "application/json"], body: "{}")) + + resource().statePublisher() + .sinkUntilTested + { + (state: ResourceState) in + if let error = state.latestError + { + expect(error.cause).to(beAKindOf(RequestError.Cause.WrongContentType.self)) + return true + } + return false + } + } + + it("combines correctly with other operators") + { + NetworkStub.add( + .get, resource, + returning: HTTPResponse(headers: ["Content-type": "text/plain"], body: "pee hoo")) + + resource() + .statePublisher() + .combineLatest(Publishers.Sequence(sequence: 1...10)) + .sinkUntilTested + { + (state: ResourceState, i: Int) in + if let content = state.content + { + expect(content) == "pee hoo" + return true + } + return false + } + } + } + + + describe("contentPublisher()") + { + it("outputs content changes") + { + NetworkStub.add( + .get, resource, + returning: HTTPResponse(headers: ["Content-type": "text/plain"], body: "pee hoo")) + + let observableExpectation = QuickSpec.current.expectation(description: "awaiting observable") + + resource().contentPublisher() + .handleEvents(receiveOutput: + { + (_: String) in + NetworkStub.add( + .get, resource, + returning: HTTPResponse(headers: ["Content-type": "text/plain"], body: "whoo baa")) + + resource().load() + }) + .removeDuplicates() + .prefix(2) + .collect() + .sink + { + expect($0) == ["pee hoo", "whoo baa"] + observableExpectation.fulfill() + } + .store(in: &self.subs) + + QuickSpec.current.waitForExpectations(timeout: 1) + self.subs.removeAll() + } + + it("outputs content when the content is already cached") + { + NetworkStub.add( + .get, resource, + returning: HTTPResponse(headers: ["Content-type": "text/plain"], body: "pee hoo")) + + let observableExpectation = QuickSpec.current.expectation(description: "awaiting observable") + resource().contentPublisher() + .prefix(1) + .map { (_: String) in resource().contentPublisher() } + .switchToLatest() + .sink + { (s: String) in + expect(s) == "pee hoo" + observableExpectation.fulfill() + } + .store(in: &self.subs) + + QuickSpec.current.waitForExpectations(timeout: 1) + self.subs.removeAll() + } + } + + + // -- Resource requests -- + + describe("Resource.dataRequestPublisher()") { + it("outputs content when the request succeeds") { + NetworkStub.add( + .post, resource, + returning: HTTPResponse(headers: ["Content-type": "text/plain"], body: "whoo baa")) + + let expectation = QuickSpec.current.expectation(description: "awaiting completion") + + resource().dataRequestPublisher { $0.request(.post) } + .sink( + receiveCompletion: { _ in }, + receiveValue: + { + (s: String) in + expect(s) == "whoo baa" + expectation.fulfill() + }) + .store(in: &self.subs) + + QuickSpec.current.waitForExpectations(timeout: 1) + self.subs.removeAll() + } + + it("fails when the request fails") { + NetworkStub.add(.post, resource, status: 500) + + let expectation = QuickSpec.current.expectation(description: "awaiting error") + + resource().dataRequestPublisher { $0.request(.post) } + .sink( + receiveCompletion: + { if case .failure = $0 { expectation.fulfill() } }, + receiveValue: + { (_: String) in }) + .store(in: &self.subs) + + QuickSpec.current.waitForExpectations(timeout: 1) + self.subs.removeAll() + } + } + + + describe("Resource.requestPublisher()") + { + it("completes successfully without output when the request has no output") + { + NetworkStub.add( + .post, resource, + status: 200) + + let expectation = QuickSpec.current.expectation(description: "awaiting completion") + + resource() + .requestPublisher { $0.request(.post) } + .sink( + receiveCompletion: + { _ in expectation.fulfill() }, + receiveValue: + { _ in }) + .store(in: &self.subs) + + QuickSpec.current.waitForExpectations(timeout: 1) + self.subs.removeAll() + } + + it("fails when a request without output fails") + { + NetworkStub.add(.post, resource, status: 500) + + let expectation = QuickSpec.current.expectation(description: "awaiting error") + + resource() + .requestPublisher { $0.request(.post) } + .sink( + receiveCompletion: + { if case .failure = $0 { expectation.fulfill() } }, + receiveValue: + { _ in }) + .store(in: &self.subs) + + QuickSpec.current.waitForExpectations(timeout: 1) + self.subs.removeAll() + } + } + + + // -- Request publishers -- + + describe("Request.dataPublisher()") + { + it("outputs content when the request succeeds") + { + NetworkStub.add( + .post, resource, + returning: HTTPResponse(headers: ["Content-type": "text/plain"], body: "whoo baa")) + + let expectation = QuickSpec.current.expectation(description: "awaiting completion") + + resource() + .request(.post) + .dataPublisher() + .sink( + receiveCompletion: { _ in }, + receiveValue: { (s: String) in + expect(s) == "whoo baa" + expectation.fulfill() + }) + .store(in: &self.subs) + + QuickSpec.current.waitForExpectations(timeout: 1) + self.subs.removeAll() + } + + it("fails when the request fails") + { + NetworkStub.add(.post, resource, status: 500) + + let expectation = QuickSpec.current.expectation(description: "awaiting error") + + resource() + .request(.post) + .dataPublisher() + .sink( + receiveCompletion: + { if case .failure = $0 { expectation.fulfill() } }, + receiveValue: + { (_: String) in } + ) + .store(in: &self.subs) + + QuickSpec.current.waitForExpectations(timeout: 1) + self.subs.removeAll() + } + } + + + describe("Request.publisher()") + { + it("completes successfully without output when the request has no output") + { + NetworkStub.add( + .post, resource, + status: 200) + + let expectation = QuickSpec.current.expectation(description: "awaiting completion") + + resource() + .request(.post) + .publisher() + .sink( + receiveCompletion: + { _ in expectation.fulfill() }, + receiveValue: + { _ in }) + .store(in: &self.subs) + + QuickSpec.current.waitForExpectations(timeout: 1) + self.subs.removeAll() + } + + it("fails when a request without output fails") + { + NetworkStub.add(.post, resource, status: 500) + + let expectation = QuickSpec.current.expectation(description: "awaiting error") + + resource() + .request(.post) + .publisher() + .sink( + receiveCompletion: + { if case .failure = $0 { expectation.fulfill() } }, + receiveValue: + { _ in }) + .store(in: &self.subs) + + QuickSpec.current.waitForExpectations(timeout: 1) + self.subs.removeAll() + } + } + } + } + +@available(iOS 13, tvOS 13, OSX 10.15, *) +extension Publisher + { + fileprivate func sinkUntilTested(timeout: TimeInterval = 1, testNext: @escaping (Output) -> Bool) + { + let observableExpectation = QuickSpec.current.expectation(description: "awaiting observable") + + let sub = prefix + { elt in + var finished: Bool? + let fe = gatherFailingExpectations { finished = testNext(elt) } + return (finished == nil || finished == false) && fe.isEmpty + } + .sink(receiveCompletion: { _ in observableExpectation.fulfill() }, receiveValue: { _ in }) + + QuickSpec.current.waitForExpectations(timeout: timeout) + expect(sub).notTo(beNil()) // silence Xcode's warning + } + } diff --git a/Tests/Functional/RxSwiftSpec.swift b/Tests/Functional/RxSwiftSpec.swift new file mode 100644 index 00000000..d8f66a9b --- /dev/null +++ b/Tests/Functional/RxSwiftSpec.swift @@ -0,0 +1,467 @@ +// +// RxSwiftSpec.swift +// Siesta +// +// Created by Adrian on 2020/4/15. +// Copyright © 2020 Bust Out Solutions. All rights reserved. +// + +import Foundation +import XCTest +import Siesta +import Quick +import Nimble +import RxSwift +import Siesta_RxSwift + +class RxSwiftSpec: ResourceSpecBase + { + override func resourceSpec(_ service: @escaping () -> Service, _ resource: @escaping () -> Resource) + { + describe("Resource.rx.state()") + { + it("outputs content when the request succeeds") + { + NetworkStub.add( + .get, resource, + returning: HTTPResponse(headers: ["Content-type": "text/plain"], body: "pee hoo")) + + let loadingExpectation = QuickSpec.current.expectation(description: "isLoading set") + let requestingExpectation = QuickSpec.current.expectation(description: "isRequesting set") + let newDataEventExpectation = QuickSpec.current.expectation(description: "awaiting newData event") + + resource().rx.state() + .subscribeUntilTested + { + (state: ResourceState) in + expect(state.latestError).to(beNil()) + if state.isLoading { loadingExpectation.fulfill() } + if state.isRequesting { requestingExpectation.fulfill() } + if case .newData = state.latestEvent { newDataEventExpectation.fulfill() } + + if let content = state.content + { + expect(content) == "pee hoo" + return true + } + return false + } + } + + it("outputs content when the content is already cached") + { + NetworkStub.add( + .get, resource, + returning: HTTPResponse(headers: ["Content-type": "text/plain"], body: "pee hoo")) + + let observableExpectation = QuickSpec.current.expectation(description: "awaiting observable") + resource().rx.state() + .compactMap + { + (state: ResourceState) in + state.content != nil ? resource().rx.state() : nil + } + .take(1) + .switchLatest() + .subscribeUntilTested + { + (state: ResourceState) in + if let s = state.content + { + expect(s) == "pee hoo" + observableExpectation.fulfill() + return true + } + return false + } + } + + it("outputs content again when it changes") + { + NetworkStub.add( + .get, resource, + returning: HTTPResponse(headers: ["Content-type": "text/plain"], body: "pee hoo")) + + let observableExpectation = QuickSpec.current.expectation(description: "awaiting observable") + + _ = resource().rx.state() + .do(onNext: { expect($0.latestError).to(beNil()) }) + .compactMap { $0.content as String? } + .do(onNext: + { + _ in + NetworkStub.add( + .get, resource, + returning: HTTPResponse(headers: ["Content-type": "text/plain"], body: "whoo baa")) + + resource().load() + }) + .distinctUntilChanged() + .take(2) + .toArray() + .subscribe(onSuccess: + { + expect($0) == ["pee hoo", "whoo baa"] + observableExpectation.fulfill() + }) + + QuickSpec.current.waitForExpectations(timeout: 1) + } + + it("outputs an error when the request fails") + { + NetworkStub.add(.get, resource, status: 404) + + resource().rx.state() + .subscribeUntilTested + { + (state: ResourceState) in + if state.latestError != nil + { return true } + return false + } + } + + it("outputs an error if the content type is wrong") + { + NetworkStub.add( + .get, resource, + returning: HTTPResponse(headers: ["Content-Type": "application/json"], body: "{}")) + + resource().rx.state() + .subscribeUntilTested + { + (state: ResourceState) in + if let error = state.latestError + { + expect(error.cause).to(beAKindOf(RequestError.Cause.WrongContentType.self)) + return true + } + return false + } + } + } + + + describe("Resource.rx.content()") + { + it("outputs content changes") + { + NetworkStub.add( + .get, resource, + returning: HTTPResponse(headers: ["Content-type": "text/plain"], body: "pee hoo")) + + let observableExpectation = QuickSpec.current.expectation(description: "awaiting observable") + + _ = resource().rx.content() + .do(onNext: + { + (_: String) in + NetworkStub.add( + .get, resource, + returning: HTTPResponse(headers: ["Content-type": "text/plain"], body: "whoo baa")) + + resource().load() + }) + .distinctUntilChanged() + .take(2) + .toArray() + .subscribe(onSuccess: + { + expect($0) == ["pee hoo", "whoo baa"] + observableExpectation.fulfill() + }) + + QuickSpec.current.waitForExpectations(timeout: 1) + } + + it("outputs content when the content is already cached") + { + NetworkStub.add( + .get, resource, + returning: HTTPResponse(headers: ["Content-type": "text/plain"], body: "pee hoo")) + + let observableExpectation = QuickSpec.current.expectation(description: "awaiting observable") + _ = resource().rx.content() + .take(1) + .map { (_: String) in resource().rx.content() } + .switchLatest() + .take(1) + .subscribe(onNext: + { + (s: String) in + expect(s) == "pee hoo" + observableExpectation.fulfill() + }) + + QuickSpec.current.waitForExpectations(timeout: 1) + } + } + + + // ------------ Resource.rx.request* ------------ + + describe("Resource.rx.request()") + { + it("outputs content then completes") + { + NetworkStub.add( + .put, resource, + returning: HTTPResponse(headers: ["Content-type": "text/plain"], body: "ree sult")) + + let outputExpectation = QuickSpec.current.expectation(description: "awaiting completion") + let completionExpectation = QuickSpec.current.expectation(description: "awaiting completion") + + _ = resource().rx.request { $0.request(.put) } + .subscribe( + onNext: { (result: String) in + expect(result) == "ree sult" + outputExpectation.fulfill() + }, + onCompleted: { completionExpectation.fulfill() } + ) + + QuickSpec.current.waitForExpectations(timeout: 1) + } + + it("fails when the request fails") + { + NetworkStub.add(.put, resource, status: 500) + + let expectation = QuickSpec.current.expectation(description: "awaiting error") + + _ = resource().rx.request { $0.request(.put) } + .subscribe( + onNext: { (_: String) in }, + onError: { _ in expectation.fulfill() } + ) + + QuickSpec.current.waitForExpectations(timeout: 1) + } + } + + + describe("Resource.rx.requestSingle()") + { + it("outputs content") + { + NetworkStub.add( + .put, resource, + returning: HTTPResponse(headers: ["Content-type": "text/plain"], body: "ree sult")) + + let expectation = QuickSpec.current.expectation(description: "awaiting completion") + + _ = resource().rx.requestSingle { $0.request(.put) } + .subscribe(onSuccess: { (result: String) in + expect(result) == "ree sult" + expectation.fulfill() + }) + + QuickSpec.current.waitForExpectations(timeout: 1) + } + + it("fails when the request fails") + { + NetworkStub.add(.put, resource, status: 500) + + let expectation = QuickSpec.current.expectation(description: "awaiting error") + + _ = resource().rx.requestSingle { $0.request(.put) } + .subscribe( + onSuccess: { (_: String) in }, + onError: { _ in expectation.fulfill() } + ) + + QuickSpec.current.waitForExpectations(timeout: 1) + } + } + + + describe("Resource.rx.requestCompletable()") + { + it("completes when the request succeeds") + { + NetworkStub.add(.put, resource, status: 200) + + let expectation = QuickSpec.current.expectation(description: "awaiting completion") + + _ = resource().rx.requestCompletable { $0.request(.put) } + .subscribe(onCompleted: { expectation.fulfill() }) + + QuickSpec.current.waitForExpectations(timeout: 1) + } + + it("fails when the request fails") + { + NetworkStub.add(.put, resource, status: 500) + + let expectation = QuickSpec.current.expectation(description: "awaiting error") + + _ = resource().rx.requestCompletable { $0.request(.put) } + .subscribe(onError: { _ in expectation.fulfill() }) + + QuickSpec.current.waitForExpectations(timeout: 1) + } + } + + + describe("Resource.rx.requestSingle()") + { + it("outputs content when the request succeeds") + { + NetworkStub.add( + .put, resource, + returning: HTTPResponse(headers: ["Content-type": "text/plain"], body: "whoo baa")) + + let expectation = QuickSpec.current.expectation(description: "awaiting completion") + + _ = resource().rx.requestSingle { $0.request(.put) } + .subscribe(onSuccess: + { + (s: String) in + expect(s) == "whoo baa" + expectation.fulfill() + }) + + QuickSpec.current.waitForExpectations(timeout: 1) + } + + it("fails when the request fails") + { + NetworkStub.add(.put, resource, status: 500) + + let expectation = QuickSpec.current.expectation(description: "awaiting error") + + _ = resource().rx.requestSingle { $0.request(.put) } + .subscribe( + onSuccess: { (_: String) in }, + onError: { _ in expectation.fulfill() } + ) + + QuickSpec.current.waitForExpectations(timeout: 1) + } + } + + + // ------------ Request.rx.* ------------ + + describe("Request.rx.observable()") + { + it("outputs content then completes") + { + NetworkStub.add( + .put, resource, + returning: HTTPResponse(headers: ["Content-type": "text/plain"], body: "ree sult")) + + let outputExpectation = QuickSpec.current.expectation(description: "awaiting completion") + let completionExpectation = QuickSpec.current.expectation(description: "awaiting completion") + + _ = resource().request(.put).rx.observable().subscribe( + onNext: { (result: String) in + expect(result) == "ree sult" + outputExpectation.fulfill() + }, + onCompleted: { completionExpectation.fulfill() } + ) + + QuickSpec.current.waitForExpectations(timeout: 1) + } + + it("fails when the request fails") + { + NetworkStub.add(.put, resource, status: 500) + + let expectation = QuickSpec.current.expectation(description: "awaiting error") + + _ = resource().request(.put).rx.observable().subscribe( + onNext: { (_: String) in }, + onError: { _ in expectation.fulfill() } + ) + + QuickSpec.current.waitForExpectations(timeout: 1) + } + } + + describe("Request.rx.completable()") + { + it("completes when the request succeeds") + { + NetworkStub.add(.put, resource, status: 200) + + let expectation = QuickSpec.current.expectation(description: "awaiting completion") + + _ = resource() + .request(.put).rx.completable().subscribe( + onCompleted: { expectation.fulfill() } + ) + + QuickSpec.current.waitForExpectations(timeout: 1) + } + + it("fails when the request fails") + { + NetworkStub.add(.put, resource, status: 500) + + let expectation = QuickSpec.current.expectation(description: "awaiting error") + + _ = resource() + .request(.put).rx.completable().subscribe( + onError: { _ in expectation.fulfill() } + ) + + QuickSpec.current.waitForExpectations(timeout: 1) + } + } + + describe("Request.rx.single()") { + it("outputs content when the request succeeds") + { + NetworkStub.add( + .put, resource, + returning: HTTPResponse(headers: ["Content-type": "text/plain"], body: "whoo baa")) + + let expectation = QuickSpec.current.expectation(description: "awaiting completion") + + _ = resource() + .request(.put).rx.single().subscribe( + onSuccess: { (s: String) in + expect(s) == "whoo baa" + expectation.fulfill() + }) + + QuickSpec.current.waitForExpectations(timeout: 1) + } + + it("fails when the request fails") + { + NetworkStub.add(.put, resource, status: 500) + + let expectation = QuickSpec.current.expectation(description: "awaiting error") + + _ = resource().request(.put).rx.single().subscribe( + onSuccess: { (_: String) in }, + onError: { _ in expectation.fulfill() } + ) + + QuickSpec.current.waitForExpectations(timeout: 1) + } + } + } + } + +extension Observable + { + fileprivate func subscribeUntilTested(testNext: @escaping (Element) -> Bool) + { + let observableExpectation = QuickSpec.current.expectation(description: "awaiting observable") + + _ = takeUntil(.inclusive) { elt in + var res: Bool? + let fe = gatherFailingExpectations { res = testNext(elt) } + return res! || !fe.isEmpty + } + .subscribe(onDisposed: { observableExpectation.fulfill() }) + + QuickSpec.current.waitForExpectations(timeout: 1) + } + }