Merge pull request #312 from TouchInstinct/feature/token_interceptor

feat: ApiInteractor, TokenInterceptor, FingerprintsTrustEvaluator and more
This commit is contained in:
Ivan Smolin 2022-06-27 10:58:08 +03:00 committed by GitHub
commit 5e43ca7fe2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 686 additions and 173 deletions

View File

@ -1,5 +1,15 @@
# Changelog
### 1.21.0
- **Update**: `AsyncEventHandler` was replaced with `EndpointRequestRetrier`
- **Add**: `FingerprintsTrustEvaluator` and `FingerprintsProvider` for fingerprint-based host trust evaluation
- **Add**: `DefaultTokenInterceptor` for queue-based token refresh across all requests of single api interactor (network service).
- **Update**: `DefaultRecoverableJsonNetworkService` now returns collection of errors in result
- **Update**: `CancellableTask` was renamed to `Cancellable`. Cancellable implementations has been moved from `TIMoyaNetworking` to `TIFoundationUtils`.
- **Add**: `ApiInteractor` protocol with basic request/response methods
### 1.20.0
- **Add**: OpenAPI security schemes support for EndpointRequest's.

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = "LeadKit"
s.version = "1.20.0"
s.version = "1.21.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

@ -64,7 +64,7 @@ let package = Package(
// MARK: - Networking
.target(name: "TINetworking", dependencies: ["TISwiftUtils", "Alamofire"], path: "TINetworking/Sources"),
.target(name: "TINetworking", dependencies: ["TIFoundationUtils", "Alamofire"], path: "TINetworking/Sources"),
.target(name: "TIMoyaNetworking", dependencies: ["TINetworking", "TIFoundationUtils", "Moya"], path: "TIMoyaNetworking"),
.target(name: "TINetworkingCache", dependencies: ["TIFoundationUtils", "TINetworking", "Cache"], path: "TINetworkingCache/Sources"),

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIAppleMapUtils'
s.version = '1.20.0'
s.version = '1.21.0'
s.summary = 'Set of helpers for map objects clustering and interacting using Apple MapKit.'
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 = 'TIAuth'
s.version = '1.20.0'
s.version = '1.21.0'
s.summary = 'Login, registration, confirmation and other related actions'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -22,10 +22,10 @@
public final class ClosureAsyncOperation<Output, Failure: Error>: AsyncOperation<Output, Failure> {
public typealias AsyncTaskClosure = () async -> Result<Output, Failure>
public typealias CancellableTaskClosure = (@escaping (Result<Output, Failure>) -> Void) -> CancellableTask
public typealias CancellableTaskClosure = (@escaping (Result<Output, Failure>) -> Void) -> Cancellable
private let cancellableTaskClosure: CancellableTaskClosure
private var cancellableTask: CancellableTask?
private var cancellableTask: Cancellable?
public init(cancellableTaskClosure: @escaping CancellableTaskClosure) {
self.cancellableTaskClosure = cancellableTaskClosure
@ -66,7 +66,7 @@ public final class ClosureAsyncOperation<Output, Failure: Error>: AsyncOperation
}
public extension ClosureAsyncOperation {
typealias NeverFailCancellableTaskClosure = (@escaping (Output) -> Void) -> CancellableTask
typealias NeverFailCancellableTaskClosure = (@escaping (Output) -> Void) -> Cancellable
convenience init(neverFailCancellableTaskClosure: @escaping NeverFailCancellableTaskClosure) where Failure == Never {
self.init(cancellableTaskClosure: { completion in

View File

@ -20,10 +20,7 @@
// THE SOFTWARE.
//
import Moya
import TIFoundationUtils
open class BaseCancellable: Cancellable, CancellableTask {
open class BaseCancellable: Cancellable {
private(set) public var isCancelled = false
open func cancel() {

View File

@ -20,10 +20,8 @@
// THE SOFTWARE.
//
import Moya
open class CancellableBag: BaseCancellable {
var cancellables: [Cancellable] = []
open class BaseCancellableBag: BaseCancellable {
public var cancellables: [Cancellable] = []
public override init() {}
@ -33,6 +31,7 @@ open class CancellableBag: BaseCancellable {
}
cancellables.forEach { $0.cancel() }
cancellables.removeAll()
super.cancel()
}
@ -47,7 +46,7 @@ open class CancellableBag: BaseCancellable {
}
public extension Cancellable {
func add(to cancellableBag: CancellableBag) {
func add(to cancellableBag: BaseCancellableBag) {
cancellableBag.add(cancellable: self)
}
}

View File

@ -20,11 +20,14 @@
// THE SOFTWARE.
//
import Moya
import Foundation
@available(iOS 13.0, *)
extension _Concurrency.Task: Cancellable {}
public protocol Cancellable {
func cancel()
}
@available(iOS 13.0, *)
extension Task: Cancellable {}
extension DispatchWorkItem: Cancellable {}
extension Operation: Cancellable {}
extension DispatchWorkItem: Cancellable {}

View File

@ -20,10 +20,6 @@
// THE SOFTWARE.
//
import Moya
import TISwiftUtils
import TIFoundationUtils
public struct Cancellables {
public static func nonCancellable() -> Cancellable {
NonCancellable()
@ -32,8 +28,4 @@ public struct Cancellables {
public static func scoped(scopeCancellableClosure: ScopeCancellable.ScopeCancellableClosure) -> Cancellable {
ScopeCancellable(scopeCancellableClosure: scopeCancellableClosure)
}
public static func nonCancellableTask() -> CancellableTask {
NonCancellable()
}
}

View File

@ -20,10 +20,7 @@
// THE SOFTWARE.
//
import Moya
import TIFoundationUtils
public struct NonCancellable: Cancellable, CancellableTask {
public struct NonCancellable: Cancellable {
public let isCancelled = true
public init() {}

View File

@ -22,17 +22,20 @@
import TISwiftUtils
@available(iOS 13.0.0, *)
public struct AnyAsyncEventHandler<EventType, ResultType>: AsyncEventHandler {
private let processClosure: AsyncClosure<EventType, ResultType>
public final class ScopeCancellable: Cancellable {
public typealias ScopeCancellableClosure = Closure<BaseCancellableBag, Cancellable>
public init<Handler: AsyncEventHandler>(handler: Handler)
where Handler.EventType == EventType, Handler.ResultType == ResultType {
private let cancellableBag: BaseCancellableBag
self.processClosure = handler.handle
public init(cancellableBag: BaseCancellableBag = .init(),
scopeCancellableClosure: ScopeCancellableClosure) {
self.cancellableBag = cancellableBag
cancellableBag.add(cancellable: scopeCancellableClosure(cancellableBag))
}
public func handle(_ event: EventType) async -> ResultType {
await processClosure(event)
public func cancel() {
cancellableBag.cancel()
}
}

View File

@ -20,17 +20,18 @@
// THE SOFTWARE.
//
@available(iOS 13.0.0, *)
public protocol AsyncEventHandler {
associatedtype EventType
associatedtype ResultType
public struct WeakTargetCancellable<T: AnyObject>: Cancellable {
public typealias CancelClosure = (T?) -> Void
func handle(_ event: EventType) async -> ResultType
}
private let cancelClosure: CancelClosure
private weak var target: T?
@available(iOS 13.0.0, *)
public extension AsyncEventHandler {
func asAnyAsyncEventHandler() -> AnyAsyncEventHandler<EventType, ResultType> {
.init(handler: self)
public init(target: T?, cancelClosure: @escaping CancelClosure) {
self.target = target
self.cancelClosure = cancelClosure
}
public func cancel() {
cancelClosure(target)
}
}

View File

@ -22,6 +22,7 @@
import Foundation
@available(iOS 11.0, *)
open class UnarchiverKeyValueDecoder: CodableKeyValueDecoder {
public init() {}

View File

@ -22,6 +22,7 @@
import Foundation
@available(iOS 11.0, *)
open class ArchiverKeyValueEncoder: CodableKeyValueEncoder {
public init() {}

View File

@ -105,6 +105,7 @@ public extension KeyedDecodingContainer {
// ISO8601
@available(iOS 11.0, *)
public extension KeyedDecodingContainer {
func decodeDate(forKey key: Key,
userInfo: [CodingUserInfoKey: Any],

View File

@ -1,13 +1,13 @@
Pod::Spec.new do |s|
s.name = 'TIFoundationUtils'
s.version = '1.20.0'
s.version = '1.21.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' }
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.ios.deployment_target = '10.0'
s.swift_versions = ['5.3']
s.source_files = s.name + '/**/Sources/**/*'

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIGoogleMapUtils'
s.version = '1.20.0'
s.version = '1.21.0'
s.summary = 'Set of helpers for map objects clustering and interacting using Google Maps SDK.'
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.20.0'
s.version = '1.21.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

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIMapUtils'
s.version = '1.20.0'
s.version = '1.21.0'
s.summary = 'Set of helpers for map objects clustering and interacting.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }

View File

@ -27,8 +27,8 @@ import TISwiftUtils
import Foundation
import TIFoundationUtils
open class DefaultJsonNetworkService {
public typealias RequestResult<S: Decodable, AE: Decodable> = EndpointRequestResult<S, AE, MoyaError>
open class DefaultJsonNetworkService: ApiInteractor {
public typealias NetworkError = MoyaError
public var session: Session
@ -68,43 +68,11 @@ open class DefaultJsonNetworkService {
defaultServer: openApi.defaultServer)
}
@available(iOS 13.0.0, *)
open func process<B: Encodable, S, F>(request: EndpointRequest<B, S>) async -> RequestResult<S, F> {
await process(request: request,
mapSuccess: Result.success,
mapFailure: { .failure(.apiError($0)) },
mapMoyaError: { .failure(.networkError($0)) })
}
@available(iOS 13.0.0, *)
open func process<B: Encodable, S: Decodable, F: Decodable, R>(request: EndpointRequest<B, S>,
mapSuccess: @escaping Closure<S, R>,
mapFailure: @escaping Closure<F, R>,
mapMoyaError: @escaping Closure<MoyaError, R>) async -> R {
let cancellableBag = CancellableBag()
return await withTaskCancellationHandler(handler: {
cancellableBag.cancel()
}, operation: {
await withCheckedContinuation { continuation in
process(request: request,
mapSuccess: mapSuccess,
mapFailure: mapFailure,
mapMoyaError: mapMoyaError) {
continuation.resume(returning: $0)
}
.add(to: cancellableBag)
}
})
}
open func process<B: Encodable, S: Decodable, F: Decodable, R>(request: EndpointRequest<B, S>,
mapSuccess: @escaping Closure<S, R>,
mapFailure: @escaping Closure<F, R>,
mapMoyaError: @escaping Closure<MoyaError, R>,
completion: @escaping ParameterClosure<R>) -> Cancellable {
mapNetworkError: @escaping Closure<MoyaError, R>,
completion: @escaping ParameterClosure<R>) -> TIFoundationUtils.Cancellable {
ScopeCancellable { [serializationQueue, callbackQueue, preprocessors] scope in
let workItem = DispatchWorkItem {
@ -122,11 +90,11 @@ open class DefaultJsonNetworkService {
scope.add(cancellable: self.process(request: serializedRequest,
mapSuccess: mapSuccess,
mapFailure: mapFailure,
mapMoyaError: mapMoyaError,
mapNetworkError: mapNetworkError,
completion: completion))
} catch {
callbackQueue.async {
completion(mapMoyaError(.encodableMapping(error)))
completion(mapNetworkError(.encodableMapping(error)))
}
}
}
@ -140,8 +108,8 @@ open class DefaultJsonNetworkService {
open func process<S: Decodable, F: Decodable, R>(request: SerializedRequest,
mapSuccess: @escaping Closure<S, R>,
mapFailure: @escaping Closure<F, R>,
mapMoyaError: @escaping Closure<MoyaError, R>,
completion: @escaping ParameterClosure<R>) -> Cancellable {
mapNetworkError: @escaping Closure<MoyaError, R>,
completion: @escaping ParameterClosure<R>) -> TIFoundationUtils.Cancellable {
createProvider().request(request) { [jsonDecoder,
callbackQueue,
@ -185,7 +153,7 @@ open class DefaultJsonNetworkService {
result = model
pluginResult = .success(rawResponse)
case let .failure(moyaError):
result = mapMoyaError(moyaError)
result = mapNetworkError(moyaError)
pluginResult = .failure(moyaError)
}
@ -193,13 +161,14 @@ open class DefaultJsonNetworkService {
$0.didReceive(pluginResult, target: request)
}
case let .failure(moyaError):
result = mapMoyaError(moyaError)
result = mapNetworkError(moyaError)
}
callbackQueue.async {
completion(result)
}
}
.asFoundationUtilsCancellable()
}
public func register<T: RawRepresentable>(securityPreprocessors: [T: SecuritySchemePreprocessor]) where T.RawValue == String {

View File

@ -20,14 +20,10 @@
// THE SOFTWARE.
//
import TINetworking
import Moya
import Foundation
public enum EndpointErrorResult<ApiError, NetworkError>: Error {
case apiError(ApiError)
case networkError(NetworkError)
}
public extension EndpointErrorResult where NetworkError == MoyaError {
var isNetworkConnectionProblem: Bool {
guard case let .networkError(moyaError) = self,

View File

@ -21,14 +21,18 @@
//
import Moya
import TISwiftUtils
import TIFoundationUtils
public final class ScopeCancellable: CancellableBag {
public typealias ScopeCancellableClosure = Closure<ScopeCancellable, Cancellable>
struct MoyaCancellableWrapper: TIFoundationUtils.Cancellable {
let moyaCancellable: Moya.Cancellable
public init(scopeCancellableClosure: ScopeCancellableClosure) {
super.init()
cancellables = [scopeCancellableClosure(self)]
func cancel() {
moyaCancellable.cancel()
}
}
extension Moya.Cancellable {
func asFoundationUtilsCancellable() -> TIFoundationUtils.Cancellable {
MoyaCancellableWrapper(moyaCancellable: self)
}
}

View File

@ -26,55 +26,60 @@ import TISwiftUtils
@available(iOS 13.0.0, *)
open class DefaultRecoverableJsonNetworkService<ApiError: Decodable & Error>: DefaultJsonNetworkService {
public typealias EndpointResponse<S: Decodable> = EndpointRecoverableRequestResult<S, ApiError, MoyaError>
public typealias ErrorType = EndpointErrorResult<ApiError, MoyaError>
public typealias ErrorHandlerResultType = RecoverableErrorHandlerResult<ErrorType>
public typealias ErrorHandlerType = AnyAsyncEventHandler<ErrorType, ErrorHandlerResultType>
public typealias RequestRetrier = AnyEndpointRequestRetrier<ErrorType>
public private(set) var defaultErrorHandlers: [ErrorHandlerType] = []
public private(set) var defaultRequestRetriers: [RequestRetrier] = []
open func process<B: Encodable, S>(recoverableRequest: EndpointRequest<B, S>,
prependErrorHandlers: [ErrorHandlerType] = [],
appendErrorHandlers: [ErrorHandlerType] = []) async ->
RequestResult<S, ApiError> {
prependRequestRetriers: [RequestRetrier] = [],
appendRequestRetriers: [RequestRetrier] = []) async ->
EndpointResponse<S> {
await process(recoverableRequest: recoverableRequest,
errorHandlers: prependErrorHandlers + defaultErrorHandlers + appendErrorHandlers)
errorHandlers: prependRequestRetriers + defaultRequestRetriers + appendRequestRetriers)
}
open func process<B: Encodable, S>(recoverableRequest: EndpointRequest<B, S>,
errorHandlers: [ErrorHandlerType]) async -> RequestResult<S, ApiError> {
errorHandlers: [RequestRetrier]) async -> EndpointResponse<S> {
let result: RequestResult<S, ApiError> = await process(request: recoverableRequest)
if case let .failure(errorResponse) = result {
var failures = [errorResponse]
for handler in errorHandlers {
let handlerResult = await handler.handle(errorResponse)
let handlerResult = await handler.validateAndRepair(errorResults: failures)
switch handlerResult {
case let .forwardError(error):
return .failure(error)
case .recoverRequest:
return await process(recoverableRequest: recoverableRequest, errorHandlers: errorHandlers)
case .skipError:
break
case let .success(retryResult):
switch retryResult {
case .retry, .retryWithDelay:
return await process(recoverableRequest: recoverableRequest, errorHandlers: errorHandlers)
case .doNotRetry, .doNotRetryWithError:
break
}
case let .failure(error):
failures.append(error)
}
}
return .failure(.init(failures: failures))
}
return result
return result.mapError { .init(failures: [$0]) }
}
public func register<ErrorHandler: AsyncErrorHandler>(defaultErrorHandler: ErrorHandler)
where ErrorHandler.EventType == ErrorHandlerType.EventType, ErrorHandler.ResultType == ErrorHandlerType.ResultType {
public func register<RequestRetrier: EndpointRequestRetrier>(defaultRequestRetrier: RequestRetrier)
where RequestRetrier.ErrorResult == ErrorType {
defaultErrorHandlers.append(defaultErrorHandler.asAnyAsyncEventHandler())
defaultRequestRetriers.append(defaultRequestRetrier.asAnyEndpointRequestRetrier())
}
public func set<ErrorHandler: AsyncErrorHandler>(defaultErrorHandlers: ErrorHandler...)
where ErrorHandler.EventType == ErrorHandlerType.EventType, ErrorHandler.ResultType == ErrorHandlerType.ResultType {
public func set<RequestRetrier: EndpointRequestRetrier>(defaultRequestRetriers: RequestRetrier...)
where RequestRetrier.ErrorResult == ErrorType {
self.defaultErrorHandlers = defaultErrorHandlers.map { $0.asAnyAsyncEventHandler() }
self.defaultRequestRetriers = defaultRequestRetriers.map { $0.asAnyEndpointRequestRetrier() }
}
}

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIMoyaNetworking'
s.version = '1.20.0'
s.version = '1.21.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

@ -0,0 +1,48 @@
//
// 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.
//
open class DefaultFingerprintsProvider: FingerprintsProvider {
public var secureStorage: FingerprintsSecureStorage
public var settingsStorage: FingerprintsSettingsStorage
public init(secureStorage: FingerprintsSecureStorage,
settingsStorage: FingerprintsSettingsStorage,
bundledFingerprints: [String: Set<String>]) {
self.secureStorage = secureStorage
self.settingsStorage = settingsStorage
if settingsStorage.shouldResetFingerprints {
self.secureStorage.knownPins = bundledFingerprints
self.settingsStorage.shouldResetFingerprints = false
}
}
public func fingerprints(forHost host: String) -> Set<String> {
secureStorage.knownPins[host] ?? []
}
public func add(fingerprints: [String], forHost host: String) {
let pinsForHost = (secureStorage.knownPins[host] ?? []).union(fingerprints)
secureStorage.knownPins.updateValue(pinsForHost, forKey: host)
}
}

View File

@ -20,14 +20,7 @@
// THE SOFTWARE.
//
import Foundation
public protocol CancellableTask {
func cancel()
public protocol FingerprintsProvider {
func fingerprints(forHost host: String) -> Set<String>
func add(fingerprints: [String], forHost host: String)
}
@available(iOS 13.0, *)
extension Task: CancellableTask {}
extension Operation: CancellableTask {}
extension DispatchWorkItem: CancellableTask {}

View File

@ -20,8 +20,6 @@
// THE SOFTWARE.
//
public enum RecoverableErrorHandlerResult<ErrorType: Error> {
case skipError
case recoverRequest
case forwardError(ErrorType)
public protocol FingerprintsSecureStorage {
var knownPins: [String: Set<String>] { get set }
}

View File

@ -0,0 +1,26 @@
//
// 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 protocol FingerprintsSettingsStorage {
/// Should be true by default (on app first run)
var shouldResetFingerprints: Bool { get set }
}

View File

@ -0,0 +1,68 @@
//
// 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 Alamofire
import Security
import Foundation
import CommonCrypto
open class FingerprintsTrustEvaluator: ServerTrustEvaluating {
public enum PinValidationFailed: Error {
case unableToExtractPin(trust: SecTrust)
case serverPinMissing(knownFingerprints: Set<String>, serverFingerprint: String)
}
private let fingerprintsProvider: FingerprintsProvider
public init(fingerprintsProvider: FingerprintsProvider) {
self.fingerprintsProvider = fingerprintsProvider
}
open func evaluate(_ trust: SecTrust, forHost host: String) throws {
guard SecTrustGetCertificateCount(trust) > 0,
let certificate = SecTrustGetCertificateAtIndex(trust, 0) else {
throw PinValidationFailed.unableToExtractPin(trust: trust)
}
let certificateData = SecCertificateCopyData(certificate) as Data
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
certificateData.withUnsafeBytes {
_ = CC_SHA256($0.baseAddress, CC_LONG(certificateData.count), &hash)
}
let certificateHash = Data(hash)
let certificatePin = certificateHash
.map { String(format: "%02X", $0) }
.joined(separator: ":")
let knownFingerprintsForHost = fingerprintsProvider.fingerprints(forHost: host)
guard knownFingerprintsForHost.contains(certificatePin) else {
throw PinValidationFailed.serverPinMissing(knownFingerprints: knownFingerprintsForHost,
serverFingerprint: certificatePin)
}
}
}

View File

@ -0,0 +1,69 @@
//
// 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 TISwiftUtils
import TIFoundationUtils
public protocol ApiInteractor {
associatedtype NetworkError
typealias RequestResult<S: Decodable, AE: Decodable> = EndpointRequestResult<S, AE, NetworkError>
func process<B: Encodable, S: Decodable, AE: Decodable, R>(request: EndpointRequest<B, S>,
mapSuccess: @escaping Closure<S, R>,
mapFailure: @escaping Closure<AE, R>,
mapNetworkError: @escaping Closure<NetworkError, R>,
completion: @escaping ParameterClosure<R>) -> Cancellable
}
@available(iOS 13.0.0, *)
public extension ApiInteractor {
func process<B: Encodable, S, F>(request: EndpointRequest<B, S>) async -> RequestResult<S, F> {
await process(request: request,
mapSuccess: Result.success,
mapFailure: { .failure(.apiError($0)) },
mapNetworkError: { .failure(.networkError($0)) })
}
func process<B: Encodable, S: Decodable, F: Decodable, R>(request: EndpointRequest<B, S>,
mapSuccess: @escaping Closure<S, R>,
mapFailure: @escaping Closure<F, R>,
mapNetworkError: @escaping Closure<NetworkError, R>) async -> R {
let cancellableBag = BaseCancellableBag()
return await withTaskCancellationHandler(handler: {
cancellableBag.cancel()
}, operation: {
await withCheckedContinuation { continuation in
process(request: request,
mapSuccess: mapSuccess,
mapFailure: mapFailure,
mapNetworkError: mapNetworkError) {
continuation.resume(returning: $0)
}
.add(to: cancellableBag)
}
})
}
}

View File

@ -20,8 +20,7 @@
// THE SOFTWARE.
//
@available(iOS 13.0.0, *)
public protocol AsyncErrorHandler: AsyncEventHandler where EventType: Error {}
@available(iOS 13.0.0, *)
extension AnyAsyncEventHandler: AsyncErrorHandler where EventType: Error {}
public enum EndpointErrorResult<ApiError, NetworkError>: Error {
case apiError(ApiError)
case networkError(NetworkError)
}

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 EndpointRecoverableRequestResult<S: Decodable, AE: Decodable, NE> = Result<S, ErrorCollection<EndpointErrorResult<AE, NE>>>

View File

@ -0,0 +1,41 @@
//
// 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 struct ErrorCollection<E>: Error {
public let failures: [E]
public init(failures: [E]) {
self.failures = failures
}
public func map<R>(transform: (E) -> R) -> ErrorCollection<R> {
.init(failures: failures.map(transform))
}
public func firstOr(_ default: E) -> E {
failures.first ?? `default`
}
public func lastOr(_ default: E) -> E {
failures.last ?? `default`
}
}

View File

@ -0,0 +1,44 @@
//
// 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 Alamofire
import TIFoundationUtils
public struct AnyEndpointRequestRetrier<ErrorResult: Error>: EndpointRequestRetrier {
typealias ValidateAndRepairClosure = ([ErrorResult], @escaping (Result<RetryResult, ErrorResult>) -> Void) -> Cancellable
private let validateAndRepairClosure: ValidateAndRepairClosure
public init<RR: EndpointRequestRetrier>(retrier: RR) where RR.ErrorResult == ErrorResult {
self.validateAndRepairClosure = retrier.validateAndRepair
}
public func validateAndRepair(errorResults: [ErrorResult], completion: @escaping (EndpointRetryResult) -> Void) -> Cancellable {
validateAndRepairClosure(errorResults, completion)
}
}
public extension EndpointRequestRetrier {
func asAnyEndpointRequestRetrier() -> AnyEndpointRequestRetrier<ErrorResult> {
.init(retrier: self)
}
}

View File

@ -0,0 +1,123 @@
//
// 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 Alamofire
import Foundation
import TIFoundationUtils
open class DefaultTokenInterceptor<RefreshError: Error>: RequestInterceptor {
public typealias IsTokenInvalidClosure = (HTTPURLResponse?, Error?) -> Bool
public typealias RefreshTokenClosure = (@escaping (RefreshError?) -> Void) -> Cancellable
public typealias RequestModificationClosure = (URLRequest) -> URLRequest
let refreshLock = NSLock()
let isTokenInvalidClosure: IsTokenInvalidClosure
let refreshTokenClosure: RefreshTokenClosure
public var defaultRetryStrategy: RetryResult = .doNotRetry
public var requestModificationClosure: RequestModificationClosure?
public init(isTokenInvalidClosure: @escaping IsTokenInvalidClosure,
refreshTokenClosure: @escaping RefreshTokenClosure,
requestModificationClosure: RequestModificationClosure? = nil) {
self.isTokenInvalidClosure = isTokenInvalidClosure
self.refreshTokenClosure = refreshTokenClosure
self.requestModificationClosure = requestModificationClosure
}
// MARK: - RequestAdapter
open func adapt(_ urlRequest: URLRequest,
for session: Session,
completion: @escaping (Result<URLRequest, Error>) -> Void) {
let adaptBag = BaseCancellableBag()
let adaptCompletion: (Result<URLRequest, RefreshError>) -> Void = {
adaptBag.cancellables.removeAll()
completion($0.mapError { $0 as Error })
}
let modifiedRequest = requestModificationClosure?(urlRequest) ?? urlRequest
validateAndRepair(validationClosure: { true },
completion: adaptCompletion,
defaultCompletionResult: modifiedRequest,
recoveredCompletionResult: modifiedRequest)
.add(to: adaptBag)
}
// MARK: - RequestRetrier
open func retry(_ request: Request,
for session: Session,
dueTo error: Error,
completion: @escaping (RetryResult) -> Void) {
let retryBag = BaseCancellableBag()
let retryCompletion: (Result<RetryResult, RefreshError>) -> Void = {
retryBag.cancellables.removeAll()
switch $0 {
case let .success(retryResult):
completion(retryResult)
case let .failure(refreshError):
completion(.doNotRetryWithError(refreshError))
}
}
validateAndRepair(validationClosure: { isTokenInvalidClosure(request.response, error) },
completion: retryCompletion,
defaultCompletionResult: defaultRetryStrategy,
recoveredCompletionResult: .retry)
.add(to: retryBag)
}
open func validateAndRepair<T>(validationClosure: () -> Bool,
completion: @escaping (Result<T, RefreshError>) -> Void,
defaultCompletionResult: T,
recoveredCompletionResult: T) -> Cancellable {
refreshLock.lock()
if validationClosure() {
return refreshTokenClosure { [refreshLock] in
refreshLock.unlock()
if let error = $0 {
completion(.failure(error))
} else {
completion(.success(recoveredCompletionResult))
}
}
} else {
refreshLock.unlock()
completion(.success(defaultCompletionResult))
return Cancellables.nonCancellable()
}
}
}

View File

@ -0,0 +1,51 @@
//
// 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 Alamofire
import TIFoundationUtils
public protocol EndpointRequestRetrier {
associatedtype ErrorResult: Error
typealias EndpointRetryResult = Result<RetryResult, ErrorResult>
func validateAndRepair(errorResults: [ErrorResult],
completion: @escaping (EndpointRetryResult) -> Void) -> Cancellable
}
@available(iOS 13.0.0, *)
public extension EndpointRequestRetrier {
func validateAndRepair(errorResults: [ErrorResult]) async -> EndpointRetryResult {
let cancellableBag = BaseCancellableBag()
return await withTaskCancellationHandler(handler: {
cancellableBag.cancel()
}, operation: {
await withCheckedContinuation { continuation in
validateAndRepair(errorResults: errorResults) {
continuation.resume(returning: $0)
}
.add(to: cancellableBag)
}
})
}
}

View File

@ -0,0 +1,60 @@
//
// 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 Alamofire
import TIFoundationUtils
open class EndpointResponseTokenInterceptor<AE, NE>: DefaultTokenInterceptor<EndpointErrorResult<AE, NE>>, EndpointRequestRetrier {
public typealias IsTokenInvalidErrorResultClosure = (EndpointErrorResult<AE, NE>) -> Bool
public typealias RepairResult = Result<RetryResult, EndpointErrorResult<AE, NE>>
private let isTokenInvalidErrorResultClosure: IsTokenInvalidErrorResultClosure
public init(isTokenInvalidClosure: @escaping IsTokenInvalidClosure,
refreshTokenClosure: @escaping RefreshTokenClosure,
isTokenInvalidErrorResultClosure: @escaping IsTokenInvalidErrorResultClosure,
requestModificationClosure: RequestModificationClosure? = nil) {
self.isTokenInvalidErrorResultClosure = isTokenInvalidErrorResultClosure
super.init(isTokenInvalidClosure: isTokenInvalidClosure,
refreshTokenClosure: refreshTokenClosure,
requestModificationClosure: requestModificationClosure)
}
// MARK: - EndpointRequestRetrier
public func validateAndRepair(errorResults: [EndpointErrorResult<AE, NE>],
completion: @escaping (RepairResult) -> Void) -> Cancellable {
guard let firstErrorResult = errorResults.first else {
completion(.success(.doNotRetry))
return Cancellables.nonCancellable()
}
return validateAndRepair(validationClosure: { isTokenInvalidErrorResultClosure(firstErrorResult) },
completion: completion,
defaultCompletionResult: defaultRetryStrategy,
recoveredCompletionResult: .retry)
}
}

View File

@ -20,8 +20,6 @@
// THE SOFTWARE.
//
import TINetworking
open class DefaultSecuritySchemePreprocessor: SecuritySchemePreprocessor {
struct ValueNotProvidedError: Error {}

View File

@ -20,8 +20,6 @@
// THE SOFTWARE.
//
import TINetworking
open class DefaultEndpointSecurityPreprocessor: EndpointRequestPreprocessor {
enum PreprocessError: Error {
case missingSecurityScheme(String, [String: SecurityScheme])

View File

@ -20,8 +20,6 @@
// THE SOFTWARE.
//
import TINetworking
public protocol EndpointRequestPreprocessor {
func preprocess<B,S>(request: EndpointRequest<B,S>) throws -> EndpointRequest<B,S>
}

View File

@ -20,8 +20,6 @@
// THE SOFTWARE.
//
import TINetworking
public protocol SecuritySchemePreprocessor {
func preprocess<B,S>(request: EndpointRequest<B,S>, using security: SecurityScheme) throws -> EndpointRequest<B,S>
}

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TINetworking'
s.version = '1.20.0'
s.version = '1.21.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' }
@ -12,7 +12,6 @@ Pod::Spec.new do |s|
s.source_files = s.name + '/Sources/**/*'
s.dependency 'TISwiftUtils', s.version.to_s
s.dependency 'TIFoundationUtils', s.version.to_s
s.dependency 'Alamofire', "~> 5.4"
end

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TINetworkingCache'
s.version = '1.20.0'
s.version = '1.21.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' }

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIPagination'
s.version = '1.20.0'
s.version = '1.21.0'
s.summary = 'Generic pagination component.'
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 = 'TISwiftUICore'
s.version = '1.20.0'
s.version = '1.21.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

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TISwiftUtils'
s.version = '1.20.0'
s.version = '1.21.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

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TITableKitUtils'
s.version = '1.20.0'
s.version = '1.21.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.20.0'
s.version = '1.21.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.20.0'
s.version = '1.21.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.20.0'
s.version = '1.21.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

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TIYandexMapUtils'
s.version = '1.20.0'
s.version = '1.21.0'
s.summary = 'Set of helpers for map objects clustering and interacting using Yandex Maps SDK.'
s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name
s.license = { :type => 'MIT', :file => 'LICENSE' }