add TINetworking

This commit is contained in:
Ivan Smolin 2021-07-01 22:30:41 +03:00
parent 67488af9c6
commit fb0e1090e5
30 changed files with 520 additions and 3 deletions

View File

@ -1,6 +1,15 @@
{
"object": {
"pins": [
{
"package": "Alamofire",
"repositoryURL": "https://github.com/Alamofire/Alamofire.git",
"state": {
"branch": null,
"revision": "f96b619bcb2383b43d898402283924b80e2c4bae",
"version": "5.4.3"
}
},
{
"package": "Cursors",
"repositoryURL": "https://github.com/petropavel13/Cursors",

View File

@ -17,6 +17,7 @@ let package = Package(
.library(name: "TIFoundationUtils", targets: ["TIFoundationUtils"]),
.library(name: "TIKeychainUtils", targets: ["TIKeychainUtils"]),
.library(name: "TITableKitUtils", targets: ["TITableKitUtils"]),
.library(name: "TINetworking", targets: ["TINetworking"]),
// MARK: - Elements
.library(name: "OTPSwiftView", targets: ["OTPSwiftView"]),
@ -24,9 +25,10 @@ let package = Package(
.library(name: "TIPagination", targets: ["TIPagination"]),
],
dependencies: [
.package(url: "https://github.com/maxsokolov/TableKit.git", from: "2.11.0"),
.package(url: "https://github.com/kishikawakatsumi/KeychainAccess.git", from: "4.2.2"),
.package(url: "https://github.com/petropavel13/Cursors", from: "0.5.1")
.package(url: "https://github.com/maxsokolov/TableKit.git", .upToNextMajor(from: "2.11.0")),
.package(url: "https://github.com/kishikawakatsumi/KeychainAccess.git", .upToNextMajor(from: "4.2.2")),
.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")),
],
targets: [
@ -39,6 +41,7 @@ let package = Package(
.target(name: "TIFoundationUtils", dependencies: ["TISwiftUtils"], path: "TIFoundationUtils/Sources"),
.target(name: "TIKeychainUtils", dependencies: ["TIFoundationUtils", "KeychainAccess"], path: "TIKeychainUtils/Sources"),
.target(name: "TITableKitUtils", dependencies: ["TIUIElements", "TableKit"], path: "TITableKitUtils/Sources"),
.target(name: "TINetworking", dependencies: ["Alamofire"], path: "TINetworking/Sources"),
// MARK: - Elements

View File

@ -0,0 +1,25 @@
import Foundation
open class ApplicationJsonBodyContent<Body>: BodyContent {
public var mediaTypeName: String {
MediaType.applicationJson.rawValue
}
private let encodingClosure: () throws -> Data
public init(body: Body, jsonEncoder: JSONEncoder = JSONEncoder()) where Body: Encodable {
self.encodingClosure = {
try jsonEncoder.encode(body)
}
}
public init(jsonBody: Body, options: JSONSerialization.WritingOptions = .prettyPrinted) {
self.encodingClosure = {
try JSONSerialization.data(withJSONObject: jsonBody, options: options)
}
}
public func encodeBody() throws -> Data {
try encodingClosure()
}
}

View File

@ -0,0 +1,5 @@
import Foundation
public protocol BodyContent: Content {
func encodeBody() throws -> Data
}

View File

@ -0,0 +1,13 @@
import Foundation
public struct EmptyBodyContent: BodyContent {
public var mediaTypeName: String {
MediaType.textPlain.rawValue
}
public init() {}
public func encodeBody() throws -> Data {
Data()
}
}

View File

@ -0,0 +1,21 @@
public extension KeyedDecodingContainer {
func decode<T: Decodable>(_ type: T?.Type,
forKey key: Key,
required: Bool) throws -> T? {
required ? try decode(type, forKey: key) : try decodeIfPresent(T.self, forKey: key)
}
}
public extension KeyedEncodingContainer {
mutating func encode<T: Encodable>(_ value: T?,
forKey key: Key,
required: Bool) throws {
if let value = value {
try encode(value, forKey: key)
} else if required {
try encodeNil(forKey: key)
}
}
}

View File

@ -0,0 +1,3 @@
public protocol Content {
var mediaTypeName: String { get }
}

View File

@ -0,0 +1,4 @@
public enum MediaType: String {
case applicationJson = "application/json"
case textPlain = "text/plain"
}

View File

@ -0,0 +1,22 @@
public struct AnyTypeMapping<R> {
public let type: Any.Type
private let mappingClosure: () -> Result<R, Error>
public init<T: Decodable>(decoder: Decoder,
transform: @escaping (T) -> R) {
type = T.self
mappingClosure = {
do {
return .success(transform(try T(from: decoder)))
} catch {
return .failure(error)
}
}
}
public func decode() -> Result<R, Error> {
mappingClosure()
}
}

View File

@ -0,0 +1,21 @@
public struct OneOfMappingError: Error, CustomDebugStringConvertible {
public typealias MappingFailures = [KeyValueTuple<Any.Type, Error>]
public let codingPath: [CodingKey]
public let mappingFailures: MappingFailures
public init(codingPath: [CodingKey], mappingFailures: MappingFailures) {
self.codingPath = codingPath
self.mappingFailures = mappingFailures
}
public var debugDescription: String {
var formattedString = "OneOf mapping failed for codingPath \(codingPath)\nwith following errors:\n"
for (type, error) in mappingFailures {
formattedString += "\(type) mapping failed with error: \(error)\n"
}
return formattedString
}
}

View File

@ -0,0 +1,17 @@
import Foundation
open class ApplicationJsonResponseContent<Model: Decodable>: ResponseContent {
public var mediaTypeName: String {
MediaType.applicationJson.rawValue
}
public let jsonDecoder: JSONDecoder
public init(jsonDecoder: JSONDecoder = JSONDecoder()) {
self.jsonDecoder = jsonDecoder
}
public func decodeResponse(data: Data) throws -> Model {
try jsonDecoder.decode(Model.self, from: data)
}
}

View File

@ -0,0 +1,18 @@
import Foundation
public struct MapResponseContent<Model>: ResponseContent {
public let mediaTypeName: String
private let decodeClosure: (Data) throws -> Model
public init<C: ResponseContent>(responseContent: C, transform: @escaping (C.Model) -> Model) {
self.mediaTypeName = responseContent.mediaTypeName
self.decodeClosure = {
transform(try responseContent.decodeResponse(data: $0))
}
}
public func decodeResponse(data: Data) throws -> Model {
try decodeClosure(data)
}
}

View File

@ -0,0 +1,7 @@
import Foundation
public protocol ResponseContent: Content {
associatedtype Model
func decodeResponse(data: Data) throws -> Model
}

View File

@ -0,0 +1,24 @@
import Foundation
public struct TextPlainResponseContent: ResponseContent {
struct UnableToDecodeStringError: Error {
let data: Data
let encoding: String.Encoding
}
private let encoding: String.Encoding
public init(encoding: String.Encoding = .utf8) {
self.encoding = encoding
}
public let mediaTypeName = MediaType.textPlain.rawValue
public func decodeResponse(data: Data) throws -> String {
guard let plainText = String(data: data, encoding: encoding) else {
throw UnableToDecodeStringError(data: data, encoding: encoding)
}
return plainText
}
}

View File

@ -0,0 +1,30 @@
import Alamofire
open class BaseUrlParameterEncoding {
private let encoding: URLEncoding = .queryString
public init() {
}
open func encode<L: ParameterLocation>(parameters: [String: Parameter<L>]) -> [KeyValueTuple<String, String>] {
var filteredComponents: [(String, String)] = []
for key in parameters.keys.sorted(by: <) {
guard let parameter = parameters[key] else {
continue
}
let components: [KeyValueTuple<String, String>] = encoding.queryComponents(fromKey: key, value: parameter.value)
for component in components {
if component.value.isEmpty && !parameter.allowEmptyValue {
continue
}
filteredComponents.append(component)
}
}
return filteredComponents
}
}

View File

@ -0,0 +1,13 @@
open class HeadersParameterEncoding: BaseUrlParameterEncoding, ParameterEncoding {
public let sequenceSeparator: String
public init(sequenceSeparator: String = ";") {
self.sequenceSeparator = sequenceSeparator
}
open func encode(parameters: [String: Parameter<LocationHeader>]) -> [String: String] {
Dictionary(encode(parameters: parameters)) {
$0 + sequenceSeparator + $1
}
}
}

View File

@ -0,0 +1,11 @@
open class PathParameterEncoding: BaseUrlParameterEncoding, ParameterEncoding {
public let templateUrl: String
public init(templateUrl: String) {
self.templateUrl = templateUrl
}
open func encode(parameters: [String: Parameter<LocationPath>]) -> String {
.render(template: templateUrl, using: encode(parameters: parameters))
}
}

View File

@ -0,0 +1,9 @@
open class QueryStringParameterEncoding: BaseUrlParameterEncoding, ParameterEncoding {
open func encode(parameters: [String: Parameter<LocationQuery>]) -> [String: Any] {
let includedKeys = Set(super.encode(parameters: parameters).map { $0.key })
return parameters
.filter { includedKeys.contains($0.key) }
.mapValues { $0.value }
}
}

View File

@ -0,0 +1,9 @@
public struct Parameter<Location: ParameterLocation> {
public let value: Any
public let allowEmptyValue: Bool
public init(value: Any, allowEmptyValue: Bool = false) {
self.value = value
self.allowEmptyValue = allowEmptyValue
}
}

View File

@ -0,0 +1,6 @@
protocol ParameterEncoding {
associatedtype Location: ParameterLocation
associatedtype Result
func encode(parameters: [String: Parameter<Location>]) -> Result
}

View File

@ -0,0 +1,6 @@
public protocol ParameterLocation {}
public struct LocationQuery: ParameterLocation {}
public struct LocationPath: ParameterLocation {}
public struct LocationHeader: ParameterLocation {}
public struct LocationCookie: ParameterLocation {}

View File

@ -0,0 +1,42 @@
import Alamofire
public struct Request<Content: BodyContent> {
public var templatePath: String
public var method: HTTPMethod
public var requestBodyContent: Content
public var queryParameters: [String: Parameter<LocationQuery>]
public var pathParameters: [String: Parameter<LocationPath>]
public var headerParameters: [String: Parameter<LocationHeader>]
public var cookieParameters: [String: Parameter<LocationCookie>]
public var acceptableStatusCodes: Set<Int>
public var serverOverride: Server?
public var serverVariablesOverride: [KeyValueTuple<String, String>]
public var path: String {
PathParameterEncoding(templateUrl: templatePath).encode(parameters: pathParameters)
}
public init(templatePath: String,
method: HTTPMethod,
requestBodyContent: Content,
queryParameters: [String: Parameter<LocationQuery>] = [:],
pathParameters: [String: Parameter<LocationPath>] = [:],
headerParameters: [String: Parameter<LocationHeader>] = [:],
cookieParameters: [String: Parameter<LocationCookie>] = [:],
acceptableStatusCodes: Set<Int> = [200],
serverOverride: Server? = nil,
serverVariablesOverride: [KeyValueTuple<String, String>] = []) {
self.templatePath = templatePath
self.method = method
self.requestBodyContent = requestBodyContent
self.queryParameters = queryParameters
self.pathParameters = pathParameters
self.headerParameters = headerParameters
self.cookieParameters = cookieParameters
self.acceptableStatusCodes = acceptableStatusCodes
self.serverOverride = serverOverride
self.serverVariablesOverride = serverVariablesOverride
}
}

View File

@ -0,0 +1,12 @@
open class MimeTypeUnsupportedError: Error, CustomDebugStringConvertible {
public let mimeType: String?
public init(mimeType: String?) {
self.mimeType = mimeType
}
public var debugDescription: String {
"Mime type \(mimeType.debugDescription) isn't supported."
}
}

View File

@ -0,0 +1,45 @@
public struct ResponseTypeDecodingError: Error {
public let statusCode: Int
public let contentType: String
}
public struct ContentMapping<Content: ResponseContent> {
public let statusCode: Int
public let mimeType: String?
public let responseContent: Content
public init(statusCode: Int, mimeType: String?, responseContent: Content) {
self.statusCode = statusCode
self.mimeType = mimeType
self.responseContent = responseContent
}
public func map<NewModel>(transform: @escaping (Content.Model) -> NewModel) -> ContentMapping<MapResponseContent<NewModel>> {
.init(statusCode: statusCode,
mimeType: mimeType,
responseContent: MapResponseContent(responseContent: responseContent,
transform: transform))
}
}
public extension ResponseType {
func decode<C>(contentMapping: [ContentMapping<C>]) -> Result<C.Model, ErrorType> {
for mapping in contentMapping where mapping.statusCode == statusCode && mapping.mimeType == mimeType {
do {
return .success(try mapping.responseContent.decodeResponse(data: data))
} catch {
return .failure(objectMappingError(underlyingError: error))
}
}
guard contentMapping.contains(where: { $0.statusCode == statusCode }) else {
return .failure(unsupportedStatusCodeError(statusCode: statusCode))
}
guard contentMapping.contains(where: { $0.mimeType == mimeType }) else {
return .failure(unsupportedMimeTypeError(mimeType: mimeType))
}
return .failure(unsupportedStatusCodeMimeTypePairError(statusCode: statusCode, mimeType: mimeType))
}
}

View File

@ -0,0 +1,14 @@
import Foundation
public protocol ResponseType {
associatedtype ErrorType: Error
var statusCode: Int { get }
var data: Data { get }
var mimeType: String? { get }
func unsupportedStatusCodeError(statusCode: Int) -> ErrorType
func unsupportedMimeTypeError(mimeType: String?) -> ErrorType
func objectMappingError(underlyingError: Error) -> ErrorType
func unsupportedStatusCodeMimeTypePairError(statusCode: Int, mimeType: String?) -> ErrorType
}

View File

@ -0,0 +1,14 @@
open class StatusCodeMimeTypePairUnsupportedError: MimeTypeUnsupportedError {
public let statusCode: Int
public init(statusCode: Int, mimeType: String?) {
self.statusCode = statusCode
super.init(mimeType: mimeType)
}
public override var debugDescription: String {
"Status code: \(statusCode), mimeType: \(mimeType ?? "nil") pair is unsupported!"
}
}

View File

@ -0,0 +1,59 @@
import Foundation
private enum Scheme: String {
case https
var urlPrefix: String {
rawValue + "://"
}
}
public struct Server {
public struct Variable {
public let values: [String]
public let defaultValue: String
public init(values: [String], defaultValue: String) {
self.values = values
self.defaultValue = defaultValue
}
}
public let urlTemplate: String
public let variables: [String: Variable]
public init(urlTemplate: String, variables: [String: Variable]) {
self.urlTemplate = urlTemplate
self.variables = variables
}
public init(baseUrl: String) {
self.init(urlTemplate: baseUrl, variables: [:])
}
private var defaultVariables: [KeyValueTuple<String, String>] {
variables.map { ($0.key, $0.value.defaultValue) }
}
public func url(using variables: [KeyValueTuple<String, String>] = [],
appendHttpsSchemeIfMissing: Bool = true) -> URL? {
guard !variables.isEmpty else {
return URL(string: .render(template: urlTemplate, using: defaultVariables))
}
let defaultVariablesToApply = self.defaultVariables
.filter { (key, _) in variables.contains { $0.key == key } }
let defaultParametersTemplate = String.render(template: urlTemplate,
using: defaultVariablesToApply)
let formattedUrlString = String.render(template: defaultParametersTemplate, using: variables)
if appendHttpsSchemeIfMissing, !formattedUrlString.contains(Scheme.https.urlPrefix) {
return URL(string: Scheme.https.urlPrefix + formattedUrlString)
}
return URL(string: formattedUrlString)
}
}

View File

@ -0,0 +1,37 @@
import Foundation
import Alamofire
open class SessionFactory {
/// Timeout interval for requests.
public var timeoutInterval: TimeInterval
/// A dictionary of additional headers to send with requests.
public var additionalHttpHeaders: HTTPHeaders
/// Server trust policies.
public var serverTrustPolicies: [String: ServerTrustEvaluating]
public init(timeoutInterval: TimeInterval = 20,
additionalHttpHeaders: HTTPHeaders = HTTPHeaders(),
trustPolicies: [String: ServerTrustEvaluating] = [:]) {
self.timeoutInterval = timeoutInterval
self.additionalHttpHeaders = additionalHttpHeaders
self.serverTrustPolicies = Dictionary(uniqueKeysWithValues: trustPolicies.map { ($0.key.urlHost, $0.value) })
}
open func createSession() -> Session {
Session(configuration: createSessionConfiguration(),
serverTrustManager: ServerTrustManager(allHostsMustBeEvaluated: false,
evaluators: serverTrustPolicies))
}
open func createSessionConfiguration() -> URLSessionConfiguration {
let sessionConfiguration = URLSessionConfiguration.default
sessionConfiguration.timeoutIntervalForResource = timeoutInterval
sessionConfiguration.httpAdditionalHeaders = additionalHttpHeaders.dictionary
return sessionConfiguration
}
}

View File

@ -0,0 +1,17 @@
import Foundation
public extension String {
var urlEscaped: String {
return addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? self
}
var urlHost: String {
URL(string: self)?.host ?? self
}
static func render(template: String, using variables: [KeyValueTuple<String, String>]) -> String {
variables.reduce(template) {
$0.replacingOccurrences(of: "{\($1.key)}", with: $1.value.urlEscaped)
}
}
}

View File

@ -0,0 +1 @@
public typealias KeyValueTuple<K, V> = (key: K, value: V)