Skip to content

Commit

Permalink
[feat] Add cache + better error throwing
Browse files Browse the repository at this point in the history
  • Loading branch information
jtouzy committed Apr 5, 2023
1 parent 369a264 commit 03b0b50
Show file tree
Hide file tree
Showing 10 changed files with 283 additions and 33 deletions.
52 changes: 52 additions & 0 deletions Sources/Spyder/API+Cache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public enum CachePolicy: Equatable {
case none
case inMemory(duration: TimeInterval)
}

public struct CacheManager {
var policy: CachePolicy
var entries: [URLRequest: Entry]

public init(policy: CachePolicy) {
self.policy = policy
self.entries = [:]
}
}

extension CacheManager {
struct Entry: Equatable {
let successfulResult: Data
let expirationDate: Date
}
}

extension CacheManager {
public mutating func registerEntryIfNeeded(_ dataEntry: Data, for request: URLRequest, storageDate: Date = .init()) {
guard case .inMemory(let inMemoryDuration) = policy else {
return
}
let expirationDate = storageDate.addingTimeInterval(inMemoryDuration)
entries[request] = .init(successfulResult: dataEntry, expirationDate: expirationDate)
}
}

extension CacheManager {
mutating func findNonExpiredEntry(for request: URLRequest, comparisonDate: Date = .init()) -> Data? {
guard policy != .none else {
return .none
}
guard let expectedEntry = entries[request] else {
return .none
}
guard expectedEntry.expirationDate > comparisonDate else {
entries.removeValue(forKey: request)
return .none
}
return expectedEntry.successfulResult
}
}
5 changes: 5 additions & 0 deletions Sources/Spyder/API+Errors.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
extension API {
public enum Error: Swift.Error, Equatable {
case invalidStatusCodeInResponse(response: HTTPResponse)
}
}
54 changes: 34 additions & 20 deletions Sources/Spyder/API+Invoking.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,34 +9,48 @@ extension API {
public func invokeAndForget<Input>(request: Input) async throws
where Input: URLRequestBuilder {
let urlRequest = try request.urlRequest(for: self)
logInvoke(for: urlRequest)
try await invokeWithCacheCheck(urlRequest)
}
public func invokeWaitingResponse<Input, Output>(request: Input) async throws -> Output
where Input: URLRequestBuilder, Output: Decodable {
let urlRequest = try request.urlRequest(for: self)
let responseData = try await invokeWithCacheCheck(urlRequest)
return try decodeResponseData(responseData, from: urlRequest)
}
}

