diff --git a/.github/workflows/swiftpm.yml b/.github/workflows/swiftpm.yml index f8272297..26515a14 100644 --- a/.github/workflows/swiftpm.yml +++ b/.github/workflows/swiftpm.yml @@ -2,9 +2,9 @@ name: SwiftPM regression tests on: push: - branches: [ master ] + branches: [ main, ci-experiments ] pull_request: - branches: [ master ] + branches: [ main ] jobs: test: diff --git a/.github/workflows/xcode.yml b/.github/workflows/xcode.yml index 3760d146..dcf58c56 100644 --- a/.github/workflows/xcode.yml +++ b/.github/workflows/xcode.yml @@ -2,9 +2,9 @@ name: Xcode regression tests on: push: - branches: [ $default-branch, main, master, ci-experiments ] + branches: [ main, ci-experiments ] pull_request: - branches: [ $default-branch, main, master ] + branches: [ main ] jobs: test: diff --git a/Examples/GithubBrowser/GithubBrowser.xcodeproj/xcshareddata/xcschemes/GithubBrowser.xcscheme b/Examples/GithubBrowser/GithubBrowser.xcodeproj/xcshareddata/xcschemes/GithubBrowser.xcscheme new file mode 100644 index 00000000..aca962d4 --- /dev/null +++ b/Examples/GithubBrowser/GithubBrowser.xcodeproj/xcshareddata/xcschemes/GithubBrowser.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/GithubBrowser/Source/API/GithubAPI.swift b/Examples/GithubBrowser/Source/API/GithubAPI.swift index d7c2ae09..99bc94d9 100644 --- a/Examples/GithubBrowser/Source/API/GithubAPI.swift +++ b/Examples/GithubBrowser/Source/API/GithubAPI.swift @@ -17,7 +17,7 @@ class _GitHubAPI { fileprivate init() { #if DEBUG // Bare-bones logging of which network calls Siesta makes: - SiestaLog.Category.enabled = [.network] + SiestaLog.Category.enabled = [.network, .cache] // For more info about how Siesta decides whether to make a network call, // and which state updates it broadcasts to the app: @@ -44,13 +44,48 @@ class _GitHubAPI { $0.pipeline[.cleanup].add( GitHubErrorMessageExtractor(jsonDecoder: jsonDecoder)) + + // Cache API results for fast launch & offline access: + + $0.pipeline[.rawData].cacheUsing { + try FileCache( + poolName: "api.github.com", + dataIsolation: .perUser(identifiedBy: self.username)) // Show each user their own data + } + + // Using the closure form of cacheUsing above signals that if we encounter an error trying create a cache + // directory or generate a cache isolation key from the username, we should simply proceed silently without + // having a persistent cache. + + // Note that the dataIsolation uses only username. This means that users will not _see_ other users’ data; + // however, it does not _secure_ one user’s data from another. A user with permission to see the cache + // directory could in principle see all the cached data. + // + // To fully secure one user’s data from another, the application would need to generate some long-lived + // secret that is unique to each user. A password can work, though it will essentially empty the user’s + // cache if the password changes. The server could also send some kind of high-entropy per-user token in + // the authentication response. } + RemoteImageView.defaultImageService.configure { + // We can cache images offline too: + + $0.pipeline[.rawData].cacheUsing { + try FileCache( + poolName: "images", + dataIsolation: .sharedByAllUsers) // images aren't secret, so no need to isolate them + } + } + + // –––––– Resource-specific configuration –––––– service.configure("/search/**") { // Refresh search results after 10 seconds (Siesta default is 30) $0.expirationTime = 10 + + // Don't cache search results between runs, so we don't see stale results on launch + $0.pipeline.removeAllCaches() } // –––––– Auth configuration –––––– @@ -116,12 +151,14 @@ class _GitHubAPI { // MARK: - Authentication func logIn(username: String, password: String) { - if let auth = "\(username):\(password)".data(using: String.Encoding.utf8) { + self.username = username + if let auth = "\(username):\(password)".data(using: .utf8) { basicAuthHeader = "Basic \(auth.base64EncodedString())" } } func logOut() { + username = nil basicAuthHeader = nil } @@ -129,6 +166,8 @@ class _GitHubAPI { return basicAuthHeader != nil } + private var username: String? + private var basicAuthHeader: String? { didSet { // These two calls are almost always necessary when you have changing auth for your API: diff --git a/Examples/GithubBrowser/Source/UI/RepositoryListViewController.swift b/Examples/GithubBrowser/Source/UI/RepositoryListViewController.swift index a5fc1fd8..805ee237 100644 --- a/Examples/GithubBrowser/Source/UI/RepositoryListViewController.swift +++ b/Examples/GithubBrowser/Source/UI/RepositoryListViewController.swift @@ -38,7 +38,9 @@ class RepositoryListViewController: UITableViewController, ResourceObserver { super.viewDidLoad() view.backgroundColor = SiestaTheme.darkColor + statusOverlay.embed(in: self) + statusOverlay.displayPriority = [.anyData, .loading, .error] } override func viewDidLayoutSubviews() { diff --git a/Examples/GithubBrowser/Source/UI/RepositoryViewController.swift b/Examples/GithubBrowser/Source/UI/RepositoryViewController.swift index 64e56103..acfbd3db 100644 --- a/Examples/GithubBrowser/Source/UI/RepositoryViewController.swift +++ b/Examples/GithubBrowser/Source/UI/RepositoryViewController.swift @@ -76,6 +76,7 @@ class RepositoryViewController: UIViewController, ResourceObserver { super.viewDidLoad() view.backgroundColor = SiestaTheme.darkColor + statusOverlay.embed(in: self) statusOverlay.displayPriority = [.anyData, .loading, .error] // Prioritize partial data over loading indicator diff --git a/Examples/GithubBrowser/Source/UI/UserViewController.swift b/Examples/GithubBrowser/Source/UI/UserViewController.swift index 1a938185..ef7bf60f 100644 --- a/Examples/GithubBrowser/Source/UI/UserViewController.swift +++ b/Examples/GithubBrowser/Source/UI/UserViewController.swift @@ -53,6 +53,8 @@ class UserViewController: UIViewController, UISearchBarDelegate, ResourceObserve view.backgroundColor = SiestaTheme.darkColor statusOverlay.embed(in: self) + statusOverlay.displayPriority = [.anyData, .loading, .error] + showUser(nil) searchBar.becomeFirstResponder() diff --git a/Package.swift b/Package.swift index 912c5df0..42036fe1 100644 --- a/Package.swift +++ b/Package.swift @@ -27,6 +27,10 @@ let package = Package( .target( name: "Siesta" ), + .target( + name: "SiestaTools", + dependencies: ["Siesta"] + ), .target( name: "SiestaUI", dependencies: ["Siesta"], @@ -42,7 +46,7 @@ let package = Package( ), .testTarget( name: "SiestaTests", - dependencies: ["SiestaUI", "Siesta_Alamofire", "Quick", "Nimble"], + dependencies: ["SiestaUI", "SiestaTools", "Siesta_Alamofire", "Quick", "Nimble"], path: "Tests/Functional", exclude: ["ObjcCompatibilitySpec.m"] // SwiftPM currently only supports Swift ), diff --git a/Siesta.podspec b/Siesta.podspec index a721af78..1c9e2a2b 100644 --- a/Siesta.podspec +++ b/Siesta.podspec @@ -79,6 +79,12 @@ Pod::Spec.new do |s| s.exclude_files = "**/Info*.plist" end + s.subspec "Tools" do |s| + s.source_files = "Source/SiestaTools/**/*" + s.exclude_files = "**/Info*.plist" + s.dependency "Siesta/Core" + end + s.subspec "UI" do |s| s.ios.source_files = "Source/SiestaUI/**/*.{swift,m,h}" s.dependency "Siesta/Core" diff --git a/Siesta.xcodeproj/project.pbxproj b/Siesta.xcodeproj/project.pbxproj index e800c8c1..7fd6db1c 100644 --- a/Siesta.xcodeproj/project.pbxproj +++ b/Siesta.xcodeproj/project.pbxproj @@ -144,6 +144,12 @@ DA34E2C9222BAAE70025A77A /* Optional+Siesta.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA34E2C7222BA8650025A77A /* Optional+Siesta.swift */; }; DA34E2CA222BAAEA0025A77A /* Optional+Siesta.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA34E2C7222BA8650025A77A /* Optional+Siesta.swift */; }; DA34E2CB222BAAEB0025A77A /* Optional+Siesta.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA34E2C7222BA8650025A77A /* Optional+Siesta.swift */; }; + DA3E165F243C24A3001F7CCA /* FileCacheSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3E165E243C24A3001F7CCA /* FileCacheSpec.swift */; }; + DA3E1660243C24A3001F7CCA /* FileCacheSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3E165E243C24A3001F7CCA /* FileCacheSpec.swift */; }; + DA3E1661243C24A3001F7CCA /* FileCacheSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3E165E243C24A3001F7CCA /* FileCacheSpec.swift */; }; + DA3E1662243D6D00001F7CCA /* SiestaTools.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DA8B5B5A22DEAC93008E47B0 /* SiestaTools.framework */; }; + DA3E1663243D6D0E001F7CCA /* SiestaTools.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DA8C83521FC600A900C947F9 /* SiestaTools.framework */; }; + DA3E1664243D6D18001F7CCA /* SiestaTools.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DA8B5B6922DEADDB008E47B0 /* SiestaTools.framework */; }; DA3FBD9B1B55917600161A25 /* Siesta-ObjC.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3FBD9A1B55917600161A25 /* Siesta-ObjC.swift */; }; DA3FBDB11B55BF0E00161A25 /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3FBDB01B55BF0E00161A25 /* Logging.swift */; }; DA3FD66C1D0DF5DE00C75742 /* PipelineConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACE0BAD1D0201F800607F3E /* PipelineConfiguration.swift */; }; @@ -188,6 +194,12 @@ DA788A8F1D6AC1590085C820 /* ObjcCompatibilitySpec.m in Sources */ = {isa = PBXBuildFile; fileRef = DA4D61971B751FEE00F6BB9C /* ObjcCompatibilitySpec.m */; }; DA7D05C41D57C2B500431980 /* PipelineProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7D05C31D57C2B500431980 /* PipelineProcessing.swift */; }; DA7D05C51D57C30400431980 /* PipelineProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7D05C31D57C2B500431980 /* PipelineProcessing.swift */; }; + DA8B5B5522DEAC93008E47B0 /* SiestaTools.h in Headers */ = {isa = PBXBuildFile; fileRef = DA8C83571FC6096100C947F9 /* SiestaTools.h */; settings = {ATTRIBUTES = (Public, ); }; }; + DA8B5B6422DEADDB008E47B0 /* SiestaTools.h in Headers */ = {isa = PBXBuildFile; fileRef = DA8C83571FC6096100C947F9 /* SiestaTools.h */; settings = {ATTRIBUTES = (Public, ); }; }; + DA8B5B7122DEB0DF008E47B0 /* FileCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8DEB491FC6763300555D92 /* FileCache.swift */; }; + DA8B5B7222DEB0DF008E47B0 /* FileCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8DEB491FC6763300555D92 /* FileCache.swift */; }; + DA8C83581FC6096100C947F9 /* SiestaTools.h in Headers */ = {isa = PBXBuildFile; fileRef = DA8C83571FC6096100C947F9 /* SiestaTools.h */; settings = {ATTRIBUTES = (Public, ); }; }; + DA8DEB4A1FC6763300555D92 /* FileCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8DEB491FC6763300555D92 /* FileCache.swift */; }; DA8EF6D11BC20AFE002175EB /* ProgressSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8EF6CF1BC1A917002175EB /* ProgressSpec.swift */; }; DA99B5C91B38C8E6009C6937 /* String+Siesta.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA99B5C81B38C8E6009C6937 /* String+Siesta.swift */; }; DA9AA8151CCAFDD20016DB18 /* ConfigurationPatternConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9AA8141CCAFDD20016DB18 /* ConfigurationPatternConvertible.swift */; }; @@ -293,6 +305,48 @@ remoteGlobalIDString = DA5E4B3922DCE9670059ED10; remoteInfo = "SiestaUI tvOS"; }; + DA8B5B5C22DEACA4008E47B0 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DA336E461B2E6DDB006F702A /* Project object */; + proxyType = 1; + remoteGlobalIDString = 9F2FB51D1C28645E0068DFFA; + remoteInfo = "Siesta macOS"; + }; + DA8B5B6B22DEAE72008E47B0 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DA336E461B2E6DDB006F702A /* Project object */; + proxyType = 1; + remoteGlobalIDString = CDCCAF711E6A31D900860D18; + remoteInfo = "Siesta tvOS"; + }; + DA8B5B6D22DEAE97008E47B0 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DA336E461B2E6DDB006F702A /* Project object */; + proxyType = 1; + remoteGlobalIDString = DA8B5B4F22DEAC93008E47B0; + remoteInfo = "SiestaTools macOS"; + }; + DA8B5B6F22DEAEA2008E47B0 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DA336E461B2E6DDB006F702A /* Project object */; + proxyType = 1; + remoteGlobalIDString = DA8B5B5E22DEADDB008E47B0; + remoteInfo = "SiestaTools tvOS"; + }; + DA8C83421FC600A900C947F9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DA336E461B2E6DDB006F702A /* Project object */; + proxyType = 1; + remoteGlobalIDString = DA336E4E1B2E6DDB006F702A; + remoteInfo = "SiestaTools iOS"; + }; + DA8C83591FC60A2900C947F9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DA336E461B2E6DDB006F702A /* Project object */; + proxyType = 1; + remoteGlobalIDString = DA8C83401FC600A900C947F9; + remoteInfo = "SiestaTools iOS"; + }; DAC4CFCA1DAC10EC00EECEDE /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = DA336E461B2E6DDB006F702A /* Project object */; @@ -391,6 +445,7 @@ DA336E601B2E6DDB006F702A /* Info-iOS.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Info-iOS.plist"; sourceTree = ""; }; DA336E701B2F6659006F702A /* Service.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Service.swift; sourceTree = ""; }; DA34E2C7222BA8650025A77A /* Optional+Siesta.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Siesta.swift"; sourceTree = ""; }; + DA3E165E243C24A3001F7CCA /* FileCacheSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileCacheSpec.swift; sourceTree = ""; }; DA3FBD9A1B55917600161A25 /* Siesta-ObjC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Siesta-ObjC.swift"; sourceTree = ""; }; DA3FBDB01B55BF0E00161A25 /* Logging.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = ""; }; DA4353511B6AD63C00543843 /* Networking.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Networking.swift; sourceTree = ""; }; @@ -408,6 +463,11 @@ DA60F5FB1B30B2F800D76DC6 /* Resource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Resource.swift; sourceTree = ""; }; DA62C97C2428589B00398674 /* NetworkStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkStub.swift; sourceTree = ""; }; DA7D05C31D57C2B500431980 /* PipelineProcessing.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PipelineProcessing.swift; sourceTree = ""; }; + DA8B5B5A22DEAC93008E47B0 /* SiestaTools.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SiestaTools.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + DA8B5B6922DEADDB008E47B0 /* SiestaTools.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SiestaTools.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + DA8C83521FC600A900C947F9 /* SiestaTools.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SiestaTools.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + DA8C83571FC6096100C947F9 /* SiestaTools.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SiestaTools.h; sourceTree = ""; }; + DA8DEB491FC6763300555D92 /* FileCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileCache.swift; sourceTree = ""; }; DA8EF6CF1BC1A917002175EB /* ProgressSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgressSpec.swift; sourceTree = ""; }; DA99B5C81B38C8E6009C6937 /* String+Siesta.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Siesta.swift"; sourceTree = ""; }; DA9AA8141CCAFDD20016DB18 /* ConfigurationPatternConvertible.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigurationPatternConvertible.swift; sourceTree = ""; }; @@ -456,6 +516,7 @@ buildActionMask = 2147483647; files = ( 9F0FAFB01D033FF400CE1B61 /* Siesta.framework in Frameworks */, + DA3E1662243D6D00001F7CCA /* SiestaTools.framework in Frameworks */, DAC4CFCE1DAC10FD00EECEDE /* SiestaUI.framework in Frameworks */, DAF2B7A827189B5A00E5B31A /* Quick in Frameworks */, DAF2B7A627189B5A00E5B31A /* Alamofire in Frameworks */, @@ -489,6 +550,7 @@ buildActionMask = 2147483647; files = ( CDCCAF7B1E6A31DA00860D18 /* Siesta.framework in Frameworks */, + DA3E1664243D6D18001F7CCA /* SiestaTools.framework in Frameworks */, DA5E4B5B22DCF0BB0059ED10 /* SiestaUI.framework in Frameworks */, DAA9102427239BDE00B2211D /* Nimble in Frameworks */, DAA9102027239BDE00B2211D /* Alamofire in Frameworks */, @@ -516,6 +578,7 @@ buildActionMask = 2147483647; files = ( DA336E5A1B2E6DDB006F702A /* Siesta.framework in Frameworks */, + DA3E1663243D6D0E001F7CCA /* SiestaTools.framework in Frameworks */, DAC4CFCF1DAC110500EECEDE /* SiestaUI.framework in Frameworks */, DAA9101E27239BD700B2211D /* Nimble in Frameworks */, DAA9101A27239BD700B2211D /* Alamofire in Frameworks */, @@ -530,6 +593,27 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DA8B5B5322DEAC93008E47B0 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DA8B5B6222DEADDB008E47B0 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DA8C834A1FC600A900C947F9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DAC0B3A21D651CB500D25C44 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -617,6 +701,9 @@ CDCCAF721E6A31D900860D18 /* Siesta.framework */, CDCCAF7A1E6A31DA00860D18 /* SiestaTests.xctest */, DA5E4B4B22DCE9670059ED10 /* SiestaUI.framework */, + DA8C83521FC600A900C947F9 /* SiestaTools.framework */, + DA8B5B5A22DEAC93008E47B0 /* SiestaTools.framework */, + DA8B5B6922DEADDB008E47B0 /* SiestaTools.framework */, ); name = Products; sourceTree = ""; @@ -625,6 +712,7 @@ isa = PBXGroup; children = ( DAC0B37B1D651A4600D25C44 /* Core */, + DA8C833F1FC5FFDA00C947F9 /* Tools */, DAE490981B4E51AA004D97D6 /* UI (iOS) */, ); path = Source; @@ -647,10 +735,20 @@ DA9F4BDD1B3F8E2E00E8966F /* WeakCacheSpec.swift */, DA4D61971B751FEE00F6BB9C /* ObjcCompatibilitySpec.m */, DA99B5C71B38C36A009C6937 /* Support */, + DA3E165E243C24A3001F7CCA /* FileCacheSpec.swift */, ); path = Functional; sourceTree = ""; }; + DA8C833F1FC5FFDA00C947F9 /* Tools */ = { + isa = PBXGroup; + children = ( + DA8DEB491FC6763300555D92 /* FileCache.swift */, + ); + name = Tools; + path = SiestaTools; + sourceTree = ""; + }; DA99B5C61B38C356009C6937 /* Support */ = { isa = PBXGroup; children = ( @@ -659,6 +757,7 @@ CDCCAF681E6A313800860D18 /* Info-watchOS.plist */, CDCCAF751E6A31D900860D18 /* Info-tvOS.plist */, DA336E521B2E6DDB006F702A /* Siesta.h */, + DA8C83571FC6096100C947F9 /* SiestaTools.h */, DA6022801D65590800FB5673 /* SiestaUI.h */, DA3FBD9A1B55917600161A25 /* Siesta-ObjC.swift */, DA9F4BDB1B3DFE3700E8966F /* WeakCache.swift */, @@ -811,6 +910,30 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DA8B5B5422DEAC93008E47B0 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + DA8B5B5522DEAC93008E47B0 /* SiestaTools.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DA8B5B6322DEADDB008E47B0 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + DA8B5B6422DEADDB008E47B0 /* SiestaTools.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DA8C834B1FC600A900C947F9 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + DA8C83581FC6096100C947F9 /* SiestaTools.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DAC0B3A31D651CB500D25C44 /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; @@ -844,6 +967,7 @@ ); dependencies = ( 9F0FAFAF1D033FE800CE1B61 /* PBXTargetDependency */, + DA8B5B6E22DEAE97008E47B0 /* PBXTargetDependency */, DAC4CFCD1DAC10F500EECEDE /* PBXTargetDependency */, ); name = "SiestaTests macOS"; @@ -926,6 +1050,7 @@ ); dependencies = ( CDCCAF7D1E6A31DA00860D18 /* PBXTargetDependency */, + DA8B5B7022DEAEA2008E47B0 /* PBXTargetDependency */, DA5E4B5122DCEFCA0059ED10 /* PBXTargetDependency */, ); name = "SiestaTests tvOS"; @@ -991,6 +1116,7 @@ ); dependencies = ( DA336E5C1B2E6DDB006F702A /* PBXTargetDependency */, + DA8C835A1FC60A2900C947F9 /* PBXTargetDependency */, DAC4CFCB1DAC10EC00EECEDE /* PBXTargetDependency */, ); name = "SiestaTests iOS"; @@ -1022,6 +1148,63 @@ productReference = DA5E4B4B22DCE9670059ED10 /* SiestaUI.framework */; productType = "com.apple.product-type.framework"; }; + DA8B5B4F22DEAC93008E47B0 /* SiestaTools macOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = DA8B5B5722DEAC93008E47B0 /* Build configuration list for PBXNativeTarget "SiestaTools macOS" */; + buildPhases = ( + DA8B5B5222DEAC93008E47B0 /* Sources */, + DA8B5B5322DEAC93008E47B0 /* Frameworks */, + DA8B5B5422DEAC93008E47B0 /* Headers */, + DA8B5B5622DEAC93008E47B0 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + DA8B5B5D22DEACA4008E47B0 /* PBXTargetDependency */, + ); + name = "SiestaTools macOS"; + productName = Siesta; + productReference = DA8B5B5A22DEAC93008E47B0 /* SiestaTools.framework */; + productType = "com.apple.product-type.framework"; + }; + DA8B5B5E22DEADDB008E47B0 /* SiestaTools tvOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = DA8B5B6622DEADDB008E47B0 /* Build configuration list for PBXNativeTarget "SiestaTools tvOS" */; + buildPhases = ( + DA8B5B6122DEADDB008E47B0 /* Sources */, + DA8B5B6222DEADDB008E47B0 /* Frameworks */, + DA8B5B6322DEADDB008E47B0 /* Headers */, + DA8B5B6522DEADDB008E47B0 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + DA8B5B6C22DEAE72008E47B0 /* PBXTargetDependency */, + ); + name = "SiestaTools tvOS"; + productName = Siesta; + productReference = DA8B5B6922DEADDB008E47B0 /* SiestaTools.framework */; + productType = "com.apple.product-type.framework"; + }; + DA8C83401FC600A900C947F9 /* SiestaTools iOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = DA8C834F1FC600A900C947F9 /* Build configuration list for PBXNativeTarget "SiestaTools iOS" */; + buildPhases = ( + DA8C83431FC600A900C947F9 /* Sources */, + DA8C834A1FC600A900C947F9 /* Frameworks */, + DA8C834B1FC600A900C947F9 /* Headers */, + DA8C834D1FC600A900C947F9 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + DA8C83411FC600A900C947F9 /* PBXTargetDependency */, + ); + name = "SiestaTools iOS"; + productName = Siesta; + productReference = DA8C83521FC600A900C947F9 /* SiestaTools.framework */; + productType = "com.apple.product-type.framework"; + }; DAC0B37D1D651CB500D25C44 /* SiestaUI iOS */ = { isa = PBXNativeTarget; buildConfigurationList = DAC0B3A71D651CB500D25C44 /* Build configuration list for PBXNativeTarget "SiestaUI iOS" */; @@ -1105,6 +1288,9 @@ DA5E4B3922DCE9670059ED10 = { ProvisioningStyle = Manual; }; + DA8C83401FC600A900C947F9 = { + LastSwiftMigration = 0910; + }; DAC0B37D1D651CB500D25C44 = { LastSwiftMigration = 1000; }; @@ -1135,6 +1321,9 @@ 9F2FB51D1C28645E0068DFFA /* Siesta macOS */, CDCCAF641E6A313800860D18 /* Siesta watchOS */, CDCCAF711E6A31D900860D18 /* Siesta tvOS */, + DA8C83401FC600A900C947F9 /* SiestaTools iOS */, + DA8B5B4F22DEAC93008E47B0 /* SiestaTools macOS */, + DA8B5B5E22DEADDB008E47B0 /* SiestaTools tvOS */, DAC0B37D1D651CB500D25C44 /* SiestaUI iOS */, DAC0B3AC1D651CC700D25C44 /* SiestaUI macOS */, DA5E4B3922DCE9670059ED10 /* SiestaUI tvOS */, @@ -1210,6 +1399,27 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DA8B5B5622DEAC93008E47B0 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DA8B5B6522DEADDB008E47B0 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DA8C834D1FC600A900C947F9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DAC0B3A51D651CB500D25C44 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1302,6 +1512,7 @@ DA788A8F1D6AC1590085C820 /* ObjcCompatibilitySpec.m in Sources */, 9F0FAF991D033FCD00CE1B61 /* TestService.swift in Sources */, 9F0FAF9A1D033FCD00CE1B61 /* SiestaSpec.swift in Sources */, + DA3E1660243C24A3001F7CCA /* FileCacheSpec.swift in Sources */, DA5A90312054E0C500309D8B /* EntityCacheSpec.swift in Sources */, 9F0FAF9B1D033FCD00CE1B61 /* ResourcePathsSpec.swift in Sources */, DA7285E71D0DF46600132CD9 /* PipelineSpec.swift in Sources */, @@ -1451,6 +1662,7 @@ CD5651F71E6F306B009F224A /* ResponseDataHandlingSpec.swift in Sources */, CD5651F21E6F306B009F224A /* ResourcePathsSpec.swift in Sources */, CD5651F81E6F306B009F224A /* ProgressSpec.swift in Sources */, + DA3E1661243C24A3001F7CCA /* FileCacheSpec.swift in Sources */, DA5E4B5C22DCF1260059ED10 /* RemoteImageViewSpec.swift in Sources */, DA62C9862428670500398674 /* NetworkStub.swift in Sources */, CD5651F11E6F306B009F224A /* ResourceSpecBase.swift in Sources */, @@ -1529,6 +1741,7 @@ DA2FF1061C0F97F600C98FF1 /* Networking-Alamofire.swift in Sources */, DA8EF6D11BC20AFE002175EB /* ProgressSpec.swift in Sources */, DAC0CE6D1B4A3ADA004FBB4B /* ResourceObserversSpec.swift in Sources */, + DA3E165F243C24A3001F7CCA /* FileCacheSpec.swift in Sources */, DA49EA801B36174700AE1B8F /* SpecHelpers.swift in Sources */, DA62C9842428670400398674 /* NetworkStub.swift in Sources */, DA9F4BDE1B3F8E2E00E8966F /* WeakCacheSpec.swift in Sources */, @@ -1559,6 +1772,30 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DA8B5B5222DEAC93008E47B0 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DA8B5B7122DEB0DF008E47B0 /* FileCache.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DA8B5B6122DEADDB008E47B0 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DA8B5B7222DEB0DF008E47B0 /* FileCache.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DA8C83431FC600A900C947F9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DA8DEB4A1FC6763300555D92 /* FileCache.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DAC0B37E1D651CB500D25C44 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1612,6 +1849,36 @@ target = DA5E4B3922DCE9670059ED10 /* SiestaUI tvOS */; targetProxy = DA5E4B5022DCEFCA0059ED10 /* PBXContainerItemProxy */; }; + DA8B5B5D22DEACA4008E47B0 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 9F2FB51D1C28645E0068DFFA /* Siesta macOS */; + targetProxy = DA8B5B5C22DEACA4008E47B0 /* PBXContainerItemProxy */; + }; + DA8B5B6C22DEAE72008E47B0 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = CDCCAF711E6A31D900860D18 /* Siesta tvOS */; + targetProxy = DA8B5B6B22DEAE72008E47B0 /* PBXContainerItemProxy */; + }; + DA8B5B6E22DEAE97008E47B0 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DA8B5B4F22DEAC93008E47B0 /* SiestaTools macOS */; + targetProxy = DA8B5B6D22DEAE97008E47B0 /* PBXContainerItemProxy */; + }; + DA8B5B7022DEAEA2008E47B0 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DA8B5B5E22DEADDB008E47B0 /* SiestaTools tvOS */; + targetProxy = DA8B5B6F22DEAEA2008E47B0 /* PBXContainerItemProxy */; + }; + DA8C83411FC600A900C947F9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DA336E4E1B2E6DDB006F702A /* Siesta iOS */; + targetProxy = DA8C83421FC600A900C947F9 /* PBXContainerItemProxy */; + }; + DA8C835A1FC60A2900C947F9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DA8C83401FC600A900C947F9 /* SiestaTools iOS */; + targetProxy = DA8C83591FC60A2900C947F9 /* PBXContainerItemProxy */; + }; DAC4CFCB1DAC10EC00EECEDE /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = DAC0B37D1D651CB500D25C44 /* SiestaUI iOS */; @@ -1638,6 +1905,7 @@ 9F0FAFAA1D033FCD00CE1B61 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CLANG_ENABLE_MODULES = YES; COMBINE_HIDPI_IMAGES = YES; INFOPLIST_FILE = "$(SRCROOT)/Tests/Info-macOS.plist"; @@ -1655,6 +1923,7 @@ 9F0FAFAB1D033FCD00CE1B61 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CLANG_ENABLE_MODULES = YES; COMBINE_HIDPI_IMAGES = YES; INFOPLIST_FILE = "$(SRCROOT)/Tests/Info-macOS.plist"; @@ -2068,6 +2337,7 @@ DA336E671B2E6DDB006F702A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CLANG_ENABLE_MODULES = YES; INFOPLIST_FILE = "$(SRCROOT)/Tests/Info-iOS.plist"; LD_RUNPATH_SEARCH_PATHS = ( @@ -2083,6 +2353,7 @@ DA336E681B2E6DDB006F702A /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CLANG_ENABLE_MODULES = YES; INFOPLIST_FILE = "$(SRCROOT)/Tests/Info-iOS.plist"; LD_RUNPATH_SEARCH_PATHS = ( @@ -2144,6 +2415,127 @@ }; name = Release; }; + DA8B5B5822DEAC93008E47B0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = "$(SRCROOT)/Source/Info-macOS.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.bustoutsolutions.SiestaTools; + PRODUCT_NAME = SiestaTools; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + DA8B5B5922DEAC93008E47B0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = "$(SRCROOT)/Source/Info-macOS.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.bustoutsolutions.SiestaTools; + PRODUCT_NAME = SiestaTools; + SDKROOT = macosx; + SKIP_INSTALL = YES; + }; + name = Release; + }; + DA8B5B6722DEADDB008E47B0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = "$(SRCROOT)/Source/Info-tvOS.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.bustoutsolutions.SiestaTools; + PRODUCT_NAME = SiestaTools; + SDKROOT = appletvos; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + DA8B5B6822DEADDB008E47B0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = "$(SRCROOT)/Source/Info-tvOS.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.bustoutsolutions.SiestaTools; + PRODUCT_NAME = SiestaTools; + SDKROOT = appletvos; + SKIP_INSTALL = YES; + }; + name = Release; + }; + DA8C83501FC600A900C947F9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = "$(SRCROOT)/Source/Info-iOS.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.bustoutsolutions.SiestaTools; + PRODUCT_NAME = SiestaTools; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + DA8C83511FC600A900C947F9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = "$(SRCROOT)/Source/Info-iOS.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.bustoutsolutions.SiestaTools; + PRODUCT_NAME = SiestaTools; + SKIP_INSTALL = YES; + }; + name = Release; + }; DAC0B3A81D651CB500D25C44 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -2318,6 +2710,33 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + DA8B5B5722DEAC93008E47B0 /* Build configuration list for PBXNativeTarget "SiestaTools macOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DA8B5B5822DEAC93008E47B0 /* Debug */, + DA8B5B5922DEAC93008E47B0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DA8B5B6622DEADDB008E47B0 /* Build configuration list for PBXNativeTarget "SiestaTools tvOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DA8B5B6722DEADDB008E47B0 /* Debug */, + DA8B5B6822DEADDB008E47B0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DA8C834F1FC600A900C947F9 /* Build configuration list for PBXNativeTarget "SiestaTools iOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DA8C83501FC600A900C947F9 /* Debug */, + DA8C83511FC600A900C947F9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; DAC0B3A71D651CB500D25C44 /* Build configuration list for PBXNativeTarget "SiestaUI iOS" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Siesta.xcodeproj/xcshareddata/xcschemes/SiestaTools iOS.xcscheme b/Siesta.xcodeproj/xcshareddata/xcschemes/SiestaTools iOS.xcscheme new file mode 100644 index 00000000..c6d389c8 --- /dev/null +++ b/Siesta.xcodeproj/xcshareddata/xcschemes/SiestaTools iOS.xcscheme @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Siesta.xcodeproj/xcshareddata/xcschemes/SiestaTools macOS.xcscheme b/Siesta.xcodeproj/xcshareddata/xcschemes/SiestaTools macOS.xcscheme new file mode 100644 index 00000000..cb3d95ba --- /dev/null +++ b/Siesta.xcodeproj/xcshareddata/xcschemes/SiestaTools macOS.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Siesta.xcodeproj/xcshareddata/xcschemes/SiestaTools tvOS.xcscheme b/Siesta.xcodeproj/xcshareddata/xcschemes/SiestaTools tvOS.xcscheme new file mode 100644 index 00000000..20720125 --- /dev/null +++ b/Siesta.xcodeproj/xcshareddata/xcschemes/SiestaTools tvOS.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/Siesta/Entity.swift b/Source/Siesta/Entity.swift index 13e210ca..4fa1b8cf 100644 --- a/Source/Siesta/Entity.swift +++ b/Source/Siesta/Entity.swift @@ -156,6 +156,12 @@ public struct Entity } +extension Entity: Codable where ContentType: Codable + { + // codability synthesized + } + + /** Mixin that provides convenience accessors for the content of an optional contained entity. diff --git a/Source/Siesta/EntityCache.swift b/Source/Siesta/EntityCache.swift index 4e043edd..9a2c055c 100644 --- a/Source/Siesta/EntityCache.swift +++ b/Source/Siesta/EntityCache.swift @@ -15,8 +15,10 @@ import Foundation - recover from low memory situations with fewer reissued network requests, and - work offline. - Siesta uses any HTTP request caching provided by the networking layer (e.g. `URLCache`). Why another type of - caching, then? Because `URLCache` has a subtle but significant mismatch with the use cases above: + Siesta can aldo use whatever HTTP request the networking layer provides (e.g. `URLCache`). Why another type of + caching, then? Because `URLCache` has a several subtle but significant mismatches with the use cases above. + + The big one: * The purpose of HTTP caching is to _prevent_ network requests, but what we need is a way to show old data _while issuing new requests_. This is the real deal-killer. @@ -29,7 +31,7 @@ import Foundation exhibit the behavior we want; the logic involved is far more tangled and brittle than implementing a separate cache. * Precisely because of the complexity of these rules, APIs frequently disable all caching via headers. * HTTP caching does not preserve Siesta’s timestamps, which thwarts the staleness logic. - * HTTP caching stores raw responses; storing parsed responses offers the opportunity for faster app launch. + * HTTP caching stores raw responses. Apps may wish instead to cache responses in an app-specific parsed form. Siesta currently does not include any implementations of `EntityCache`, but a future version will. @@ -46,6 +48,12 @@ public protocol EntityCache */ associatedtype Key + /** + The type of payload this cache knows how to store and retrieve. If the response data configured at a particular + point in the cache does not match this content type, Siesta will log a warning and bypass the cache. + */ + associatedtype ContentType + /** Provides the key appropriate to this cache for the given resource. @@ -53,8 +61,11 @@ public protocol EntityCache This method is called for both cache writes _and_ for cache reads. The `resource` therefore may not have any content. Implementations will almost always examine `resource.url`. (Cache keys should be _at least_ as unique - as URLs except in very unusual circumstances.) Implementations may also want to examine `resource.configuration`, - for example to take authentication into account. + as URLs except in very unusual circumstances.) + + - Warning: When working with an authenticated API, caches must take care not to accidentally mix cached responses + for different users. The usual solution to this is to make `Key` vary with some sort of user ID as + well as the URL. - Note: This method is always called on the **main thread**. However, the key it returns will be passed repeatedly across threads. Siesta therefore strongly recommends making `Key` a value type, i.e. a struct. @@ -64,42 +75,39 @@ public protocol EntityCache /** Return the entity associated with the given key, or nil if it is not in the cache. - If this method returns an entity, it does _not_ pass through the transformer pipeline. Implementations should - return the entity as if already fully parsed and transformed — with the same type of `entity.content` that was - originally sent to `writeEntity(...)`. + If this method returns an entity, it passes through the portion of the transformer pipeline _after_ this cache. - Warning: This method may be called on a background thread. Make sure your implementation is threadsafe. */ - func readEntity(forKey key: Key) -> Entity? + func readEntity(forKey key: Key) throws -> Entity? /** - Store the given entity in the cache, associated with the given key. The key’s format is arbitrary, and internal - to Siesta. (OK, it’s just the resource’s URL, but you should pretend you don’t know that in your implementation. - Cache implementations should treat the `forKey` parameter as an opaque value.) + Store the given entity in the cache, associated with the given key. - This method receives entities _after_ they have been through the transformer pipeline. The `entity.content` will - be a parsed object, not raw data. + This method receives entities _after_ they have been through the stage of the transformer pipeline for which this + cache is configured. Implementations are under no obligation to actually perform the write. This method can — and should — examine the - type of the entity’s `content` and/or its header values, and ignore it if it is not encodable. + type of the entity’s `content` and/or its header values, and ignore it if it is unencodable or otherwise + unsuitable for caching. Note that this method does not receive a URL as input; if you need to limit caching to specific resources, use Siesta’s configuration mechanism to control which resources are cacheable. - Warning: The method may be called on a background thread. Make sure your implementation is threadsafe. */ - func writeEntity(_ entity: Entity, forKey key: Key) + func writeEntity(_ entity: Entity, forKey key: Key) throws /** Update the timestamp of the entity for the given key. If there is no such cache entry, do nothing. */ - func updateEntityTimestamp(_ timestamp: TimeInterval, forKey key: Key) + func updateEntityTimestamp(_ timestamp: TimeInterval, forKey key: Key) throws /** Remove any entities cached for the given key. After a call to `removeEntity(forKey:)`, subsequent calls to `readEntity(forKey:)` for the same key **must** return nil until the next call to `writeEntity(_:forKey:)`. */ - func removeEntity(forKey key: Key) + func removeEntity(forKey key: Key) throws /** Returns the GCD queue on which this cache implementation will do its work. @@ -125,11 +133,11 @@ extension EntityCache While this default implementation always gives the correct behavior, cache implementations may choose to override it for performance reasons. */ - public func updateEntityTimestamp(_ timestamp: TimeInterval, forKey key: Key) + public func updateEntityTimestamp(_ timestamp: TimeInterval, forKey key: Key) throws { - guard var entity = readEntity(forKey: key) else + guard var entity = try readEntity(forKey: key) else { return } entity.timestamp = timestamp - writeEntity(entity, forKey: key) + try writeEntity(entity, forKey: key) } } diff --git a/Source/Siesta/Pipeline/PipelineConfiguration.swift b/Source/Siesta/Pipeline/PipelineConfiguration.swift index ae758741..c9f52106 100644 --- a/Source/Siesta/Pipeline/PipelineConfiguration.swift +++ b/Source/Siesta/Pipeline/PipelineConfiguration.swift @@ -69,7 +69,7 @@ public struct Pipeline .map(\.key) let missingStages = Set(nonEmptyStages).subtracting(newValue) if !missingStages.isEmpty - { SiestaLog.log(.pipeline, ["WARNING: Stages", missingStages, "configured but not present in custom pipeline order, will be ignored:", newValue]) } + { SiestaLog.log(.pipeline, ["WARNING: Stages", missingStages, "are configured but not present in custom pipeline order", newValue, "and will be ignored"]) } } } @@ -183,7 +183,8 @@ public struct PipelineStage } /** - An optional persistent cache for this stage. + Configures a persistent cache for responses after they pass this stage. Passing nil removes any previously + configured caching. When processing a response, the cache will receive the resulting entity after this stage’s transformers have run. @@ -195,7 +196,26 @@ public struct PipelineStage initially see an empty resources and then get a `newData(Cache)` event — even if you never call `load()`. */ public mutating func cacheUsing(_ cache: T) - { cacheBox = CacheBox(cache: cache) } + { + cacheBox = CacheBox(cache: cache) + } + + /** + Convenience for `cacheUsing(_:)` that takes a failable closure, for situations where caching is optional. + + Configures a persistent cache at this stage if the given closure succeeds. Disables caching at this stage if the + closure throws an error. + */ + public mutating func cacheUsing(_ cache: () throws -> T) + { + do + { cacheBox = CacheBox(cache: try cache()) } + catch + { + SiestaLog.log(.cache, ["Error while attempting to create persistent cache for", self, "; caching disabled at this stage:", error]) + doNotCache() + } + } /** Removes any caching that had been configured at this stage. diff --git a/Source/Siesta/Pipeline/PipelineProcessing.swift b/Source/Siesta/Pipeline/PipelineProcessing.swift index a1eb394e..2aece5b9 100644 --- a/Source/Siesta/Pipeline/PipelineProcessing.swift +++ b/Source/Siesta/Pipeline/PipelineProcessing.swift @@ -13,7 +13,7 @@ extension Pipeline private var stagesInOrder: [PipelineStage] { order.compactMap { self[$0] } } - private typealias StageAndEntry = (PipelineStage, CacheEntryProtocol?) + private typealias StageAndEntry = (stage: PipelineStage, cacheEntry: CacheEntryProtocol?) private func stagesAndEntries(for resource: Resource) -> [StageAndEntry] { @@ -21,7 +21,7 @@ extension Pipeline { stage in (stage, stage.cacheBox?.buildEntry(resource)) } } - internal func makeProcessor(_ rawResponse: Response, resource: Resource) -> () -> Response + internal func makeProcessor(_ rawResponse: Response, resource: Resource) -> () -> ResponseInfo { // Generate cache keys on main thread (because this touches Resource) let stagesAndEntries = self.stagesAndEntries(for: resource) @@ -29,40 +29,53 @@ extension Pipeline // Return deferred processor to run on background queue return { - let result = Pipeline.processAndCache(rawResponse, using: stagesAndEntries) + let result = Pipeline.process(rawResponse, using: stagesAndEntries) - SiestaLog.log(.pipeline, [" └╴Response after pipeline:", result.summary()]) - SiestaLog.log(.networkDetails, [" Details:", result.dump(" ")]) + SiestaLog.log(.pipeline, [" └╴Response after pipeline:", result.response.summary()]) + SiestaLog.log(.networkDetails, [" Details:", result.response.dump(" ")]) return result } } // Runs on a background queue - private static func processAndCache( + private static func process( _ rawResponse: Response, using stagesAndEntries: StagesAndEntries) - -> Response + -> ResponseInfo where StagesAndEntries.Iterator.Element == StageAndEntry { - stagesAndEntries.reduce(rawResponse) + stagesAndEntries.reduce(into: ResponseInfo(response: rawResponse)) { - let input = $0, - (stage, cacheEntry) = $1 + let (stage, cacheEntry) = $1 - let output = stage.process(input) + $0.response = stage.process($0.response) - if case .success(let entity) = output, + if case .success(let entity) = $0.response, let cacheEntry = cacheEntry { - SiestaLog.log(.cache, [" ├╴Caching entity with", type(of: entity.content), "content in", cacheEntry]) - cacheEntry.write(entity) + $0.cacheActions.append( + cacheAction(writing: entity, into: cacheEntry)) } + } + } + + // swiftlint:disable implicit_return - return output + fileprivate static func cacheAction( + writing entity: Entity, + into cacheEntry: CacheEntryProtocol) + -> () -> Void + { + return + { + SiestaLog.log(.cache, ["Caching entity with", type(of: entity.content), "content for", cacheEntry]) + cacheEntry.write(entity) } } + // swiftlint:enable implicit_return + internal func checkCache(for resource: Resource) -> Request { Resource @@ -92,11 +105,13 @@ extension Pipeline private struct CacheRequestDelegate: RequestDelegate { let requestDescription: String + private weak var resource: Resource? private let stagesAndEntries: [StageAndEntry] init(for resource: Resource, searching stagesAndEntries: [StageAndEntry]) { - requestDescription = "Cache request for \(resource)" + requestDescription = "Cache check for \(resource)" + self.resource = resource self.stagesAndEntries = stagesAndEntries } @@ -104,19 +119,18 @@ extension Pipeline { defaultEntityCacheWorkQueue.async { - let response: Response - if let entity = self.performCacheLookup() - { response = .success(entity) } - else - { - response = .failure(RequestError( - userMessage: NSLocalizedString("Cache miss", comment: "userMessage"), - cause: RequestError.Cause.CacheMiss())) - } + var result = performCacheLookup() + ?? ResponseInfo( + response: .failure(RequestError( + userMessage: NSLocalizedString("Cache miss", comment: "userMessage"), + cause: RequestError.Cause.CacheMiss()))) + + if let resource = resource + { result.configurationSource = .init(method: .get, resource: resource) } DispatchQueue.main.async { - completionHandler.broadcastResponse(ResponseInfo(response: response)) + completionHandler.broadcastResponse(result) } } } @@ -128,7 +142,7 @@ extension Pipeline { self } // Runs on a background queue - private func performCacheLookup() -> Entity? + private func performCacheLookup() -> ResponseInfo? { for (index, (_, cacheEntry)) in stagesAndEntries.enumerated().reversed() { @@ -136,22 +150,38 @@ extension Pipeline { SiestaLog.log(.cache, ["Cache hit for", cacheEntry]) - let processed = Pipeline.processAndCache( + var processed = Pipeline.process( .success(result), using: stagesAndEntries.suffix(from: index + 1)) - switch processed + // TODO: explain this + + if let cacheEntry = cacheEntry + { + processed.cacheActions.insert( + Pipeline.cacheAction(writing: result, into: cacheEntry), + at: 0) + } + + processed.cacheActions.append(contentsOf: + stagesAndEntries.prefix(upTo: index) + .compactMap { $0.cacheEntry?.remove }) // Can't use keypath due to https://bugs.swift.org/browse/SR-12519 + + switch processed.response { case .failure: SiestaLog.log(.cache, ["Error processing cached entity; will ignore cached value. Error:", processed]) - case .success(let entity): - return entity + case .success: + return processed } } } return nil } + + var logCategory: SiestaLog.Category? + { .cache } } } @@ -180,7 +210,10 @@ private protocol CacheEntryProtocol func remove() } -private struct CacheEntry: CacheEntryProtocol + +// MARK: Cache Entry + +private struct CacheEntry: CacheEntryProtocol, CustomStringConvertible where Cache: EntityCache, Cache.Key == Key { let cache: Cache @@ -199,24 +232,56 @@ private struct CacheEntry: CacheEntryProtocol func read() -> Entity? { cache.workQueue.sync - { self.cache.readEntity(forKey: self.key) } + { + catchAndLogErrors(attemptingTo: "read cached entity") + { try cache.readEntity(forKey: key)?.withContentRetyped() } + } } func write(_ entity: Entity) { + guard let cacheableEntity = entity.withContentRetyped() as Entity? else + { + SiestaLog.log(.cache, ["WARNING: Unable to cache entity:", Cache.self, "expects", Cache.ContentType.self, "but content at this stage of the pipeline is", type(of: entity.content)]) + return + } + cache.workQueue.async - { self.cache.writeEntity(entity, forKey: self.key) } + { + catchAndLogErrors(attemptingTo: "write cached entity") + { try cache.writeEntity(cacheableEntity, forKey: key) } + } } func updateTimestamp(_ timestamp: TimeInterval) { cache.workQueue.async - { self.cache.updateEntityTimestamp(timestamp, forKey: self.key) } + { + catchAndLogErrors(attemptingTo: "update entity timestamp") + { try cache.updateEntityTimestamp(timestamp, forKey: key) } + } } func remove() { cache.workQueue.async - { self.cache.removeEntity(forKey: self.key) } + { + catchAndLogErrors(attemptingTo: "remove entity from cache") + { try cache.removeEntity(forKey: key) } + } } + + private func catchAndLogErrors(attemptingTo actionName: String, action: () throws -> T?) -> T? + { + do + { return try action() } + catch + { + SiestaLog.log(.cache, ["WARNING:", cache, "unable to", actionName, "for", key, ":", error]) + return nil + } + } + + var description: String + { "\(key) in \(cache)" } } diff --git a/Source/Siesta/Request/LiveRequest.swift b/Source/Siesta/Request/LiveRequest.swift index f562f345..3ab779ab 100644 --- a/Source/Siesta/Request/LiveRequest.swift +++ b/Source/Siesta/Request/LiveRequest.swift @@ -92,6 +92,11 @@ public protocol RequestDelegate A description of the underlying operation suitable for logging and debugging. */ var requestDescription: String { get } + + /** + Indicates where information about requests using this delegate should be logged. + */ + var logCategory: SiestaLog.Category? { get } } extension RequestDelegate @@ -107,6 +112,12 @@ extension RequestDelegate */ public var progressReportingInterval: TimeInterval { 0.05 } + + /** + Log info to `SiestaLog.Category.network`. + */ + public var logCategory: SiestaLog.Category? + { .network } } /** @@ -164,7 +175,7 @@ private final class LiveRequest: Request, RequestCompletionHandler, CustomDebugS return self } - SiestaLog.log(.network, [delegate.requestDescription]) + logDelegateStateChange([delegate.requestDescription]) underlyingOperationStarted = true delegate.startUnderlyingOperation(passingResponseTo: self) @@ -186,7 +197,7 @@ private final class LiveRequest: Request, RequestCompletionHandler, CustomDebugS return } - SiestaLog.log(.network, ["Cancelled", delegate.requestDescription]) + logDelegateStateChange(["Cancelled", delegate.requestDescription]) delegate.cancelUnderlyingOperation() @@ -274,6 +285,12 @@ private final class LiveRequest: Request, RequestCompletionHandler, CustomDebugS // MARK: Debug + func logDelegateStateChange(_ messageParts: @autoclosure () -> [Any?]) + { + if let category = delegate.logCategory + { SiestaLog.log(category, messageParts()) } + } + final var debugDescription: String { "Request:" diff --git a/Source/Siesta/Request/NetworkRequest.swift b/Source/Siesta/Request/NetworkRequest.swift index 2e094d87..ba355483 100644 --- a/Source/Siesta/Request/NetworkRequest.swift +++ b/Source/Siesta/Request/NetworkRequest.swift @@ -12,8 +12,9 @@ internal final class NetworkRequestDelegate: RequestDelegate { // Basic metadata private let resource: Resource + private let method: RequestMethod internal var config: Configuration - { resource.configuration(for: underlyingRequest) } + { resource.configuration(for: method) } internal let requestDescription: String // Networking @@ -32,6 +33,9 @@ internal final class NetworkRequestDelegate: RequestDelegate self.requestBuilder = requestBuilder underlyingRequest = requestBuilder() + method = RequestMethod(rawValue: underlyingRequest.httpMethod?.lowercased() ?? "") + ?? .get // All unrecognized methods default to .get + requestDescription = SiestaLog.Category.enabled.contains(.network) || SiestaLog.Category.enabled.contains(.networkDetails) ? debugStr([underlyingRequest.httpMethod, underlyingRequest.url]) @@ -90,7 +94,7 @@ internal final class NetworkRequestDelegate: RequestDelegate { DispatchQueue.mainThreadPrecondition() - SiestaLog.log(.network, ["Response: ", underlyingResponse?.statusCode ?? error, "←", requestDescription]) + SiestaLog.log(.network, ["Response:", underlyingResponse?.statusCode ?? error, "←", requestDescription]) SiestaLog.log(.networkDetails, ["Raw response headers:", underlyingResponse?.allHeaderFields]) SiestaLog.log(.networkDetails, ["Raw response body:", body?.count ?? 0, "bytes"]) @@ -148,12 +152,18 @@ internal final class NetworkRequestDelegate: RequestDelegate { let processor = config.pipeline.makeProcessor(rawInfo.response, resource: resource) - DispatchQueue.global(qos: DispatchQoS.QoSClass.userInitiated).async + let processingQueue = DispatchQueue.global(qos: DispatchQoS.QoSClass.userInitiated) + processingQueue.async { - let processedInfo = - rawInfo.isNew - ? ResponseInfo(response: processor(), isNew: true) - : rawInfo + var processedInfo: ResponseInfo + if rawInfo.isNew + { + processedInfo = processor() + processedInfo.isNew = true + processedInfo.configurationSource = .init(method: self.method, resource: self.resource) + } + else + { processedInfo = rawInfo } // result from a 304 is already transformed, cached, etc. DispatchQueue.main.async { afterTransformation(processedInfo) } diff --git a/Source/Siesta/Request/Request.swift b/Source/Siesta/Request/Request.swift index 7a6a8a09..e681bdfa 100644 --- a/Source/Siesta/Request/Request.swift +++ b/Source/Siesta/Request/Request.swift @@ -118,7 +118,7 @@ public protocol Request: AnyObject The property will always be 1 if a request is completed. Note that the converse is not true: a value of 1 does not necessarily mean the request is completed; it means only that we estimate the request _should_ be completed - by now. Use the `isCompleted` property to test for actual completion. + by now. Use the `state` property to test for actual completion. */ var progress: Double { get } @@ -252,6 +252,12 @@ public struct ResponseInfo /// Used to distinguish `ResourceEvent.newData` from `ResourceEvent.notModified`. public var isNew: Bool + /// Used to determine whether the response is suitable for caching when loaded by a particular resource + var configurationSource: ConfigurationSource? + + /// Callbacks to cache this response according to the pipeline config originally used to process it + var cacheActions: [() -> Void] = [] + /// Creates new responseInfo, with `isNew` true by default. public init(response: Response, isNew: Bool = true) { @@ -264,4 +270,10 @@ public struct ResponseInfo response: .failure(RequestError( userMessage: NSLocalizedString("Request cancelled", comment: "userMessage"), cause: RequestError.Cause.RequestCancelled(networkError: nil)))) + + struct ConfigurationSource: Equatable + { + var method: RequestMethod + weak var resource: Resource? + } } diff --git a/Source/Siesta/Request/RequestCallbacks.swift b/Source/Siesta/Request/RequestCallbacks.swift index 03c8b516..48e8ffb3 100644 --- a/Source/Siesta/Request/RequestCallbacks.swift +++ b/Source/Siesta/Request/RequestCallbacks.swift @@ -90,7 +90,7 @@ internal struct CallbackGroup completedValue = arguments // We need to let this mutating method finish before calling the callbacks. Some of them inspect - // completeValue (via isCompleted), which causes a simultaneous access error at runtime. + // completeValue (via request.state), which causes a simultaneous access error at runtime. // See https://github.com/apple/swift-evolution/blob/master/proposals/0176-enforce-exclusive-access-to-memory.md let snapshot = self diff --git a/Source/Siesta/Request/RequestChaining.swift b/Source/Siesta/Request/RequestChaining.swift index c9558b3d..0da605b4 100644 --- a/Source/Siesta/Request/RequestChaining.swift +++ b/Source/Siesta/Request/RequestChaining.swift @@ -135,4 +135,10 @@ internal struct RequestChainDelgate: RequestDelegate { RequestChainDelgate(wrapping: wrappedRequest.repeated(), whenCompleted: determineAction) } + + /** + Chain requests are silent since their underlying requests are logged already. + */ + var logCategory: SiestaLog.Category? + { nil } } diff --git a/Source/Siesta/Resource/Resource.swift b/Source/Siesta/Resource/Resource.swift index 94e94b02..bc21c893 100644 --- a/Source/Siesta/Resource/Resource.swift +++ b/Source/Siesta/Resource/Resource.swift @@ -68,13 +68,6 @@ public final class Resource: NSObject { service.configuration(forResource: self, requestMethod: method) } } - internal func configuration(for request: URLRequest) -> Configuration - { - configuration(for: - RequestMethod(rawValue: request.httpMethod?.lowercased() ?? "") - ?? .get) // All unrecognized methods default to .get - } - private var cachedConfig: [RequestMethod:Configuration] = [:] private var configVersion: UInt64 = 0 @@ -376,8 +369,8 @@ public final class Resource: NSObject if case .inProgress(let cacheRequest) = cacheCheckStatus { // isLoading needs to be: - // - false at first, - // - true after loadIfNeeded() while the cache check is in progress, but + // - false at first, even if a cache check has already started, + // - true after loadIfNeeded() while the cache check is still in progress, but // - false again before observers receive a cache hit. // // To make this happen, we need to add the chained cacheThenNetwork below @@ -397,10 +390,10 @@ public final class Resource: NSObject { _ in // We don’t need the result of the cache request here; resource state is already updated - if self.isUpToDate // If cached data is up to date... + if self.isUpToDate // If cached data is up to date... { - self.receiveDataNotModified() // ...tell observers isLoading is false... - return .useThisResponse // ...and no need to make a network call! + self.notifyObservers(.notModified) // ...tell observers isLoading is false... + return .useThisResponse // ...and no need to make a network call! } else { @@ -474,6 +467,23 @@ public final class Resource: NSObject req.onProgress(notifyObservers(ofProgress:)) + req.onCompletion + { + // TODO: explain this + if let configurationSource = $0.configurationSource + { + if configurationSource == .init(method: .get, resource: self) + { + for action in $0.cacheActions + { action() } + } + else + { + SiestaLog.log(.cache, ["Resource.load(using:) will not cache the results of this request because it is not a GET and/or is for a different resource:", configurationSource.method, configurationSource.resource]) + } + } + } + req.onNewData(receiveNewDataFromNetwork) req.onNotModified(receiveDataNotModified) req.onFailure(receiveError) @@ -710,19 +720,25 @@ public final class Resource: NSObject { if case .notStarted = cacheCheckStatus { + if _latestData != nil + { + cacheCheckStatus = .completed + return + } + cacheCheckStatus = .inProgress( configuration.pipeline.checkCache(for: self) .onCompletion { [weak self] result in - guard let resource = self, resource.latestData == nil else + self?.cacheCheckStatus = .completed + + guard let resource = self, resource._latestData == nil else { SiestaLog.log(.cache, ["Ignoring cache hit for", self, " because it is either deallocated or already has data"]) return } - resource.cacheCheckStatus = .completed - if case .success(let entity) = result.response { resource.receiveNewData(entity, source: .cache) } } diff --git a/Source/Siesta/Support/Logging.swift b/Source/Siesta/Support/Logging.swift index a52be73f..d70b3e31 100644 --- a/Source/Siesta/Support/Logging.swift +++ b/Source/Siesta/Support/Logging.swift @@ -77,7 +77,7 @@ public enum SiestaLog .forceUnwrapped(because: "Modulus always maps thread IDs to valid unicode scalars"))) threadID /= 0x55 } - threadName += "]" + threadName += "] " } let prefix = "Siesta:\(paddedCategory) │ \(threadName)" let indentedMessage = $1.replacingOccurrences(of: "\n", with: "\n" + prefix) diff --git a/Source/Siesta/Support/SiestaTools.h b/Source/Siesta/Support/SiestaTools.h new file mode 100644 index 00000000..a984896b --- /dev/null +++ b/Source/Siesta/Support/SiestaTools.h @@ -0,0 +1,16 @@ +// +// SiestaTools.h +// Siesta +// +// Created by Paul on 2017/11/22. +// Copyright © 2016 Bust Out Solutions. All rights reserved. +// + +#import + +//! Project version number for SiestaTools. +FOUNDATION_EXPORT double SiestaToolsVersionNumber; + +//! Project version string for SiestaTools. +FOUNDATION_EXPORT const unsigned char SiestaToolsVersionString[]; + diff --git a/Source/SiestaTools/FileCache.swift b/Source/SiestaTools/FileCache.swift new file mode 100644 index 00000000..9cef7d15 --- /dev/null +++ b/Source/SiestaTools/FileCache.swift @@ -0,0 +1,173 @@ +// +// FileCache.swift +// Siesta +// +// Created by Paul on 2017/11/22. +// Copyright © 2017 Bust Out Solutions. All rights reserved. +// + +#if !COCOAPODS + import Siesta +#endif +import Foundation +import CommonCrypto + +private typealias File = URL + +private let fileCacheFormatVersion: [UInt8] = [0] + +private let decoder = PropertyListDecoder() +private let encoder: PropertyListEncoder = + { + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + return encoder + }() + +public struct FileCache: EntityCache, CustomStringConvertible + where ContentType: Codable + { + private let isolationStrategy: DataIsolationStrategy + private let cacheDir: File + + public let description: String + + public init(poolName: String = "Default", dataIsolation isolationStrategy: DataIsolationStrategy) throws + { + let cacheDir = try FileManager.default + .url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + .appendingPathComponent(Bundle.main.bundleIdentifier ?? "") // no bundle → directly inside cache dir + .appendingPathComponent("Siesta") + .appendingPathComponent(poolName) + try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) + + self.init(inDirectory: cacheDir, dataIsolation: isolationStrategy, cacheName: "poolName: " + poolName) + } + + public init( + inDirectory cacheDir: URL, + dataIsolation isolationStrategy: DataIsolationStrategy, + cacheName: String? = nil) + { + self.cacheDir = cacheDir + self.isolationStrategy = isolationStrategy + self.description = "\(type(of: self))(\(cacheName ?? cacheDir.path))" + } + + // MARK: - Keys and filenames + + public func key(for resource: Resource) -> Key? + { Key(resource: resource, isolationStrategy: isolationStrategy) } + + public struct Key: CustomStringConvertible + { + fileprivate var hash: String + private var url: URL + + fileprivate init(resource: Resource, isolationStrategy: DataIsolationStrategy) + { + url = resource.url + hash = isolationStrategy.keyData(for: url) + .sha256 + .urlSafeBase64EncodedString + } + + public var description: String + { "FileCache.Key(\(url))" } + } + + private func file(for key: Key) -> File + { cacheDir.appendingPathComponent(key.hash + ".cache") } + + // MARK: - Reading and writing + + public func readEntity(forKey key: Key) throws -> Entity? + { + do { + return try + decoder.decode( + Entity.self, + from: Data(contentsOf: file(for: key))) + } + catch CocoaError.fileReadNoSuchFile + { } // a cache miss is just fine; don't log it + return nil + } + + public func writeEntity(_ entity: Entity, forKey key: Key) throws + { + #if os(macOS) + let options: Data.WritingOptions = [.atomic] + #else + let options: Data.WritingOptions = [.atomic, .completeFileProtection] + #endif + + try encoder.encode(entity) + .write(to: file(for: key), options: options) + } + + public func removeEntity(forKey key: Key) throws + { + try FileManager.default.removeItem(at: file(for: key)) + } + } + +extension FileCache + { + public struct DataIsolationStrategy + { + private let keyPrefix: Data + + private init(keyIsolator: Data) + { + keyPrefix = + fileCacheFormatVersion // prevents us from parsing old cache entries using some new future format + // TODO: include pipeline stage name here + + "\(ContentType.self)".utf8 // prevent data collision when caching at multiple pipeline stages + + [0] // null-terminate ContentType to prevent bleed into username + + keyIsolator // prevents one user from seeing another’s cached requests + + [0] // separator for URL + } + + fileprivate func keyData(for url: URL) -> Data + { + Data(keyPrefix + url.absoluteString.utf8) + } + + public static var sharedByAllUsers: DataIsolationStrategy + { DataIsolationStrategy(keyIsolator: Data()) } + + public static func perUser(identifiedBy partitionID: T) throws -> DataIsolationStrategy + where T: Codable + { + DataIsolationStrategy( + keyIsolator: try encoder.encode([partitionID])) + } + } + } + +// MARK: - Encryption helpers + +extension Data + { + fileprivate var sha256: Data + { + var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + _ = withUnsafeBytes + { CC_SHA256($0.baseAddress, CC_LONG(count), &hash) } + return Data(hash) + } + + fileprivate var shortenWithSHA256: Data + { + count > 32 ? sha256 : self + } + + fileprivate var urlSafeBase64EncodedString: String + { + base64EncodedString() + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "=", with: "") + } + } diff --git a/Tests/.swiftlint.yml b/Tests/.swiftlint.yml index 70f5c2d6..8fbcc6c8 100644 --- a/Tests/.swiftlint.yml +++ b/Tests/.swiftlint.yml @@ -8,5 +8,5 @@ disabled_rules: custom_rules: focused_spec: - name: "Focused spec in effect" - regex: '(fit|fdescribe)\s*\(' + name: "Remember not to commit any focused or disabled specs!" + regex: '\b[fx](it|describe)\s*\(' diff --git a/Tests/Functional/EntityCacheSpec.swift b/Tests/Functional/EntityCacheSpec.swift index da3c3d06..6487e832 100644 --- a/Tests/Functional/EntityCacheSpec.swift +++ b/Tests/Functional/EntityCacheSpec.swift @@ -6,7 +6,7 @@ // Copyright © 2018 Bust Out Solutions. All rights reserved. // -import Siesta +@testable import Siesta import Foundation import Quick @@ -16,17 +16,26 @@ class EntityCacheSpec: ResourceSpecBase { override func resourceSpec(_ service: @escaping () -> Service, _ resource: @escaping () -> Resource) { - func configureCache(_ cache: C, at stageKey: PipelineStageKey) + func configureCache( + _ cache: C, + for pattern: ConfigurationPatternConvertible = "**", + at stageKey: PipelineStageKey) { - service().configure + service().configure(pattern) { $0.pipeline[stageKey].cacheUsing(cache) } } func waitForCacheRead(_ cache: TestCache) - { expect(cache.receivedCacheRead).toEventually(beTrue()) } + { + expect(cache.receivedCacheRead).toEventually(beTrue()) + cache.receivedCacheRead = false + } func waitForCacheWrite(_ cache: TestCache) - { expect(cache.receivedCacheWrite).toEventually(beTrue()) } + { + expect(cache.receivedCacheWrite).toEventually(beTrue()) + cache.receivedCacheWrite = false + } beforeEach { @@ -168,6 +177,15 @@ class EntityCacheSpec: ResourceSpecBase describe("write") { + @discardableResult + func stubAndAwaitRequestWithoutLoading(for resource: Resource, method: RequestMethod) -> Request + { + NetworkStub.add(method, { resource }) + let req = resource.request(method) + awaitNewData(req, initialState: .inProgress) + return req + } + func expectCacheWrite(to cache: TestCache, content: String) { waitForCacheWrite(cache) @@ -175,7 +193,7 @@ class EntityCacheSpec: ResourceSpecBase expect(cache.entries.values.first?.typedContent()) == content } - it("caches new data on success") + it("caches new data on a successful load()") { let testCache = TestCache("new data") configureCache(testCache, at: .cleanup) @@ -219,6 +237,17 @@ class EntityCacheSpec: ResourceSpecBase .toEventually(equal(2000)) } + it("preserves the timestamp of cached data") + { + let testCache = UnwritableCache(cachedValue: + Entity(content: "hi", charset: nil, headers: [:], timestamp: 2001)) + configureCache(testCache, at: .cleanup) + + setResourceTime(2010) + awaitNewData(resource().loadIfNeeded()!) + expect(resource().latestData?.timestamp) == 2001 + } + it("clears cached data on local override") { let testCache = TestCache("local override") @@ -231,6 +260,119 @@ class EntityCacheSpec: ResourceSpecBase expect(testCache.entries).toEventually(beEmpty()) } + + it("does not write previously cached data back to the cache when reading it") + { + let testCache = TestCache("does not write previously cached") + configureCache(testCache, at: .parsing) + configureCache(UnwritableCache(), at: .model) + configureCache(UnwritableCache(), at: .cleanup) + + testCache.entries[TestCacheKey(forTestResourceIn: testCache)] = + Entity(content: "🌮", contentType: "text/plain") + awaitNewData(resource().loadIfNeeded()!, initialState: .inProgress) + expect(resource().typedContent()) == "🌮modcle" + } + + it("does not cache anything for call to Resource.request() without load()") + { + configureCache(UnwritableCache(), at: .cleanup) + stubAndAwaitRequestWithoutLoading(for: resource(), method: .get) + } + + it("caches new data for a GET on the same resource passed to load(using:)") + { + let testCache = TestCache("new data from load(using:)") + configureCache(testCache, at: .cleanup) + let req = stubAndAwaitRequestWithoutLoading(for: resource(), method: .get) + resource().load(using: req) + expectCacheWrite(to: testCache, content: "decparmodcle") + } + + it("does not cache anything for a non-GET request, even if passed to load(using:)") + { + configureCache(UnwritableCache(), at: .cleanup) + for method in RequestMethod.allCases + where method != .get + { + let req = stubAndAwaitRequestWithoutLoading(for: resource(), method: method) + resource().load(using: req) + } + } + + it("does not cache anything for a GET request for a different resource, even if passed to load(using:)") + { + let otherResource = service().resource("/otherResource") + configureCache(UnwritableCache(), at: .cleanup) + let req = stubAndAwaitRequestWithoutLoading(for: otherResource, method: .get) + resource().load(using: req) + } + + func stubText(_ text: String) + { + NetworkStub.add( + .get, resource, + returning: HTTPResponse( + headers: ["content-type": "text/plain; charset=utf-8"], + body: text)) + } + + it("will restore cache state to an older request if passed to load(using:)") + { + let testCache = TestCache("restore cache state") + configureCache(testCache, at: .model) + service().configure + { + $0.pipeline[.decoding].removeTransformers() + $0.pipeline[.decoding].add(TextResponseTransformer()) + } + + stubText("🌮") + let originalReq = resource().load() + awaitNewData(originalReq, initialState: .inProgress) + expectCacheWrite(to: testCache, content: "🌮parmod") + + stubText("🧇") + awaitNewData(resource().load(), initialState: .inProgress) + expectCacheWrite(to: testCache, content: "🧇parmod") + + resource().load(using: originalReq) + expectCacheWrite(to: testCache, content: "🌮parmod") + } + + it("will restore cache state to original state if original cache request is passed to load(using:)") + { + let testCacheDec = TestCache("restore cache state - dec") + let testCacheMod = TestCache("restore cache state - mod") + let testCacheCle = TestCache("restore cache state - cle") + configureCache(testCacheDec, at: .decoding) + configureCache(testCacheMod, at: .model) + configureCache(testCacheCle, at: .cleanup) + service().configure + { + $0.pipeline[.decoding].removeTransformers() + $0.pipeline[.decoding].add(TextResponseTransformer()) + } + + testCacheMod.entries[TestCacheKey(forTestResourceIn: testCacheMod)] = + Entity(content: "🌮", contentType: "text/plain") + let originalReq = resource().loadIfNeeded()! + awaitNewData(originalReq, initialState: .inProgress) + expect(resource().typedContent()) == "🌮cle" + + stubText("🧇") + awaitNewData(resource().load(), initialState: .inProgress) + expectCacheWrite(to: testCacheDec, content: "🧇") + expectCacheWrite(to: testCacheMod, content: "🧇parmod") + expectCacheWrite(to: testCacheCle, content: "🧇parmodcle") + + resource().load(using: originalReq) + expectCacheWrite(to: testCacheMod, content: "🌮") + expectCacheWrite(to: testCacheCle, content: "🌮cle") + waitForCacheWrite(testCacheDec) + expect(testCacheDec.entries[TestCacheKey(forTestResourceIn: testCacheDec)]) + .toEventually(beNil()) + } } func exerciseCache() @@ -300,8 +442,11 @@ private class TestCache: EntityCache func removeEntity(forKey key: TestCacheKey) { - _ = DispatchQueue.main.sync - { entries.removeValue(forKey: key) } + DispatchQueue.main.sync + { + entries.removeValue(forKey: key) + self.receivedCacheWrite = true + } } } @@ -369,17 +514,22 @@ private class KeylessCache: EntityCache private struct UnwritableCache: EntityCache { + let cachedValue: Entity? + + init(cachedValue: Entity? = nil) + { self.cachedValue = cachedValue } + func key(for resource: Resource) -> URL? { resource.url } func readEntity(forKey key: URL) -> Entity? - { nil } + { cachedValue } func writeEntity(_ entity: Entity, forKey key: URL) - { fatalError("cache should never be written to") } + { fail("cache should never be written to") } func removeEntity(forKey key: URL) - { fatalError("cache should never be written to") } + { fail("cache should never be written to") } } private class ObserverEventRecorder: ResourceObserver diff --git a/Tests/Functional/FileCacheSpec.swift b/Tests/Functional/FileCacheSpec.swift new file mode 100644 index 00000000..f98f117a --- /dev/null +++ b/Tests/Functional/FileCacheSpec.swift @@ -0,0 +1,24 @@ +// +// FileCacheSpec.swift +// Siesta +// +// Created by Paul on 2020/4/6. +// Copyright © 2020 Bust Out Solutions. All rights reserved. +// + +import Siesta +import SiestaTools + +import Foundation +import Quick +import Nimble + +class FileCacheSpec: ResourceSpecBase + { + override func resourceSpec(_ service: @escaping () -> Service, _ resource: @escaping () -> Resource) + { + it("needs testing") + { + } + } + } diff --git a/Tests/Functional/RequestSpec.swift b/Tests/Functional/RequestSpec.swift index d2d89bda..f7bf5624 100644 --- a/Tests/Functional/RequestSpec.swift +++ b/Tests/Functional/RequestSpec.swift @@ -670,7 +670,7 @@ class RequestSpec: ResourceSpecBase expectResult("yoyo", for: chainedReq) } - it("isCompleted is false until a “use” action") + it("state is inProgress until callback returns a “use response” action") { let reqStub = stubText("yo").delay() let req = resource().request(.get).chained