diff --git a/Cartfile b/Cartfile
index e69de29b..8b137891 100644
--- a/Cartfile
+++ b/Cartfile
@@ -0,0 +1 @@
+
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/Podfile b/Examples/GithubBrowser/Podfile
index e82ee473..a2d08eb4 100644
--- a/Examples/GithubBrowser/Podfile
+++ b/Examples/GithubBrowser/Podfile
@@ -4,4 +4,5 @@ target 'GithubBrowser' do
use_frameworks!
pod 'Siesta/UI', path: '../..'
+ pod 'Siesta/Tools', path: '../..'
end
diff --git a/Examples/GithubBrowser/Podfile.lock b/Examples/GithubBrowser/Podfile.lock
index 3881b4e5..8a395947 100644
--- a/Examples/GithubBrowser/Podfile.lock
+++ b/Examples/GithubBrowser/Podfile.lock
@@ -1,9 +1,12 @@
PODS:
- Siesta/Core (1.5.0)
+ - Siesta/Tools (1.5.0):
+ - Siesta/Core
- Siesta/UI (1.5.0):
- Siesta/Core
DEPENDENCIES:
+ - Siesta/Tools (from `../..`)
- Siesta/UI (from `../..`)
EXTERNAL SOURCES:
@@ -11,8 +14,8 @@ EXTERNAL SOURCES:
:path: "../.."
SPEC CHECKSUMS:
- Siesta: 407b99ae05344d2de33d98e9e239551086daee6a
+ Siesta: 31a12f6f9905bd144cfadaa861a0cdd55c2faa17
-PODFILE CHECKSUM: 974001388daa9ecbfa915ea0bc4093a33242099c
+PODFILE CHECKSUM: 96de1def0845d136c0ee748e2599bcfc49e6b814
COCOAPODS: 1.9.1
diff --git a/Examples/GithubBrowser/Source/API/GithubAPI.swift b/Examples/GithubBrowser/Source/API/GithubAPI.swift
index 44ae643b..1e0a89c2 100644
--- a/Examples/GithubBrowser/Source/API/GithubAPI.swift
+++ b/Examples/GithubBrowser/Source/API/GithubAPI.swift
@@ -16,7 +16,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:
@@ -43,13 +43,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 ––––––
@@ -115,12 +150,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
}
@@ -128,6 +165,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 2b55ed94..14ff4ab7 100644
--- a/Examples/GithubBrowser/Source/UI/RepositoryListViewController.swift
+++ b/Examples/GithubBrowser/Source/UI/RepositoryListViewController.swift
@@ -5,30 +5,13 @@ class RepositoryListViewController: UITableViewController, ResourceObserver {
// MARK: Interesting Siesta stuff
- var repositoriesResource: Resource? {
- didSet {
- oldValue?.removeObservers(ownedBy: self)
-
- repositoriesResource?
- .addObserver(self)
- .addObserver(statusOverlay, owner: self)
- .loadIfNeeded()
- }
- }
-
- var repositories: [Repository] = [] {
- didSet {
- tableView.reloadData()
- }
- }
+ @ResourceBacked(default: [])
+ var repositories: [Repository]
var statusOverlay = ResourceStatusOverlay()
func resourceChanged(_ resource: Resource, event: ResourceEvent) {
- // Siesta’s typedContent() infers from the type of the repositories property that
- // repositoriesResource should hold content of type [Repository].
-
- repositories = repositoriesResource?.typedContent() ?? []
+ tableView.reloadData()
}
// MARK: Standard table view stuff
@@ -37,7 +20,12 @@ class RepositoryListViewController: UITableViewController, ResourceObserver {
super.viewDidLoad()
view.backgroundColor = SiestaTheme.darkColor
+
statusOverlay.embed(in: self)
+ statusOverlay.displayPriority = [.anyData, .loading, .error]
+
+ $repositories.addObserver(self)
+ $repositories.addObserver(statusOverlay)
}
override func viewDidLayoutSubviews() {
@@ -65,8 +53,8 @@ class RepositoryListViewController: UITableViewController, ResourceObserver {
if let repositoryVC = segue.destination as? RepositoryViewController,
let cell = sender as? RepositoryTableViewCell {
- repositoryVC.repositoryResource =
- repositoriesResource?.optionalRelative(
+ repositoryVC.$repository.resource =
+ $repositories.resource?.optionalRelative(
cell.repository?.url)
}
}
diff --git a/Examples/GithubBrowser/Source/UI/RepositoryViewController.swift b/Examples/GithubBrowser/Source/UI/RepositoryViewController.swift
index 73f58571..282b8f08 100644
--- a/Examples/GithubBrowser/Source/UI/RepositoryViewController.swift
+++ b/Examples/GithubBrowser/Source/UI/RepositoryViewController.swift
@@ -25,48 +25,25 @@ class RepositoryViewController: UIViewController, ResourceObserver {
// MARK: Resources
- var repositoryResource: Resource? {
- didSet {
- updateObservation(from: oldValue, to: repositoryResource)
- }
- }
+ @ResourceBacked(default: nil)
+ var repository: Repository?
- var starredResource: Resource? {
- didSet {
- updateObservation(from: oldValue, to: starredResource)
- }
- }
+ @ResourceBacked(default: false)
+ var isStarred: Bool
- var languagesResource: Resource? {
- didSet {
- updateObservation(from: oldValue, to: languagesResource)
- }
- }
+ @ResourceBacked(default: [:])
+ var languages: [String:Int]
- var contributorsResource: Resource? {
- didSet {
- updateObservation(from: oldValue, to: contributorsResource)
- }
- }
+ @ResourceBacked(default: [])
+ var contributors: [User]
private func updateObservation(from oldResource: Resource?, to newResource: Resource?) {
guard oldResource != newResource else { return }
- oldResource?.removeObservers(ownedBy: self)
- newResource?
- .addObserver(self)
- .addObserver(statusOverlay, owner: self)
- .loadIfNeeded()
- }
-
- // MARK: Content conveniences
-
- var repository: Repository? {
- return repositoryResource?.typedContent()
- }
-
- var isStarred: Bool {
- return starredResource?.typedContent() ?? false
+ for resourceBox in [$repository, $isStarred, $languages, $contributors] as [AnyResourceBacked] {
+ resourceBox.addObserver(self)
+ resourceBox.addObserver(statusOverlay)
+ }
}
// MARK: Display
@@ -75,6 +52,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
@@ -117,37 +95,30 @@ class RepositoryViewController: UIViewController, ResourceObserver {
descriptionLabel?.text = repository?.description
homepageButton?.setTitle(repository?.homepage, for: .normal)
- if let contributors: [User] = contributorsResource?.typedContent() {
- contributorsLabel?.text = contributors
- .map { $0.login }
- .joined(separator: "\n")
- } else {
- contributorsLabel?.text = "–"
- }
+ contributorsLabel?.text = contributors
+ .map { $0.login }
+ .joined(separator: "\n")
- if let languages: [String:Int] = languagesResource?.typedContent() {
- languagesLabel?.text = languages.keys.joined(separator: " • ")
- } else {
- languagesLabel?.text = "–"
- }
+ languagesLabel?.text = languages.keys.joined(separator: " • ")
}
func showStarred() {
if let repository = repository {
- starredResource = GitHubAPI.currentUserStarred(repository)
+ $isStarred.resource = GitHubAPI.currentUserStarred(repository)
} else {
- starredResource = nil
+ $isStarred.resource = nil
}
- contributorsResource = repositoryResource?.optionalRelative(
+ $contributors.resource = $repository.resource?.optionalRelative(
repository?.contributorsURL)
- languagesResource = repositoryResource?.optionalRelative(
+ $languages.resource = $repository.resource?.optionalRelative(
repository?.languagesURL)
starCountLabel?.text = repository?.starCount?.description
starIcon?.text = isStarred ? "★" : "☆"
starButton?.setTitle(isStarred ? "Unstar" : "Star", for: .normal)
- starButton?.isEnabled = (repository != nil)
+ starButton?.isEnabled = ($isStarred.resource != nil)
+ starButton?.alpha = ($isStarred.resource != nil) ? 1 : 0.3
}
// MARK: Actions
diff --git a/Examples/GithubBrowser/Source/UI/UserViewController.swift b/Examples/GithubBrowser/Source/UI/UserViewController.swift
index e4ccd5a7..efe93bda 100644
--- a/Examples/GithubBrowser/Source/UI/UserViewController.swift
+++ b/Examples/GithubBrowser/Source/UI/UserViewController.swift
@@ -52,6 +52,8 @@ class UserViewController: UIViewController, UISearchBarDelegate, ResourceObserve
view.backgroundColor = SiestaTheme.darkColor
statusOverlay.embed(in: self)
+ statusOverlay.displayPriority = [.anyData, .loading, .error]
+
showUser(nil)
searchBar.becomeFirstResponder()
@@ -144,7 +146,7 @@ class UserViewController: UIViewController, UISearchBarDelegate, ResourceObserve
// Setting the repositoriesResource property of the embedded VC triggers load & display of the user’s repos.
- repoListVC?.repositoriesResource = repositoriesResource
+ repoListVC?.$repositories.resource = repositoriesResource
usernameLabel.text = title
}
diff --git a/Siesta.podspec b/Siesta.podspec
index 0a82c9db..29c9893d 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 188cbfdf..3d612b30 100644
--- a/Siesta.xcodeproj/project.pbxproj
+++ b/Siesta.xcodeproj/project.pbxproj
@@ -135,6 +135,10 @@
CDCCAFCE1E6A389800860D18 /* PipelineConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACE0BAD1D0201F800607F3E /* PipelineConfiguration.swift */; };
CDCCAFCF1E6A389800860D18 /* PipelineProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7D05C31D57C2B500431980 /* PipelineProcessing.swift */; };
CDCCAFD01E6A389800860D18 /* ResponseTransformer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA33142A1B4DA04900A7A175 /* ResponseTransformer.swift */; };
+ DA02C9252433BDA00098EDC6 /* ResourceBacked.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA02C9242433BDA00098EDC6 /* ResourceBacked.swift */; };
+ DA02C9262433BDA00098EDC6 /* ResourceBacked.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA02C9242433BDA00098EDC6 /* ResourceBacked.swift */; };
+ DA02C9272433BDA00098EDC6 /* ResourceBacked.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA02C9242433BDA00098EDC6 /* ResourceBacked.swift */; };
+ DA02C9282433BDA00098EDC6 /* ResourceBacked.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA02C9242433BDA00098EDC6 /* ResourceBacked.swift */; };
DA0AB90B1B5B5DD9006A3067 /* ARC+Siesta.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0AB90A1B5B5DD9006A3067 /* ARC+Siesta.swift */; };
DA0AB90D1B5C0090006A3067 /* Collection+Siesta.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0AB90C1B5C0090006A3067 /* Collection+Siesta.swift */; };
DA0B49991B2F68DC00BFBF38 /* Quick.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DA0B49971B2F68DC00BFBF38 /* Quick.framework */; };
@@ -206,6 +210,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 */; };
@@ -302,6 +312,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 */;
@@ -400,6 +452,7 @@
CDCCAF751E6A31D900860D18 /* Info-tvOS.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "Info-tvOS.plist"; path = "../../Info-tvOS.plist"; sourceTree = ""; };
CDCCAF7A1E6A31DA00860D18 /* SiestaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SiestaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
CDCCAF811E6A31DA00860D18 /* Info-tvOS.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Info-tvOS.plist"; sourceTree = ""; };
+ DA02C9242433BDA00098EDC6 /* ResourceBacked.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ResourceBacked.swift; sourceTree = ""; };
DA0AB90A1B5B5DD9006A3067 /* ARC+Siesta.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ARC+Siesta.swift"; sourceTree = ""; };
DA0AB90C1B5C0090006A3067 /* Collection+Siesta.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Collection+Siesta.swift"; sourceTree = ""; };
DA0B49961B2F68DC00BFBF38 /* Nimble.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Nimble.framework; path = Carthage/Build/iOS/Nimble.framework; sourceTree = ""; };
@@ -439,6 +492,11 @@
DA612E421B38AD1000DE9A9F /* Alamofire.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Alamofire.framework; path = Carthage/Build/iOS/Alamofire.framework; 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 = ""; };
@@ -561,6 +619,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;
@@ -660,6 +739,9 @@
CDCCAF721E6A31D900860D18 /* Siesta.framework */,
CDCCAF7A1E6A31DA00860D18 /* SiestaTests.xctest */,
DA5E4B4B22DCE9670059ED10 /* SiestaUI.framework */,
+ DA8C83521FC600A900C947F9 /* SiestaTools.framework */,
+ DA8B5B5A22DEAC93008E47B0 /* SiestaTools.framework */,
+ DA8B5B6922DEADDB008E47B0 /* SiestaTools.framework */,
);
name = Products;
sourceTree = "";
@@ -668,6 +750,7 @@
isa = PBXGroup;
children = (
DAC0B37B1D651A4600D25C44 /* Core */,
+ DA8C833F1FC5FFDA00C947F9 /* Tools */,
DAE490981B4E51AA004D97D6 /* UI (iOS) */,
);
path = Source;
@@ -726,6 +809,15 @@
name = tvOS;
sourceTree = "";
};
+ DA8C833F1FC5FFDA00C947F9 /* Tools */ = {
+ isa = PBXGroup;
+ children = (
+ DA8DEB491FC6763300555D92 /* FileCache.swift */,
+ );
+ name = Tools;
+ path = SiestaTools;
+ sourceTree = "";
+ };
DA99B5C61B38C356009C6937 /* Support */ = {
isa = PBXGroup;
children = (
@@ -734,6 +826,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 */,
@@ -771,6 +864,7 @@
DA60F5FB1B30B2F800D76DC6 /* Resource.swift */,
DAC0B3721D63880600D25C44 /* ResourceNavigation.swift */,
DA9F4BDF1B41C9CC00E8966F /* ResourceObserver.swift */,
+ DA02C9242433BDA00098EDC6 /* ResourceBacked.swift */,
);
path = Resource;
sourceTree = "";
@@ -879,6 +973,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;
@@ -912,6 +1030,7 @@
);
dependencies = (
9F0FAFAF1D033FE800CE1B61 /* PBXTargetDependency */,
+ DA8B5B6E22DEAE97008E47B0 /* PBXTargetDependency */,
DAC4CFCD1DAC10F500EECEDE /* PBXTargetDependency */,
);
name = "SiestaTests macOS";
@@ -987,6 +1106,7 @@
);
dependencies = (
CDCCAF7D1E6A31DA00860D18 /* PBXTargetDependency */,
+ DA8B5B7022DEAEA2008E47B0 /* PBXTargetDependency */,
DA5E4B5122DCEFCA0059ED10 /* PBXTargetDependency */,
);
name = "SiestaTests tvOS";
@@ -1045,6 +1165,7 @@
);
dependencies = (
DA336E5C1B2E6DDB006F702A /* PBXTargetDependency */,
+ DA8C835A1FC60A2900C947F9 /* PBXTargetDependency */,
DAC4CFCB1DAC10EC00EECEDE /* PBXTargetDependency */,
);
name = "SiestaTests iOS";
@@ -1071,6 +1192,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" */;
@@ -1154,6 +1332,9 @@
DA5E4B3922DCE9670059ED10 = {
ProvisioningStyle = Manual;
};
+ DA8C83401FC600A900C947F9 = {
+ LastSwiftMigration = 0910;
+ };
DAC0B37D1D651CB500D25C44 = {
LastSwiftMigration = 1000;
};
@@ -1180,6 +1361,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 */,
@@ -1255,6 +1439,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;
@@ -1394,6 +1599,7 @@
9F2FB53B1C2864CB0068DFFA /* Service.swift in Sources */,
DAF19BC81F355611000B9734 /* StandardTransformers.swift in Sources */,
9F2FB52B1C2864970068DFFA /* String+Siesta.swift in Sources */,
+ DA02C9262433BDA00098EDC6 /* ResourceBacked.swift in Sources */,
9F2FB5331C2864BD0068DFFA /* Networking.swift in Sources */,
DACD23471D618CE300848C04 /* Ω_Deprecations.swift in Sources */,
);
@@ -1438,6 +1644,7 @@
CDCCAF981E6A384E00860D18 /* Networking.swift in Sources */,
DAF19BCB1F355615000B9734 /* StandardTransformers.swift in Sources */,
CDCCAFCB1E6A389700860D18 /* PipelineConfiguration.swift in Sources */,
+ DA02C9272433BDA00098EDC6 /* ResourceBacked.swift in Sources */,
CDCCAFC31E6A389200860D18 /* NetworkRequest.swift in Sources */,
CDCCAFCD1E6A389700860D18 /* ResponseTransformer.swift in Sources */,
);
@@ -1482,6 +1689,7 @@
CDCCAF9B1E6A384F00860D18 /* Networking.swift in Sources */,
DAF19BCA1F355614000B9734 /* StandardTransformers.swift in Sources */,
CDCCAFCE1E6A389800860D18 /* PipelineConfiguration.swift in Sources */,
+ DA02C9282433BDA00098EDC6 /* ResourceBacked.swift in Sources */,
CDCCAFC91E6A389300860D18 /* NetworkRequest.swift in Sources */,
CDCCAFD01E6A389800860D18 /* ResponseTransformer.swift in Sources */,
);
@@ -1560,6 +1768,7 @@
DA99B5C91B38C8E6009C6937 /* String+Siesta.swift in Sources */,
DAF19BC71F355606000B9734 /* StandardTransformers.swift in Sources */,
DA9F7B1F1B8BC3B000A604C3 /* EntityCache.swift in Sources */,
+ DA02C9252433BDA00098EDC6 /* ResourceBacked.swift in Sources */,
DA3FD66D1D0DF65B00C75742 /* PipelineConfiguration.swift in Sources */,
DACD23461D618CE300848C04 /* Ω_Deprecations.swift in Sources */,
);
@@ -1604,6 +1813,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;
@@ -1657,6 +1890,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 */;
@@ -1683,6 +1946,7 @@
9F0FAFAA1D033FCD00CE1B61 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
+ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CLANG_ENABLE_MODULES = YES;
COMBINE_HIDPI_IMAGES = YES;
FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Carthage/Build/Mac/";
@@ -1697,6 +1961,7 @@
9F0FAFAB1D033FCD00CE1B61 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
+ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CLANG_ENABLE_MODULES = YES;
COMBINE_HIDPI_IMAGES = YES;
FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Carthage/Build/Mac/";
@@ -2070,6 +2335,7 @@
DA336E671B2E6DDB006F702A /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
+ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CLANG_ENABLE_MODULES = YES;
FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Carthage/Build/iOS/";
INFOPLIST_FILE = "$(SRCROOT)/Tests/Info-iOS.plist";
@@ -2082,6 +2348,7 @@
DA336E681B2E6DDB006F702A /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
+ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CLANG_ENABLE_MODULES = YES;
FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Carthage/Build/iOS/";
INFOPLIST_FILE = "$(SRCROOT)/Tests/Info-iOS.plist";
@@ -2134,6 +2401,109 @@
};
name = Release;
};
+ DA8B5B5822DEAC93008E47B0 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CLANG_ENABLE_MODULES = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "";
+ DYLIB_INSTALL_NAME_BASE = "@rpath";
+ FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Carthage/Build/macOS/";
+ 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";
+ FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Carthage/Build/macOS/";
+ 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";
+ FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Carthage/Build/tvOS/";
+ 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";
+ FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Carthage/Build/tvOS/";
+ 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";
+ FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Carthage/Build/iOS/";
+ 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";
+ FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Carthage/Build/iOS/";
+ 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 = {
@@ -2296,6 +2666,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/Source/Siesta/Entity.swift b/Source/Siesta/Entity.swift
index 1b58d755..38c5c1be 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 f630907e..22e8861b 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 556267dd..e625338b 100644
--- a/Source/Siesta/Pipeline/PipelineConfiguration.swift
+++ b/Source/Siesta/Pipeline/PipelineConfiguration.swift
@@ -69,7 +69,7 @@ public struct Pipeline
.map { key, _ in 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 03964f26..353ee17d 100644
--- a/Source/Siesta/Pipeline/PipelineProcessing.swift
+++ b/Source/Siesta/Pipeline/PipelineProcessing.swift
@@ -13,7 +13,7 @@ extension Pipeline
private var stagesInOrder: [PipelineStage]
{ return 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,37 +29,46 @@ 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
{
- return 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))
}
+ }
+ }
- return output
+ fileprivate static func cacheAction(
+ writing entity: Entity,
+ into cacheEntry: CacheEntryProtocol)
+ -> () -> ()
+ {
+ return
+ {
+ SiestaLog.log(.cache, ["Caching entity with", type(of: entity.content), "content for", cacheEntry])
+ cacheEntry.write(entity)
}
}
@@ -92,11 +101,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 +115,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 = self.performCacheLookup()
+ ?? ResponseInfo(
+ response: .failure(RequestError(
+ userMessage: NSLocalizedString("Cache miss", comment: "userMessage"),
+ cause: RequestError.Cause.CacheMiss())))
+
+ if let resource = self.resource
+ { result.configurationSource = .init(method: .get, resource: resource) }
DispatchQueue.main.async
{
- completionHandler.broadcastResponse(ResponseInfo(response: response))
+ completionHandler.broadcastResponse(result)
}
}
}
@@ -128,7 +138,7 @@ extension Pipeline
{ return self }
// Runs on a background queue
- private func performCacheLookup() -> Entity?
+ private func performCacheLookup() -> ResponseInfo?
{
for (index, (_, cacheEntry)) in stagesAndEntries.enumerated().reversed()
{
@@ -136,22 +146,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?
+ { return .cache }
}
}
@@ -180,7 +206,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 +228,56 @@ private struct CacheEntry: CacheEntryProtocol
func read() -> Entity?
{
return cache.workQueue.sync
- { self.cache.readEntity(forKey: self.key) }
+ {
+ catchAndLogErrors(attemptingTo: "read cached entity")
+ { try self.cache.readEntity(forKey: self.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) }
+ {
+ self.catchAndLogErrors(attemptingTo: "write cached entity")
+ { try self.cache.writeEntity(cacheableEntity, forKey: self.key) }
+ }
}
func updateTimestamp(_ timestamp: TimeInterval)
{
cache.workQueue.async
- { self.cache.updateEntityTimestamp(timestamp, forKey: self.key) }
+ {
+ self.catchAndLogErrors(attemptingTo: "update entity timestamp")
+ { try self.cache.updateEntityTimestamp(timestamp, forKey: self.key) }
+ }
}
func remove()
{
cache.workQueue.async
- { self.cache.removeEntity(forKey: self.key) }
+ {
+ self.catchAndLogErrors(attemptingTo: "remove entity from cache")
+ { try self.cache.removeEntity(forKey: self.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
+ { return "\(key) in \(cache)" }
}
diff --git a/Source/Siesta/Request/LiveRequest.swift b/Source/Siesta/Request/LiveRequest.swift
index 70690a61..c4e56514 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
{ return 0.05 }
+
+ /**
+ Log info to `SiestaLog.Category.network`.
+ */
+ public var logCategory: SiestaLog.Category?
+ { return .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
+ public func logDelegateStateChange(_ messageParts: @autoclosure () -> [Any?])
+ {
+ if let category = delegate.logCategory
+ { SiestaLog.log(category, messageParts()) }
+ }
+
final var debugDescription: String
{
return "Request:"
diff --git a/Source/Siesta/Request/NetworkRequest.swift b/Source/Siesta/Request/NetworkRequest.swift
index 45ae3481..858306eb 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
- { return resource.configuration(for: underlyingRequest) }
+ { return 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 c28c8da3..2e6058f7 100644
--- a/Source/Siesta/Request/Request.swift
+++ b/Source/Siesta/Request/Request.swift
@@ -120,7 +120,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 }
@@ -255,6 +255,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: [() -> ()] = []
+
/// Creates new responseInfo, with `isNew` true by default.
public init(response: Response, isNew: Bool = true)
{
@@ -267,4 +273,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 78397a00..27887c8e 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 83b48e90..a28dfa0f 100644
--- a/Source/Siesta/Request/RequestChaining.swift
+++ b/Source/Siesta/Request/RequestChaining.swift
@@ -135,4 +135,10 @@ internal struct RequestChainDelgate: RequestDelegate
{
return RequestChainDelgate(wrapping: wrappedRequest.repeated(), whenCompleted: determineAction)
}
+
+ /**
+ Chain requests are silent since their underlying requests are logged already.
+ */
+ var logCategory: SiestaLog.Category?
+ { return nil }
}
diff --git a/Source/Siesta/Resource/Resource.swift b/Source/Siesta/Resource/Resource.swift
index acc6bad7..151f79ed 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
- {
- return 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/Resource/ResourceBacked.swift b/Source/Siesta/Resource/ResourceBacked.swift
new file mode 100644
index 00000000..2b474e29
--- /dev/null
+++ b/Source/Siesta/Resource/ResourceBacked.swift
@@ -0,0 +1,91 @@
+//
+// ResourceBacked.swift
+// Siesta
+//
+// Created by Paul on 2020/3/31.
+// Copyright © 2020 Bust Out Solutions. All rights reserved.
+//
+
+import Foundation
+
+@propertyWrapper
+public class ResourceBacked: AnyResourceBacked
+ {
+ public var defaultData: ContentType
+ public var loadAutomatically: Bool
+
+ private var observers = [ResourceObserver]()
+
+ public var resource: Resource?
+ {
+ willSet
+ {
+ resource?.removeObservers(ownedBy: self)
+ }
+
+ didSet
+ {
+ resource?.addObserver(owner: self)
+ {
+ [weak self] _,_ in
+ if let self = self
+ { self.wrappedValue = self.resource?.typedContent() ?? self.defaultData }
+ }
+
+ for observer in observers
+ { resource?.addObserver(observer, owner: self) }
+
+ if loadAutomatically
+ { resource?.loadIfNeeded() }
+ }
+ }
+
+ public init(default defaultData: ContentType, loadAutomatically: Bool = true)
+ {
+ self.wrappedValue = defaultData
+ self.defaultData = defaultData
+ self.loadAutomatically = loadAutomatically
+ }
+
+ public private(set) var wrappedValue: ContentType
+
+ public var projectedValue: ResourceBacked
+ { self }
+
+ @discardableResult
+ public func addObserver(_ observer: ResourceObserver) -> Self
+ {
+ observers.append(observer)
+ resource?.addObserver(observer, owner: self)
+ return self
+ }
+
+ @discardableResult
+ public func addObserver(
+ file: String = #file,
+ line: Int = #line,
+ closure: @escaping ResourceObserverClosure)
+ -> Self
+ {
+ addObserver(
+ ClosureObserver(
+ closure: closure,
+ debugDescription: "ClosureObserver(\(conciseSourceLocation(file: file, line: line)))"))
+ }
+
+ public var isLoading: Bool
+ { resource?.isLoading ?? false }
+ }
+
+public protocol AnyResourceBacked
+ {
+ var resource: Resource? { get set }
+
+ @discardableResult
+ func addObserver(_ observer: ResourceObserver) -> Self
+
+ @discardableResult
+ func addObserver(file: String, line: Int, closure: @escaping ResourceObserverClosure) -> Self
+
+ var isLoading: Bool { get }
+ }
diff --git a/Source/Siesta/Resource/ResourceObserver.swift b/Source/Siesta/Resource/ResourceObserver.swift
index 2ae47406..ca03f5bc 100644
--- a/Source/Siesta/Resource/ResourceObserver.swift
+++ b/Source/Siesta/Resource/ResourceObserver.swift
@@ -398,7 +398,7 @@ internal class ObserverEntry: CustomStringConvertible
}
}
-private struct ClosureObserver: ResourceObserver, CustomDebugStringConvertible
+struct ClosureObserver: ResourceObserver, CustomDebugStringConvertible
{
let closure: ResourceObserverClosure
let debugDescription: String
diff --git a/Source/Siesta/Support/Logging.swift b/Source/Siesta/Support/Logging.swift
index 1b138be5..86f4f3af 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..d9247501
--- /dev/null
+++ b/Source/SiestaTools/FileCache.swift
@@ -0,0 +1,172 @@
+//
+// FileCache.swift
+// Siesta
+//
+// Created by Paul on 2017/11/22.
+// Copyright © 2017 Bust Out Solutions. All rights reserved.
+//
+
+#if !COCOAPODS
+ import Siesta
+#endif
+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?
+ { return 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
+ { return "FileCache.Key(\(url))" }
+ }
+
+ private func file(for key: Key) -> File
+ { return 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
+ {
+ return Data(keyPrefix + url.absoluteString.utf8)
+ }
+
+ public static var sharedByAllUsers: DataIsolationStrategy
+ { return DataIsolationStrategy(keyIsolator: Data()) }
+
+ public static func perUser(identifiedBy partitionID: T) throws -> DataIsolationStrategy
+ where T: Codable
+ {
+ return 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
+ {
+ return count > 32 ? sha256 : self
+ }
+
+ fileprivate var urlSafeBase64EncodedString: String
+ {
+ return 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 d4642fe1..d12ea38c 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.all
+ 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()
@@ -301,7 +443,10 @@ private class TestCache: EntityCache
func removeEntity(forKey key: TestCacheKey)
{
_ = DispatchQueue.main.sync
- { entries.removeValue(forKey: key) }
+ {
+ 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?
{ return resource.url }
func readEntity(forKey key: URL) -> Entity?
- { return nil }
+ { return 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/RequestSpec.swift b/Tests/Functional/RequestSpec.swift
index 539f6868..ef1ce41c 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
diff --git a/Tests/Functional/TestService.swift b/Tests/Functional/TestService.swift
index e92ae648..1a534e55 100644
--- a/Tests/Functional/TestService.swift
+++ b/Tests/Functional/TestService.swift
@@ -43,5 +43,6 @@ public class TestService: Service // for Obj-C tests only
req.onCompletion { _ in responseExpectation.fulfill() }
QuickSpec.current.waitForExpectations(timeout: 1)
}
+ allRequests = []
}
}