feat: Network services in TIMoyaNetworking now passes MoyaError in result of EnpointRequest execution.

feat: TINetworkingCache module - caching results of EndpointRequests.
This commit is contained in:
Ivan Smolin 2022-04-14 15:04:59 +03:00
parent 19134573fa
commit 532e54fe9e
22 changed files with 249 additions and 33 deletions

View File

@ -1,5 +1,11 @@
# Changelog
### 1.15.0
- **Update**: Network services in TIMoyaNetworking now passes MoyaError in result of EnpointRequest execution.
- **Add**: `TINetworkingCache` module - caching results of EndpointRequests.
- **Important Note**: `TINetworkingCache` may require you to add `DISABLE_DIAMOND_PROBLEM_DIAGNOSTIC=YES` flag to build settings of project target (see [probably related problem](https://forums.swift.org/t/adding-a-package-to-two-targets-in-one-projects-results-in-an-error/35007/18))
### 1.14.3
- **Fix**: Creating headerView and footerView when initializing a section with rows in `TITableKitUtils`.

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = "LeadKit"
s.version = "1.14.3"
s.version = "1.15.0"
s.summary = "iOS framework with a bunch of tools for rapid development"
s.homepage = "https://github.com/TouchInstinct/LeadKit"
s.license = "Apache License, Version 2.0"

View File

@ -10,6 +10,15 @@
"version": "5.4.3"
}
},
{
"package": "Cache",
"repositoryURL": "https://github.com/hyperoslo/Cache.git",
"state": {
"branch": null,
"revision": "c7f4d633049c3bd649a353bad36f6c17e9df085f",
"version": "6.0.0"
}
},
{
"package": "Cursors",
"repositoryURL": "https://github.com/petropavel13/Cursors",

View File

@ -17,8 +17,12 @@ let package = Package(
.library(name: "TIFoundationUtils", targets: ["TIFoundationUtils"]),
.library(name: "TIKeychainUtils", targets: ["TIKeychainUtils"]),
.library(name: "TITableKitUtils", targets: ["TITableKitUtils"]),
// MARK: - Networking
.library(name: "TINetworking", targets: ["TINetworking"]),
.library(name: "TIMoyaNetworking", targets: ["TIMoyaNetworking"]),
.library(name: "TINetworkingCache", targets: ["TINetworkingCache"]),
// MARK: - Elements
.library(name: "OTPSwiftView", targets: ["OTPSwiftView"]),
@ -31,6 +35,7 @@ let package = Package(
.package(url: "https://github.com/petropavel13/Cursors", .upToNextMajor(from: "0.5.1")),
.package(url: "https://github.com/Alamofire/Alamofire.git", .upToNextMajor(from: "5.4.0")),
.package(url: "https://github.com/Moya/Moya.git", .upToNextMajor(from: "15.0.0")),
.package(url: "https://github.com/hyperoslo/Cache.git", .upToNextMajor(from: "6.0.0"))
],
targets: [
@ -48,6 +53,7 @@ let package = Package(
.target(name: "TINetworking", dependencies: ["TISwiftUtils", "Alamofire"], path: "TINetworking/Sources"),
.target(name: "TIMoyaNetworking", dependencies: ["TINetworking", "TIFoundationUtils", "Moya"], path: "TIMoyaNetworking"),
.target(name: "TINetworkingCache", dependencies: ["TIFoundationUtils", "TINetworking", "Cache"], path: "TINetworkingCache/Sources"),
// MARK: - Elements

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIFoundationUtils'
s.version = '1.14.3'
s.version = '1.15.0'
s.summary = 'Set of helpers for Foundation framework classes.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIKeychainUtils'
s.version = '1.14.3'
s.version = '1.15.0'
s.summary = 'Set of helpers for Keychain classes.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -59,13 +59,17 @@ open class DefaultJsonNetworkService {
plugins: plugins)
}
open func serialize<B: Encodable, S: Decodable>(request: EndpointRequest<B, S>) throws -> SerializedRequest {
try request.serialize(using: ApplicationJsonBodySerializer(jsonEncoder: jsonEncoder),
defaultServer: defaultServer)
}
@available(iOS 13.0.0, *)
open func process<B: Encodable, S: Decodable, F: Decodable>(request: EndpointRequest<B, S>,
mapMoyaError: @escaping Closure<MoyaError, F>) async -> Result<S, F> {
open func process<B: Encodable, S, F>(request: EndpointRequest<B, S>) async -> EndpointRequestResult<S, F> {
await process(request: request,
mapSuccess: Result.success,
mapFailure: Result.failure,
mapMoyaError: { .failure(mapMoyaError($0)) })
mapFailure: { .failure(.apiError($0)) },
mapMoyaError: { .failure(.networkError($0)) })
}
@available(iOS 13.0.0, *)
@ -98,15 +102,14 @@ open class DefaultJsonNetworkService {
mapMoyaError: @escaping Closure<MoyaError, R>,
completion: @escaping ParameterClosure<R>) -> Cancellable {
ScopeCancellable { [jsonEncoder, serializationQueue, callbackQueue, defaultServer] scope in
ScopeCancellable { [serializationQueue, callbackQueue] scope in
let workItem = DispatchWorkItem {
guard !scope.isCancelled else {
return
}
do {
let serializedRequest = try request.serialize(using: ApplicationJsonBodySerializer(jsonEncoder: jsonEncoder),
defaultServer: defaultServer)
let serializedRequest = try self.serialize(request: request)
scope.add(cancellable: self.process(request: serializedRequest,
mapSuccess: mapSuccess,

View File

@ -0,0 +1,43 @@
//
// Copyright (c) 2022 Touch Instinct
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the Software), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
//
import Moya
import Foundation
public enum EndpointErrorResult<E>: Error {
case apiError(E)
case networkError(MoyaError)
}
public extension EndpointErrorResult {
var isNetworkConnectionProblem: Bool {
guard case let .networkError(moyaError) = self,
case let .underlying(error, _) = moyaError,
case let .sessionTaskFailed(urlSessionTaskError) = error.asAFError,
let urlError = urlSessionTaskError as? URLError else {
return false
}
return urlError.code == .notConnectedToInternet
}
}

View File

@ -0,0 +1,23 @@
//
// Copyright (c) 2022 Touch Instinct
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the Software), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
//
public typealias EndpointRequestResult<S: Decodable, F: Decodable> = Result<S, EndpointErrorResult<F>>

View File

@ -26,45 +26,40 @@ import Moya
@available(iOS 13.0.0, *)
open class DefaultRecoverableJsonNetworkService<ApiError: Decodable & Error>: DefaultJsonNetworkService {
public typealias ErrorHandler = AnyAsyncEventHandler<ApiError>
public typealias ErrorHandler = AnyAsyncEventHandler<EndpointErrorResult<ApiError>>
private(set) public var defaultErrorHandlers: [ErrorHandler] = []
public func process<B: Encodable, S: Decodable>(recoverableRequest: EndpointRequest<B, S>,
prependErrorHandlers: [ErrorHandler],
appendErrorHandlers: [ErrorHandler],
mapMoyaError: @escaping Closure<MoyaError, ApiError>) async -> Result<S, ApiError> {
open func process<B: Encodable, S>(recoverableRequest: EndpointRequest<B, S>,
prependErrorHandlers: [ErrorHandler] = [],
appendErrorHandlers: [ErrorHandler] = []) async -> EndpointRequestResult<S, ApiError> {
await process(recoverableRequest: recoverableRequest,
errorHandlers: prependErrorHandlers + defaultErrorHandlers + appendErrorHandlers,
mapMoyaError: mapMoyaError)
errorHandlers: prependErrorHandlers + defaultErrorHandlers + appendErrorHandlers)
}
public func process<B: Encodable, S: Decodable>(recoverableRequest: EndpointRequest<B, S>,
errorHandlers: [ErrorHandler],
mapMoyaError: @escaping Closure<MoyaError, ApiError>) async -> Result<S, ApiError> {
open func process<B: Encodable, S>(recoverableRequest: EndpointRequest<B, S>,
errorHandlers: [ErrorHandler]) async -> EndpointRequestResult<S, ApiError> {
let result = await process(request: recoverableRequest,
mapMoyaError: mapMoyaError)
let result: EndpointRequestResult<S, ApiError> = await process(request: recoverableRequest)
if case let .failure(errorResponse) = result {
let chain = AsyncEventHandlingChain(handlers: errorHandlers)
if await chain.handle(errorResponse) {
return await process(recoverableRequest: recoverableRequest,
errorHandlers: errorHandlers,
mapMoyaError: mapMoyaError)
errorHandlers: errorHandlers)
}
}
return result
}
public func register<ErrorHandler: AsyncErrorHandler>(defaultErrorHandler: ErrorHandler) where ErrorHandler.EventType == ApiError {
public func register<ErrorHandler: AsyncErrorHandler>(defaultErrorHandler: ErrorHandler) where ErrorHandler.EventType == EndpointErrorResult<ApiError> {
defaultErrorHandlers.append(defaultErrorHandler.asAnyAsyncEventHandler())
}
public func set<ErrorHandler: AsyncErrorHandler>(defaultErrorHandlers: ErrorHandler...) where ErrorHandler.EventType == ApiError {
public func set<ErrorHandler: AsyncErrorHandler>(defaultErrorHandlers: ErrorHandler...) where ErrorHandler.EventType == EndpointErrorResult<ApiError> {
self.defaultErrorHandlers = defaultErrorHandlers.map { $0.asAnyAsyncEventHandler() }
}
}

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIMoyaNetworking'
s.version = '1.14.3'
s.version = '1.15.0'
s.summary = 'Moya + Swagger network service.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -52,3 +52,33 @@ public struct SerializedRequest {
self.acceptableStatusCodes = acceptableStatusCodes
}
}
extension SerializedRequest: Hashable {
private var comparableQueryParameters: [String: String] {
queryParameters.mapValues(String.init(describing:))
}
public static func == (lhs: SerializedRequest, rhs: SerializedRequest) -> Bool {
lhs.baseURL == rhs.baseURL &&
lhs.path == rhs.path &&
lhs.method == rhs.method &&
lhs.bodyData == rhs.bodyData &&
lhs.comparableQueryParameters == rhs.comparableQueryParameters &&
lhs.headers == rhs.headers &&
lhs.cookies == rhs.cookies &&
lhs.acceptableStatusCodes == rhs.acceptableStatusCodes
}
public func hash(into hasher: inout Hasher) {
hasher.combine(baseURL)
hasher.combine(path)
hasher.combine(method)
hasher.combine(bodyData)
hasher.combine(comparableQueryParameters.keys.sorted())
hasher.combine(comparableQueryParameters.values.sorted())
hasher.combine(headers?.keys.sorted() ?? [])
hasher.combine(headers?.values.sorted() ?? [])
hasher.combine(cookies)
hasher.combine(acceptableStatusCodes.sorted())
}
}

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TINetworking'
s.version = '1.14.3'
s.version = '1.15.0'
s.summary = 'Swagger-frendly networking layer helpers.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -0,0 +1,81 @@
//
// Copyright (c) 2022 Touch Instinct
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the Software), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
//
import TINetworking
import TIFoundationUtils
import Cache
import Foundation
public struct EndpointCacheService<Content: Codable> {
private let serializedRequest: SerializedRequest
private let multiLevelStorage: Storage<SerializedRequest, Content>
public var cachedContent: Content? {
get {
guard let entry = try? multiLevelStorage.entry(forKey: serializedRequest), !entry.expiry.isExpired else {
try? multiLevelStorage.removeObject(forKey: serializedRequest)
return nil
}
return entry.object
}
nonmutating set {
if let object = newValue {
try? multiLevelStorage.setObject(object,
forKey: serializedRequest)
} else {
try? multiLevelStorage.removeObject(forKey: serializedRequest)
}
}
}
public init(serializedRequest: SerializedRequest,
cacheLifetime: TimeInterval,
jsonCodingConfigurator: JsonCodingConfigurator) throws {
self.serializedRequest = serializedRequest
let nameWithoutLeadingSlash: String
if serializedRequest.path.starts(with: "/") {
nameWithoutLeadingSlash = serializedRequest.path.drop { $0 == "/" }.string
} else {
nameWithoutLeadingSlash = serializedRequest.path
}
let diskConfig = DiskConfig(name: nameWithoutLeadingSlash,
expiry: .seconds(cacheLifetime))
let memoryConfig = MemoryConfig(expiry: .seconds(cacheLifetime),
countLimit: 0,
totalCostLimit: 0)
let transformer = Transformer {
try jsonCodingConfigurator.jsonEncoder.encode($0)
} fromData: {
try jsonCodingConfigurator.jsonDecoder.decode(Content.self, from: $0)
}
self.multiLevelStorage = try Storage(diskConfig: diskConfig,
memoryConfig: memoryConfig,
transformer: transformer)
}
}

View File

@ -0,0 +1,18 @@
Pod::Spec.new do |s|
s.name = 'TINetworkingCache'
s.version = '1.15.0'
s.summary = 'Caching results of EndpointRequests.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }
s.author = { 'petropavel13' => 'ivan.smolin@touchin.ru' }
s.source = { :git => 'https://github.com/TouchInstinct/LeadKit.git', :tag => s.version.to_s }
s.ios.deployment_target = '11.0'
s.swift_versions = ['5.3']
s.source_files = s.name + '/Sources/**/*'
s.dependency 'TIFoundationUtils', s.version.to_s
s.dependency 'TINetworking', s.version.to_s
s.dependency 'Cache', "~> 6.0.0"
end

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TISwiftUtils'
s.version = '1.14.3'
s.version = '1.15.0'
s.summary = 'Bunch of useful helpers for Swift development.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -21,6 +21,7 @@
//
import TableKit
import class UIKit.UIView
public extension TableSection {

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TITableKitUtils'
s.version = '1.14.3'
s.version = '1.15.0'
s.summary = 'Set of helpers for TableKit classes.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TITransitions'
s.version = '1.14.3'
s.version = '1.15.0'
s.summary = 'Set of custom transitions to present controller. '
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIUIElements'
s.version = '1.14.3'
s.version = '1.15.0'
s.summary = 'Bunch of useful protocols and views.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIUIKitCore'
s.version = '1.14.3'
s.version = '1.15.0'
s.summary = 'Core UI elements: protocols, views and helpers.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -12,6 +12,7 @@ ORDERED_PODSPECS="../TISwiftUtils/TISwiftUtils.podspec
../TIUIElements/TIUIElements.podspec
../TITableKitUtils/TITableKitUtils.podspec
../TINetworking/TINetworking.podspec
../TINetworking/TINetworkingCache.podspec
../TIMoyaNetworking/TIMoyaNetworking.podspec"
for podspec_path in ${ORDERED_PODSPECS}; do