diff --git a/Package.resolved b/Package.resolved index 3dfc4c9c..94b92e20 100644 --- a/Package.resolved +++ b/Package.resolved @@ -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", diff --git a/Package.swift b/Package.swift index cc2dca77..f4763d53 100644 --- a/Package.swift +++ b/Package.swift @@ -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 diff --git a/TINetworking/Sources/Mapping/BodyContent/ApplicationJsonBodyContent.swift b/TINetworking/Sources/Mapping/BodyContent/ApplicationJsonBodyContent.swift new file mode 100644 index 00000000..316b3113 --- /dev/null +++ b/TINetworking/Sources/Mapping/BodyContent/ApplicationJsonBodyContent.swift @@ -0,0 +1,25 @@ +import Foundation + +open class ApplicationJsonBodyContent: 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() + } +} diff --git a/TINetworking/Sources/Mapping/BodyContent/BodyContent.swift b/TINetworking/Sources/Mapping/BodyContent/BodyContent.swift new file mode 100644 index 00000000..58093c6c --- /dev/null +++ b/TINetworking/Sources/Mapping/BodyContent/BodyContent.swift @@ -0,0 +1,5 @@ +import Foundation + +public protocol BodyContent: Content { + func encodeBody() throws -> Data +} diff --git a/TINetworking/Sources/Mapping/BodyContent/EmptyBodyContent.swift b/TINetworking/Sources/Mapping/BodyContent/EmptyBodyContent.swift new file mode 100644 index 00000000..12d1579f --- /dev/null +++ b/TINetworking/Sources/Mapping/BodyContent/EmptyBodyContent.swift @@ -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() + } +} diff --git a/TINetworking/Sources/Mapping/Codable+Required.swift b/TINetworking/Sources/Mapping/Codable+Required.swift new file mode 100644 index 00000000..8cceb3b6 --- /dev/null +++ b/TINetworking/Sources/Mapping/Codable+Required.swift @@ -0,0 +1,21 @@ +public extension KeyedDecodingContainer { + func decode(_ 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(_ value: T?, + forKey key: Key, + required: Bool) throws { + + if let value = value { + try encode(value, forKey: key) + } else if required { + try encodeNil(forKey: key) + } + } +} diff --git a/TINetworking/Sources/Mapping/Content.swift b/TINetworking/Sources/Mapping/Content.swift new file mode 100644 index 00000000..41b26a9b --- /dev/null +++ b/TINetworking/Sources/Mapping/Content.swift @@ -0,0 +1,3 @@ +public protocol Content { + var mediaTypeName: String { get } +} diff --git a/TINetworking/Sources/Mapping/MediaType.swift b/TINetworking/Sources/Mapping/MediaType.swift new file mode 100644 index 00000000..387a36f1 --- /dev/null +++ b/TINetworking/Sources/Mapping/MediaType.swift @@ -0,0 +1,4 @@ +public enum MediaType: String { + case applicationJson = "application/json" + case textPlain = "text/plain" +} diff --git a/TINetworking/Sources/Mapping/OneOfMapping/AnyTypeMapping.swift b/TINetworking/Sources/Mapping/OneOfMapping/AnyTypeMapping.swift new file mode 100644 index 00000000..b1b97cb2 --- /dev/null +++ b/TINetworking/Sources/Mapping/OneOfMapping/AnyTypeMapping.swift @@ -0,0 +1,22 @@ +public struct AnyTypeMapping { + public let type: Any.Type + private let mappingClosure: () -> Result + + public init(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 { + mappingClosure() + } +} diff --git a/TINetworking/Sources/Mapping/OneOfMapping/OneOfMappingError.swift b/TINetworking/Sources/Mapping/OneOfMapping/OneOfMappingError.swift new file mode 100644 index 00000000..c7c025b8 --- /dev/null +++ b/TINetworking/Sources/Mapping/OneOfMapping/OneOfMappingError.swift @@ -0,0 +1,21 @@ +public struct OneOfMappingError: Error, CustomDebugStringConvertible { + public typealias MappingFailures = [KeyValueTuple] + + 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 + } +} diff --git a/TINetworking/Sources/Mapping/ResponseContent/ApplicationJsonResponseContent.swift b/TINetworking/Sources/Mapping/ResponseContent/ApplicationJsonResponseContent.swift new file mode 100644 index 00000000..498ea08b --- /dev/null +++ b/TINetworking/Sources/Mapping/ResponseContent/ApplicationJsonResponseContent.swift @@ -0,0 +1,17 @@ +import Foundation + +open class ApplicationJsonResponseContent: 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) + } +} diff --git a/TINetworking/Sources/Mapping/ResponseContent/MapResponseContent.swift b/TINetworking/Sources/Mapping/ResponseContent/MapResponseContent.swift new file mode 100644 index 00000000..ebcca1c0 --- /dev/null +++ b/TINetworking/Sources/Mapping/ResponseContent/MapResponseContent.swift @@ -0,0 +1,18 @@ +import Foundation + +public struct MapResponseContent: ResponseContent { + public let mediaTypeName: String + + private let decodeClosure: (Data) throws -> Model + + public init(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) + } +} diff --git a/TINetworking/Sources/Mapping/ResponseContent/ResponseContent.swift b/TINetworking/Sources/Mapping/ResponseContent/ResponseContent.swift new file mode 100644 index 00000000..3d600f79 --- /dev/null +++ b/TINetworking/Sources/Mapping/ResponseContent/ResponseContent.swift @@ -0,0 +1,7 @@ +import Foundation + +public protocol ResponseContent: Content { + associatedtype Model + + func decodeResponse(data: Data) throws -> Model +} diff --git a/TINetworking/Sources/Mapping/ResponseContent/TextPlainResponseContent.swift b/TINetworking/Sources/Mapping/ResponseContent/TextPlainResponseContent.swift new file mode 100644 index 00000000..ebfa6c73 --- /dev/null +++ b/TINetworking/Sources/Mapping/ResponseContent/TextPlainResponseContent.swift @@ -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 + } +} diff --git a/TINetworking/Sources/Parameters/Encoding/BaseUrlParameterEncoding.swift b/TINetworking/Sources/Parameters/Encoding/BaseUrlParameterEncoding.swift new file mode 100644 index 00000000..85b1c119 --- /dev/null +++ b/TINetworking/Sources/Parameters/Encoding/BaseUrlParameterEncoding.swift @@ -0,0 +1,30 @@ +import Alamofire + +open class BaseUrlParameterEncoding { + private let encoding: URLEncoding = .queryString + + public init() { + } + + open func encode(parameters: [String: Parameter]) -> [KeyValueTuple] { + var filteredComponents: [(String, String)] = [] + + for key in parameters.keys.sorted(by: <) { + guard let parameter = parameters[key] else { + continue + } + + let components: [KeyValueTuple] = encoding.queryComponents(fromKey: key, value: parameter.value) + + for component in components { + if component.value.isEmpty && !parameter.allowEmptyValue { + continue + } + + filteredComponents.append(component) + } + } + + return filteredComponents + } +} diff --git a/TINetworking/Sources/Parameters/Encoding/HeadersParameterEncoding.swift b/TINetworking/Sources/Parameters/Encoding/HeadersParameterEncoding.swift new file mode 100644 index 00000000..7c4f058a --- /dev/null +++ b/TINetworking/Sources/Parameters/Encoding/HeadersParameterEncoding.swift @@ -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]) -> [String: String] { + Dictionary(encode(parameters: parameters)) { + $0 + sequenceSeparator + $1 + } + } +} diff --git a/TINetworking/Sources/Parameters/Encoding/PathParameterEncoding.swift b/TINetworking/Sources/Parameters/Encoding/PathParameterEncoding.swift new file mode 100644 index 00000000..f9501c5f --- /dev/null +++ b/TINetworking/Sources/Parameters/Encoding/PathParameterEncoding.swift @@ -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]) -> String { + .render(template: templateUrl, using: encode(parameters: parameters)) + } +} diff --git a/TINetworking/Sources/Parameters/Encoding/QueryStringParameterEncoding.swift b/TINetworking/Sources/Parameters/Encoding/QueryStringParameterEncoding.swift new file mode 100644 index 00000000..bef188bb --- /dev/null +++ b/TINetworking/Sources/Parameters/Encoding/QueryStringParameterEncoding.swift @@ -0,0 +1,9 @@ +open class QueryStringParameterEncoding: BaseUrlParameterEncoding, ParameterEncoding { + open func encode(parameters: [String: Parameter]) -> [String: Any] { + let includedKeys = Set(super.encode(parameters: parameters).map { $0.key }) + + return parameters + .filter { includedKeys.contains($0.key) } + .mapValues { $0.value } + } +} diff --git a/TINetworking/Sources/Parameters/Parameter.swift b/TINetworking/Sources/Parameters/Parameter.swift new file mode 100644 index 00000000..5158c7dd --- /dev/null +++ b/TINetworking/Sources/Parameters/Parameter.swift @@ -0,0 +1,9 @@ +public struct Parameter { + public let value: Any + public let allowEmptyValue: Bool + + public init(value: Any, allowEmptyValue: Bool = false) { + self.value = value + self.allowEmptyValue = allowEmptyValue + } +} diff --git a/TINetworking/Sources/Parameters/ParameterEncoding.swift b/TINetworking/Sources/Parameters/ParameterEncoding.swift new file mode 100644 index 00000000..ff1b362c --- /dev/null +++ b/TINetworking/Sources/Parameters/ParameterEncoding.swift @@ -0,0 +1,6 @@ +protocol ParameterEncoding { + associatedtype Location: ParameterLocation + associatedtype Result + + func encode(parameters: [String: Parameter]) -> Result +} diff --git a/TINetworking/Sources/Parameters/ParameterLocation.swift b/TINetworking/Sources/Parameters/ParameterLocation.swift new file mode 100644 index 00000000..92e0d943 --- /dev/null +++ b/TINetworking/Sources/Parameters/ParameterLocation.swift @@ -0,0 +1,6 @@ +public protocol ParameterLocation {} + +public struct LocationQuery: ParameterLocation {} +public struct LocationPath: ParameterLocation {} +public struct LocationHeader: ParameterLocation {} +public struct LocationCookie: ParameterLocation {} diff --git a/TINetworking/Sources/Request/Request.swift b/TINetworking/Sources/Request/Request.swift new file mode 100644 index 00000000..3505e96e --- /dev/null +++ b/TINetworking/Sources/Request/Request.swift @@ -0,0 +1,42 @@ +import Alamofire + +public struct Request { + + public var templatePath: String + public var method: HTTPMethod + public var requestBodyContent: Content + public var queryParameters: [String: Parameter] + public var pathParameters: [String: Parameter] + public var headerParameters: [String: Parameter] + public var cookieParameters: [String: Parameter] + public var acceptableStatusCodes: Set + public var serverOverride: Server? + public var serverVariablesOverride: [KeyValueTuple] + + public var path: String { + PathParameterEncoding(templateUrl: templatePath).encode(parameters: pathParameters) + } + + public init(templatePath: String, + method: HTTPMethod, + requestBodyContent: Content, + queryParameters: [String: Parameter] = [:], + pathParameters: [String: Parameter] = [:], + headerParameters: [String: Parameter] = [:], + cookieParameters: [String: Parameter] = [:], + acceptableStatusCodes: Set = [200], + serverOverride: Server? = nil, + serverVariablesOverride: [KeyValueTuple] = []) { + + 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 + } +} diff --git a/TINetworking/Sources/Response/MimeTypeUnsupportedError.swift b/TINetworking/Sources/Response/MimeTypeUnsupportedError.swift new file mode 100644 index 00000000..8a885b58 --- /dev/null +++ b/TINetworking/Sources/Response/MimeTypeUnsupportedError.swift @@ -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." + } +} diff --git a/TINetworking/Sources/Response/ResponseType+Decoding.swift b/TINetworking/Sources/Response/ResponseType+Decoding.swift new file mode 100644 index 00000000..e1f475a5 --- /dev/null +++ b/TINetworking/Sources/Response/ResponseType+Decoding.swift @@ -0,0 +1,45 @@ +public struct ResponseTypeDecodingError: Error { + public let statusCode: Int + public let contentType: String +} + +public struct ContentMapping { + 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(transform: @escaping (Content.Model) -> NewModel) -> ContentMapping> { + .init(statusCode: statusCode, + mimeType: mimeType, + responseContent: MapResponseContent(responseContent: responseContent, + transform: transform)) + } +} + +public extension ResponseType { + func decode(contentMapping: [ContentMapping]) -> Result { + 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)) + } +} diff --git a/TINetworking/Sources/Response/ResponseType.swift b/TINetworking/Sources/Response/ResponseType.swift new file mode 100644 index 00000000..0e45359c --- /dev/null +++ b/TINetworking/Sources/Response/ResponseType.swift @@ -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 +} diff --git a/TINetworking/Sources/Response/StatusCodeMimeTypePairUnsupportedError.swift b/TINetworking/Sources/Response/StatusCodeMimeTypePairUnsupportedError.swift new file mode 100644 index 00000000..909c20a9 --- /dev/null +++ b/TINetworking/Sources/Response/StatusCodeMimeTypePairUnsupportedError.swift @@ -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!" + } +} diff --git a/TINetworking/Sources/Server.swift b/TINetworking/Sources/Server.swift new file mode 100644 index 00000000..2d3a11c3 --- /dev/null +++ b/TINetworking/Sources/Server.swift @@ -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] { + variables.map { ($0.key, $0.value.defaultValue) } + } + + public func url(using variables: [KeyValueTuple] = [], + 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) + } +} diff --git a/TINetworking/Sources/SessionFactory.swift b/TINetworking/Sources/SessionFactory.swift new file mode 100644 index 00000000..275975e4 --- /dev/null +++ b/TINetworking/Sources/SessionFactory.swift @@ -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 + } +} diff --git a/TINetworking/Sources/String+URLExtensions.swift b/TINetworking/Sources/String+URLExtensions.swift new file mode 100644 index 00000000..0d722e85 --- /dev/null +++ b/TINetworking/Sources/String+URLExtensions.swift @@ -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 { + variables.reduce(template) { + $0.replacingOccurrences(of: "{\($1.key)}", with: $1.value.urlEscaped) + } + } +} diff --git a/TINetworking/Sources/Typealiases.swift b/TINetworking/Sources/Typealiases.swift new file mode 100644 index 00000000..308efd25 --- /dev/null +++ b/TINetworking/Sources/Typealiases.swift @@ -0,0 +1 @@ +public typealias KeyValueTuple = (key: K, value: V)