├── .swift-version ├── .mention-bot ├── Resources ├── CacheIcon.png └── CachePresentation.png ├── Package.resolved ├── Playgrounds ├── Storage.playground │ ├── contents.xcplayground │ ├── playground.xcworkspace │ │ └── contents.xcworkspacedata │ └── Contents.swift └── SimpleStorage.playground │ ├── contents.xcplayground │ └── Contents.swift ├── Cache.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ ├── Cache-iOS.xcscheme │ ├── Cache-tvOS.xcscheme │ └── Cache-macOS.xcscheme ├── Source ├── Shared │ ├── Library │ │ ├── Types.swift │ │ ├── Optional+Extension.swift │ │ ├── ObservationToken.swift │ │ ├── TypeWrapper.swift │ │ ├── Transformer.swift │ │ ├── Result.swift │ │ ├── Entry.swift │ │ ├── MemoryCapsule.swift │ │ ├── StorageError.swift │ │ ├── ExpirationMode.swift │ │ ├── DataSerializer.swift │ │ ├── ImageWrapper.swift │ │ ├── Expiry.swift │ │ ├── JSONArrayWrapper.swift │ │ ├── JSONDictionaryWrapper.swift │ │ ├── TransformerFactory.swift │ │ └── MD5.swift │ ├── Extensions │ │ ├── Date+Extensions.swift │ │ └── JSONDecoder+Extensions.swift │ ├── Storage │ │ ├── Storage+Transform.swift │ │ ├── StorageObservationRegistry.swift │ │ ├── KeyObservationRegistry.swift │ │ ├── SyncStorage.swift │ │ ├── MemoryStorage.swift │ │ ├── StorageAware.swift │ │ ├── AsyncStorage.swift │ │ ├── Storage.swift │ │ ├── HybridStorage.swift │ │ └── DiskStorage.swift │ └── Configuration │ │ ├── MemoryConfig.swift │ │ └── DiskConfig.swift ├── iOS │ └── UIImage+Extensions.swift └── Mac │ └── NSImage+Extensions.swift ├── Tests ├── iOS │ ├── Tests │ │ ├── Library │ │ │ ├── ObservationTokenTests.swift │ │ │ ├── ImageWrapperTests.swift │ │ │ ├── MemoryCapsuleTests.swift │ │ │ ├── ExpiryTests.swift │ │ │ ├── JSONWrapperTests.swift │ │ │ ├── ObjectConverterTests.swift │ │ │ ├── TypeWrapperTests.swift │ │ │ └── MD5Tests.swift │ │ ├── Extensions │ │ │ └── Date+ExtensionsTests.swift │ │ └── Storage │ │ │ ├── SyncStorageTests.swift │ │ │ ├── AsyncStorageTests.swift │ │ │ ├── MemoryStorageTests.swift │ │ │ ├── StorageSupportTests.swift │ │ │ ├── DiskStorageTests.swift │ │ │ ├── StorageTests.swift │ │ │ └── HybridStorageTests.swift │ ├── Helpers │ │ ├── TestHelper+iOS.swift │ │ └── UIImage+ExtensionsTests.swift │ └── Info.plist ├── Mac │ ├── Helpers │ │ ├── TestHelper+OSX.swift │ │ └── NSImage+ExtensionsTests.swift │ └── Info.plist ├── Shared │ ├── TestCase+Extensions.swift │ ├── User.swift │ └── TestHelper.swift └── tvOS │ └── Info.plist ├── .gitignore ├── CONTRIBUTING.md ├── Package.swift ├── SupportFiles ├── tvOS │ └── Info.plist ├── iOS │ └── Info.plist └── Mac │ └── Info.plist ├── Cache.podspec ├── .circleci └── config.yml ├── LICENSE.md ├── .swiftlint.yml ├── CHANGELOG.md └── README.md /.swift-version: -------------------------------------------------------------------------------- 1 | 5.0 2 | -------------------------------------------------------------------------------- /.mention-bot: -------------------------------------------------------------------------------- 1 | { 2 | "maxReviewers": 2 3 | } 4 | -------------------------------------------------------------------------------- /Resources/CacheIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-archive/Cache/master/Resources/CacheIcon.png -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | 5 | ] 6 | }, 7 | "version": 1 8 | } 9 | -------------------------------------------------------------------------------- /Resources/CachePresentation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-archive/Cache/master/Resources/CachePresentation.png -------------------------------------------------------------------------------- /Playgrounds/Storage.playground/contents.xcplayground: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /Cache.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Playgrounds/SimpleStorage.playground/contents.xcplayground: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /Playgrounds/Storage.playground/playground.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Source/Shared/Library/Types.swift: -------------------------------------------------------------------------------- 1 | #if os(iOS) || os(tvOS) 2 | import UIKit 3 | public typealias Image = UIImage 4 | #elseif os(watchOS) 5 | 6 | #elseif os(OSX) 7 | import AppKit 8 | public typealias Image = NSImage 9 | #endif 10 | -------------------------------------------------------------------------------- /Source/Shared/Extensions/Date+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /** 4 | Helper NSDate extension. 5 | */ 6 | extension Date { 7 | 8 | /// Checks if the date is in the past. 9 | var inThePast: Bool { 10 | return timeIntervalSinceNow < 0 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Source/Shared/Library/Optional+Extension.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension Optional { 4 | func unwrapOrThrow(error: Error) throws -> Wrapped { 5 | if let value = self { 6 | return value 7 | } else { 8 | throw error 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Cache.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Source/Shared/Library/ObservationToken.swift: -------------------------------------------------------------------------------- 1 | public final class ObservationToken { 2 | private let cancellationClosure: () -> Void 3 | 4 | init(cancellationClosure: @escaping () -> Void) { 5 | self.cancellationClosure = cancellationClosure 6 | } 7 | 8 | public func cancel() { 9 | cancellationClosure() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Source/Shared/Library/TypeWrapper.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Used to wrap Codable object 4 | public struct TypeWrapper: Codable { 5 | enum CodingKeys: String, CodingKey { 6 | case object 7 | } 8 | 9 | public let object: T 10 | 11 | public init(object: T) { 12 | self.object = object 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Source/Shared/Library/Transformer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public class Transformer { 4 | let toData: (T) throws -> Data 5 | let fromData: (Data) throws -> T 6 | 7 | public init(toData: @escaping (T) throws -> Data, fromData: @escaping (Data) throws -> T) { 8 | self.toData = toData 9 | self.fromData = fromData 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Tests/iOS/Tests/Library/ObservationTokenTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Cache 3 | 4 | final class ObservationTokenTests: XCTestCase { 5 | func testCancel() { 6 | var cancelled = false 7 | 8 | let token = ObservationToken { 9 | cancelled = true 10 | } 11 | 12 | token.cancel() 13 | XCTAssertTrue(cancelled) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Tests/Mac/Helpers/TestHelper+OSX.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | extension TestHelper { 4 | static func image(color: NSColor = .red, size: NSSize = .init(width: 1, height: 1)) -> NSImage { 5 | let image = NSImage(size: size) 6 | image.lockFocus() 7 | color.drawSwatch(in: NSMakeRect(0, 0, size.width, size.height)) 8 | image.unlockFocus() 9 | return image 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Tests/iOS/Tests/Extensions/Date+ExtensionsTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Cache 3 | 4 | final class DateCacheTests: XCTestCase { 5 | func testInThePast() { 6 | var date = Date(timeInterval: 100000, since: Date()) 7 | XCTAssertFalse(date.inThePast) 8 | 9 | date = Date(timeInterval: -100000, since: Date()) 10 | XCTAssertTrue(date.inThePast) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Tests/Shared/TestCase+Extensions.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | extension XCTestCase { 4 | func given(_ description: String, closure: () throws -> Void) rethrows { 5 | try closure() 6 | } 7 | 8 | func when(_ description: String, closure: () throws -> Void) rethrows { 9 | try closure() 10 | } 11 | 12 | func then(_ description: String, closure: () throws -> Void) rethrows { 13 | try closure() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Tests/Shared/User.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import Cache 3 | 4 | struct User: Codable, Equatable { 5 | let firstName: String 6 | let lastName: String 7 | 8 | enum CodingKeys: String, CodingKey { 9 | case firstName = "first_name" 10 | case lastName = "last_name" 11 | } 12 | } 13 | 14 | func == (lhs:User, rhs: User) -> Bool { 15 | return lhs.firstName == rhs.firstName 16 | && lhs.lastName == rhs.lastName 17 | } 18 | -------------------------------------------------------------------------------- /Source/Shared/Library/Result.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Used for callback in async operations. 4 | public enum Result { 5 | case value(T) 6 | case error(Error) 7 | 8 | public func map(_ transform: (T) -> U) -> Result { 9 | switch self { 10 | case .value(let value): 11 | return Result.value(transform(value)) 12 | case .error(let error): 13 | return Result.error(error) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Tests/Mac/Helpers/NSImage+ExtensionsTests.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | @testable import Cache 3 | 4 | extension NSImage { 5 | func isEqualToImage(_ image: NSImage) -> Bool { 6 | return data == image.data 7 | } 8 | 9 | var data: Data { 10 | let representation = tiffRepresentation! 11 | let imageFileType: NSBitmapImageRep.FileType = .png 12 | 13 | return NSBitmapImageRep(data: representation)! 14 | .representation(using: imageFileType, properties: [:])! 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS X 2 | .DS_Store 3 | .AppleDouble 4 | .LSOverride 5 | Icon 6 | ._* 7 | .Spotlight-V100 8 | .Trashes 9 | 10 | # Xcode 11 | # 12 | build/ 13 | *.pbxuser 14 | !default.pbxuser 15 | *.mode1v3 16 | !default.mode1v3 17 | *.mode2v3 18 | !default.mode2v3 19 | *.perspectivev3 20 | !default.perspectivev3 21 | xcuserdata 22 | *.xccheckout 23 | *.moved-aside 24 | DerivedData 25 | *.hmap 26 | *.ipa 27 | *.xcuserstate 28 | 29 | # CocoaPods 30 | Pods 31 | 32 | # Carthage 33 | Carthage 34 | /.build 35 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | GitHub Issues is for reporting bugs, discussing features and general feedback in **Cache**. Be sure to check our [documentation](http://cocoadocs.org/docsets/Cache), [FAQ](https://github.com/hyperoslo/Cache/wiki/FAQ) and [past issues](https://github.com/hyperoslo/Cache/issues?state=closed) before opening any new issues. 2 | 3 | If you are posting about a crash in your application, a stack trace is helpful, but additional context, in the form of code and explanation, is necessary to be of any use. 4 | -------------------------------------------------------------------------------- /Source/Shared/Library/Entry.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A wrapper around cached object and its expiry date. 4 | public struct Entry { 5 | /// Cached object 6 | public let object: T 7 | /// Expiry date 8 | public let expiry: Expiry 9 | /// File path to the cached object 10 | public let filePath: String? 11 | 12 | init(object: T, expiry: Expiry, filePath: String? = nil) { 13 | self.object = object 14 | self.expiry = expiry 15 | self.filePath = filePath 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Tests/iOS/Tests/Library/ImageWrapperTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Cache 3 | 4 | final class ImageWrapperTests: XCTestCase { 5 | func testImage() { 6 | let image = TestHelper.image(size: CGSize(width: 100, height: 100)) 7 | let wrapper = ImageWrapper(image: image) 8 | 9 | let data = try! JSONEncoder().encode(wrapper) 10 | let anotherWrapper = try! JSONDecoder().decode(ImageWrapper.self, from: data) 11 | 12 | XCTAssertTrue(image.isEqualToImage(anotherWrapper.image)) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Tests/iOS/Helpers/TestHelper+iOS.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension TestHelper { 4 | static func image(_ color: UIColor = .red, size: CGSize = .init(width: 1, height: 1)) -> UIImage { 5 | UIGraphicsBeginImageContextWithOptions(size, false, 0) 6 | 7 | let context = UIGraphicsGetCurrentContext() 8 | context?.setFillColor(color.cgColor) 9 | context?.fill(CGRect(x: 0, y: 0, width: size.width, height: size.height)) 10 | 11 | let image = UIGraphicsGetImageFromCurrentImageContext() 12 | UIGraphicsEndImageContext() 13 | 14 | return image! 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Source/Shared/Library/MemoryCapsule.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Helper class to hold cached instance and expiry date. 4 | /// Used in memory storage to work with NSCache. 5 | class MemoryCapsule: NSObject { 6 | /// Object to be cached 7 | let object: Any 8 | /// Expiration date 9 | let expiry: Expiry 10 | 11 | /** 12 | Creates a new instance of Capsule. 13 | - Parameter value: Object to be cached 14 | - Parameter expiry: Expiration date 15 | */ 16 | init(value: Any, expiry: Expiry) { 17 | self.object = value 18 | self.expiry = expiry 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Source/Shared/Library/StorageError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum StorageError: Error { 4 | /// Object can not be found 5 | case notFound 6 | /// Object is found, but casting to requested type failed 7 | case typeNotMatch 8 | /// The file attributes are malformed 9 | case malformedFileAttributes 10 | /// Can't perform Decode 11 | case decodingFailed 12 | /// Can't perform Encode 13 | case encodingFailed 14 | /// The storage has been deallocated 15 | case deallocated 16 | /// Fail to perform transformation to or from Data 17 | case transformerFail 18 | } 19 | -------------------------------------------------------------------------------- /Source/Shared/Storage/Storage+Transform.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension Storage { 4 | func transformData() -> Storage { 5 | let storage = transform(transformer: TransformerFactory.forData()) 6 | return storage 7 | } 8 | 9 | func transformImage() -> Storage { 10 | let storage = transform(transformer: TransformerFactory.forImage()) 11 | return storage 12 | } 13 | 14 | func transformCodable(ofType: U.Type) -> Storage { 15 | let storage = transform(transformer: TransformerFactory.forCodable(ofType: U.self)) 16 | return storage 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Cache", 7 | products: [ 8 | .library( 9 | name: "Cache", 10 | targets: ["Cache"]), 11 | ], 12 | dependencies: [], 13 | targets: [ 14 | .target( 15 | name: "Cache", 16 | path: "Source/Shared", 17 | exclude: ["Library/ImageWrapper.swift"]), // relative to the target path 18 | .testTarget( 19 | name: "CacheTests", 20 | dependencies: ["Cache"], 21 | path: "Tests"), 22 | ] 23 | ) 24 | -------------------------------------------------------------------------------- /Source/Shared/Library/ExpirationMode.swift: -------------------------------------------------------------------------------- 1 | /// Sets the expiration mode for the `CacheManager`. The default value is `.auto` which means that `Cache` 2 | /// will handle expiration internally. It will trigger cache clean up tasks depending on the events its receives 3 | /// from the application. If expiration mode is set to manual, it means that you manually have to invoke the clear 4 | /// cache methods yourself. 5 | /// 6 | /// - auto: Automatic cleanup of expired objects (default). 7 | /// - manual: Manual means that you opt out from any automatic expiration handling. 8 | public enum ExpirationMode { 9 | case auto, manual 10 | } 11 | -------------------------------------------------------------------------------- /Tests/iOS/Tests/Library/MemoryCapsuleTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Cache 3 | 4 | final class MemoryCapsuleTests: XCTestCase { 5 | let testObject = User(firstName: "a", lastName: "b") 6 | 7 | func testExpiredWhenNotExpired() { 8 | let date = Date(timeInterval: 100000, since: Date()) 9 | let capsule = MemoryCapsule(value: testObject, expiry: .date(date)) 10 | 11 | XCTAssertFalse(capsule.expiry.isExpired) 12 | } 13 | 14 | func testExpiredWhenExpired() { 15 | let date = Date(timeInterval: -100000, since: Date()) 16 | let capsule = MemoryCapsule(value: testObject, expiry: .date(date)) 17 | 18 | XCTAssertTrue(capsule.expiry.isExpired) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/iOS/Helpers/UIImage+ExtensionsTests.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIImage { 4 | func isEqualToImage(_ image: UIImage) -> Bool { 5 | let data = normalizedData() 6 | return data == image.normalizedData() 7 | } 8 | 9 | func normalizedData() -> Data { 10 | let pixelSize = CGSize( 11 | width : size.width * scale, 12 | height : size.height * scale 13 | ) 14 | 15 | UIGraphicsBeginImageContext(pixelSize) 16 | draw( 17 | in: CGRect(x: 0, y: 0, width: pixelSize.width, 18 | height: pixelSize.height) 19 | ) 20 | 21 | let drawnImage = UIGraphicsGetImageFromCurrentImageContext() 22 | UIGraphicsEndImageContext() 23 | 24 | return drawnImage!.cgImage!.dataProvider!.data! as Data 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Tests/tvOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Source/Shared/Library/DataSerializer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Convert to and from data 4 | class DataSerializer { 5 | 6 | /// Convert object to data 7 | /// 8 | /// - Parameter object: The object to convert 9 | /// - Returns: Data 10 | /// - Throws: Encoder error if any 11 | static func serialize(object: T) throws -> Data { 12 | let encoder = JSONEncoder() 13 | return try encoder.encode(object) 14 | } 15 | 16 | /// Convert data to object 17 | /// 18 | /// - Parameter data: The data to convert 19 | /// - Returns: The object 20 | /// - Throws: Decoder error if any 21 | static func deserialize(data: Data) throws -> T { 22 | let decoder = JSONDecoder() 23 | return try decoder.decode(T.self, from: data) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Source/iOS/UIImage+Extensions.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// Helper UIImage extension. 4 | extension UIImage { 5 | /// Checks if image has alpha component 6 | var hasAlpha: Bool { 7 | let result: Bool 8 | 9 | guard let alpha = cgImage?.alphaInfo else { 10 | return false 11 | } 12 | 13 | switch alpha { 14 | case .none, .noneSkipFirst, .noneSkipLast: 15 | result = false 16 | default: 17 | result = true 18 | } 19 | 20 | return result 21 | } 22 | 23 | /// Convert to data 24 | func cache_toData() -> Data? { 25 | #if swift(>=4.2) 26 | return hasAlpha 27 | ? pngData() 28 | : jpegData(compressionQuality: 1.0) 29 | #else 30 | return hasAlpha 31 | ? UIImagePNGRepresentation(self) 32 | : UIImageJPEGRepresentation(self, 1.0) 33 | #endif 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Tests/Mac/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | 24 | 25 | -------------------------------------------------------------------------------- /Tests/iOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | 24 | 25 | -------------------------------------------------------------------------------- /SupportFiles/tvOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | NSPrincipalClass 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /Source/Shared/Configuration/MemoryConfig.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct MemoryConfig { 4 | /// Expiry date that will be applied by default for every added object 5 | /// if it's not overridden in the add(key: object: expiry: completion:) method 6 | public let expiry: Expiry 7 | /// The maximum number of objects in memory the cache should hold. 8 | /// If 0, there is no count limit. The default value is 0. 9 | public let countLimit: UInt 10 | 11 | /// The maximum total cost that the cache can hold before it starts evicting objects. 12 | /// If 0, there is no total cost limit. The default value is 0 13 | public let totalCostLimit: UInt 14 | 15 | public init(expiry: Expiry = .never, countLimit: UInt = 0, totalCostLimit: UInt = 0) { 16 | self.expiry = expiry 17 | self.countLimit = countLimit 18 | self.totalCostLimit = totalCostLimit 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Cache.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = "Cache" 3 | s.summary = "Nothing but cache." 4 | s.version = "5.2.0" 5 | s.homepage = "https://github.com/hyperoslo/Cache" 6 | s.license = 'MIT' 7 | s.author = { "Hyper Interaktiv AS" => "ios@hyper.no" } 8 | s.source = { :git => "https://github.com/hyperoslo/Cache.git", :tag => s.version.to_s } 9 | s.social_media_url = 'https://twitter.com/hyperoslo' 10 | 11 | s.ios.deployment_target = '8.0' 12 | s.osx.deployment_target = '10.9' 13 | s.tvos.deployment_target = '9.2' 14 | 15 | s.requires_arc = true 16 | s.ios.source_files = 'Source/{iOS,Shared}/**/*' 17 | s.osx.source_files = 'Source/{Mac,Shared}/**/*' 18 | s.tvos.source_files = 'Source/{iOS,Shared}/**/*' 19 | 20 | s.frameworks = 'Foundation' 21 | 22 | s.pod_target_xcconfig = { 'SWIFT_VERSION' => '4.2' } 23 | end 24 | -------------------------------------------------------------------------------- /SupportFiles/iOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | NSPrincipalClass 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /Source/Shared/Library/ImageWrapper.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct ImageWrapper: Codable { 4 | public let image: Image 5 | 6 | public enum CodingKeys: String, CodingKey { 7 | case image 8 | } 9 | 10 | public init(image: Image) { 11 | self.image = image 12 | } 13 | 14 | public init(from decoder: Decoder) throws { 15 | let container = try decoder.container(keyedBy: CodingKeys.self) 16 | let data = try container.decode(Data.self, forKey: CodingKeys.image) 17 | guard let image = Image(data: data) else { 18 | throw StorageError.decodingFailed 19 | } 20 | 21 | self.image = image 22 | } 23 | 24 | public func encode(to encoder: Encoder) throws { 25 | var container = encoder.container(keyedBy: CodingKeys.self) 26 | guard let data = image.cache_toData() else { 27 | throw StorageError.encodingFailed 28 | } 29 | 30 | try container.encode(data, forKey: CodingKeys.image) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/iOS/Tests/Library/ExpiryTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Cache 3 | 4 | final class ExpiryTests: XCTestCase { 5 | /// Tests that it returns date in the distant future 6 | func testNever() { 7 | let date = Date(timeIntervalSince1970: 60 * 60 * 24 * 365 * 68) 8 | let expiry = Expiry.never 9 | 10 | XCTAssertEqual(expiry.date, date) 11 | } 12 | 13 | /// Tests that it returns date by adding time interval 14 | func testSeconds() { 15 | let date = Date().addingTimeInterval(1000) 16 | let expiry = Expiry.seconds(1000) 17 | 18 | XCTAssertEqual( 19 | expiry.date.timeIntervalSinceReferenceDate, 20 | date.timeIntervalSinceReferenceDate, 21 | accuracy: 0.1 22 | ) 23 | } 24 | 25 | /// Tests that it returns a specified date 26 | func testDate() { 27 | let date = Date().addingTimeInterval(1000) 28 | let expiry = Expiry.date(date) 29 | 30 | XCTAssertEqual(expiry.date, date) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Source/Shared/Library/Expiry.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /** 4 | Helper enum to set the expiration date 5 | */ 6 | public enum Expiry { 7 | /// Object will be expired in the nearest future 8 | case never 9 | /// Object will be expired in the specified amount of seconds 10 | case seconds(TimeInterval) 11 | /// Object will be expired on the specified date 12 | case date(Date) 13 | 14 | /// Returns the appropriate date object 15 | public var date: Date { 16 | switch self { 17 | case .never: 18 | // Ref: http://lists.apple.com/archives/cocoa-dev/2005/Apr/msg01833.html 19 | return Date(timeIntervalSince1970: 60 * 60 * 24 * 365 * 68) 20 | case .seconds(let seconds): 21 | return Date().addingTimeInterval(seconds) 22 | case .date(let date): 23 | return date 24 | } 25 | } 26 | 27 | /// Checks if cached object is expired according to expiration date 28 | public var isExpired: Bool { 29 | return date.inThePast 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Source/Mac/NSImage+Extensions.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | /// Helper UIImage extension. 4 | extension NSImage { 5 | /// Checks if image has alpha component 6 | var hasAlpha: Bool { 7 | var imageRect: CGRect = CGRect(x: 0, y: 0, width: size.width, height: size.height) 8 | 9 | guard let imageRef = cgImage(forProposedRect: &imageRect, context: nil, hints: nil) else { 10 | return false 11 | } 12 | 13 | let result: Bool 14 | let alpha = imageRef.alphaInfo 15 | 16 | switch alpha { 17 | case .none, .noneSkipFirst, .noneSkipLast: 18 | result = false 19 | default: 20 | result = true 21 | } 22 | 23 | return result 24 | } 25 | 26 | /// Convert to data 27 | func cache_toData() -> Data? { 28 | guard let data = tiffRepresentation else { 29 | return nil 30 | } 31 | 32 | let imageFileType: NSBitmapImageRep.FileType = hasAlpha ? .png : .jpeg 33 | return NSBitmapImageRep(data: data)? 34 | .representation(using: imageFileType, properties: [:]) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /SupportFiles/Mac/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | NSHumanReadableCopyright 24 | Copyright © 2015 Hyper Interaktiv AS. All rights reserved. 25 | NSPrincipalClass 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build-and-test: 4 | macos: 5 | xcode: "10.0.0" 6 | shell: /bin/bash --login -o pipefail 7 | steps: 8 | - checkout 9 | - run: xcodebuild -project Cache.xcodeproj -scheme "Cache-macOS" -sdk macosx clean 10 | - run: xcodebuild -project Cache.xcodeproj -scheme "Cache-macOS" -sdk macosx -enableCodeCoverage YES test 11 | - run: xcodebuild -project Cache.xcodeproj -scheme "Cache-iOS" -sdk iphonesimulator clean 12 | - run: xcodebuild -project Cache.xcodeproj -scheme "Cache-iOS" -sdk iphonesimulator -destination 'platform=iOS Simulator,OS=12.0,name=iPhone X' -enableCodeCoverage YES test 13 | - run: xcodebuild -project Cache.xcodeproj -scheme "Cache-tvOS" -destination 'platform=tvOS Simulator,name=Apple TV,OS=12.0' clean 14 | - run: xcodebuild -project Cache.xcodeproj -scheme "Cache-tvOS" -destination 'platform=tvOS Simulator,name=Apple TV,OS=12.0' -enableCodeCoverage YES test 15 | 16 | workflows: 17 | version: 2 18 | build-and-test: 19 | jobs: 20 | - build-and-test 21 | -------------------------------------------------------------------------------- /Tests/Shared/TestHelper.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | #if os(iOS) || os(tvOS) 4 | import UIKit 5 | #elseif os(OSX) 6 | import AppKit 7 | #endif 8 | 9 | struct TestHelper { 10 | static func data(_ length : Int) -> Data { 11 | let buffer = [UInt8](repeating: 0, count: length) 12 | return Data(bytes: buffer) 13 | } 14 | 15 | static func triggerApplicationEvents() { 16 | #if (iOS) 17 | NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) 18 | NotificationCenter.default.post(name: UIApplication.willTerminateNotification, object: nil) 19 | #elseif os(tvOS) 20 | NotificationCenter.default.post(name: Notification.Name.UIApplicationDidEnterBackground, object: nil) 21 | NotificationCenter.default.post(name: Notification.Name.UIApplicationWillTerminate, object: nil) 22 | #else 23 | NotificationCenter.default.post(name: NSApplication.willTerminateNotification, object: nil) 24 | NotificationCenter.default.post(name: NSApplication.didResignActiveNotification, object: nil) 25 | #endif 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Source/Shared/Library/JSONArrayWrapper.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public typealias JSONArray = [JSONDictionary] 4 | 5 | public struct JSONArrayWrapper: Codable { 6 | public let jsonArray: JSONArray 7 | 8 | public enum CodingKeys: String, CodingKey { 9 | case jsonArray 10 | } 11 | 12 | public init(jsonArray: JSONArray) { 13 | self.jsonArray = jsonArray 14 | } 15 | 16 | public init(from decoder: Decoder) throws { 17 | let container = try decoder.container(keyedBy: CodingKeys.self) 18 | let data = try container.decode(Data.self, forKey: CodingKeys.jsonArray) 19 | let object = try JSONSerialization.jsonObject( 20 | with: data, 21 | options: [] 22 | ) 23 | 24 | guard let jsonArray = object as? JSONArray else { 25 | throw StorageError.decodingFailed 26 | } 27 | 28 | self.jsonArray = jsonArray 29 | } 30 | 31 | public func encode(to encoder: Encoder) throws { 32 | var container = encoder.container(keyedBy: CodingKeys.self) 33 | let data = try JSONSerialization.data( 34 | withJSONObject: jsonArray, 35 | options: [] 36 | ) 37 | 38 | try container.encode(data, forKey: CodingKeys.jsonArray) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Licensed under the **MIT** license 2 | 3 | > Copyright (c) 2015 Hyper Interaktiv AS 4 | > 5 | > Permission is hereby granted, free of charge, to any person obtaining 6 | > a copy of this software and associated documentation files (the 7 | > "Software"), to deal in the Software without restriction, including 8 | > without limitation the rights to use, copy, modify, merge, publish, 9 | > distribute, sublicense, and/or sell copies of the Software, and to 10 | > permit persons to whom the Software is furnished to do so, subject to 11 | > the following conditions: 12 | > 13 | > The above copyright notice and this permission notice shall be 14 | > included in all copies or substantial portions of the Software. 15 | > 16 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | > EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | > MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | > IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | > CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | > TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | > SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Source/Shared/Library/JSONDictionaryWrapper.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public typealias JSONDictionary = [String: Any] 4 | 5 | public struct JSONDictionaryWrapper: Codable { 6 | public let jsonDictionary: JSONDictionary 7 | 8 | public enum CodingKeys: String, CodingKey { 9 | case jsonDictionary 10 | } 11 | 12 | public init(jsonDictionary: JSONDictionary) { 13 | self.jsonDictionary = jsonDictionary 14 | } 15 | 16 | public init(from decoder: Decoder) throws { 17 | let container = try decoder.container(keyedBy: CodingKeys.self) 18 | let data = try container.decode(Data.self, forKey: CodingKeys.jsonDictionary) 19 | let object = try JSONSerialization.jsonObject( 20 | with: data, 21 | options: [] 22 | ) 23 | 24 | guard let jsonDictionary = object as? JSONDictionary else { 25 | throw StorageError.decodingFailed 26 | } 27 | 28 | self.jsonDictionary = jsonDictionary 29 | } 30 | 31 | public func encode(to encoder: Encoder) throws { 32 | var container = encoder.container(keyedBy: CodingKeys.self) 33 | let data = try JSONSerialization.data( 34 | withJSONObject: jsonDictionary, 35 | options: [] 36 | ) 37 | 38 | try container.encode(data, forKey: CodingKeys.jsonDictionary) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Tests/iOS/Tests/Library/JSONWrapperTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Cache 3 | 4 | final class JSONWrapperTests: XCTestCase { 5 | func testJSONDictionary() { 6 | let json: JSONDictionary = [ 7 | "name": "John Snow", 8 | "location": "Winterfell" 9 | ] 10 | 11 | let wrapper = JSONDictionaryWrapper(jsonDictionary: json) 12 | 13 | let data = try! JSONEncoder().encode(wrapper) 14 | let decodedWrapper = try! JSONDecoder().decode(JSONDictionaryWrapper.self, from: data) 15 | 16 | XCTAssertEqual( 17 | NSDictionary(dictionary: decodedWrapper.jsonDictionary), 18 | NSDictionary(dictionary: json) 19 | ) 20 | } 21 | 22 | func testJSONArray() { 23 | let json: JSONArray = [ 24 | [ 25 | "name": "John Snow", 26 | "location": "Winterfell" 27 | ], 28 | [ 29 | "name": "Daenerys Targaryen", 30 | "location": "Dragonstone" 31 | ] 32 | ] 33 | 34 | let wrapper = JSONArrayWrapper(jsonArray: json) 35 | 36 | let data = try! JSONEncoder().encode(wrapper) 37 | let decodedWrapper = try! JSONDecoder().decode(JSONArrayWrapper.self, from: data) 38 | 39 | zip(json, decodedWrapper.jsonArray).forEach { 40 | XCTAssertEqual( 41 | NSDictionary(dictionary: $0), 42 | NSDictionary(dictionary: $1) 43 | ) 44 | } 45 | } 46 | } 47 | 48 | -------------------------------------------------------------------------------- /Source/Shared/Library/TransformerFactory.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public class TransformerFactory { 4 | public static func forData() -> Transformer { 5 | let toData: (Data) throws -> Data = { $0 } 6 | 7 | let fromData: (Data) throws -> Data = { $0 } 8 | 9 | return Transformer(toData: toData, fromData: fromData) 10 | } 11 | 12 | public static func forImage() -> Transformer { 13 | let toData: (Image) throws -> Data = { image in 14 | return try image.cache_toData().unwrapOrThrow(error: StorageError.transformerFail) 15 | } 16 | 17 | let fromData: (Data) throws -> Image = { data in 18 | return try Image(data: data).unwrapOrThrow(error: StorageError.transformerFail) 19 | } 20 | 21 | return Transformer(toData: toData, fromData: fromData) 22 | } 23 | 24 | public static func forCodable(ofType: U.Type) -> Transformer { 25 | let toData: (U) throws -> Data = { object in 26 | let wrapper = TypeWrapper(object: object) 27 | let encoder = JSONEncoder() 28 | return try encoder.encode(wrapper) 29 | } 30 | 31 | let fromData: (Data) throws -> U = { data in 32 | let decoder = JSONDecoder() 33 | return try decoder.decode(TypeWrapper.self, from: data).object 34 | } 35 | 36 | return Transformer(toData: toData, fromData: fromData) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | included: # paths to include during linting. `--path` is ignored if present. 2 | - Source 3 | excluded: # paths to ignore during linting. Takes precedence over `included`. 4 | - Carthage 5 | - Pods 6 | disabled_rules: 7 | - type_name 8 | - vertical_parameter_alignment 9 | 10 | # configurable rules can be customized from this configuration file 11 | # binary rules can set their severity level 12 | force_cast: warning # implicitly 13 | force_try: 14 | severity: warning # explicitly 15 | # rules that have both warning and error levels, can set just the warning level 16 | # implicitly 17 | line_length: 200 18 | # they can set both implicitly with an array 19 | type_body_length: 20 | - 300 # warning 21 | - 400 # error 22 | # or they can set both explicitly 23 | file_length: 24 | warning: 500 25 | error: 1200 26 | # naming rules can set warnings/errors for min_length and max_length 27 | # additionally they can set excluded names 28 | type_name: 29 | min_length: 3 # only warning 30 | max_length: # warning and error 31 | warning: 40 32 | error: 50 33 | excluded: iPhone # excluded via string 34 | variable_name: 35 | min_length: # only min_length 36 | error: 2 # only error 37 | excluded: # excluded via string array 38 | - x 39 | - y 40 | - id 41 | - URL 42 | - GlobalAPIKey 43 | reporter: "xcode" # reporter type (xcode, json, csv, checkstyle) 44 | -------------------------------------------------------------------------------- /Source/Shared/Storage/StorageObservationRegistry.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A protocol used for adding and removing storage observations 4 | public protocol StorageObservationRegistry { 5 | associatedtype S: StorageAware 6 | 7 | /** 8 | Registers observation closure which will be removed automatically 9 | when the weakly captured observer has been deallocated. 10 | - Parameter observer: Any object that helps determine if the observation is still valid 11 | - Parameter closure: Observation closure 12 | - Returns: Token used to cancel the observation and remove the observation closure 13 | */ 14 | @discardableResult 15 | func addStorageObserver( 16 | _ observer: O, 17 | closure: @escaping (O, S, StorageChange) -> Void 18 | ) -> ObservationToken 19 | 20 | /// Removes all registered key observers 21 | func removeAllStorageObservers() 22 | } 23 | 24 | // MARK: - StorageChange 25 | 26 | public enum StorageChange: Equatable { 27 | case add(key: String) 28 | case remove(key: String) 29 | case removeAll 30 | case removeExpired 31 | } 32 | 33 | public func == (lhs: StorageChange, rhs: StorageChange) -> Bool { 34 | switch (lhs, rhs) { 35 | case (.add(let key1), .add(let key2)), (.remove(let key1), .remove(let key2)): 36 | return key1 == key2 37 | case (.removeAll, .removeAll), (.removeExpired, .removeExpired): 38 | return true 39 | default: 40 | return false 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tests/iOS/Tests/Library/ObjectConverterTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Cache 3 | 4 | final class JSONDecoderExtensionsTests: XCTestCase { 5 | private var storage: HybridStorage! 6 | 7 | override func setUp() { 8 | super.setUp() 9 | let memory = MemoryStorage(config: MemoryConfig()) 10 | let disk = try! DiskStorage( 11 | config: DiskConfig(name: "HybridDisk"), 12 | transformer: TransformerFactory.forCodable(ofType: User.self) 13 | ) 14 | 15 | storage = HybridStorage(memoryStorage: memory, diskStorage: disk) 16 | } 17 | 18 | override func tearDown() { 19 | try? storage.removeAll() 20 | super.tearDown() 21 | } 22 | 23 | func testJsonDictionary() throws { 24 | let json: [String: Any] = [ 25 | "first_name": "John", 26 | "last_name": "Snow" 27 | ] 28 | 29 | let user = try JSONDecoder.decode(json, to: User.self) 30 | try storage.setObject(user, forKey: "user") 31 | 32 | let cachedObject = try storage.object(forKey: "user") 33 | XCTAssertEqual(user, cachedObject) 34 | } 35 | 36 | func testJsonString() throws { 37 | let string: String = "{\"first_name\": \"John\", \"last_name\": \"Snow\"}" 38 | 39 | let user = try JSONDecoder.decode(string, to: User.self) 40 | try storage.setObject(user, forKey: "user") 41 | 42 | let cachedObject = try storage.object(forKey: "user") 43 | XCTAssertEqual(cachedObject.firstName, "John") 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /Source/Shared/Configuration/DiskConfig.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct DiskConfig { 4 | /// The name of disk storage, this will be used as folder name within directory 5 | public let name: String 6 | /// Expiry date that will be applied by default for every added object 7 | /// if it's not overridden in the add(key: object: expiry: completion:) method 8 | public let expiry: Expiry 9 | /// Maximum size of the disk cache storage (in bytes) 10 | public let maxSize: UInt 11 | /// A folder to store the disk cache contents. Defaults to a prefixed directory in Caches if nil 12 | public let directory: URL? 13 | #if os(iOS) || os(tvOS) 14 | /// Data protection is used to store files in an encrypted format on disk and to decrypt them on demand. 15 | /// Support only on iOS and tvOS. 16 | public let protectionType: FileProtectionType? 17 | 18 | public init(name: String, expiry: Expiry = .never, 19 | maxSize: UInt = 0, directory: URL? = nil, 20 | protectionType: FileProtectionType? = nil) { 21 | self.name = name 22 | self.expiry = expiry 23 | self.maxSize = maxSize 24 | self.directory = directory 25 | self.protectionType = protectionType 26 | } 27 | #else 28 | public init(name: String, expiry: Expiry = .never, 29 | maxSize: UInt = 0, directory: URL? = nil) { 30 | self.name = name 31 | self.expiry = expiry 32 | self.maxSize = maxSize 33 | self.directory = directory 34 | } 35 | #endif 36 | } 37 | -------------------------------------------------------------------------------- /Source/Shared/Extensions/JSONDecoder+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Convert json string, dictionary, data to Codable objects 4 | public extension JSONDecoder { 5 | /// Convert json string to Codable object 6 | /// 7 | /// - Parameters: 8 | /// - string: Json string. 9 | /// - type: Type information. 10 | /// - Returns: Codable object. 11 | /// - Throws: Error if failed. 12 | static func decode(_ string: String, to type: T.Type) throws -> T { 13 | guard let data = string.data(using: .utf8) else { 14 | throw StorageError.decodingFailed 15 | } 16 | 17 | return try decode(data, to: type.self) 18 | } 19 | 20 | /// Convert json dictionary to Codable object 21 | /// 22 | /// - Parameters: 23 | /// - json: Json dictionary. 24 | /// - type: Type information. 25 | /// - Returns: Codable object 26 | /// - Throws: Error if failed 27 | static func decode(_ json: [String: Any], to type: T.Type) throws -> T { 28 | let data = try JSONSerialization.data(withJSONObject: json, options: []) 29 | return try decode(data, to: type) 30 | } 31 | 32 | /// Convert json data to Codable object 33 | /// 34 | /// - Parameters: 35 | /// - json: Json dictionary. 36 | /// - type: Type information. 37 | /// - Returns: Codable object 38 | /// - Throws: Error if failed 39 | static func decode(_ data: Data, to type: T.Type) throws -> T { 40 | return try JSONDecoder().decode(T.self, from: data) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tests/iOS/Tests/Storage/SyncStorageTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Dispatch 3 | @testable import Cache 4 | 5 | final class SyncStorageTests: XCTestCase { 6 | private var storage: SyncStorage! 7 | let user = User(firstName: "John", lastName: "Snow") 8 | 9 | override func setUp() { 10 | super.setUp() 11 | 12 | let memory = MemoryStorage(config: MemoryConfig()) 13 | let disk = try! DiskStorage(config: DiskConfig(name: "HybridDisk"), transformer: TransformerFactory.forCodable(ofType: User.self)) 14 | 15 | let hybridStorage = HybridStorage(memoryStorage: memory, diskStorage: disk) 16 | storage = SyncStorage(storage: hybridStorage, serialQueue: DispatchQueue(label: "Sync")) 17 | } 18 | 19 | override func tearDown() { 20 | try? storage.removeAll() 21 | super.tearDown() 22 | } 23 | 24 | func testSetObject() throws { 25 | try storage.setObject(user, forKey: "user") 26 | let cachedObject = try storage.object(forKey: "user") 27 | 28 | XCTAssertEqual(cachedObject, user) 29 | } 30 | 31 | func testRemoveAll() throws { 32 | let intStorage = storage.transform(transformer: TransformerFactory.forCodable(ofType: Int.self)) 33 | try given("add a lot of objects") { 34 | try Array(0..<100).forEach { 35 | try intStorage.setObject($0, forKey: "key-\($0)") 36 | } 37 | } 38 | 39 | try when("remove all") { 40 | try intStorage.removeAll() 41 | } 42 | 43 | try then("all are removed") { 44 | XCTAssertFalse(try intStorage.existsObject(forKey: "key-99")) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Playgrounds/SimpleStorage.playground/Contents.swift: -------------------------------------------------------------------------------- 1 | //: Playground - noun: a place where people can play 2 | import PlaygroundSupport 3 | import UIKit 4 | import Cache 5 | 6 | struct User: Codable { 7 | let id: Int 8 | let firstName: String 9 | let lastName: String 10 | 11 | var name: String { 12 | return "\(firstName) \(lastName)" 13 | } 14 | } 15 | 16 | let diskConfig = DiskConfig(name: "UserCache") 17 | let memoryConfig = MemoryConfig(expiry: .never, countLimit: 10, totalCostLimit: 10) 18 | 19 | let storage = try! Storage( 20 | diskConfig: diskConfig, 21 | memoryConfig: memoryConfig, 22 | transformer: TransformerFactory.forCodable(ofType: User.self) 23 | ) 24 | 25 | let user = User(id: 1, firstName: "John", lastName: "Snow") 26 | let key = "\(user.id)" 27 | 28 | // Add objects to the cache 29 | try storage.setObject(user, forKey: key) 30 | 31 | // Fetch object from the cache 32 | storage.async.object(forKey: key) { result in 33 | switch result { 34 | case .value(let user): 35 | print(user.name) 36 | case .error(let error): 37 | print(error) 38 | } 39 | } 40 | 41 | // Remove object from the cache 42 | try storage.removeObject(forKey: key) 43 | 44 | // Try to fetch removed object from the cache 45 | storage.async.object(forKey: key) { result in 46 | switch result { 47 | case .value(let user): 48 | print(user.name) 49 | case .error: 50 | print("no such object") 51 | } 52 | } 53 | 54 | // Clear cache 55 | try storage.removeAll() 56 | 57 | PlaygroundPage.current.needsIndefiniteExecution = true 58 | -------------------------------------------------------------------------------- /Source/Shared/Storage/KeyObservationRegistry.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A protocol used for adding and removing key observations 4 | public protocol KeyObservationRegistry { 5 | associatedtype S: StorageAware 6 | 7 | /** 8 | Registers observation closure which will be removed automatically 9 | when the weakly captured observer has been deallocated. 10 | - Parameter observer: Any object that helps determine if the observation is still valid 11 | - Parameter key: Unique key to identify the object in the cache 12 | - Parameter closure: Observation closure 13 | - Returns: Token used to cancel the observation and remove the observation closure 14 | */ 15 | @discardableResult 16 | func addObserver( 17 | _ observer: O, 18 | forKey key: String, 19 | closure: @escaping (O, S, KeyChange) -> Void 20 | ) -> ObservationToken 21 | 22 | /** 23 | Removes observer by the given key. 24 | - Parameter key: Unique key to identify the object in the cache 25 | */ 26 | func removeObserver(forKey key: String) 27 | 28 | /// Removes all registered key observers 29 | func removeAllKeyObservers() 30 | } 31 | 32 | // MARK: - KeyChange 33 | 34 | public enum KeyChange { 35 | case edit(before: T?, after: T) 36 | case remove 37 | } 38 | 39 | extension KeyChange: Equatable where T: Equatable { 40 | public static func == (lhs: KeyChange, rhs: KeyChange) -> Bool { 41 | switch (lhs, rhs) { 42 | case (.edit(let before1, let after1), .edit(let before2, let after2)): 43 | return before1 == before2 && after1 == after2 44 | case (.remove, .remove): 45 | return true 46 | default: 47 | return false 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Source/Shared/Storage/SyncStorage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Dispatch 3 | 4 | /// Manipulate storage in a "all sync" manner. 5 | /// Block the current queue until the operation completes. 6 | public class SyncStorage { 7 | public let innerStorage: HybridStorage 8 | public let serialQueue: DispatchQueue 9 | 10 | public init(storage: HybridStorage, serialQueue: DispatchQueue) { 11 | self.innerStorage = storage 12 | self.serialQueue = serialQueue 13 | } 14 | } 15 | 16 | extension SyncStorage: StorageAware { 17 | public func entry(forKey key: String) throws -> Entry { 18 | var entry: Entry! 19 | try serialQueue.sync { 20 | entry = try innerStorage.entry(forKey: key) 21 | } 22 | 23 | return entry 24 | } 25 | 26 | public func removeObject(forKey key: String) throws { 27 | try serialQueue.sync { 28 | try self.innerStorage.removeObject(forKey: key) 29 | } 30 | } 31 | 32 | public func setObject(_ object: T, forKey key: String, expiry: Expiry? = nil) throws { 33 | try serialQueue.sync { 34 | try innerStorage.setObject(object, forKey: key, expiry: expiry) 35 | } 36 | } 37 | 38 | public func removeAll() throws { 39 | try serialQueue.sync { 40 | try innerStorage.removeAll() 41 | } 42 | } 43 | 44 | public func removeExpiredObjects() throws { 45 | try serialQueue.sync { 46 | try innerStorage.removeExpiredObjects() 47 | } 48 | } 49 | } 50 | 51 | public extension SyncStorage { 52 | func transform(transformer: Transformer) -> SyncStorage { 53 | let storage = SyncStorage( 54 | storage: innerStorage.transform(transformer: transformer), 55 | serialQueue: serialQueue 56 | ) 57 | 58 | return storage 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Source/Shared/Storage/MemoryStorage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public class MemoryStorage: StorageAware { 4 | fileprivate let cache = NSCache() 5 | // Memory cache keys 6 | fileprivate var keys = Set() 7 | /// Configuration 8 | fileprivate let config: MemoryConfig 9 | 10 | public init(config: MemoryConfig) { 11 | self.config = config 12 | self.cache.countLimit = Int(config.countLimit) 13 | self.cache.totalCostLimit = Int(config.totalCostLimit) 14 | } 15 | } 16 | 17 | extension MemoryStorage { 18 | public func setObject(_ object: T, forKey key: String, expiry: Expiry? = nil) { 19 | let capsule = MemoryCapsule(value: object, expiry: .date(expiry?.date ?? config.expiry.date)) 20 | cache.setObject(capsule, forKey: NSString(string: key)) 21 | keys.insert(key) 22 | } 23 | 24 | public func removeAll() { 25 | cache.removeAllObjects() 26 | keys.removeAll() 27 | } 28 | 29 | public func removeExpiredObjects() { 30 | let allKeys = keys 31 | for key in allKeys { 32 | removeObjectIfExpired(forKey: key) 33 | } 34 | } 35 | 36 | public func removeObjectIfExpired(forKey key: String) { 37 | if let capsule = cache.object(forKey: NSString(string: key)), capsule.expiry.isExpired { 38 | removeObject(forKey: key) 39 | } 40 | } 41 | 42 | public func removeObject(forKey key: String) { 43 | cache.removeObject(forKey: NSString(string: key)) 44 | keys.remove(key) 45 | } 46 | 47 | public func entry(forKey key: String) throws -> Entry { 48 | guard let capsule = cache.object(forKey: NSString(string: key)) else { 49 | throw StorageError.notFound 50 | } 51 | 52 | guard let object = capsule.object as? T else { 53 | throw StorageError.typeNotMatch 54 | } 55 | 56 | return Entry(object: object, expiry: capsule.expiry) 57 | } 58 | } 59 | 60 | public extension MemoryStorage { 61 | func transform() -> MemoryStorage { 62 | let storage = MemoryStorage(config: config) 63 | return storage 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Tests/iOS/Tests/Storage/AsyncStorageTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Dispatch 3 | @testable import Cache 4 | 5 | final class AsyncStorageTests: XCTestCase { 6 | private var storage: AsyncStorage! 7 | let user = User(firstName: "John", lastName: "Snow") 8 | 9 | override func setUp() { 10 | super.setUp() 11 | let memory = MemoryStorage(config: MemoryConfig()) 12 | let disk = try! DiskStorage(config: DiskConfig(name: "Async Disk"), transformer: TransformerFactory.forCodable(ofType: User.self)) 13 | let hybrid = HybridStorage(memoryStorage: memory, diskStorage: disk) 14 | storage = AsyncStorage(storage: hybrid, serialQueue: DispatchQueue(label: "Async")) 15 | } 16 | 17 | override func tearDown() { 18 | storage.removeAll(completion: { _ in }) 19 | super.tearDown() 20 | } 21 | 22 | func testSetObject() throws { 23 | let expectation = self.expectation(description: #function) 24 | 25 | storage.setObject(user, forKey: "user", completion: { _ in }) 26 | storage.object(forKey: "user", completion: { result in 27 | switch result { 28 | case .value(let cachedUser): 29 | XCTAssertEqual(cachedUser, self.user) 30 | expectation.fulfill() 31 | default: 32 | XCTFail() 33 | } 34 | }) 35 | 36 | wait(for: [expectation], timeout: 1) 37 | } 38 | 39 | func testRemoveAll() { 40 | let intStorage = storage.transform(transformer: TransformerFactory.forCodable(ofType: Int.self)) 41 | let expectation = self.expectation(description: #function) 42 | given("add a lot of objects") { 43 | Array(0..<100).forEach { 44 | intStorage.setObject($0, forKey: "key-\($0)", completion: { _ in }) 45 | } 46 | } 47 | 48 | when("remove all") { 49 | intStorage.removeAll(completion: { _ in }) 50 | } 51 | 52 | then("all are removed") { 53 | intStorage.existsObject(forKey: "key-99", completion: { result in 54 | switch result { 55 | case .value: 56 | XCTFail() 57 | default: 58 | expectation.fulfill() 59 | } 60 | }) 61 | } 62 | 63 | wait(for: [expectation], timeout: 1) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Source/Shared/Storage/StorageAware.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A protocol used for saving and loading from storage 4 | public protocol StorageAware { 5 | associatedtype T 6 | /** 7 | Tries to retrieve the object from the storage. 8 | - Parameter key: Unique key to identify the object in the cache 9 | - Returns: Cached object or nil if not found 10 | */ 11 | func object(forKey key: String) throws -> T 12 | 13 | /** 14 | Get cache entry which includes object with metadata. 15 | - Parameter key: Unique key to identify the object in the cache 16 | - Returns: Object wrapper with metadata or nil if not found 17 | */ 18 | func entry(forKey key: String) throws -> Entry 19 | 20 | /** 21 | Removes the object by the given key. 22 | - Parameter key: Unique key to identify the object. 23 | */ 24 | func removeObject(forKey key: String) throws 25 | 26 | /** 27 | Saves passed object. 28 | - Parameter key: Unique key to identify the object in the cache. 29 | - Parameter object: Object that needs to be cached. 30 | - Parameter expiry: Overwrite expiry for this object only. 31 | */ 32 | func setObject(_ object: T, forKey key: String, expiry: Expiry?) throws 33 | 34 | /** 35 | Check if an object exist by the given key. 36 | - Parameter key: Unique key to identify the object. 37 | */ 38 | func existsObject(forKey key: String) throws -> Bool 39 | 40 | /** 41 | Removes all objects from the cache storage. 42 | */ 43 | func removeAll() throws 44 | 45 | /** 46 | Clears all expired objects. 47 | */ 48 | func removeExpiredObjects() throws 49 | 50 | /** 51 | Check if an expired object by the given key. 52 | - Parameter key: Unique key to identify the object. 53 | */ 54 | func isExpiredObject(forKey key: String) throws -> Bool 55 | } 56 | 57 | public extension StorageAware { 58 | func object(forKey key: String) throws -> T { 59 | return try entry(forKey: key).object 60 | } 61 | 62 | func existsObject(forKey key: String) throws -> Bool { 63 | do { 64 | let _: T = try object(forKey: key) 65 | return true 66 | } catch { 67 | return false 68 | } 69 | } 70 | 71 | func isExpiredObject(forKey key: String) throws -> Bool { 72 | do { 73 | let entry = try self.entry(forKey: key) 74 | return entry.expiry.isExpired 75 | } catch { 76 | return true 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Playgrounds/Storage.playground/Contents.swift: -------------------------------------------------------------------------------- 1 | //: Playground - noun: a place where people can play 2 | import PlaygroundSupport 3 | import UIKit 4 | import Cache 5 | 6 | struct Helper { 7 | static func image(_ color: UIColor = .red, size: CGSize = .init(width: 1, height: 1)) -> UIImage { 8 | UIGraphicsBeginImageContextWithOptions(size, false, 1) 9 | 10 | let context = UIGraphicsGetCurrentContext() 11 | context?.setFillColor(color.cgColor) 12 | context?.fill(CGRect(x: 0, y: 0, width: size.width, height: size.height)) 13 | 14 | let image = UIGraphicsGetImageFromCurrentImageContext() 15 | UIGraphicsEndImageContext() 16 | 17 | return image! 18 | } 19 | 20 | static func data(length: Int) -> Data { 21 | var buffer = [UInt8](repeating: 0, count: length) 22 | return Data(bytes: &buffer, count: length) 23 | } 24 | } 25 | 26 | // MARK: - Storage 27 | 28 | let diskConfig = DiskConfig(name: "Mix") 29 | 30 | let dataStorage = try! Storage( 31 | diskConfig: diskConfig, 32 | memoryConfig: MemoryConfig(), 33 | transformer: TransformerFactory.forData() 34 | ) 35 | 36 | let stringStorage = dataStorage.transformCodable(ofType: String.self) 37 | let imageStorage = dataStorage.transformImage() 38 | let dateStorage = dataStorage.transformCodable(ofType: Date.self) 39 | 40 | // We already have Codable conformances for: 41 | // String, UIImage, NSData and NSDate (just for fun =) 42 | 43 | let string = "This is a string" 44 | let image = Helper.image() 45 | let data = Helper.data(length: 64) 46 | let date = Date(timeInterval: 100000, since: Date()) 47 | 48 | // Add objects to the cache 49 | try stringStorage.setObject(string, forKey: "string") 50 | try imageStorage.setObject(image, forKey: "image") 51 | try dataStorage.setObject(data, forKey: "data") 52 | try dateStorage.setObject(date, forKey: "date") 53 | // 54 | //// Get objects from the cache 55 | let cachedString = try? stringStorage.object(forKey: "string") 56 | print(cachedString as Any) 57 | 58 | imageStorage.async.object(forKey: "image") { result in 59 | if case .value(let image) = result { 60 | print(image) 61 | } 62 | } 63 | 64 | dataStorage.async.object(forKey: "data") { result in 65 | if case .value(let data) = result { 66 | print(data) 67 | } 68 | } 69 | 70 | dateStorage.async.object(forKey: "date") { result in 71 | if case .value(let date) = result { 72 | print(date) 73 | } 74 | } 75 | 76 | // Clean the cache 77 | dataStorage.async.removeAll(completion: { (result) in 78 | if case .value = result { 79 | print("Cache cleaned") 80 | } 81 | }) 82 | 83 | PlaygroundPage.current.needsIndefiniteExecution = true 84 | -------------------------------------------------------------------------------- /Tests/iOS/Tests/Library/TypeWrapperTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Cache 3 | 4 | final class TypeWrapperTests: XCTestCase { 5 | func testString() { 6 | let value = "Hello" 7 | let wrapper = TypeWrapper(object: value) 8 | 9 | let data = try! JSONEncoder().encode(wrapper) 10 | let anotherWrapper = try! JSONDecoder().decode(TypeWrapper.self, from: data) 11 | 12 | XCTAssertEqual(value, anotherWrapper.object) 13 | } 14 | 15 | func testInt() { 16 | let value = 10 17 | let wrapper = TypeWrapper(object: value) 18 | 19 | let data = try! JSONEncoder().encode(wrapper) 20 | let anotherWrapper = try! JSONDecoder().decode(TypeWrapper.self, from: data) 21 | 22 | XCTAssertEqual(value, anotherWrapper.object) 23 | } 24 | 25 | func testDate() { 26 | let value = Date() 27 | let wrapper = TypeWrapper(object: value) 28 | 29 | let data = try! JSONEncoder().encode(wrapper) 30 | let anotherWrapper = try! JSONDecoder().decode(TypeWrapper.self, from: data) 31 | 32 | XCTAssertEqual(value, anotherWrapper.object) 33 | } 34 | 35 | func testBool() { 36 | let value = true 37 | let wrapper = TypeWrapper(object: value) 38 | 39 | let data = try! JSONEncoder().encode(wrapper) 40 | let anotherWrapper = try! JSONDecoder().decode(TypeWrapper.self, from: data) 41 | 42 | XCTAssertEqual(value, anotherWrapper.object) 43 | } 44 | 45 | func testData() { 46 | let value = "Hello".data(using: .utf8)! 47 | let wrapper = TypeWrapper(object: value) 48 | 49 | let data = try! JSONEncoder().encode(wrapper) 50 | let anotherWrapper = try! JSONDecoder().decode(TypeWrapper.self, from: data) 51 | 52 | XCTAssertEqual(value, anotherWrapper.object) 53 | } 54 | 55 | func testArray() { 56 | let value = [1, 2, 3] 57 | let wrapper = TypeWrapper(object: value) 58 | 59 | let data = try! JSONEncoder().encode(wrapper) 60 | let anotherWrapper = try! JSONDecoder().decode(TypeWrapper>.self, from: data) 61 | 62 | XCTAssertEqual(value, anotherWrapper.object) 63 | } 64 | 65 | func testDictionary() { 66 | let value = [ 67 | "key1": 1, 68 | "key2": 2 69 | ] 70 | 71 | let wrapper = TypeWrapper(object: value) 72 | 73 | let data = try! JSONEncoder().encode(wrapper) 74 | let anotherWrapper = try! JSONDecoder().decode(TypeWrapper>.self, from: data) 75 | 76 | XCTAssertEqual(value, anotherWrapper.object) 77 | } 78 | 79 | func testSet() { 80 | let value = Set(arrayLiteral: 1, 2, 3) 81 | let wrapper = TypeWrapper(object: value) 82 | 83 | let data = try! JSONEncoder().encode(wrapper) 84 | let anotherWrapper = try! JSONDecoder().decode(TypeWrapper>.self, from: data) 85 | 86 | XCTAssertEqual(value, anotherWrapper.object) 87 | } 88 | } 89 | 90 | -------------------------------------------------------------------------------- /Source/Shared/Storage/AsyncStorage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Dispatch 3 | 4 | /// Manipulate storage in a "all async" manner. 5 | /// The completion closure will be called when operation completes. 6 | public class AsyncStorage { 7 | public let innerStorage: HybridStorage 8 | public let serialQueue: DispatchQueue 9 | 10 | public init(storage: HybridStorage, serialQueue: DispatchQueue) { 11 | self.innerStorage = storage 12 | self.serialQueue = serialQueue 13 | } 14 | } 15 | 16 | extension AsyncStorage { 17 | public func entry(forKey key: String, completion: @escaping (Result>) -> Void) { 18 | serialQueue.async { [weak self] in 19 | guard let `self` = self else { 20 | completion(Result.error(StorageError.deallocated)) 21 | return 22 | } 23 | 24 | do { 25 | let anEntry = try self.innerStorage.entry(forKey: key) 26 | completion(Result.value(anEntry)) 27 | } catch { 28 | completion(Result.error(error)) 29 | } 30 | } 31 | } 32 | 33 | public func removeObject(forKey key: String, completion: @escaping (Result<()>) -> Void) { 34 | serialQueue.async { [weak self] in 35 | guard let `self` = self else { 36 | completion(Result.error(StorageError.deallocated)) 37 | return 38 | } 39 | 40 | do { 41 | try self.innerStorage.removeObject(forKey: key) 42 | completion(Result.value(())) 43 | } catch { 44 | completion(Result.error(error)) 45 | } 46 | } 47 | } 48 | 49 | public func setObject( 50 | _ object: T, 51 | forKey key: String, 52 | expiry: Expiry? = nil, 53 | completion: @escaping (Result<()>) -> Void) { 54 | serialQueue.async { [weak self] in 55 | guard let `self` = self else { 56 | completion(Result.error(StorageError.deallocated)) 57 | return 58 | } 59 | 60 | do { 61 | try self.innerStorage.setObject(object, forKey: key, expiry: expiry) 62 | completion(Result.value(())) 63 | } catch { 64 | completion(Result.error(error)) 65 | } 66 | } 67 | } 68 | 69 | public func removeAll(completion: @escaping (Result<()>) -> Void) { 70 | serialQueue.async { [weak self] in 71 | guard let `self` = self else { 72 | completion(Result.error(StorageError.deallocated)) 73 | return 74 | } 75 | 76 | do { 77 | try self.innerStorage.removeAll() 78 | completion(Result.value(())) 79 | } catch { 80 | completion(Result.error(error)) 81 | } 82 | } 83 | } 84 | 85 | public func removeExpiredObjects(completion: @escaping (Result<()>) -> Void) { 86 | serialQueue.async { [weak self] in 87 | guard let `self` = self else { 88 | completion(Result.error(StorageError.deallocated)) 89 | return 90 | } 91 | 92 | do { 93 | try self.innerStorage.removeExpiredObjects() 94 | completion(Result.value(())) 95 | } catch { 96 | completion(Result.error(error)) 97 | } 98 | } 99 | } 100 | 101 | public func object(forKey key: String, completion: @escaping (Result) -> Void) { 102 | entry(forKey: key, completion: { (result: Result>) in 103 | completion(result.map({ entry in 104 | return entry.object 105 | })) 106 | }) 107 | } 108 | 109 | public func existsObject( 110 | forKey key: String, 111 | completion: @escaping (Result) -> Void) { 112 | object(forKey: key, completion: { (result: Result) in 113 | completion(result.map({ _ in 114 | return true 115 | })) 116 | }) 117 | } 118 | } 119 | 120 | public extension AsyncStorage { 121 | func transform(transformer: Transformer) -> AsyncStorage { 122 | let storage = AsyncStorage( 123 | storage: innerStorage.transform(transformer: transformer), 124 | serialQueue: serialQueue 125 | ) 126 | 127 | return storage 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /Source/Shared/Storage/Storage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Dispatch 3 | 4 | /// Manage storage. Use memory storage if specified. 5 | /// Synchronous by default. Use `async` for asynchronous operations. 6 | public final class Storage { 7 | /// Used for sync operations 8 | private let syncStorage: SyncStorage 9 | private let asyncStorage: AsyncStorage 10 | private let hybridStorage: HybridStorage 11 | 12 | /// Initialize storage with configuration options. 13 | /// 14 | /// - Parameters: 15 | /// - diskConfig: Configuration for disk storage 16 | /// - memoryConfig: Optional. Pass config if you want memory cache 17 | /// - Throws: Throw StorageError if any. 18 | public convenience init(diskConfig: DiskConfig, memoryConfig: MemoryConfig, transformer: Transformer) throws { 19 | let disk = try DiskStorage(config: diskConfig, transformer: transformer) 20 | let memory = MemoryStorage(config: memoryConfig) 21 | let hybridStorage = HybridStorage(memoryStorage: memory, diskStorage: disk) 22 | self.init(hybridStorage: hybridStorage) 23 | } 24 | 25 | /// Initialise with sync and async storages 26 | /// 27 | /// - Parameter syncStorage: Synchronous storage 28 | /// - Paraeter: asyncStorage: Asynchronous storage 29 | public init(hybridStorage: HybridStorage) { 30 | self.hybridStorage = hybridStorage 31 | self.syncStorage = SyncStorage( 32 | storage: hybridStorage, 33 | serialQueue: DispatchQueue(label: "Cache.SyncStorage.SerialQueue") 34 | ) 35 | self.asyncStorage = AsyncStorage( 36 | storage: hybridStorage, 37 | serialQueue: DispatchQueue(label: "Cache.AsyncStorage.SerialQueue") 38 | ) 39 | } 40 | 41 | /// Used for async operations 42 | public lazy var async = self.asyncStorage 43 | } 44 | 45 | extension Storage: StorageAware { 46 | public func entry(forKey key: String) throws -> Entry { 47 | return try self.syncStorage.entry(forKey: key) 48 | } 49 | 50 | public func removeObject(forKey key: String) throws { 51 | try self.syncStorage.removeObject(forKey: key) 52 | } 53 | 54 | public func setObject(_ object: T, forKey key: String, expiry: Expiry? = nil) throws { 55 | try self.syncStorage.setObject(object, forKey: key, expiry: expiry) 56 | } 57 | 58 | public func removeAll() throws { 59 | try self.syncStorage.removeAll() 60 | } 61 | 62 | public func removeExpiredObjects() throws { 63 | try self.syncStorage.removeExpiredObjects() 64 | } 65 | } 66 | 67 | public extension Storage { 68 | func transform(transformer: Transformer) -> Storage { 69 | return Storage(hybridStorage: hybridStorage.transform(transformer: transformer)) 70 | } 71 | } 72 | 73 | extension Storage: StorageObservationRegistry { 74 | @discardableResult 75 | public func addStorageObserver( 76 | _ observer: O, 77 | closure: @escaping (O, Storage, StorageChange) -> Void 78 | ) -> ObservationToken { 79 | return hybridStorage.addStorageObserver(observer) { [weak self] observer, _, change in 80 | guard let strongSelf = self else { return } 81 | closure(observer, strongSelf, change) 82 | } 83 | } 84 | 85 | public func removeAllStorageObservers() { 86 | hybridStorage.removeAllStorageObservers() 87 | } 88 | } 89 | 90 | extension Storage: KeyObservationRegistry { 91 | @discardableResult 92 | public func addObserver( 93 | _ observer: O, 94 | forKey key: String, 95 | closure: @escaping (O, Storage, KeyChange) -> Void 96 | ) -> ObservationToken { 97 | return hybridStorage.addObserver(observer, forKey: key) { [weak self] observer, _, change in 98 | guard let strongSelf = self else { return } 99 | closure(observer, strongSelf, change) 100 | } 101 | } 102 | 103 | public func removeObserver(forKey key: String) { 104 | hybridStorage.removeObserver(forKey: key) 105 | } 106 | 107 | public func removeAllKeyObservers() { 108 | hybridStorage.removeAllKeyObservers() 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Cache.xcodeproj/xcshareddata/xcschemes/Cache-iOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 35 | 41 | 42 | 43 | 44 | 45 | 51 | 52 | 53 | 54 | 55 | 56 | 66 | 67 | 73 | 74 | 75 | 76 | 77 | 78 | 84 | 85 | 91 | 92 | 93 | 94 | 96 | 97 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /Cache.xcodeproj/xcshareddata/xcschemes/Cache-tvOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 35 | 41 | 42 | 43 | 44 | 45 | 51 | 52 | 53 | 54 | 55 | 56 | 66 | 67 | 73 | 74 | 75 | 76 | 77 | 78 | 84 | 85 | 91 | 92 | 93 | 94 | 96 | 97 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /Tests/iOS/Tests/Storage/MemoryStorageTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Cache 3 | 4 | final class MemoryStorageTests: XCTestCase { 5 | private let key = "youknownothing" 6 | private let testObject = User(firstName: "John", lastName: "Snow") 7 | private var storage: MemoryStorage! 8 | private let config = MemoryConfig(expiry: .never, countLimit: 10, totalCostLimit: 10) 9 | 10 | override func setUp() { 11 | super.setUp() 12 | storage = MemoryStorage(config: config) 13 | } 14 | 15 | override func tearDown() { 16 | storage.removeAll() 17 | super.tearDown() 18 | } 19 | 20 | /// Test that it saves an object 21 | func testSetObject() { 22 | storage.setObject(testObject, forKey: key) 23 | let cachedObject = try! storage.object(forKey: key) 24 | XCTAssertNotNil(cachedObject) 25 | XCTAssertEqual(cachedObject.firstName, testObject.firstName) 26 | XCTAssertEqual(cachedObject.lastName, testObject.lastName) 27 | } 28 | 29 | func testCacheEntry() { 30 | // Returns nil if entry doesn't exist 31 | var entry = try? storage.entry(forKey: key) 32 | XCTAssertNil(entry) 33 | 34 | // Returns entry if object exists 35 | storage.setObject(testObject, forKey: key) 36 | entry = try! storage.entry(forKey: key) 37 | 38 | XCTAssertEqual(entry?.object.firstName, testObject.firstName) 39 | XCTAssertEqual(entry?.object.lastName, testObject.lastName) 40 | XCTAssertEqual(entry?.expiry.date, config.expiry.date) 41 | } 42 | 43 | func testSetObjectWithExpiry() { 44 | let date = Date().addingTimeInterval(1) 45 | storage.setObject(testObject, forKey: key, expiry: .seconds(1)) 46 | var entry = try! storage.entry(forKey: key) 47 | XCTAssertEqual(entry.expiry.date.timeIntervalSinceReferenceDate, 48 | date.timeIntervalSinceReferenceDate, 49 | accuracy: 0.1) 50 | //Timer vs sleep: do not complicate 51 | sleep(1) 52 | entry = try! storage.entry(forKey: key) 53 | XCTAssertEqual(entry.expiry.date.timeIntervalSinceReferenceDate, 54 | date.timeIntervalSinceReferenceDate, 55 | accuracy: 0.1) 56 | } 57 | 58 | /// Test that it removes cached object 59 | func testRemoveObject() { 60 | storage.setObject(testObject, forKey: key) 61 | storage.removeObject(forKey: key) 62 | let cachedObject = try? storage.object(forKey: key) 63 | XCTAssertNil(cachedObject) 64 | } 65 | 66 | /// Test that it removes expired object 67 | func testRemoveObjectIfExpiredWhenExpired() { 68 | let expiry: Expiry = .date(Date().addingTimeInterval(-10)) 69 | storage.setObject(testObject, forKey: key, expiry: expiry) 70 | storage.removeObjectIfExpired(forKey: key) 71 | let cachedObject = try? storage.object(forKey: key) 72 | 73 | XCTAssertNil(cachedObject) 74 | } 75 | 76 | /// Test that it doesn't remove not expired object 77 | func testRemoveObjectIfExpiredWhenNotExpired() { 78 | storage.setObject(testObject, forKey: key) 79 | storage.removeObjectIfExpired(forKey: key) 80 | let cachedObject = try! storage.object(forKey: key) 81 | 82 | XCTAssertNotNil(cachedObject) 83 | } 84 | 85 | /// Test expired object 86 | func testExpiredObject() throws { 87 | storage.setObject(testObject, forKey: key, expiry: .seconds(0.9)) 88 | XCTAssertFalse(try! storage.isExpiredObject(forKey: key)) 89 | sleep(1) 90 | XCTAssertTrue(try! storage.isExpiredObject(forKey: key)) 91 | } 92 | 93 | /// Test that it clears cache directory 94 | func testRemoveAll() { 95 | storage.setObject(testObject, forKey: key) 96 | storage.removeAll() 97 | let cachedObject = try? storage.object(forKey: key) 98 | XCTAssertNil(cachedObject) 99 | } 100 | 101 | /// Test that it removes expired objects 102 | func testClearExpired() { 103 | let expiry1: Expiry = .date(Date().addingTimeInterval(-10)) 104 | let expiry2: Expiry = .date(Date().addingTimeInterval(10)) 105 | let key1 = "item1" 106 | let key2 = "item2" 107 | storage.setObject(testObject, forKey: key1, expiry: expiry1) 108 | storage.setObject(testObject, forKey: key2, expiry: expiry2) 109 | storage.removeExpiredObjects() 110 | let object1 = try? storage.object(forKey: key1) 111 | let object2 = try! storage.object(forKey: key2) 112 | 113 | XCTAssertNil(object1) 114 | XCTAssertNotNil(object2) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /Tests/iOS/Tests/Library/MD5Tests.swift: -------------------------------------------------------------------------------- 1 | // https://github.com/onmyway133/SwiftHash/blob/master/SwiftHashTests/Tests.swift 2 | 3 | import XCTest 4 | @testable import Cache 5 | 6 | class Tests: XCTestCase { 7 | 8 | func testHelper() { 9 | XCTAssertEqual(str2rstr_utf8("hello"), [104, 101, 108, 108, 111]) 10 | 11 | let hello = str2rstr_utf8("hello") 12 | let world = str2rstr_utf8("world") 13 | let google = str2rstr_utf8("https://www.google.com") 14 | 15 | XCTAssertEqual(rstr2tr(hello), "hello") 16 | XCTAssertEqual(rstr2tr(world), "world") 17 | XCTAssertEqual(rstr2tr(google), "https://www.google.com") 18 | 19 | XCTAssertEqual(rstr2binl(hello), [1819043176, 111]) 20 | XCTAssertEqual(rstr2binl(world), [1819438967, 100]) 21 | XCTAssertEqual(rstr2binl(google), [1886680168, 791624307, 779581303, 1735356263, 1663985004, 28015]) 22 | 23 | XCTAssertEqual(rstr2hex(hello), "68656C6C6F") 24 | XCTAssertEqual(rstr2hex(world), "776F726C64") 25 | XCTAssertEqual(rstr2hex(google), "68747470733A2F2F7777772E676F6F676C652E636F6D") 26 | 27 | XCTAssertEqual(Int32(-991732713) << Int32(12), 901869568) 28 | XCTAssertEqual(Int32(991732713) << Int32(12), -901869568) 29 | XCTAssertEqual(zeroFillRightShift(-991732713, (32 - 12)), 3150) 30 | 31 | XCTAssertEqual(bit_rol(1589092186, 17), 1052032367) 32 | XCTAssertEqual(bit_rol(-991732713, 12), 901872718) 33 | 34 | XCTAssertEqual(md5_cmn(-617265063, -615378706, -617265106, 0, 17, -1473231341), 434767261) 35 | 36 | XCTAssertEqual(md5_ff(271733878, -615343318, -271733879, -1732584194, 32879, 12, -389564586), 286529400) 37 | 38 | XCTAssertEqual(binl_md5(rstr2binl(hello), hello.count * 8), [708854109, 1982483388, -1851952711, -1832577264]) 39 | XCTAssertEqual(binl_md5(rstr2binl(world), world.count * 8), [925923709, -2046724448, -2113778857, -415894286]) 40 | } 41 | 42 | func testMD5() { 43 | XCTAssertEqual(MD5("hello"), 44 | "5D41402ABC4B2A76B9719D911017C592") 45 | XCTAssertEqual(MD5("world"), 46 | "7D793037A0760186574B0282F2F435E7") 47 | XCTAssertEqual(MD5("https://www.google.com"), 48 | "8FFDEFBDEC956B595D257F0AAEEFD623") 49 | XCTAssertEqual(MD5("https://www.google.com/logos/doodles/2016/parents-day-in-korea-5757703554072576-hp2x.jpg"), 50 | "0DFB10E8D2AE771B3B3ED4544139644E") 51 | XCTAssertEqual(MD5("https://unsplash.it/600/300/?image=1"), 52 | "D59E956EBB1BE415970F04EC77F4C875") 53 | XCTAssertEqual(MD5(""), 54 | "D41D8CD98F00B204E9800998ECF8427E") 55 | XCTAssertEqual(MD5("ABCDEFGHIJKLMNOPQRSTWXYZ1234567890"), 56 | "B8F4F38629EC4F4A23F5DCC6086F8035") 57 | XCTAssertEqual(MD5("abcdefghijklmnopqrstwxyz1234567890"), 58 | "B2E875F4D53CCF6CEFB5CDA3F86FC542") 59 | XCTAssertEqual(MD5("0123456789"), 60 | "781E5E245D69B566979B86E28D23F2C7") 61 | XCTAssertEqual(MD5("0"), 62 | "CFCD208495D565EF66E7DFF9F98764DA") 63 | XCTAssertEqual(MD5("https://twitter.com/_HairForceOne/status/745235759460810752"), 64 | "40C2BFA3D7BFC7A453013ECD54022255") 65 | XCTAssertEqual(MD5("Det er et velkjent faktum at lesere distraheres av lesbart innhold på en side når man ser på dens layout. Poenget med å bruke Lorem Ipsum er at det har en mer eller mindre normal fordeling av bokstaver i ord, i motsetning til 'Innhold her, innhold her', og gir inntrykk av å være lesbar tekst. Mange webside- og sideombrekkingsprogrammer bruker nå Lorem Ipsum som sin standard for provisorisk tekst"), 66 | "6B2880BCC7554CF07E72DB9C99BF3284") 67 | XCTAssertEqual(MD5("\\"), 68 | "28D397E87306B8631F3ED80D858D35F0") 69 | XCTAssertEqual(MD5("http://res.cloudinary.com/demo/image/upload/w_300,h_200,c_crop/sample.jpg"), 70 | "6E30D9CC4C08BE4EEA49076328D4C1F0") 71 | XCTAssertEqual(MD5("http://res.cloudinary.com/demo/image/upload/x_355,y_410,w_300,h_200,c_crop/brown_sheep.jpg"), 72 | "019E9D72B5AF84EF114868875C1597ED") 73 | XCTAssertEqual(MD5("http://www.w3schools.com/tags/html_form_submit.asp?text=Hello+G%C3%BCnter"), 74 | "C89A2146CD3DF34ECDA86B6E0709B3FD") 75 | XCTAssertEqual(MD5("!%40%23%24%25%5E%26*()%2C.%3C%3E%5C'1234567890-%3D"), 76 | "09A1790760693160E74B9D6FCEC7EF64") 77 | } 78 | 79 | func testMD5_Data() { 80 | let data = "https://www.google.com".data(using: String.Encoding.utf8) 81 | XCTAssertEqual(MD5(String(data: data!, encoding: String.Encoding.utf8)!), 82 | "8FFDEFBDEC956B595D257F0AAEEFD623") 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Cache.xcodeproj/xcshareddata/xcschemes/Cache-macOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 44 | 45 | 49 | 55 | 56 | 57 | 58 | 59 | 65 | 66 | 67 | 68 | 69 | 70 | 80 | 81 | 87 | 88 | 89 | 90 | 91 | 92 | 98 | 99 | 105 | 106 | 107 | 108 | 110 | 111 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /Source/Shared/Storage/HybridStorage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Use both memory and disk storage. Try on memory first. 4 | public final class HybridStorage { 5 | public let memoryStorage: MemoryStorage 6 | public let diskStorage: DiskStorage 7 | 8 | private(set) var storageObservations = [UUID: (HybridStorage, StorageChange) -> Void]() 9 | private(set) var keyObservations = [String: (HybridStorage, KeyChange) -> Void]() 10 | 11 | public init(memoryStorage: MemoryStorage, diskStorage: DiskStorage) { 12 | self.memoryStorage = memoryStorage 13 | self.diskStorage = diskStorage 14 | 15 | diskStorage.onRemove = { [weak self] path in 16 | self?.handleRemovedObject(at: path) 17 | } 18 | } 19 | 20 | private func handleRemovedObject(at path: String) { 21 | notifyObserver(about: .remove) { key in 22 | let fileName = diskStorage.makeFileName(for: key) 23 | return path.contains(fileName) 24 | } 25 | } 26 | } 27 | 28 | extension HybridStorage: StorageAware { 29 | public func entry(forKey key: String) throws -> Entry { 30 | do { 31 | return try memoryStorage.entry(forKey: key) 32 | } catch { 33 | let entry = try diskStorage.entry(forKey: key) 34 | // set back to memoryStorage 35 | memoryStorage.setObject(entry.object, forKey: key, expiry: entry.expiry) 36 | return entry 37 | } 38 | } 39 | 40 | public func removeObject(forKey key: String) throws { 41 | memoryStorage.removeObject(forKey: key) 42 | try diskStorage.removeObject(forKey: key) 43 | 44 | notifyStorageObservers(about: .remove(key: key)) 45 | } 46 | 47 | public func setObject(_ object: T, forKey key: String, expiry: Expiry? = nil) throws { 48 | var keyChange: KeyChange? 49 | 50 | if keyObservations[key] != nil { 51 | keyChange = .edit(before: try? self.object(forKey: key), after: object) 52 | } 53 | 54 | memoryStorage.setObject(object, forKey: key, expiry: expiry) 55 | try diskStorage.setObject(object, forKey: key, expiry: expiry) 56 | 57 | if let change = keyChange { 58 | notifyObserver(forKey: key, about: change) 59 | } 60 | 61 | notifyStorageObservers(about: .add(key: key)) 62 | } 63 | 64 | public func removeAll() throws { 65 | memoryStorage.removeAll() 66 | try diskStorage.removeAll() 67 | 68 | notifyStorageObservers(about: .removeAll) 69 | notifyKeyObservers(about: .remove) 70 | } 71 | 72 | public func removeExpiredObjects() throws { 73 | memoryStorage.removeExpiredObjects() 74 | try diskStorage.removeExpiredObjects() 75 | 76 | notifyStorageObservers(about: .removeExpired) 77 | } 78 | } 79 | 80 | public extension HybridStorage { 81 | func transform(transformer: Transformer) -> HybridStorage { 82 | let storage = HybridStorage( 83 | memoryStorage: memoryStorage.transform(), 84 | diskStorage: diskStorage.transform(transformer: transformer) 85 | ) 86 | 87 | return storage 88 | } 89 | } 90 | 91 | extension HybridStorage: StorageObservationRegistry { 92 | @discardableResult 93 | public func addStorageObserver( 94 | _ observer: O, 95 | closure: @escaping (O, HybridStorage, StorageChange) -> Void 96 | ) -> ObservationToken { 97 | let id = UUID() 98 | 99 | storageObservations[id] = { [weak self, weak observer] storage, change in 100 | guard let observer = observer else { 101 | self?.storageObservations.removeValue(forKey: id) 102 | return 103 | } 104 | 105 | closure(observer, storage, change) 106 | } 107 | 108 | return ObservationToken { [weak self] in 109 | self?.storageObservations.removeValue(forKey: id) 110 | } 111 | } 112 | 113 | public func removeAllStorageObservers() { 114 | storageObservations.removeAll() 115 | } 116 | 117 | private func notifyStorageObservers(about change: StorageChange) { 118 | storageObservations.values.forEach { closure in 119 | closure(self, change) 120 | } 121 | } 122 | } 123 | 124 | extension HybridStorage: KeyObservationRegistry { 125 | @discardableResult 126 | public func addObserver( 127 | _ observer: O, 128 | forKey key: String, 129 | closure: @escaping (O, HybridStorage, KeyChange) -> Void 130 | ) -> ObservationToken { 131 | keyObservations[key] = { [weak self, weak observer] storage, change in 132 | guard let observer = observer else { 133 | self?.removeObserver(forKey: key) 134 | return 135 | } 136 | 137 | closure(observer, storage, change) 138 | } 139 | 140 | return ObservationToken { [weak self] in 141 | self?.keyObservations.removeValue(forKey: key) 142 | } 143 | } 144 | 145 | public func removeObserver(forKey key: String) { 146 | keyObservations.removeValue(forKey: key) 147 | } 148 | 149 | public func removeAllKeyObservers() { 150 | keyObservations.removeAll() 151 | } 152 | 153 | private func notifyObserver(forKey key: String, about change: KeyChange) { 154 | keyObservations[key]?(self, change) 155 | } 156 | 157 | private func notifyObserver(about change: KeyChange, whereKey closure: ((String) -> Bool)) { 158 | let observation = keyObservations.first { key, _ in closure(key) }?.value 159 | observation?(self, change) 160 | } 161 | 162 | private func notifyKeyObservers(about change: KeyChange) { 163 | keyObservations.values.forEach { closure in 164 | closure(self, change) 165 | } 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /Tests/iOS/Tests/Storage/StorageSupportTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Cache 3 | 4 | final class StorageSupportTests: XCTestCase { 5 | private var storage: HybridStorage! 6 | 7 | override func setUp() { 8 | super.setUp() 9 | let memory = MemoryStorage(config: MemoryConfig()) 10 | let disk = try! DiskStorage(config: DiskConfig(name: "PrimitiveDisk"), transformer: TransformerFactory.forCodable(ofType: Bool.self)) 11 | storage = HybridStorage(memoryStorage: memory, diskStorage: disk) 12 | } 13 | 14 | override func tearDown() { 15 | try? storage.removeAll() 16 | super.tearDown() 17 | } 18 | 19 | func testSetPrimitive() throws { 20 | do { 21 | let s = storage.transform(transformer: TransformerFactory.forCodable(ofType: Bool.self)) 22 | try s.setObject(true, forKey: "bool") 23 | XCTAssertEqual(try s.object(forKey: "bool"), true) 24 | } 25 | 26 | do { 27 | let s = storage.transform(transformer: TransformerFactory.forCodable(ofType: [Bool].self)) 28 | try s.setObject([true, false, true], forKey: "array of bools") 29 | XCTAssertEqual(try s.object(forKey: "array of bools"), [true, false, true]) 30 | } 31 | 32 | do { 33 | let s = storage.transform(transformer: TransformerFactory.forCodable(ofType: String.self)) 34 | try s.setObject("one", forKey: "string") 35 | XCTAssertEqual(try s.object(forKey: "string"), "one") 36 | } 37 | 38 | do { 39 | let s = storage.transform(transformer: TransformerFactory.forCodable(ofType: [String].self)) 40 | try s.setObject(["one", "two", "three"], forKey: "array of strings") 41 | XCTAssertEqual(try s.object(forKey: "array of strings"), ["one", "two", "three"]) 42 | } 43 | 44 | do { 45 | let s = storage.transform(transformer: TransformerFactory.forCodable(ofType: Int.self)) 46 | try s.setObject(10, forKey: "int") 47 | XCTAssertEqual(try s.object(forKey: "int"), 10) 48 | } 49 | 50 | do { 51 | let s = storage.transform(transformer: TransformerFactory.forCodable(ofType: [Int].self)) 52 | try s.setObject([1, 2, 3], forKey: "array of ints") 53 | XCTAssertEqual(try s.object(forKey: "array of ints"), [1, 2, 3]) 54 | } 55 | 56 | do { 57 | let s = storage.transform(transformer: TransformerFactory.forCodable(ofType: Float.self)) 58 | let float: Float = 1.1 59 | try s.setObject(float, forKey: "float") 60 | XCTAssertEqual(try s.object(forKey: "float"), float) 61 | } 62 | 63 | do { 64 | let s = storage.transform(transformer: TransformerFactory.forCodable(ofType: [Float].self)) 65 | let floats: [Float] = [1.1, 1.2, 1.3] 66 | try s.setObject(floats, forKey: "array of floats") 67 | XCTAssertEqual(try s.object(forKey: "array of floats"), floats) 68 | } 69 | 70 | do { 71 | let s = storage.transform(transformer: TransformerFactory.forCodable(ofType: Double.self)) 72 | let double: Double = 1.1 73 | try s.setObject(double, forKey: "double") 74 | XCTAssertEqual(try s.object(forKey: "double"), double) 75 | } 76 | 77 | do { 78 | let s = storage.transform(transformer: TransformerFactory.forCodable(ofType: [Double].self)) 79 | let doubles: [Double] = [1.1, 1.2, 1.3] 80 | try s.setObject(doubles, forKey: "array of doubles") 81 | XCTAssertEqual(try s.object(forKey: "array of doubles"), doubles) 82 | } 83 | } 84 | 85 | func testSetData() { 86 | let s = storage.transform(transformer: TransformerFactory.forData()) 87 | 88 | do { 89 | let string = "Hello" 90 | let data = string.data(using: .utf8)! 91 | try s.setObject(data, forKey: "data") 92 | 93 | let cachedObject = try s.object(forKey: "data") 94 | let cachedString = String(data: cachedObject, encoding: .utf8) 95 | 96 | XCTAssertEqual(cachedString, string) 97 | } catch { 98 | XCTFail(error.localizedDescription) 99 | } 100 | } 101 | 102 | func testSetDate() throws { 103 | let s = storage.transform(transformer: TransformerFactory.forCodable(ofType: Date.self)) 104 | 105 | let date = Date(timeIntervalSince1970: 100) 106 | try s.setObject(date, forKey: "date") 107 | let cachedObject = try s.object(forKey: "date") 108 | 109 | XCTAssertEqual(date, cachedObject) 110 | } 111 | 112 | func testSetURL() throws { 113 | let s = storage.transform(transformer: TransformerFactory.forCodable(ofType: URL.self)) 114 | let url = URL(string: "https://hyper.no")! 115 | try s.setObject(url, forKey: "url") 116 | let cachedObject = try s.object(forKey: "url") 117 | 118 | XCTAssertEqual(url, cachedObject) 119 | } 120 | 121 | func testWithSet() throws { 122 | let s = storage.transform(transformer: TransformerFactory.forCodable(ofType: Set.self)) 123 | let set = Set(arrayLiteral: 1, 2, 3) 124 | try s.setObject(set, forKey: "set") 125 | XCTAssertEqual(try s.object(forKey: "set") as Set, set) 126 | } 127 | 128 | func testWithSimpleDictionary() throws { 129 | let s = storage.transform(transformer: TransformerFactory.forCodable(ofType: [String: Int].self)) 130 | 131 | let dict: [String: Int] = [ 132 | "key1": 1, 133 | "key2": 2 134 | ] 135 | 136 | try s.setObject(dict, forKey: "dict") 137 | let cachedObject = try s.object(forKey: "dict") as [String: Int] 138 | XCTAssertEqual(cachedObject, dict) 139 | } 140 | 141 | func testWithComplexDictionary() { 142 | let _: [String: Any] = [ 143 | "key1": 1, 144 | "key2": 2 145 | ] 146 | 147 | // fatal error: Dictionary does not conform to Encodable because Any does not conform to Encodable 148 | // try storage.setObject(dict, forKey: "dict") 149 | } 150 | 151 | func testIntFloat() throws { 152 | let s = storage.transform(transformer: TransformerFactory.forCodable(ofType: Float.self)) 153 | let key = "key" 154 | try s.setObject(10, forKey: key) 155 | 156 | try then("Casting to int or float is the same") { 157 | XCTAssertEqual(try s.object(forKey: key), 10) 158 | 159 | let intStorage = s.transform(transformer: TransformerFactory.forCodable(ofType: Int.self)) 160 | XCTAssertEqual(try intStorage.object(forKey: key), 10) 161 | } 162 | } 163 | 164 | func testFloatDouble() throws { 165 | let s = storage.transform(transformer: TransformerFactory.forCodable(ofType: Float.self)) 166 | let key = "key" 167 | try s.setObject(10.5, forKey: key) 168 | 169 | try then("Casting to float or double is the same") { 170 | XCTAssertEqual(try s.object(forKey: key), 10.5) 171 | 172 | let doubleStorage = s.transform(transformer: TransformerFactory.forCodable(ofType: Double.self)) 173 | XCTAssertEqual(try doubleStorage.object(forKey: key), 10.5) 174 | } 175 | } 176 | 177 | func testCastingToAnotherType() throws { 178 | let s = storage.transform(transformer: TransformerFactory.forCodable(ofType: String.self)) 179 | try s.setObject("Hello", forKey: "string") 180 | 181 | do { 182 | let intStorage = s.transform(transformer: TransformerFactory.forCodable(ofType: Int.self)) 183 | let _ = try intStorage.object(forKey: "string") 184 | XCTFail() 185 | } catch { 186 | XCTAssertTrue(error is DecodingError) 187 | } 188 | } 189 | 190 | func testOverridenOnDisk() throws { 191 | let intStorage = storage.transform(transformer: TransformerFactory.forCodable(ofType: Int.self)) 192 | let stringStorage = storage.transform(transformer: TransformerFactory.forCodable(ofType: String.self)) 193 | 194 | let key = "sameKey" 195 | 196 | try intStorage.setObject(1, forKey: key) 197 | try stringStorage.setObject("hello world", forKey: key) 198 | 199 | let intValue = try? intStorage.diskStorage.object(forKey: key) 200 | let stringValue = try? stringStorage.diskStorage.object(forKey: key) 201 | 202 | XCTAssertNil(intValue) 203 | XCTAssertNotNil(stringValue) 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /Tests/iOS/Tests/Storage/DiskStorageTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Cache 3 | 4 | final class DiskStorageTests: XCTestCase { 5 | private let key = "youknownothing" 6 | private let testObject = User(firstName: "John", lastName: "Snow") 7 | private let fileManager = FileManager() 8 | private var storage: DiskStorage! 9 | private let config = DiskConfig(name: "Floppy") 10 | 11 | override func setUp() { 12 | super.setUp() 13 | storage = try! DiskStorage(config: config, transformer: TransformerFactory.forCodable(ofType: User.self)) 14 | } 15 | 16 | override func tearDown() { 17 | try? storage.removeAll() 18 | super.tearDown() 19 | } 20 | 21 | func testInit() { 22 | // Test that it creates cache directory 23 | let fileExist = fileManager.fileExists(atPath: storage.path) 24 | XCTAssertTrue(fileExist) 25 | 26 | // Test that it returns the default maximum size of a cache 27 | XCTAssertEqual(config.maxSize, 0) 28 | } 29 | 30 | /// Test that it returns the correct path 31 | func testDefaultPath() { 32 | let paths = NSSearchPathForDirectoriesInDomains( 33 | .cachesDirectory, FileManager.SearchPathDomainMask.userDomainMask, true 34 | ) 35 | let path = "\(paths.first!)/\(config.name.capitalized)" 36 | XCTAssertEqual(storage.path, path) 37 | } 38 | 39 | /// Test that it returns the correct path 40 | func testCustomPath() throws { 41 | let url = try fileManager.url( 42 | for: .applicationSupportDirectory, 43 | in: .userDomainMask, 44 | appropriateFor: nil, 45 | create: true 46 | ) 47 | 48 | let customConfig = DiskConfig(name: "SSD", directory: url) 49 | 50 | storage = try DiskStorage(config: customConfig, transformer: TransformerFactory.forCodable(ofType: User.self)) 51 | 52 | XCTAssertEqual( 53 | storage.path, 54 | url.appendingPathComponent("SSD", isDirectory: true).path 55 | ) 56 | } 57 | 58 | /// Test that it sets attributes 59 | func testSetDirectoryAttributes() throws { 60 | try storage.setObject(testObject, forKey: key) 61 | try storage.setDirectoryAttributes([FileAttributeKey.immutable: true]) 62 | let attributes = try fileManager.attributesOfItem(atPath: storage.path) 63 | 64 | XCTAssertTrue(attributes[FileAttributeKey.immutable] as? Bool == true) 65 | try storage.setDirectoryAttributes([FileAttributeKey.immutable: false]) 66 | } 67 | 68 | /// Test that it saves an object 69 | func testsetObject() throws { 70 | try storage.setObject(testObject, forKey: key) 71 | let fileExist = fileManager.fileExists(atPath: storage.makeFilePath(for: key)) 72 | XCTAssertTrue(fileExist) 73 | } 74 | 75 | /// Test that 76 | func testCacheEntry() throws { 77 | // Returns nil if entry doesn't exist 78 | var entry: Entry? 79 | do { 80 | entry = try storage.entry(forKey: key) 81 | } catch {} 82 | XCTAssertNil(entry) 83 | 84 | // Returns entry if object exists 85 | try storage.setObject(testObject, forKey: key) 86 | entry = try storage.entry(forKey: key) 87 | let attributes = try fileManager.attributesOfItem(atPath: storage.makeFilePath(for: key)) 88 | let expiry = Expiry.date(attributes[FileAttributeKey.modificationDate] as! Date) 89 | 90 | XCTAssertEqual(entry?.object.firstName, testObject.firstName) 91 | XCTAssertEqual(entry?.object.lastName, testObject.lastName) 92 | XCTAssertEqual(entry?.expiry.date, expiry.date) 93 | } 94 | 95 | func testCacheEntryPath() throws { 96 | let key = "test.mp4" 97 | try storage.setObject(testObject, forKey: key) 98 | let entry = try storage.entry(forKey: key) 99 | let filePath = storage.makeFilePath(for: key) 100 | 101 | XCTAssertEqual(entry.filePath, filePath) 102 | } 103 | 104 | /// Test that it resolves cached object 105 | func testSetObject() throws { 106 | try storage.setObject(testObject, forKey: key) 107 | let cachedObject: User? = try storage.object(forKey: key) 108 | 109 | XCTAssertEqual(cachedObject?.firstName, testObject.firstName) 110 | XCTAssertEqual(cachedObject?.lastName, testObject.lastName) 111 | } 112 | 113 | /// Test that it removes cached object 114 | func testRemoveObject() throws { 115 | try storage.setObject(testObject, forKey: key) 116 | try storage.removeObject(forKey: key) 117 | let fileExist = fileManager.fileExists(atPath: storage.makeFilePath(for: key)) 118 | XCTAssertFalse(fileExist) 119 | } 120 | 121 | /// Test that it removes expired object 122 | func testRemoveObjectIfExpiredWhenExpired() throws { 123 | let expiry: Expiry = .date(Date().addingTimeInterval(-100000)) 124 | try storage.setObject(testObject, forKey: key, expiry: expiry) 125 | try storage.removeObjectIfExpired(forKey: key) 126 | var cachedObject: User? 127 | do { 128 | cachedObject = try storage.object(forKey: key) 129 | } catch {} 130 | 131 | XCTAssertNil(cachedObject) 132 | } 133 | 134 | /// Test that it doesn't remove not expired object 135 | func testRemoveObjectIfExpiredWhenNotExpired() throws { 136 | try storage.setObject(testObject, forKey: key) 137 | try storage.removeObjectIfExpired(forKey: key) 138 | let cachedObject: User? = try storage.object(forKey: key) 139 | XCTAssertNotNil(cachedObject) 140 | } 141 | 142 | /// Test expired object 143 | func testExpiredObject() throws { 144 | try storage.setObject(testObject, forKey: key, expiry: .seconds(0.9)) 145 | XCTAssertFalse(try! storage.isExpiredObject(forKey: key)) 146 | sleep(1) 147 | XCTAssertTrue(try! storage.isExpiredObject(forKey: key)) 148 | } 149 | 150 | /// Test that it clears cache directory 151 | func testClear() throws { 152 | try given("create some files inside folder so that it is not empty") { 153 | try storage.setObject(testObject, forKey: key) 154 | } 155 | 156 | when("call removeAll to remove the whole the folder") { 157 | do { 158 | try storage.removeAll() 159 | } catch { 160 | XCTFail(error.localizedDescription) 161 | } 162 | } 163 | 164 | then("the folder should exist") { 165 | let fileExist = fileManager.fileExists(atPath: storage.path) 166 | XCTAssertTrue(fileExist) 167 | } 168 | 169 | then("the folder should be empty") { 170 | let contents = try? fileManager.contentsOfDirectory(atPath: storage.path) 171 | XCTAssertEqual(contents?.count, 0) 172 | } 173 | } 174 | 175 | /// Test that it clears cache files, but keeps root directory 176 | func testCreateDirectory() { 177 | do { 178 | try storage.removeAll() 179 | XCTAssertTrue(fileManager.fileExists(atPath: storage.path)) 180 | let contents = try? fileManager.contentsOfDirectory(atPath: storage.path) 181 | XCTAssertEqual(contents?.count, 0) 182 | } catch { 183 | XCTFail(error.localizedDescription) 184 | } 185 | } 186 | 187 | /// Test that it removes expired objects 188 | func testClearExpired() throws { 189 | let expiry1: Expiry = .date(Date().addingTimeInterval(-100000)) 190 | let expiry2: Expiry = .date(Date().addingTimeInterval(100000)) 191 | let key1 = "item1" 192 | let key2 = "item2" 193 | try storage.setObject(testObject, forKey: key1, expiry: expiry1) 194 | try storage.setObject(testObject, forKey: key2, expiry: expiry2) 195 | try storage.removeExpiredObjects() 196 | var object1: User? 197 | let object2 = try storage.object(forKey: key2) 198 | 199 | do { 200 | object1 = try storage.object(forKey: key1) 201 | } catch {} 202 | 203 | XCTAssertNil(object1) 204 | XCTAssertNotNil(object2) 205 | } 206 | 207 | /// Test that it returns a correct file name 208 | func testMakeFileName() { 209 | XCTAssertEqual(storage.makeFileName(for: key), MD5(key)) 210 | XCTAssertEqual(storage.makeFileName(for: "test.mp4"), "\(MD5("test.mp4")).mp4") 211 | } 212 | 213 | /// Test that it returns a correct file path 214 | func testMakeFilePath() { 215 | let filePath = "\(storage.path)/\(storage.makeFileName(for: key))" 216 | XCTAssertEqual(storage.makeFilePath(for: key), filePath) 217 | } 218 | } 219 | 220 | -------------------------------------------------------------------------------- /Tests/iOS/Tests/Storage/StorageTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Cache 3 | 4 | final class StorageTests: XCTestCase { 5 | private var storage: Storage! 6 | let user = User(firstName: "John", lastName: "Snow") 7 | 8 | override func setUp() { 9 | super.setUp() 10 | 11 | storage = try! Storage( 12 | diskConfig: DiskConfig(name: "Thor"), 13 | memoryConfig: MemoryConfig(), 14 | transformer: TransformerFactory.forCodable(ofType: User.self) 15 | ) 16 | } 17 | 18 | override func tearDown() { 19 | try? storage.removeAll() 20 | super.tearDown() 21 | } 22 | 23 | func testSync() throws { 24 | try storage.setObject(user, forKey: "user") 25 | let cachedObject = try storage.object(forKey: "user") 26 | 27 | XCTAssertEqual(cachedObject, user) 28 | } 29 | 30 | func testAsync() { 31 | let expectation = self.expectation(description: #function) 32 | storage.async.setObject(user, forKey: "user", expiry: nil, completion: { _ in }) 33 | 34 | storage.async.object(forKey: "user", completion: { result in 35 | switch result { 36 | case .value(let cachedUser): 37 | XCTAssertEqual(cachedUser, self.user) 38 | expectation.fulfill() 39 | default: 40 | XCTFail() 41 | } 42 | }) 43 | 44 | wait(for: [expectation], timeout: 1) 45 | } 46 | 47 | func testMigration() { 48 | struct Person1: Codable { 49 | let fullName: String 50 | } 51 | 52 | struct Person2: Codable { 53 | let firstName: String 54 | let lastName: String 55 | } 56 | 57 | let person1Storage = storage.transformCodable(ofType: Person1.self) 58 | let person2Storage = storage.transformCodable(ofType: Person2.self) 59 | 60 | // Firstly, save object of type Person1 61 | let person = Person1(fullName: "John Snow") 62 | 63 | try! person1Storage.setObject(person, forKey: "person") 64 | XCTAssertNil(try? person2Storage.object(forKey: "person")) 65 | 66 | // Later, convert to Person2, do the migration, then overwrite 67 | let tempPerson = try! person1Storage.object(forKey: "person") 68 | let parts = tempPerson.fullName.split(separator: " ") 69 | let migratedPerson = Person2(firstName: String(parts[0]), lastName: String(parts[1])) 70 | try! person2Storage.setObject(migratedPerson, forKey: "person") 71 | 72 | XCTAssertEqual( 73 | try! person2Storage.object(forKey: "person").firstName, 74 | "John" 75 | ) 76 | } 77 | 78 | func testSameProperties() { 79 | struct Person: Codable { 80 | let firstName: String 81 | let lastName: String 82 | } 83 | 84 | struct Alien: Codable { 85 | let firstName: String 86 | let lastName: String 87 | } 88 | 89 | let personStorage = storage.transformCodable(ofType: Person.self) 90 | let alienStorage = storage.transformCodable(ofType: Alien.self) 91 | 92 | let person = Person(firstName: "John", lastName: "Snow") 93 | try! personStorage.setObject(person, forKey: "person") 94 | 95 | // As long as it has same properties, it works too 96 | let cachedObject = try! alienStorage.object(forKey: "person") 97 | XCTAssertEqual(cachedObject.firstName, "John") 98 | } 99 | 100 | // MARK: - Storage observers 101 | 102 | func testAddStorageObserver() throws { 103 | var changes = [StorageChange]() 104 | var observer: ObserverMock? = ObserverMock() 105 | 106 | storage.addStorageObserver(observer!) { _, _, change in 107 | changes.append(change) 108 | } 109 | 110 | try storage.setObject(user, forKey: "user1") 111 | try storage.setObject(user, forKey: "user2") 112 | try storage.removeObject(forKey: "user1") 113 | try storage.removeExpiredObjects() 114 | try storage.removeAll() 115 | observer = nil 116 | try storage.setObject(user, forKey: "user1") 117 | 118 | let expectedChanges: [StorageChange] = [ 119 | .add(key: "user1"), 120 | .add(key: "user2"), 121 | .remove(key: "user1"), 122 | .removeExpired, 123 | .removeAll 124 | ] 125 | 126 | XCTAssertEqual(changes, expectedChanges) 127 | } 128 | 129 | func testRemoveAllStorageObservers() throws { 130 | var changes1 = [StorageChange]() 131 | var changes2 = [StorageChange]() 132 | 133 | storage.addStorageObserver(self) { _, _, change in 134 | changes1.append(change) 135 | } 136 | 137 | storage.addStorageObserver(self) { _, _, change in 138 | changes2.append(change) 139 | } 140 | 141 | try storage.setObject(user, forKey: "user1") 142 | XCTAssertEqual(changes1, [StorageChange.add(key: "user1")]) 143 | XCTAssertEqual(changes2, [StorageChange.add(key: "user1")]) 144 | 145 | changes1.removeAll() 146 | changes2.removeAll() 147 | storage.removeAllStorageObservers() 148 | 149 | try storage.setObject(user, forKey: "user1") 150 | XCTAssertTrue(changes1.isEmpty) 151 | XCTAssertTrue(changes2.isEmpty) 152 | } 153 | 154 | // MARK: - Key observers 155 | 156 | func testAddObserverForKey() throws { 157 | var changes = [KeyChange]() 158 | storage.addObserver(self, forKey: "user1") { _, _, change in 159 | changes.append(change) 160 | } 161 | 162 | storage.addObserver(self, forKey: "user2") { _, _, change in 163 | changes.append(change) 164 | } 165 | 166 | try storage.setObject(user, forKey: "user1") 167 | XCTAssertEqual(changes, [KeyChange.edit(before: nil, after: user)]) 168 | } 169 | 170 | func testKeyObserverWithRemoveExpired() throws { 171 | var changes = [KeyChange]() 172 | storage.addObserver(self, forKey: "user1") { _, _, change in 173 | changes.append(change) 174 | } 175 | 176 | storage.addObserver(self, forKey: "user2") { _, _, change in 177 | changes.append(change) 178 | } 179 | 180 | try storage.setObject(user, forKey: "user1", expiry: Expiry.seconds(-1000)) 181 | try storage.removeExpiredObjects() 182 | 183 | XCTAssertEqual(changes, [.edit(before: nil, after: user), .remove]) 184 | } 185 | 186 | func testKeyObserverWithRemoveAll() throws { 187 | var changes1 = [KeyChange]() 188 | var changes2 = [KeyChange]() 189 | 190 | storage.addObserver(self, forKey: "user1") { _, _, change in 191 | changes1.append(change) 192 | } 193 | 194 | storage.addObserver(self, forKey: "user2") { _, _, change in 195 | changes2.append(change) 196 | } 197 | 198 | try storage.setObject(user, forKey: "user1") 199 | try storage.setObject(user, forKey: "user2") 200 | try storage.removeAll() 201 | 202 | XCTAssertEqual(changes1, [.edit(before: nil, after: user), .remove]) 203 | XCTAssertEqual(changes2, [.edit(before: nil, after: user), .remove]) 204 | } 205 | 206 | func testRemoveKeyObserver() throws { 207 | var changes = [KeyChange]() 208 | 209 | // Test remove 210 | storage.addObserver(self, forKey: "user1") { _, _, change in 211 | changes.append(change) 212 | } 213 | 214 | storage.removeObserver(forKey: "user1") 215 | try storage.setObject(user, forKey: "user1") 216 | XCTAssertTrue(changes.isEmpty) 217 | 218 | // Test remove by token 219 | let token = storage.addObserver(self, forKey: "user2") { _, _, change in 220 | changes.append(change) 221 | } 222 | 223 | token.cancel() 224 | try storage.setObject(user, forKey: "user1") 225 | XCTAssertTrue(changes.isEmpty) 226 | } 227 | 228 | func testRemoveAllKeyObservers() throws { 229 | var changes1 = [KeyChange]() 230 | var changes2 = [KeyChange]() 231 | 232 | storage.addObserver(self, forKey: "user1") { _, _, change in 233 | changes1.append(change) 234 | } 235 | 236 | storage.addObserver(self, forKey: "user2") { _, _, change in 237 | changes2.append(change) 238 | } 239 | 240 | try storage.setObject(user, forKey: "user1") 241 | try storage.setObject(user, forKey: "user2") 242 | XCTAssertEqual(changes1, [KeyChange.edit(before: nil, after: user)]) 243 | XCTAssertEqual(changes2, [KeyChange.edit(before: nil, after: user)]) 244 | 245 | changes1.removeAll() 246 | changes2.removeAll() 247 | storage.removeAllKeyObservers() 248 | 249 | try storage.setObject(user, forKey: "user1") 250 | XCTAssertTrue(changes1.isEmpty) 251 | XCTAssertTrue(changes2.isEmpty) 252 | } 253 | } 254 | 255 | private class ObserverMock {} 256 | -------------------------------------------------------------------------------- /Tests/iOS/Tests/Storage/HybridStorageTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Cache 3 | 4 | final class HybridStorageTests: XCTestCase { 5 | private let cacheName = "WeirdoCache" 6 | private let key = "alongweirdkey" 7 | private let testObject = User(firstName: "John", lastName: "Targaryen") 8 | private var storage: HybridStorage! 9 | private let fileManager = FileManager() 10 | 11 | override func setUp() { 12 | super.setUp() 13 | let memory = MemoryStorage(config: MemoryConfig()) 14 | let disk = try! DiskStorage(config: DiskConfig(name: "HybridDisk"), transformer: TransformerFactory.forCodable(ofType: User.self)) 15 | 16 | storage = HybridStorage(memoryStorage: memory, diskStorage: disk) 17 | } 18 | 19 | override func tearDown() { 20 | try? storage.removeAll() 21 | super.tearDown() 22 | } 23 | 24 | func testSetObject() throws { 25 | try when("set to storage") { 26 | try storage.setObject(testObject, forKey: key) 27 | let cachedObject = try storage.object(forKey: key) 28 | XCTAssertEqual(cachedObject, testObject) 29 | } 30 | 31 | try then("it is set to memory too") { 32 | let memoryObject = try storage.memoryStorage.object(forKey: key) 33 | XCTAssertNotNil(memoryObject) 34 | } 35 | 36 | try then("it is set to disk too") { 37 | let diskObject = try storage.diskStorage.object(forKey: key) 38 | XCTAssertNotNil(diskObject) 39 | } 40 | } 41 | 42 | func testEntry() throws { 43 | let expiryDate = Date() 44 | try storage.setObject(testObject, forKey: key, expiry: .date(expiryDate)) 45 | let entry = try storage.entry(forKey: key) 46 | 47 | XCTAssertEqual(entry.object, testObject) 48 | XCTAssertEqual(entry.expiry.date, expiryDate) 49 | } 50 | 51 | /// Should resolve from disk and set in-memory cache if object not in-memory 52 | func testObjectCopyToMemory() throws { 53 | try when("set to disk only") { 54 | try storage.diskStorage.setObject(testObject, forKey: key) 55 | let cachedObject: User = try storage.object(forKey: key) 56 | XCTAssertEqual(cachedObject, testObject) 57 | } 58 | 59 | try then("there is no object in memory") { 60 | let inMemoryCachedObject = try storage.memoryStorage.object(forKey: key) 61 | XCTAssertEqual(inMemoryCachedObject, testObject) 62 | } 63 | } 64 | 65 | func testEntityExpiryForObjectCopyToMemory() throws { 66 | let date = Date().addingTimeInterval(3) 67 | try when("set to disk only") { 68 | try storage.diskStorage.setObject(testObject, forKey: key, expiry: .seconds(3)) 69 | let entry = try storage.entry(forKey: key) 70 | //accuracy for slow disk processes 71 | XCTAssertEqual(entry.expiry.date.timeIntervalSinceReferenceDate, 72 | date.timeIntervalSinceReferenceDate, 73 | accuracy: 1.0) 74 | } 75 | 76 | try then("there is no object in memory") { 77 | let entry = try storage.memoryStorage.entry(forKey: key) 78 | //accuracy for slow disk processes 79 | XCTAssertEqual(entry.expiry.date.timeIntervalSinceReferenceDate, 80 | date.timeIntervalSinceReferenceDate, 81 | accuracy: 1.0) 82 | } 83 | } 84 | 85 | /// Removes cached object from memory and disk 86 | func testRemoveObject() throws { 87 | try given("set to storage") { 88 | try storage.setObject(testObject, forKey: key) 89 | XCTAssertNotNil(try storage.object(forKey: key)) 90 | } 91 | 92 | try when("remove object from storage") { 93 | try storage.removeObject(forKey: key) 94 | let cachedObject = try? storage.object(forKey: key) 95 | XCTAssertNil(cachedObject) 96 | } 97 | 98 | then("there is no object in memory") { 99 | let memoryObject = try? storage.memoryStorage.object(forKey: key) 100 | XCTAssertNil(memoryObject) 101 | } 102 | 103 | then("there is no object on disk") { 104 | let diskObject = try? storage.diskStorage.object(forKey: key) 105 | XCTAssertNil(diskObject) 106 | } 107 | } 108 | 109 | /// Clears memory and disk cache 110 | func testClear() throws { 111 | try when("set and remove all") { 112 | try storage.setObject(testObject, forKey: key) 113 | try storage.removeAll() 114 | XCTAssertNil(try? storage.object(forKey: key)) 115 | } 116 | 117 | then("there is no object in memory") { 118 | let memoryObject = try? storage.memoryStorage.object(forKey: key) 119 | XCTAssertNil(memoryObject) 120 | } 121 | 122 | then("there is no object on disk") { 123 | let diskObject = try? storage.diskStorage.object(forKey: key) 124 | XCTAssertNil(diskObject) 125 | } 126 | } 127 | 128 | func testDiskEmptyAfterClear() throws { 129 | try storage.setObject(testObject, forKey: key) 130 | try storage.removeAll() 131 | 132 | then("the disk directory is empty") { 133 | let contents = try? fileManager.contentsOfDirectory(atPath: storage.diskStorage.path) 134 | XCTAssertEqual(contents?.count, 0) 135 | } 136 | } 137 | 138 | /// Clears expired objects from memory and disk cache 139 | func testClearExpired() throws { 140 | let expiry1: Expiry = .date(Date().addingTimeInterval(-10)) 141 | let expiry2: Expiry = .date(Date().addingTimeInterval(10)) 142 | let key1 = "key1" 143 | let key2 = "key2" 144 | 145 | try when("save 2 objects with different keys and expiry") { 146 | try storage.setObject(testObject, forKey: key1, expiry: expiry1) 147 | try storage.setObject(testObject, forKey: key2, expiry: expiry2) 148 | } 149 | 150 | try when("remove expired objects") { 151 | try storage.removeExpiredObjects() 152 | } 153 | 154 | then("object with key2 survived") { 155 | XCTAssertNil(try? storage.object(forKey: key1)) 156 | XCTAssertNotNil(try? storage.object(forKey: key2)) 157 | } 158 | } 159 | 160 | // MARK: - Storage observers 161 | 162 | func testAddStorageObserver() throws { 163 | var changes = [StorageChange]() 164 | storage.addStorageObserver(self) { _, _, change in 165 | changes.append(change) 166 | } 167 | 168 | try storage.setObject(testObject, forKey: "user1") 169 | XCTAssertEqual(changes, [StorageChange.add(key: "user1")]) 170 | XCTAssertEqual(storage.storageObservations.count, 1) 171 | 172 | storage.addStorageObserver(self) { _, _, _ in } 173 | XCTAssertEqual(storage.storageObservations.count, 2) 174 | } 175 | 176 | func testRemoveStorageObserver() { 177 | let token = storage.addStorageObserver(self) { _, _, _ in } 178 | XCTAssertEqual(storage.storageObservations.count, 1) 179 | 180 | token.cancel() 181 | XCTAssertTrue(storage.storageObservations.isEmpty) 182 | } 183 | 184 | func testRemoveAllStorageObservers() { 185 | storage.addStorageObserver(self) { _, _, _ in } 186 | storage.addStorageObserver(self) { _, _, _ in } 187 | XCTAssertEqual(storage.storageObservations.count, 2) 188 | 189 | storage.removeAllStorageObservers() 190 | XCTAssertTrue(storage.storageObservations.isEmpty) 191 | } 192 | 193 | // MARK: - Key observers 194 | 195 | func testAddObserverForKey() throws { 196 | var changes = [KeyChange]() 197 | storage.addObserver(self, forKey: "user1") { _, _, change in 198 | changes.append(change) 199 | } 200 | 201 | XCTAssertEqual(storage.keyObservations.count, 1) 202 | 203 | try storage.setObject(testObject, forKey: "user1") 204 | XCTAssertEqual(changes, [KeyChange.edit(before: nil, after: testObject)]) 205 | 206 | storage.addObserver(self, forKey: "user1") { _, _, _ in } 207 | XCTAssertEqual(storage.keyObservations.count, 1) 208 | 209 | storage.addObserver(self, forKey: "user2") { _, _, _ in } 210 | XCTAssertEqual(storage.keyObservations.count, 2) 211 | } 212 | 213 | func testRemoveKeyObserver() { 214 | // Test remove for key 215 | storage.addObserver(self, forKey: "user1") { _, _, _ in } 216 | XCTAssertEqual(storage.keyObservations.count, 1) 217 | 218 | storage.removeObserver(forKey: "user1") 219 | XCTAssertTrue(storage.storageObservations.isEmpty) 220 | 221 | // Test remove by token 222 | let token = storage.addObserver(self, forKey: "user2") { _, _, _ in } 223 | XCTAssertEqual(storage.keyObservations.count, 1) 224 | 225 | token.cancel() 226 | XCTAssertTrue(storage.storageObservations.isEmpty) 227 | } 228 | 229 | func testRemoveAllKeyObservers() { 230 | storage.addObserver(self, forKey: "user1") { _, _, _ in } 231 | storage.addObserver(self, forKey: "user2") { _, _, _ in } 232 | XCTAssertEqual(storage.keyObservations.count, 2) 233 | 234 | storage.removeAllKeyObservers() 235 | XCTAssertTrue(storage.keyObservations.isEmpty) 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /Source/Shared/Storage/DiskStorage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Save objects to file on disk 4 | final public class DiskStorage { 5 | enum Error: Swift.Error { 6 | case fileEnumeratorFailed 7 | } 8 | 9 | /// File manager to read/write to the disk 10 | public let fileManager: FileManager 11 | /// Configuration 12 | private let config: DiskConfig 13 | /// The computed path `directory+name` 14 | public let path: String 15 | /// The closure to be called when single file has been removed 16 | var onRemove: ((String) -> Void)? 17 | 18 | private let transformer: Transformer 19 | 20 | // MARK: - Initialization 21 | public convenience init(config: DiskConfig, fileManager: FileManager = FileManager.default, transformer: Transformer) throws { 22 | let url: URL 23 | if let directory = config.directory { 24 | url = directory 25 | } else { 26 | url = try fileManager.url( 27 | for: .cachesDirectory, 28 | in: .userDomainMask, 29 | appropriateFor: nil, 30 | create: true 31 | ) 32 | } 33 | 34 | // path 35 | let path = url.appendingPathComponent(config.name, isDirectory: true).path 36 | 37 | self.init(config: config, fileManager: fileManager, path: path, transformer: transformer) 38 | 39 | try createDirectory() 40 | 41 | // protection 42 | #if os(iOS) || os(tvOS) 43 | if let protectionType = config.protectionType { 44 | try setDirectoryAttributes([ 45 | FileAttributeKey.protectionKey: protectionType 46 | ]) 47 | } 48 | #endif 49 | } 50 | 51 | public required init(config: DiskConfig, fileManager: FileManager = FileManager.default, path: String, transformer: Transformer) { 52 | self.config = config 53 | self.fileManager = fileManager 54 | self.path = path 55 | self.transformer = transformer 56 | } 57 | } 58 | 59 | extension DiskStorage: StorageAware { 60 | public func entry(forKey key: String) throws -> Entry { 61 | let filePath = makeFilePath(for: key) 62 | let data = try Data(contentsOf: URL(fileURLWithPath: filePath)) 63 | let attributes = try fileManager.attributesOfItem(atPath: filePath) 64 | let object = try transformer.fromData(data) 65 | 66 | guard let date = attributes[.modificationDate] as? Date else { 67 | throw StorageError.malformedFileAttributes 68 | } 69 | 70 | return Entry( 71 | object: object, 72 | expiry: Expiry.date(date), 73 | filePath: filePath 74 | ) 75 | } 76 | 77 | public func setObject(_ object: T, forKey key: String, expiry: Expiry? = nil) throws { 78 | let expiry = expiry ?? config.expiry 79 | let data = try transformer.toData(object) 80 | let filePath = makeFilePath(for: key) 81 | _ = fileManager.createFile(atPath: filePath, contents: data, attributes: nil) 82 | try fileManager.setAttributes([.modificationDate: expiry.date], ofItemAtPath: filePath) 83 | } 84 | 85 | public func removeObject(forKey key: String) throws { 86 | let filePath = makeFilePath(for: key) 87 | try fileManager.removeItem(atPath: filePath) 88 | onRemove?(filePath) 89 | } 90 | 91 | public func removeAll() throws { 92 | try fileManager.removeItem(atPath: path) 93 | try createDirectory() 94 | } 95 | 96 | public func removeExpiredObjects() throws { 97 | let storageURL = URL(fileURLWithPath: path) 98 | let resourceKeys: [URLResourceKey] = [ 99 | .isDirectoryKey, 100 | .contentModificationDateKey, 101 | .totalFileAllocatedSizeKey 102 | ] 103 | var resourceObjects = [ResourceObject]() 104 | var filesToDelete = [URL]() 105 | var totalSize: UInt = 0 106 | let fileEnumerator = fileManager.enumerator( 107 | at: storageURL, 108 | includingPropertiesForKeys: resourceKeys, 109 | options: .skipsHiddenFiles, 110 | errorHandler: nil 111 | ) 112 | 113 | guard let urlArray = fileEnumerator?.allObjects as? [URL] else { 114 | throw Error.fileEnumeratorFailed 115 | } 116 | 117 | for url in urlArray { 118 | let resourceValues = try url.resourceValues(forKeys: Set(resourceKeys)) 119 | guard resourceValues.isDirectory != true else { 120 | continue 121 | } 122 | 123 | if let expiryDate = resourceValues.contentModificationDate, expiryDate.inThePast { 124 | filesToDelete.append(url) 125 | continue 126 | } 127 | 128 | if let fileSize = resourceValues.totalFileAllocatedSize { 129 | totalSize += UInt(fileSize) 130 | resourceObjects.append((url: url, resourceValues: resourceValues)) 131 | } 132 | } 133 | 134 | // Remove expired objects 135 | for url in filesToDelete { 136 | try fileManager.removeItem(at: url) 137 | onRemove?(url.path) 138 | } 139 | 140 | // Remove objects if storage size exceeds max size 141 | try removeResourceObjects(resourceObjects, totalSize: totalSize) 142 | } 143 | } 144 | 145 | extension DiskStorage { 146 | /** 147 | Sets attributes on the disk cache folder. 148 | - Parameter attributes: Directory attributes 149 | */ 150 | func setDirectoryAttributes(_ attributes: [FileAttributeKey: Any]) throws { 151 | try fileManager.setAttributes(attributes, ofItemAtPath: path) 152 | } 153 | } 154 | 155 | typealias ResourceObject = (url: Foundation.URL, resourceValues: URLResourceValues) 156 | 157 | extension DiskStorage { 158 | /** 159 | Builds file name from the key. 160 | - Parameter key: Unique key to identify the object in the cache 161 | - Returns: A md5 string 162 | */ 163 | func makeFileName(for key: String) -> String { 164 | let fileExtension = URL(fileURLWithPath: key).pathExtension 165 | let fileName = MD5(key) 166 | 167 | switch fileExtension.isEmpty { 168 | case true: 169 | return fileName 170 | case false: 171 | return "\(fileName).\(fileExtension)" 172 | } 173 | } 174 | 175 | /** 176 | Builds file path from the key. 177 | - Parameter key: Unique key to identify the object in the cache 178 | - Returns: A string path based on key 179 | */ 180 | func makeFilePath(for key: String) -> String { 181 | return "\(path)/\(makeFileName(for: key))" 182 | } 183 | 184 | /// Calculates total disk cache size. 185 | func totalSize() throws -> UInt64 { 186 | var size: UInt64 = 0 187 | let contents = try fileManager.contentsOfDirectory(atPath: path) 188 | for pathComponent in contents { 189 | let filePath = NSString(string: path).appendingPathComponent(pathComponent) 190 | let attributes = try fileManager.attributesOfItem(atPath: filePath) 191 | if let fileSize = attributes[.size] as? UInt64 { 192 | size += fileSize 193 | } 194 | } 195 | return size 196 | } 197 | 198 | func createDirectory() throws { 199 | guard !fileManager.fileExists(atPath: path) else { 200 | return 201 | } 202 | 203 | try fileManager.createDirectory(atPath: path, withIntermediateDirectories: true, 204 | attributes: nil) 205 | } 206 | 207 | /** 208 | Removes objects if storage size exceeds max size. 209 | - Parameter objects: Resource objects to remove 210 | - Parameter totalSize: Total size 211 | */ 212 | func removeResourceObjects(_ objects: [ResourceObject], totalSize: UInt) throws { 213 | guard config.maxSize > 0 && totalSize > config.maxSize else { 214 | return 215 | } 216 | 217 | var totalSize = totalSize 218 | let targetSize = config.maxSize / 2 219 | 220 | let sortedFiles = objects.sorted { 221 | if let time1 = $0.resourceValues.contentModificationDate?.timeIntervalSinceReferenceDate, 222 | let time2 = $1.resourceValues.contentModificationDate?.timeIntervalSinceReferenceDate { 223 | return time1 > time2 224 | } else { 225 | return false 226 | } 227 | } 228 | 229 | for file in sortedFiles { 230 | try fileManager.removeItem(at: file.url) 231 | onRemove?(file.url.path) 232 | 233 | if let fileSize = file.resourceValues.totalFileAllocatedSize { 234 | totalSize -= UInt(fileSize) 235 | } 236 | 237 | if totalSize < targetSize { 238 | break 239 | } 240 | } 241 | } 242 | 243 | /** 244 | Removes the object from the cache if it's expired. 245 | - Parameter key: Unique key to identify the object in the cache 246 | */ 247 | func removeObjectIfExpired(forKey key: String) throws { 248 | let filePath = makeFilePath(for: key) 249 | let attributes = try fileManager.attributesOfItem(atPath: filePath) 250 | if let expiryDate = attributes[.modificationDate] as? Date, expiryDate.inThePast { 251 | try fileManager.removeItem(atPath: filePath) 252 | onRemove?(filePath) 253 | } 254 | } 255 | } 256 | 257 | public extension DiskStorage { 258 | func transform(transformer: Transformer) -> DiskStorage { 259 | let storage = DiskStorage( 260 | config: config, 261 | fileManager: fileManager, 262 | path: path, 263 | transformer: transformer 264 | ) 265 | 266 | return storage 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [Unreleased](https://github.com/hyperoslo/Cache/tree/HEAD) 4 | 5 | [Full Changelog](https://github.com/hyperoslo/Cache/compare/2.2.1...HEAD) 6 | 7 | **Closed issues:** 8 | 9 | - Not compatible with swift 3 [\#64](https://github.com/hyperoslo/Cache/issues/64) 10 | 11 | **Merged pull requests:** 12 | 13 | - Fix compile errors in Xcode 8.3 beta [\#74](https://github.com/hyperoslo/Cache/pull/74) ([bradhowes](https://github.com/bradhowes)) 14 | - Update Cache.podspec for WatchOS [\#67](https://github.com/hyperoslo/Cache/pull/67) ([FrancoisObob](https://github.com/FrancoisObob)) 15 | 16 | ## [2.2.1](https://github.com/hyperoslo/Cache/tree/2.2.1) (2017-01-11) 17 | [Full Changelog](https://github.com/hyperoslo/Cache/compare/2.2.0...2.2.1) 18 | 19 | ## [2.2.0](https://github.com/hyperoslo/Cache/tree/2.2.0) (2017-01-11) 20 | [Full Changelog](https://github.com/hyperoslo/Cache/compare/2.1.1...2.2.0) 21 | 22 | **Closed issues:** 23 | 24 | - Swift 3 support please... again [\#62](https://github.com/hyperoslo/Cache/issues/62) 25 | - Question on threading [\#57](https://github.com/hyperoslo/Cache/issues/57) 26 | - Question - Iterating? [\#51](https://github.com/hyperoslo/Cache/issues/51) 27 | - Question: Completion handlers are called in the original threads [\#40](https://github.com/hyperoslo/Cache/issues/40) 28 | 29 | **Merged pull requests:** 30 | 31 | - Allow manual clear of expired objects [\#65](https://github.com/hyperoslo/Cache/pull/65) ([eofs](https://github.com/eofs)) 32 | - Add codecov.io to travis [\#61](https://github.com/hyperoslo/Cache/pull/61) ([zenangst](https://github.com/zenangst)) 33 | - Improve build times on Travis [\#60](https://github.com/hyperoslo/Cache/pull/60) ([zenangst](https://github.com/zenangst)) 34 | 35 | ## [2.1.1](https://github.com/hyperoslo/Cache/tree/2.1.1) (2016-10-23) 36 | [Full Changelog](https://github.com/hyperoslo/Cache/compare/2.1.0...2.1.1) 37 | 38 | **Closed issues:** 39 | 40 | - Add tvOS target for Carthage [\#55](https://github.com/hyperoslo/Cache/issues/55) 41 | 42 | **Merged pull requests:** 43 | 44 | - Update deployment target [\#59](https://github.com/hyperoslo/Cache/pull/59) ([zenangst](https://github.com/zenangst)) 45 | 46 | ## [2.1.0](https://github.com/hyperoslo/Cache/tree/2.1.0) (2016-10-23) 47 | [Full Changelog](https://github.com/hyperoslo/Cache/compare/2.0.0...2.1.0) 48 | 49 | **Merged pull requests:** 50 | 51 | - Feature/tvOS target [\#58](https://github.com/hyperoslo/Cache/pull/58) ([zenangst](https://github.com/zenangst)) 52 | 53 | ## [2.0.0](https://github.com/hyperoslo/Cache/tree/2.0.0) (2016-10-13) 54 | [Full Changelog](https://github.com/hyperoslo/Cache/compare/1.5.1...2.0.0) 55 | 56 | **Closed issues:** 57 | 58 | - what time will be support swift3? [\#54](https://github.com/hyperoslo/Cache/issues/54) 59 | - Swift 3 [\#53](https://github.com/hyperoslo/Cache/issues/53) 60 | - 2.3 branch [\#50](https://github.com/hyperoslo/Cache/issues/50) 61 | - Carthage Update error with xcode 8gm build [\#48](https://github.com/hyperoslo/Cache/issues/48) 62 | - Default size of the cache storage [\#38](https://github.com/hyperoslo/Cache/issues/38) 63 | 64 | **Merged pull requests:** 65 | 66 | - Swift 3 [\#56](https://github.com/hyperoslo/Cache/pull/56) ([zenangst](https://github.com/zenangst)) 67 | - Added Swift 2.3 support [\#52](https://github.com/hyperoslo/Cache/pull/52) ([Sumolari](https://github.com/Sumolari)) 68 | - Removed script which copied the framework - Carthage suggests it should be done at app level. [\#47](https://github.com/hyperoslo/Cache/pull/47) ([okipol88](https://github.com/okipol88)) 69 | 70 | ## [1.5.1](https://github.com/hyperoslo/Cache/tree/1.5.1) (2016-08-08) 71 | [Full Changelog](https://github.com/hyperoslo/Cache/compare/1.5.0...1.5.1) 72 | 73 | **Closed issues:** 74 | 75 | - Can integrated with obj-c project? [\#44](https://github.com/hyperoslo/Cache/issues/44) 76 | 77 | **Merged pull requests:** 78 | 79 | - Expose backStorage path in public API [\#46](https://github.com/hyperoslo/Cache/pull/46) ([zenangst](https://github.com/zenangst)) 80 | 81 | ## [1.5.0](https://github.com/hyperoslo/Cache/tree/1.5.0) (2016-06-07) 82 | [Full Changelog](https://github.com/hyperoslo/Cache/compare/1.4.0...1.5.0) 83 | 84 | **Merged pull requests:** 85 | 86 | - Feature/config for json serialization [\#43](https://github.com/hyperoslo/Cache/pull/43) ([zenangst](https://github.com/zenangst)) 87 | 88 | ## [1.4.0](https://github.com/hyperoslo/Cache/tree/1.4.0) (2016-05-24) 89 | [Full Changelog](https://github.com/hyperoslo/Cache/compare/1.3.0...1.4.0) 90 | 91 | **Closed issues:** 92 | 93 | - Cache with RealmSwift [\#36](https://github.com/hyperoslo/Cache/issues/36) 94 | 95 | **Merged pull requests:** 96 | 97 | - Improve memory handling [\#41](https://github.com/hyperoslo/Cache/pull/41) ([zenangst](https://github.com/zenangst)) 98 | - Fix Carthage copy frameworks script [\#39](https://github.com/hyperoslo/Cache/pull/39) ([vadymmarkov](https://github.com/vadymmarkov)) 99 | 100 | ## [1.3.0](https://github.com/hyperoslo/Cache/tree/1.3.0) (2016-04-27) 101 | [Full Changelog](https://github.com/hyperoslo/Cache/compare/1.2.0...1.3.0) 102 | 103 | **Closed issues:** 104 | 105 | - i'm hope obtain cache data synchronously [\#33](https://github.com/hyperoslo/Cache/issues/33) 106 | 107 | **Merged pull requests:** 108 | 109 | - Feature: sync cache [\#35](https://github.com/hyperoslo/Cache/pull/35) ([vadymmarkov](https://github.com/vadymmarkov)) 110 | 111 | ## [1.2.0](https://github.com/hyperoslo/Cache/tree/1.2.0) (2016-04-26) 112 | [Full Changelog](https://github.com/hyperoslo/Cache/compare/1.1.0...1.2.0) 113 | 114 | **Closed issues:** 115 | 116 | - can you support cache to keychain [\#30](https://github.com/hyperoslo/Cache/issues/30) 117 | 118 | **Merged pull requests:** 119 | 120 | - Bugfix/cannot save file with long URL key [\#34](https://github.com/hyperoslo/Cache/pull/34) ([attila-at-hyper](https://github.com/attila-at-hyper)) 121 | 122 | ## [1.1.0](https://github.com/hyperoslo/Cache/tree/1.1.0) (2016-04-24) 123 | [Full Changelog](https://github.com/hyperoslo/Cache/compare/1.0.0...1.1.0) 124 | 125 | **Closed issues:** 126 | 127 | - Question [\#31](https://github.com/hyperoslo/Cache/issues/31) 128 | 129 | **Merged pull requests:** 130 | 131 | - Feature tvOS [\#32](https://github.com/hyperoslo/Cache/pull/32) ([zenangst](https://github.com/zenangst)) 132 | 133 | ## [1.0.0](https://github.com/hyperoslo/Cache/tree/1.0.0) (2016-03-29) 134 | **Closed issues:** 135 | 136 | - Add a logo. [\#8](https://github.com/hyperoslo/Cache/issues/8) 137 | - Add descriptive README. [\#3](https://github.com/hyperoslo/Cache/issues/3) 138 | - Pod. [\#2](https://github.com/hyperoslo/Cache/issues/2) 139 | - Missing implementation [\#1](https://github.com/hyperoslo/Cache/issues/1) 140 | 141 | **Merged pull requests:** 142 | 143 | - Fix Expire date issues that screwed up tests on older devices. [\#28](https://github.com/hyperoslo/Cache/pull/28) ([zenangst](https://github.com/zenangst)) 144 | - Swift/2.2 [\#27](https://github.com/hyperoslo/Cache/pull/27) ([zenangst](https://github.com/zenangst)) 145 | - Remove example [\#25](https://github.com/hyperoslo/Cache/pull/25) ([vadymmarkov](https://github.com/vadymmarkov)) 146 | - Remove shared target on CacheDemo [\#24](https://github.com/hyperoslo/Cache/pull/24) ([zenangst](https://github.com/zenangst)) 147 | - Disable code signing for Mac target [\#23](https://github.com/hyperoslo/Cache/pull/23) ([zenangst](https://github.com/zenangst)) 148 | - Feature: osx support [\#22](https://github.com/hyperoslo/Cache/pull/22) ([vadymmarkov](https://github.com/vadymmarkov)) 149 | - Fix image sizes [\#21](https://github.com/hyperoslo/Cache/pull/21) ([vadymmarkov](https://github.com/vadymmarkov)) 150 | - Feature: icon [\#20](https://github.com/hyperoslo/Cache/pull/20) ([vadymmarkov](https://github.com/vadymmarkov)) 151 | - Feature: README [\#19](https://github.com/hyperoslo/Cache/pull/19) ([vadymmarkov](https://github.com/vadymmarkov)) 152 | - Feature: carthage support [\#18](https://github.com/hyperoslo/Cache/pull/18) ([vadymmarkov](https://github.com/vadymmarkov)) 153 | - Feature: example [\#17](https://github.com/hyperoslo/Cache/pull/17) ([vadymmarkov](https://github.com/vadymmarkov)) 154 | - Fix notifications handling [\#16](https://github.com/hyperoslo/Cache/pull/16) ([vadymmarkov](https://github.com/vadymmarkov)) 155 | - Feature: cache clean up [\#15](https://github.com/hyperoslo/Cache/pull/15) ([vadymmarkov](https://github.com/vadymmarkov)) 156 | - Feature: default cachable types [\#14](https://github.com/hyperoslo/Cache/pull/14) ([vadymmarkov](https://github.com/vadymmarkov)) 157 | - Feature: strict cache + storages [\#13](https://github.com/hyperoslo/Cache/pull/13) ([vadymmarkov](https://github.com/vadymmarkov)) 158 | - Feature: cache aggregator [\#12](https://github.com/hyperoslo/Cache/pull/12) ([vadymmarkov](https://github.com/vadymmarkov)) 159 | - Expiration date [\#11](https://github.com/hyperoslo/Cache/pull/11) ([vadymmarkov](https://github.com/vadymmarkov)) 160 | - Improve read and write [\#10](https://github.com/hyperoslo/Cache/pull/10) ([vadymmarkov](https://github.com/vadymmarkov)) 161 | - Remove cache task from protocol [\#9](https://github.com/hyperoslo/Cache/pull/9) ([vadymmarkov](https://github.com/vadymmarkov)) 162 | - File cache feature [\#7](https://github.com/hyperoslo/Cache/pull/7) ([vadymmarkov](https://github.com/vadymmarkov)) 163 | - Feature/base64 [\#6](https://github.com/hyperoslo/Cache/pull/6) ([vadymmarkov](https://github.com/vadymmarkov)) 164 | - Add Cachable protocol [\#5](https://github.com/hyperoslo/Cache/pull/5) ([vadymmarkov](https://github.com/vadymmarkov)) 165 | - Cache aware [\#4](https://github.com/hyperoslo/Cache/pull/4) ([vadymmarkov](https://github.com/vadymmarkov)) 166 | 167 | 168 | 169 | \* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)* -------------------------------------------------------------------------------- /Source/Shared/Library/MD5.swift: -------------------------------------------------------------------------------- 1 | // swiftlint:disable comma function_parameter_count variable_name syntactic_sugar function_body_length vertical_whitespace 2 | 3 | // https://github.com/onmyway133/SwiftHash/blob/master/Sources/MD5.swift 4 | 5 | /* 6 | * A JavaScript implementation of the RSA Data Security, Inc. MD5 Message 7 | * Digest Algorithm, as defined in RFC 1321. 8 | * Version 2.2 Copyright (C) Paul Johnston 1999 - 2009 9 | * Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet 10 | * Distributed under the BSD License 11 | * See http://pajhome.org.uk/crypt/md5 for more info. 12 | */ 13 | 14 | /** 15 | * SwiftHash 16 | * Copyright (c) Khoa Pham 2017 17 | * Licensed under the MIT license. See LICENSE file. 18 | */ 19 | 20 | import Foundation 21 | 22 | // MARK: - Public 23 | 24 | public func MD5(_ input: String) -> String { 25 | return hex_md5(input) 26 | } 27 | 28 | // MARK: - Functions 29 | 30 | func hex_md5(_ input: String) -> String { 31 | return rstr2hex(rstr_md5(str2rstr_utf8(input))) 32 | } 33 | 34 | func str2rstr_utf8(_ input: String) -> [CUnsignedChar] { 35 | return Array(input.utf8) 36 | } 37 | 38 | func rstr2tr(_ input: [CUnsignedChar]) -> String { 39 | var output: String = "" 40 | 41 | input.forEach { 42 | output.append(String(UnicodeScalar($0))) 43 | } 44 | 45 | return output 46 | } 47 | 48 | /* 49 | * Convert a raw string to a hex string 50 | */ 51 | func rstr2hex(_ input: [CUnsignedChar]) -> String { 52 | let hexTab: [Character] = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", "E", "F"] 53 | var output: [Character] = [] 54 | 55 | for i in 0..> 4) & 0x0F)] 58 | let value2 = hexTab[Int(Int32(x) & 0x0F)] 59 | 60 | output.append(value1) 61 | output.append(value2) 62 | } 63 | 64 | return String(output) 65 | } 66 | 67 | /* 68 | * Convert a raw string to an array of little-endian words 69 | * Characters >255 have their high-byte silently ignored. 70 | */ 71 | func rstr2binl(_ input: [CUnsignedChar]) -> [Int32] { 72 | var output: [Int: Int32] = [:] 73 | 74 | for i in stride(from: 0, to: input.count * 8, by: 8) { 75 | let value: Int32 = (Int32(input[i/8]) & 0xFF) << (Int32(i) % 32) 76 | 77 | output[i >> 5] = unwrap(output[i >> 5]) | value 78 | } 79 | 80 | return dictionary2array(output) 81 | } 82 | 83 | /* 84 | * Convert an array of little-endian words to a string 85 | */ 86 | func binl2rstr(_ input: [Int32]) -> [CUnsignedChar] { 87 | var output: [CUnsignedChar] = [] 88 | 89 | for i in stride(from: 0, to: input.count * 32, by: 8) { 90 | // [i>>5] >>> 91 | let value: Int32 = zeroFillRightShift(input[i>>5], Int32(i % 32)) & 0xFF 92 | output.append(CUnsignedChar(value)) 93 | } 94 | 95 | return output 96 | } 97 | 98 | /* 99 | * Calculate the MD5 of a raw string 100 | */ 101 | func rstr_md5(_ input: [CUnsignedChar]) -> [CUnsignedChar] { 102 | return binl2rstr(binl_md5(rstr2binl(input), input.count * 8)) 103 | } 104 | 105 | /* 106 | * Add integers, wrapping at 2^32. This uses 16-bit operations internally 107 | * to work around bugs in some JS interpreters. 108 | */ 109 | func safe_add(_ x: Int32, _ y: Int32) -> Int32 { 110 | let lsw = (x & 0xFFFF) + (y & 0xFFFF) 111 | let msw = (x >> 16) + (y >> 16) + (lsw >> 16) 112 | return (msw << 16) | (lsw & 0xFFFF) 113 | } 114 | 115 | /* 116 | * Bitwise rotate a 32-bit number to the left. 117 | */ 118 | func bit_rol(_ num: Int32, _ cnt: Int32) -> Int32 { 119 | // num >>> 120 | return (num << cnt) | zeroFillRightShift(num, (32 - cnt)) 121 | } 122 | 123 | 124 | /* 125 | * These funcs implement the four basic operations the algorithm uses. 126 | */ 127 | func md5_cmn(_ q: Int32, _ a: Int32, _ b: Int32, _ x: Int32, _ s: Int32, _ t: Int32) -> Int32 { 128 | return safe_add(bit_rol(safe_add(safe_add(a, q), safe_add(x, t)), s), b) 129 | } 130 | 131 | func md5_ff(_ a: Int32, _ b: Int32, _ c: Int32, _ d: Int32, _ x: Int32, _ s: Int32, _ t: Int32) -> Int32 { 132 | return md5_cmn((b & c) | ((~b) & d), a, b, x, s, t) 133 | } 134 | 135 | func md5_gg(_ a: Int32, _ b: Int32, _ c: Int32, _ d: Int32, _ x: Int32, _ s: Int32, _ t: Int32) -> Int32 { 136 | return md5_cmn((b & d) | (c & (~d)), a, b, x, s, t) 137 | } 138 | 139 | func md5_hh(_ a: Int32, _ b: Int32, _ c: Int32, _ d: Int32, _ x: Int32, _ s: Int32, _ t: Int32) -> Int32 { 140 | return md5_cmn(b ^ c ^ d, a, b, x, s, t) 141 | } 142 | 143 | func md5_ii(_ a: Int32, _ b: Int32, _ c: Int32, _ d: Int32, _ x: Int32, _ s: Int32, _ t: Int32) -> Int32 { 144 | return md5_cmn(c ^ (b | (~d)), a, b, x, s, t) 145 | } 146 | 147 | 148 | /* 149 | * Calculate the MD5 of an array of little-endian words, and a bit length. 150 | */ 151 | func binl_md5(_ input: [Int32], _ len: Int) -> [Int32] { 152 | /* append padding */ 153 | 154 | var x: [Int: Int32] = [:] 155 | for (index, value) in input.enumerated() { 156 | x[index] = value 157 | } 158 | 159 | let value: Int32 = 0x80 << Int32((len) % 32) 160 | x[len >> 5] = unwrap(x[len >> 5]) | value 161 | 162 | // >>> 9 163 | let index = (((len + 64) >> 9) << 4) + 14 164 | x[index] = unwrap(x[index]) | Int32(len) 165 | 166 | var a: Int32 = 1732584193 167 | var b: Int32 = -271733879 168 | var c: Int32 = -1732584194 169 | var d: Int32 = 271733878 170 | 171 | for i in stride(from: 0, to: length(x), by: 16) { 172 | let olda: Int32 = a 173 | let oldb: Int32 = b 174 | let oldc: Int32 = c 175 | let oldd: Int32 = d 176 | 177 | a = md5_ff(a, b, c, d, unwrap(x[i + 0]), 7 , -680876936) 178 | d = md5_ff(d, a, b, c, unwrap(x[i + 1]), 12, -389564586) 179 | c = md5_ff(c, d, a, b, unwrap(x[i + 2]), 17, 606105819) 180 | b = md5_ff(b, c, d, a, unwrap(x[i + 3]), 22, -1044525330) 181 | a = md5_ff(a, b, c, d, unwrap(x[i + 4]), 7 , -176418897) 182 | d = md5_ff(d, a, b, c, unwrap(x[i + 5]), 12, 1200080426) 183 | c = md5_ff(c, d, a, b, unwrap(x[i + 6]), 17, -1473231341) 184 | b = md5_ff(b, c, d, a, unwrap(x[i + 7]), 22, -45705983) 185 | a = md5_ff(a, b, c, d, unwrap(x[i + 8]), 7 , 1770035416) 186 | d = md5_ff(d, a, b, c, unwrap(x[i + 9]), 12, -1958414417) 187 | c = md5_ff(c, d, a, b, unwrap(x[i + 10]), 17, -42063) 188 | b = md5_ff(b, c, d, a, unwrap(x[i + 11]), 22, -1990404162) 189 | a = md5_ff(a, b, c, d, unwrap(x[i + 12]), 7 , 1804603682) 190 | d = md5_ff(d, a, b, c, unwrap(x[i + 13]), 12, -40341101) 191 | c = md5_ff(c, d, a, b, unwrap(x[i + 14]), 17, -1502002290) 192 | b = md5_ff(b, c, d, a, unwrap(x[i + 15]), 22, 1236535329) 193 | 194 | a = md5_gg(a, b, c, d, unwrap(x[i + 1]), 5 , -165796510) 195 | d = md5_gg(d, a, b, c, unwrap(x[i + 6]), 9 , -1069501632) 196 | c = md5_gg(c, d, a, b, unwrap(x[i + 11]), 14, 643717713) 197 | b = md5_gg(b, c, d, a, unwrap(x[i + 0]), 20, -373897302) 198 | a = md5_gg(a, b, c, d, unwrap(x[i + 5]), 5 , -701558691) 199 | d = md5_gg(d, a, b, c, unwrap(x[i + 10]), 9 , 38016083) 200 | c = md5_gg(c, d, a, b, unwrap(x[i + 15]), 14, -660478335) 201 | b = md5_gg(b, c, d, a, unwrap(x[i + 4]), 20, -405537848) 202 | a = md5_gg(a, b, c, d, unwrap(x[i + 9]), 5 , 568446438) 203 | d = md5_gg(d, a, b, c, unwrap(x[i + 14]), 9 , -1019803690) 204 | c = md5_gg(c, d, a, b, unwrap(x[i + 3]), 14, -187363961) 205 | b = md5_gg(b, c, d, a, unwrap(x[i + 8]), 20, 1163531501) 206 | a = md5_gg(a, b, c, d, unwrap(x[i + 13]), 5 , -1444681467) 207 | d = md5_gg(d, a, b, c, unwrap(x[i + 2]), 9 , -51403784) 208 | c = md5_gg(c, d, a, b, unwrap(x[i + 7]), 14, 1735328473) 209 | b = md5_gg(b, c, d, a, unwrap(x[i + 12]), 20, -1926607734) 210 | 211 | a = md5_hh(a, b, c, d, unwrap(x[i + 5]), 4 , -378558) 212 | d = md5_hh(d, a, b, c, unwrap(x[i + 8]), 11, -2022574463) 213 | c = md5_hh(c, d, a, b, unwrap(x[i + 11]), 16, 1839030562) 214 | b = md5_hh(b, c, d, a, unwrap(x[i + 14]), 23, -35309556) 215 | a = md5_hh(a, b, c, d, unwrap(x[i + 1]), 4 , -1530992060) 216 | d = md5_hh(d, a, b, c, unwrap(x[i + 4]), 11, 1272893353) 217 | c = md5_hh(c, d, a, b, unwrap(x[i + 7]), 16, -155497632) 218 | b = md5_hh(b, c, d, a, unwrap(x[i + 10]), 23, -1094730640) 219 | a = md5_hh(a, b, c, d, unwrap(x[i + 13]), 4 , 681279174) 220 | d = md5_hh(d, a, b, c, unwrap(x[i + 0]), 11, -358537222) 221 | c = md5_hh(c, d, a, b, unwrap(x[i + 3]), 16, -722521979) 222 | b = md5_hh(b, c, d, a, unwrap(x[i + 6]), 23, 76029189) 223 | a = md5_hh(a, b, c, d, unwrap(x[i + 9]), 4 , -640364487) 224 | d = md5_hh(d, a, b, c, unwrap(x[i + 12]), 11, -421815835) 225 | c = md5_hh(c, d, a, b, unwrap(x[i + 15]), 16, 530742520) 226 | b = md5_hh(b, c, d, a, unwrap(x[i + 2]), 23, -995338651) 227 | 228 | a = md5_ii(a, b, c, d, unwrap(x[i + 0]), 6 , -198630844) 229 | d = md5_ii(d, a, b, c, unwrap(x[i + 7]), 10, 1126891415) 230 | c = md5_ii(c, d, a, b, unwrap(x[i + 14]), 15, -1416354905) 231 | b = md5_ii(b, c, d, a, unwrap(x[i + 5]), 21, -57434055) 232 | a = md5_ii(a, b, c, d, unwrap(x[i + 12]), 6 , 1700485571) 233 | d = md5_ii(d, a, b, c, unwrap(x[i + 3]), 10, -1894986606) 234 | c = md5_ii(c, d, a, b, unwrap(x[i + 10]), 15, -1051523) 235 | b = md5_ii(b, c, d, a, unwrap(x[i + 1]), 21, -2054922799) 236 | a = md5_ii(a, b, c, d, unwrap(x[i + 8]), 6 , 1873313359) 237 | d = md5_ii(d, a, b, c, unwrap(x[i + 15]), 10, -30611744) 238 | c = md5_ii(c, d, a, b, unwrap(x[i + 6]), 15, -1560198380) 239 | b = md5_ii(b, c, d, a, unwrap(x[i + 13]), 21, 1309151649) 240 | a = md5_ii(a, b, c, d, unwrap(x[i + 4]), 6 , -145523070) 241 | d = md5_ii(d, a, b, c, unwrap(x[i + 11]), 10, -1120210379) 242 | c = md5_ii(c, d, a, b, unwrap(x[i + 2]), 15, 718787259) 243 | b = md5_ii(b, c, d, a, unwrap(x[i + 9]), 21, -343485551) 244 | 245 | a = safe_add(a, olda) 246 | b = safe_add(b, oldb) 247 | c = safe_add(c, oldc) 248 | d = safe_add(d, oldd) 249 | } 250 | 251 | return [a, b, c, d] 252 | } 253 | 254 | // MARK: - Helper 255 | 256 | func length(_ dictionary: [Int: Int32]) -> Int { 257 | return (dictionary.keys.max() ?? 0) + 1 258 | } 259 | 260 | func dictionary2array(_ dictionary: [Int: Int32]) -> [Int32] { 261 | var array = Array(repeating: 0, count: dictionary.keys.count) 262 | 263 | for i in Array(dictionary.keys).sorted() { 264 | array[i] = unwrap(dictionary[i]) 265 | } 266 | 267 | return array 268 | } 269 | 270 | func unwrap(_ value: Int32?, _ fallback: Int32 = 0) -> Int32 { 271 | if let value = value { 272 | return value 273 | } 274 | 275 | return fallback 276 | } 277 | 278 | func zeroFillRightShift(_ num: Int32, _ count: Int32) -> Int32 { 279 | let value = UInt32(bitPattern: num) >> UInt32(bitPattern: count) 280 | return Int32(bitPattern: value) 281 | } 282 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### This library has been deprecated and the repo has been archived. 2 | ### Use the original upstream at [hyperoslo/Cache](https://github.com/hyperoslo/Cache) instead. 3 | 4 | ![Cache](https://github.com/hyperoslo/Cache/blob/master/Resources/CachePresentation.png) 5 | 6 | [![CI Status](https://circleci.com/gh/hyperoslo/Cache.png)](https://circleci.com/gh/hyperoslo/Cache) 7 | [![Version](https://img.shields.io/cocoapods/v/Cache.svg?style=flat)](http://cocoadocs.org/docsets/Cache) 8 | [![Carthage Compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage) 9 | [![License](https://img.shields.io/cocoapods/l/Cache.svg?style=flat)](http://cocoadocs.org/docsets/Cache) 10 | [![Platform](https://img.shields.io/cocoapods/p/Cache.svg?style=flat)](http://cocoadocs.org/docsets/Cache) 11 | [![Documentation](https://img.shields.io/cocoapods/metrics/doc-percent/Cache.svg?style=flat)](http://cocoadocs.org/docsets/Cache) 12 | ![Swift](https://img.shields.io/badge/%20in-swift%204.0-orange.svg) 13 | 14 | ## Table of Contents 15 | 16 | * [Description](#description) 17 | * [Key features](#key-features) 18 | * [Usage](#usage) 19 | * [Storage](#storage) 20 | * [Configuration](#configuration) 21 | * [Sync APIs](#sync-apis) 22 | * [Async APIs](#async-apis) 23 | * [Expiry date](#expiry-date) 24 | * [Observations](#observations) 25 | * [Storage observations](#storage-observations) 26 | * [Key observations](#key-observations) 27 | * [Handling JSON response](#handling-json-response) 28 | * [What about images?](#what-about-images) 29 | * [Installation](#installation) 30 | * [Author](#author) 31 | * [Contributing](#contributing) 32 | * [License](#license) 33 | 34 | ## Description 35 | 36 | Cache Icon 37 | 38 | **Cache** doesn't claim to be unique in this area, but it's not another monster 39 | library that gives you a god's power. It does nothing but caching, but it does it well. It offers a good public API 40 | with out-of-box implementations and great customization possibilities. `Cache` utilizes `Codable` in Swift 4 to perform serialization. 41 | 42 | Read the story here [Open Source Stories: From Cachable to Generic Storage in Cache](https://medium.com/hyperoslo/open-source-stories-from-cachable-to-generic-storage-in-cache-418d9a230d51) 43 | 44 | ## Key features 45 | 46 | - [x] Work with Swift 4 `Codable`. Anything conforming to `Codable` will be saved and loaded easily by `Storage`. 47 | - [x] Hybrid with memory and disk storage. 48 | - [X] Many options via `DiskConfig` and `MemoryConfig`. 49 | - [x] Support `expiry` and clean up of expired objects. 50 | - [x] Thread safe. Operations can be accessed from any queue. 51 | - [x] Sync by default. Also support Async APIs. 52 | - [x] Extensive unit test coverage and great documentation. 53 | - [x] iOS, tvOS and macOS support. 54 | 55 | ## Usage 56 | 57 | ### Storage 58 | 59 | `Cache` is built based on [Chain-of-responsibility pattern](https://en.wikipedia.org/wiki/Chain-of-responsibility_pattern), in which there are many processing objects, each knows how to do 1 task and delegates to the next one, so can you compose Storages the way you like. 60 | 61 | For now the following Storage are supported 62 | 63 | - `MemoryStorage`: save object to memory. 64 | - `DiskStorage`: save object to disk. 65 | - `HybridStorage`: save object to memory and disk, so you get persistented object on disk, while fast access with in memory objects. 66 | - `SyncStorage`: blocking APIs, all read and write operations are scheduled in a serial queue, all sync manner. 67 | - `AsyncStorage`: non-blocking APIs, operations are scheduled in an internal queue for serial processing. No read and write should happen at the same time. 68 | 69 | Although you can use those Storage at your discretion, you don't have to. Because we also provide a convenient `Storage` which uses `HybridStorage` under the hood, while exposes sync and async APIs through `SyncStorage` and `AsyncStorage`. 70 | 71 | All you need to do is to specify the configuration you want with `DiskConfig` and `MemoryConfig`. The default configurations are good to go, but you can customise a lot. 72 | 73 | 74 | ```swift 75 | let diskConfig = DiskConfig(name: "Floppy") 76 | let memoryConfig = MemoryConfig(expiry: .never, countLimit: 10, totalCostLimit: 10) 77 | 78 | let storage = try? Storage( 79 | diskConfig: diskConfig, 80 | memoryConfig: memoryConfig, 81 | transformer: TransformerFactory.forCodable(ofType: User.self) // Storage 82 | ) 83 | ``` 84 | 85 | ### Generic, Type safety and Transformer 86 | 87 | All `Storage` now are generic by default, so you can get a type safety experience. Once you create a Storage, it has a type constraint that you don't need to specify type for each operation afterwards. 88 | 89 | If you want to change the type, `Cache` offers `transform` functions, look for `Transformer` and `TransformerFactory` for built-in transformers. 90 | 91 | ```swift 92 | let storage: Storage = ... 93 | storage.setObject(superman, forKey: "user") 94 | 95 | let imageStorage = storage.transformImage() // Storage 96 | imageStorage.setObject(image, forKey: "image") 97 | 98 | let stringStorage = storage.transformCodable(ofType: String.self) // Storage 99 | stringStorage.setObject("hello world", forKey: "string") 100 | ``` 101 | 102 | Each transformation allows you to work with a specific type, however the underlying caching mechanism remains the same, you're working with the same `Storage`, just with different type annotation. You can also create different `Storage` for each type if you want. 103 | 104 | `Transformer` is necessary because the need of serialising and deserialising objects to and from `Data` for disk persistency. `Cache` provides default `Transformer ` for `Data`, `Codable` and `UIImage/NSImage` 105 | 106 | #### Codable types 107 | 108 | `Storage` supports any objects that conform to [Codable](https://developer.apple.com/documentation/swift/codable) protocol. You can [make your own things conform to Codable](https://developer.apple.com/documentation/foundation/archives_and_serialization/encoding_and_decoding_custom_types) so that can be saved and loaded from `Storage`. 109 | 110 | The supported types are 111 | 112 | - Primitives like `Int`, `Float`, `String`, `Bool`, ... 113 | - Array of primitives like `[Int]`, `[Float]`, `[Double]`, ... 114 | - Set of primitives like `Set`, `Set`, ... 115 | - Simply dictionary like `[String: Int]`, `[String: String]`, ... 116 | - `Date` 117 | - `URL` 118 | - `Data` 119 | 120 | #### Error handling 121 | 122 | Error handling is done via `try catch`. `Storage` throws errors in terms of `StorageError`. 123 | 124 | ```swift 125 | public enum StorageError: Error { 126 | /// Object can not be found 127 | case notFound 128 | /// Object is found, but casting to requested type failed 129 | case typeNotMatch 130 | /// The file attributes are malformed 131 | case malformedFileAttributes 132 | /// Can't perform Decode 133 | case decodingFailed 134 | /// Can't perform Encode 135 | case encodingFailed 136 | /// The storage has been deallocated 137 | case deallocated 138 | /// Fail to perform transformation to or from Data 139 | case transformerFail 140 | } 141 | ``` 142 | 143 | There can be errors because of disk problem or type mismatch when loading from storage, so if want to handle errors, you need to do `try catch` 144 | 145 | ```swift 146 | do { 147 | let storage = try Storage(diskConfig: diskConfig, memoryConfig: memoryConfig) 148 | } catch { 149 | print(error) 150 | } 151 | ``` 152 | 153 | ### Configuration 154 | 155 | Here is how you can play with many configuration options 156 | 157 | ```swift 158 | let diskConfig = DiskConfig( 159 | // The name of disk storage, this will be used as folder name within directory 160 | name: "Floppy", 161 | // Expiry date that will be applied by default for every added object 162 | // if it's not overridden in the `setObject(forKey:expiry:)` method 163 | expiry: .date(Date().addingTimeInterval(2*3600)), 164 | // Maximum size of the disk cache storage (in bytes) 165 | maxSize: 10000, 166 | // Where to store the disk cache. If nil, it is placed in `cachesDirectory` directory. 167 | directory: try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, 168 | appropriateFor: nil, create: true).appendingPathComponent("MyPreferences"), 169 | // Data protection is used to store files in an encrypted format on disk and to decrypt them on demand 170 | protectionType: .complete 171 | ) 172 | ``` 173 | 174 | ```swift 175 | let memoryConfig = MemoryConfig( 176 | // Expiry date that will be applied by default for every added object 177 | // if it's not overridden in the `setObject(forKey:expiry:)` method 178 | expiry: .date(Date().addingTimeInterval(2*60)), 179 | /// The maximum number of objects in memory the cache should hold 180 | countLimit: 50, 181 | /// The maximum total cost that the cache can hold before it starts evicting objects 182 | totalCostLimit: 0 183 | ) 184 | ``` 185 | 186 | On iOS, tvOS we can also specify `protectionType` on `DiskConfig` to add a level of security to files stored on disk by your app in the app’s container. For more information, see [FileProtectionType](https://developer.apple.com/documentation/foundation/fileprotectiontype) 187 | 188 | ### Sync APIs 189 | 190 | `Storage` is sync by default and is `thread safe`, you can access it from any queues. All Sync functions are constrained by `StorageAware` protocol. 191 | 192 | ```swift 193 | // Save to storage 194 | try? storage.setObject(10, forKey: "score") 195 | try? storage.setObject("Oslo", forKey: "my favorite city", expiry: .never) 196 | try? storage.setObject(["alert", "sounds", "badge"], forKey: "notifications") 197 | try? storage.setObject(data, forKey: "a bunch of bytes") 198 | try? storage.setObject(authorizeURL, forKey: "authorization URL") 199 | 200 | // Load from storage 201 | let score = try? storage.object(forKey: "score") 202 | let favoriteCharacter = try? storage.object(forKey: "my favorite city") 203 | 204 | // Check if an object exists 205 | let hasFavoriteCharacter = try? storage.existsObject(forKey: "my favorite city") 206 | 207 | // Remove an object in storage 208 | try? storage.removeObject(forKey: "my favorite city") 209 | 210 | // Remove all objects 211 | try? storage.removeAll() 212 | 213 | // Remove expired objects 214 | try? storage.removeExpiredObjects() 215 | ``` 216 | 217 | #### Entry 218 | 219 | There is time you want to get object together with its expiry information and meta data. You can use `Entry` 220 | 221 | ```swift 222 | let entry = try? storage.entry(forKey: "my favorite city") 223 | print(entry?.object) 224 | print(entry?.expiry) 225 | print(entry?.meta) 226 | ``` 227 | 228 | `meta` may contain file information if the object was fetched from disk storage. 229 | 230 | #### Custom Codable 231 | 232 | `Codable` works for simple dictionary like `[String: Int]`, `[String: String]`, ... It does not work for `[String: Any]` as `Any` is not `Codable` conformance, it will raise `fatal error` at runtime. So when you get json from backend responses, you need to convert that to your custom `Codable` objects and save to `Storage` instead. 233 | 234 | ```swift 235 | struct User: Codable { 236 | let firstName: String 237 | let lastName: String 238 | } 239 | 240 | let user = User(fistName: "John", lastName: "Snow") 241 | try? storage.setObject(user, forKey: "character") 242 | ``` 243 | 244 | ### Async APIs 245 | 246 | In `async` fashion, you deal with `Result` instead of `try catch` because the result is delivered at a later time, in order to not block the current calling queue. In the completion block, you either have `value` or `error`. 247 | 248 | You access Async APIs via `storage.async`, it is also thread safe, and you can use Sync and Async APIs in any order you want. All Async functions are constrained by `AsyncStorageAware` protocol. 249 | 250 | ```swift 251 | storage.async.setObject("Oslo", forKey: "my favorite city") { result in 252 | switch result { 253 | case .value: 254 | print("saved successfully") 255 | case .error(let error): 256 | print(error) 257 | } 258 | } 259 | 260 | storage.async.object(forKey: "my favorite city") { result in 261 | switch result { 262 | case .value(let city): 263 | print("my favorite city is \(city)") 264 | case .error(let error): 265 | print(error) 266 | } 267 | } 268 | 269 | storage.async.existsObject(forKey: "my favorite city") { result in 270 | if case .value(let exists) = result, exists { 271 | print("I have a favorite city") 272 | } 273 | } 274 | 275 | storage.async.removeAll() { result in 276 | switch result { 277 | case .value: 278 | print("removal completes") 279 | case .error(let error): 280 | print(error) 281 | } 282 | } 283 | 284 | storage.async.removeExpiredObjects() { result in 285 | switch result { 286 | case .value: 287 | print("removal completes") 288 | case .error(let error): 289 | print(error) 290 | } 291 | } 292 | ``` 293 | 294 | ### Expiry date 295 | 296 | By default, all saved objects have the same expiry as the expiry you specify in `DiskConfig` or `MemoryConfig`. You can overwrite this for a specific object by specifying `expiry` for `setObject` 297 | 298 | ```swift 299 | // Default expiry date from configuration will be applied to the item 300 | try? storage.setObject("This is a string", forKey: "string") 301 | 302 | // A given expiry date will be applied to the item 303 | try? storage.setObject( 304 | "This is a string", 305 | forKey: "string" 306 | expiry: .date(Date().addingTimeInterval(2 * 3600)) 307 | ) 308 | 309 | // Clear expired objects 310 | storage.removeExpiredObjects() 311 | ``` 312 | 313 | ## Observations 314 | 315 | [Storage](#storage) allows you to observe changes in the cache layer, both on 316 | a store and a key levels. The API lets you pass any object as an observer, 317 | while also passing an observation closure. The observation closure will be 318 | removed automatically when the weakly captured observer has been deallocated. 319 | 320 | ## Storage observations 321 | 322 | ```swift 323 | // Add observer 324 | let token = storage.addStorageObserver(self) { observer, storage, change in 325 | switch change { 326 | case .add(let key): 327 | print("Added \(key)") 328 | case .remove(let key): 329 | print("Removed \(key)") 330 | case .removeAll: 331 | print("Removed all") 332 | case .removeExpired: 333 | print("Removed expired") 334 | } 335 | } 336 | 337 | // Remove observer 338 | token.cancel() 339 | 340 | // Remove all observers 341 | storage.removeAllStorageObservers() 342 | ``` 343 | 344 | ## Key observations 345 | 346 | ```swift 347 | let key = "user1" 348 | 349 | let token = storage.addObserver(self, forKey: key) { observer, storage, change in 350 | switch change { 351 | case .edit(let before, let after): 352 | print("Changed object for \(key) from \(String(describing: before)) to \(after)") 353 | case .remove: 354 | print("Removed \(key)") 355 | } 356 | } 357 | 358 | // Remove observer by token 359 | token.cancel() 360 | 361 | // Remove observer for key 362 | storage.removeObserver(forKey: key) 363 | 364 | // Remove all observers 365 | storage.removeAllKeyObservers() 366 | ``` 367 | 368 | ## Handling JSON response 369 | 370 | Most of the time, our use case is to fetch some json from backend, display it while saving the json to storage for future uses. If you're using libraries like [Alamofire](https://github.com/Alamofire/Alamofire) or [Malibu](https://github.com/hyperoslo/Malibu), you mostly get json in the form of dictionary, string, or data. 371 | 372 | `Storage` can persist `String` or `Data`. You can even save json to `Storage` using `JSONArrayWrapper` and `JSONDictionaryWrapper`, but we prefer persisting the strong typed objects, since those are the objects that you will use to display in UI. Furthermore, if the json data can't be converted to strongly typed objects, what's the point of saving it ? 😉 373 | 374 | You can use these extensions on `JSONDecoder` to decode json dictionary, string or data to objects. 375 | 376 | ```swift 377 | let user = JSONDecoder.decode(jsonString, to: User.self) 378 | let cities = JSONDecoder.decode(jsonDictionary, to: [City].self) 379 | let dragons = JSONDecoder.decode(jsonData, to: [Dragon].self) 380 | ``` 381 | 382 | This is how you perform object converting and saving with `Alamofire` 383 | 384 | ```swift 385 | Alamofire.request("https://gameofthrones.org/mostFavoriteCharacter").responseString { response in 386 | do { 387 | let user = try JSONDecoder.decode(response.result.value, to: User.self) 388 | try storage.setObject(user, forKey: "most favorite character") 389 | } catch { 390 | print(error) 391 | } 392 | } 393 | ``` 394 | 395 | ## What about images 396 | 397 | If you want to load image into `UIImageView` or `NSImageView`, then we also have a nice gift for you. It's called [Imaginary](https://github.com/hyperoslo/Imaginary) and uses `Cache` under the hood to make you life easier when it comes to working with remote images. 398 | 399 | ## Installation 400 | 401 | ### Cocoapods 402 | 403 | **Cache** is available through [CocoaPods](http://cocoapods.org). To install 404 | it, simply add the following line to your Podfile: 405 | 406 | ```ruby 407 | pod 'Cache' 408 | ``` 409 | 410 | ### Carthage 411 | 412 | **Cache** is also available through [Carthage](https://github.com/Carthage/Carthage). 413 | To install just write into your Cartfile: 414 | 415 | ```ruby 416 | github "hyperoslo/Cache" 417 | ``` 418 | 419 | You also need to add `SwiftHash.framework` in your [copy-frameworks](https://github.com/Carthage/Carthage#if-youre-building-for-ios-tvos-or-watchos) script. 420 | 421 | ## Author 422 | 423 | - [Hyper](http://hyper.no) made this with ❤️ 424 | - Inline MD5 implementation from [SwiftHash](https://github.com/onmyway133/SwiftHash) 425 | 426 | ## Contributing 427 | 428 | We would love you to contribute to **Cache**, check the [CONTRIBUTING](https://github.com/hyperoslo/Cache/blob/master/CONTRIBUTING.md) file for more info. 429 | 430 | ## License 431 | 432 | **Cache** is available under the MIT license. See the [LICENSE](https://github.com/hyperoslo/Cache/blob/master/LICENSE.md) file for more info. 433 | --------------------------------------------------------------------------------