extension API {
@discardableResult
private func invokeWithCacheCheck(_ urlRequest: URLRequest) async throws -> Data {
let cachedResponseData = cacheManager.findNonExpiredEntry(for: urlRequest)
logInvoke(for: urlRequest, isCached: cachedResponseData != nil)
guard let cachedResponseData else {
let response = try await invokeUsingInvoker(urlRequest)
cacheManager.registerEntryIfNeeded(response.data, for: urlRequest)
return response.data
}
return cachedResponseData
}
private func invokeUsingInvoker(_ urlRequest: URLRequest) async throws -> HTTPResponse {
do {
let response = try await invoker(urlRequest)
var response = try await invoker(urlRequest)
logResponse(for: urlRequest, response: response)
for middleware in responseMiddlewares {
response = try await middleware(self, response)
}
if (200...299).contains(response.statusCode) == false {
throw API.Error.invalidStatusCodeInResponse(response: response)
}
return response
} catch {
logInvocationFailure(for: urlRequest, error: error)
throw error
}
}
public func invokeWaitingResponse<Input, Output>(request: Input) async throws -> Output
where Input: URLRequestBuilder, Output: Decodable {
let urlRequest = try request.urlRequest(for: self)
logInvoke(for: urlRequest)
let response: HTTPResponse = try await {
do {
var response = try await invoker(urlRequest)
logResponse(for: urlRequest, response: response)
for middleware in responseMiddlewares {
response = try await middleware(self, response)
}
return response
} catch {
logInvocationFailure(for: urlRequest, error: error)
throw error
}
}()
private func decodeResponseData<Output>(_ data: Data, from urlRequest: URLRequest) throws -> Output
where Output: Decodable {
do {
return try jsonDecoder.decode(Output.self, from: response.data)
return try jsonDecoder.decode(Output.self, from: data)
} catch {
logDecodingError(for: urlRequest, error: error)
throw error
Expand Down
8 changes: 4 additions & 4 deletions Sources/Spyder/API+Logging.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import FoundationNetworking
#endif

extension API {
internal func logInvoke(for urlRequest: URLRequest) {
internal func logInvoke(for urlRequest: URLRequest, isCached: Bool) {
logger(
"🕸️ invoke",
"🕸️ invoke\(isCached ? " [CACHED]" : "")",
[
"method=[\(urlRequest.httpMethod ?? "GET")]",
"absolute_url=[\(urlRequest.url?.absoluteString ?? "nil")]",
Expand All @@ -22,14 +22,14 @@ extension API {
message: "\(isSuccess ? "✅ success" : "❌ failure")[\(response.statusCode)]"
)
}
internal func logInvocationFailure(for urlRequest: URLRequest, error: Error) {
internal func logInvocationFailure(for urlRequest: URLRequest, error: Swift.Error) {
logNetworkingEvent(
for: urlRequest,
message: "❌ invocationFailure",
complementaryMessage: String(reflecting: error)
)
}
internal func logDecodingError(for urlRequest: URLRequest, error: Error) {
internal func logDecodingError(for urlRequest: URLRequest, error: Swift.Error) {
logNetworkingEvent(
for: urlRequest,
message: "❌ decodingFailure",
Expand Down
4 changes: 2 additions & 2 deletions Sources/Spyder/API+Types.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ public enum HTTPMethod: String {
case delete, get, post, put
}

public struct HTTPResponse {
public struct HTTPResponse: Equatable {
public let statusCode: Int
public let headers: [Header]
public let data: Data
Expand All @@ -16,7 +16,7 @@ public struct HTTPResponse {
}
}

public struct Header: Hashable {
public struct Header: Equatable, Hashable {
public let name: String
public let value: String

Expand Down
5 changes: 4 additions & 1 deletion Sources/Spyder/API.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public class API<ConstrainedType> {
let invoker: Invoker
let logger: Logger
let responseMiddlewares: [ResponseMiddleware]
var cacheManager: CacheManager

public init(
baseURLComponents: @escaping (inout URLComponents) -> Void,
Expand All @@ -26,7 +27,8 @@ public class API<ConstrainedType> {
persistentHeaders: Set<Header> = [],
invoker: @escaping Invoker,
logger: @escaping Logger = { _, _ in },
responseMiddlewares: [ResponseMiddleware] = []
responseMiddlewares: [ResponseMiddleware] = [],
cachePolicy: CachePolicy = .none
) {
var urlComponents = URLComponents()
baseURLComponents(&urlComponents)
Expand All @@ -38,5 +40,6 @@ public class API<ConstrainedType> {
self.invoker = invoker
self.logger = logger
self.responseMiddlewares = responseMiddlewares
self.cacheManager = .init(policy: cachePolicy)
}
}
78 changes: 74 additions & 4 deletions Tests/SpyderTests/APITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,35 @@ final class APITests: XCTestCase {

private enum TestInvoker {
public static let successInvoker: API.Invoker = { request in
.init(statusCode: 200, headers: [], data: "{\"name\":\"Spyder\"}".data(using: .utf8)!)
.init(statusCode: 200, headers: [], data: try .safe(from: "{\"name\":\"Spyder\"}"))
}
public static let jsonDecodingFailureInvoker: API.Invoker = { request in
.init(statusCode: 200, headers: [], data: "{}".data(using: .utf8)!)
.init(statusCode: 200, headers: [], data: try .safe(from: "{}"))
}
public static let serverFailureInvoker: API.Invoker = { request in
.init(statusCode: 500, headers: [], data: try .safe(from: "{}"))
}
public static func spy(invoker: @escaping API.Invoker) -> SpyInvoker {
.init(internalInvoker: invoker)
}
}
private class SpyInvoker {
private var internalInvoker: API.Invoker
var invocationCount: Int = .zero

init(internalInvoker: @escaping API.Invoker) {
self.internalInvoker = internalInvoker
}

var invoker: API.Invoker {
return { [weak self] request in
guard let self = self else {
enum ReferenceError: Error { case missingSelfReferenceInContext }
throw ReferenceError.missingSelfReferenceInContext
}
self.invocationCount += 1
return try await self.internalInvoker(request)
}
}
}

Expand All @@ -23,11 +48,13 @@ private enum TestInvoker {

private func createSUT(
invoker: @escaping API.Invoker = TestInvoker.successInvoker,
headersBuilder: @escaping API.HeadersBuilder = { .init() }
headersBuilder: @escaping API.HeadersBuilder = { .init() },
cachePolicy: CachePolicy = .none
) -> GitHubAPI {
GitHubAPI.build(
using: invoker,
headersBuilder: headersBuilder
headersBuilder: headersBuilder,
cachePolicy: cachePolicy
)
}

Expand Down Expand Up @@ -74,4 +101,47 @@ extension APITests {
XCTAssertEqual(error.localizedDescription, "The data couldn’t be read because it is missing.")
}
}
func test_invoking_serverFailure() async throws {
// Given
let sut = createSUT(invoker: TestInvoker.serverFailureInvoker)
// When
do {
let _ = try await sut.getRepositories(.init())
XCTFail("The invoking should fail because server has returned a non-acceptable status")
} catch {
guard let apiError = error as? GitHubAPI.Error else {
XCTFail("The thrown error should be an API error")
return
}
XCTAssertEqual(
apiError,
GitHubAPI.Error.invalidStatusCodeInResponse(
response: .init(statusCode: 500, headers: [], data: try .safe(from: "{}"))
)
)
}
}
}

extension APITests {
func test_invoking_withoutCachePolicy() async throws {
// Given
let spy = TestInvoker.spy(invoker: TestInvoker.successInvoker)
let sut = createSUT(invoker: spy.invoker, cachePolicy: .none)
_ = try await sut.getRepositories(.init())
// When
_ = try await sut.getRepositories(.init())
//
XCTAssertEqual(spy.invocationCount, 2)
}
func test_invoking_withCachePolicy() async throws {
// Given
let spy = TestInvoker.spy(invoker: TestInvoker.successInvoker)
let sut = createSUT(invoker: spy.invoker, cachePolicy: .inMemory(duration: 30))
_ = try await sut.getRepositories(.init())
// When
_ = try await sut.getRepositories(.init())
//
XCTAssertEqual(spy.invocationCount, 1)
}
}
90 changes: 90 additions & 0 deletions Tests/SpyderTests/CacheManagerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
@testable import Spyder
import XCTest

final class CacheManagerTests: XCTestCase {
}

// ========================================================================
// MARK: Test builders
// ========================================================================

private func createSUT(policy: CachePolicy) -> CacheManager {
.init(policy: policy)
}

// ========================================================================
// MARK: Tests
// ========================================================================

extension CacheManagerTests {
func test_registerEntry_withNoCachePolicy() throws {
// Given
let request: URLRequest = .init(url: try .safe(from: "https://www.google.fr"))
let successfulData: Data = try .safe(from: "{}")
var sut = createSUT(policy: .none)
// When
sut.registerEntryIfNeeded(successfulData, for: request)
// Then
XCTAssertEqual(sut.entries, [:])
}
func test_registerEntry_withCachePolicy() throws {
// Given
let request: URLRequest = .init(url: try .safe(from: "https://www.google.fr"))
let successfulData: Data = try .safe(from: "{}")
let cacheDuration: TimeInterval = 30
var sut = createSUT(policy: .inMemory(duration: cacheDuration))
// When
let storageDate = Date()
sut.registerEntryIfNeeded(successfulData, for: request, storageDate: storageDate)
// Then
XCTAssertEqual(sut.entries, [
request: .init(successfulResult: successfulData, expirationDate: storageDate.addingTimeInterval(cacheDuration))
])
}
}

extension CacheManagerTests {
func test_findNonExpiredEntry_withNoCachePolicy() throws {
// Given
let request: URLRequest = .init(url: try .safe(from: "https://www.google.fr"))
var sut = createSUT(policy: .none)
// When
let result = sut.findNonExpiredEntry(for: request)
// Then
XCTAssertNil(result)
}
func test_findNonExpiredEntry_withNonExistingCacheEntry() throws {
// Given
let request: URLRequest = .init(url: try .safe(from: "https://www.google.fr"))
var sut = createSUT(policy: .inMemory(duration: 20))
// When
let result = sut.findNonExpiredEntry(for: request)
// Then
XCTAssertNil(result)
}
func test_findNonExpiredEntry_withNonExpiredCacheEntry() throws {
// Given
let request: URLRequest = .init(url: try .safe(from: "https://www.google.fr"))
let successfulData: Data = try .safe(from: "{}")
let baseStorageDate = Date()
var sut = createSUT(policy: .inMemory(duration: 30))
sut.registerEntryIfNeeded(successfulData, for: request, storageDate: baseStorageDate)
// When
let result = sut.findNonExpiredEntry(for: request, comparisonDate: baseStorageDate.addingTimeInterval(20))
// Then
XCTAssertEqual(result, successfulData)
}
func test_findNonExpiredEntry_withExpiredCacheEntry() throws {
// Given
let request: URLRequest = .init(url: try .safe(from: "https://www.google.fr"))
let successfulData: Data = try .safe(from: "{}")
let baseStorageDate = Date()
var sut = createSUT(policy: .inMemory(duration: 10))
sut.registerEntryIfNeeded(successfulData, for: request, storageDate: baseStorageDate)
// When
let result = sut.findNonExpiredEntry(for: request, comparisonDate: baseStorageDate.addingTimeInterval(20))
// Then
XCTAssertNil(result)
XCTAssertEqual(sut.entries, [:])
}
}
Loading

0 comments on commit 03b0b50

Please sign in to comment.