diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7825beb --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..31d031a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Otavio Cordeiro + +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. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..9a9fc40 --- /dev/null +++ b/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version:5.5 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "MicroClient", + platforms: [ + .macOS(.v11), .iOS(.v13), .tvOS(.v13), .watchOS(.v6) + ], + products: [ + .library( + name: "MicroClient", + targets: ["MicroClient"] + ) + ], + dependencies: [], + targets: [ + .target( + name: "MicroClient", + dependencies: [] + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..b17c494 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# MicroClient + +A description of this package. diff --git a/Sources/MicroClient/Extensions/URLComponents.swift b/Sources/MicroClient/Extensions/URLComponents.swift new file mode 100644 index 0000000..f14e644 --- /dev/null +++ b/Sources/MicroClient/Extensions/URLComponents.swift @@ -0,0 +1,21 @@ +import Foundation + +extension URL { + + static func makeURL( + configuration: NetworkConfiguration, + networkRequest: NetworkRequest + ) throws -> URL { + var components = URLComponents() + + components.scheme = configuration.scheme + components.host = configuration.hostname + components.path = networkRequest.path + components.queryItems = networkRequest.queryItems + + return try unwrap( + value: components.url, + error: NetworkClientError.malformedURL + ) + } +} diff --git a/Sources/MicroClient/Extensions/URLRequest.swift b/Sources/MicroClient/Extensions/URLRequest.swift new file mode 100644 index 0000000..787234a --- /dev/null +++ b/Sources/MicroClient/Extensions/URLRequest.swift @@ -0,0 +1,26 @@ +import Foundation + +extension URLRequest { + + static func makeURLRequest( + configuration: NetworkConfiguration, + networkRequest: NetworkRequest + ) throws -> URLRequest? { + let url = try URL.makeURL( + configuration: configuration, + networkRequest: networkRequest + ) + + var request = URLRequest(url: url) + request.httpMethod = networkRequest.method.rawValue + request.httpBody = try networkRequest.body + .map { + try networkRequest.encode( + payload: $0, + defaultEncoder: configuration.defaultEncoder + ) + } + + return request + } +} diff --git a/Sources/MicroClient/Extensions/Unwrap.swift b/Sources/MicroClient/Extensions/Unwrap.swift new file mode 100644 index 0000000..8563ab3 --- /dev/null +++ b/Sources/MicroClient/Extensions/Unwrap.swift @@ -0,0 +1,25 @@ +import Combine + +func unwrap( + value: T?, + error: Error +) throws -> T { + guard let value = value else { + throw error + } + + return value +} + +extension Publisher { + + func unwrap( + with error: Failure + ) -> Publishers.FlatMap.Publisher, Self> where Output == T? { + flatMap { unwrapped in + unwrapped.map { value in + Result.success(value).publisher + } ?? Result.failure(error).publisher + } + } +} diff --git a/Sources/MicroClient/HTTPMethod.swift b/Sources/MicroClient/HTTPMethod.swift new file mode 100644 index 0000000..e18b22d --- /dev/null +++ b/Sources/MicroClient/HTTPMethod.swift @@ -0,0 +1,9 @@ +import Foundation + +public enum HTTPMethod: String { + case get = "GET" + case post = "POST" + case delete = "DELETE" + case put = "PUT" + case patch = "PATCH" +} diff --git a/Sources/MicroClient/NetworkClient.swift b/Sources/MicroClient/NetworkClient.swift new file mode 100644 index 0000000..ffb25b3 --- /dev/null +++ b/Sources/MicroClient/NetworkClient.swift @@ -0,0 +1,77 @@ +import Combine +import Foundation + +public protocol NetworkClientProtocol { + + func run( + _ networkRequest: NetworkRequest + ) -> AnyPublisher, Error> +} + +public final class NetworkClient: NetworkClientProtocol { + + // MARK: - Properties + + private let configuration: NetworkConfiguration + + // MARK: - Life cycle + + public init( + configuration: NetworkConfiguration + ) { + self.configuration = configuration + } + + // MARK: - Public + + public func run( + _ networkRequest: NetworkRequest + ) -> AnyPublisher, Error> { + urlRequestPublisher(networkRequest: networkRequest) + .flatMap { request in + self.requestPublisher( + urlRequest: request, + networkRequest: networkRequest + ) + } + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + // MARK: - Private + + private func urlRequestPublisher( + networkRequest: NetworkRequest + ) -> AnyPublisher { + Result { + try URLRequest.makeURLRequest( + configuration: configuration, + networkRequest: networkRequest + ) + } + .publisher + .unwrap(with: NetworkClientError.malformedURLRequest) + .compactMap { [configuration] request in + configuration.interceptor?(request) + } + .eraseToAnyPublisher() + } + + private func requestPublisher( + urlRequest: URLRequest, + networkRequest: NetworkRequest + ) -> AnyPublisher, Error> { + configuration.session + .dataTaskPublisher(for: urlRequest) + .tryMap { [configuration] result in + NetworkResponse( + value: try networkRequest.decode( + data: result.data, + defaultDecoder: configuration.defaultDecoder + ), + response: result.response + ) + } + .eraseToAnyPublisher() + } +} diff --git a/Sources/MicroClient/NetworkClientError.swift b/Sources/MicroClient/NetworkClientError.swift new file mode 100644 index 0000000..7b5dfbd --- /dev/null +++ b/Sources/MicroClient/NetworkClientError.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum NetworkClientError: Error { + case malformedURL + case malformedURLRequest +} diff --git a/Sources/MicroClient/NetworkConfiguration.swift b/Sources/MicroClient/NetworkConfiguration.swift new file mode 100644 index 0000000..98b01d4 --- /dev/null +++ b/Sources/MicroClient/NetworkConfiguration.swift @@ -0,0 +1,40 @@ +import Foundation + +public final class NetworkConfiguration { + + /// The session used to perform the network requests. + public let session: URLSession + + /// The default JSON decoder. It can be overwritten by + /// individual requests, if necessary. + public let defaultDecoder: JSONDecoder + + /// The default JSON encoder. It can be overwritten by + /// individual requests, if necessary. + public let defaultEncoder: JSONEncoder + + /// The scheme component of the base URL. + public let scheme: String + + /// The host component of the base URL. + public let hostname: String + + /// The interceptor called right before performing the + /// network request. Can be used to modify the `URLRequest` + /// if necessary. + public var interceptor: ((URLRequest) -> URLRequest)? + + public init( + session: URLSession, + defaultDecoder: JSONDecoder, + defaultEncoder: JSONEncoder, + scheme: String, + hostname: String + ) { + self.session = session + self.defaultDecoder = defaultDecoder + self.defaultEncoder = defaultEncoder + self.scheme = scheme + self.hostname = hostname + } +} diff --git a/Sources/MicroClient/NetworkRequest.swift b/Sources/MicroClient/NetworkRequest.swift new file mode 100644 index 0000000..6980e5f --- /dev/null +++ b/Sources/MicroClient/NetworkRequest.swift @@ -0,0 +1,79 @@ +import Combine +import Foundation + +public struct NetworkRequest< + RequestModel, + ResponseModel +> where RequestModel: Encodable, ResponseModel: Decodable { + + // MARK: - Properties + + public let path: String + public let method: HTTPMethod + public var parameters: [String: String]? + public var body: RequestModel? + public var decoder: JSONDecoder? + public var encoder: JSONEncoder? + + // MARK: - Life cycle + + public init( + path: String, + method: HTTPMethod, + parameters: [String : String]? = nil, + body: RequestModel? = nil, + decoder: JSONDecoder? = nil, + encoder: JSONEncoder? = nil + ) { + self.path = path + self.method = method + self.parameters = parameters + self.body = body + self.decoder = decoder + self.encoder = encoder + } +} + +// MARK: - Query Items + +extension NetworkRequest { + + public var queryItems: [URLQueryItem]? { + parameters?.compactMap { parameter in + URLQueryItem( + name: parameter.key, + value: parameter.value + ) + } + } +} + +// MARK: - HTTP Body + +extension NetworkRequest { + + public func encode( + payload: RequestModel, + defaultEncoder: JSONEncoder + ) throws -> Data { + let encoder = encoder ?? defaultEncoder + return try encoder.encode(payload) + } +} + +// MARK: - Decode + +extension NetworkRequest { + + public func decode( + data: Data, + defaultDecoder: JSONDecoder + ) throws -> ResponseModel { + let decoder = decoder ?? defaultDecoder + + return try decoder.decode( + ResponseModel.self, + from: data + ) + } +} diff --git a/Sources/MicroClient/NetworkResponse.swift b/Sources/MicroClient/NetworkResponse.swift new file mode 100644 index 0000000..924d9b2 --- /dev/null +++ b/Sources/MicroClient/NetworkResponse.swift @@ -0,0 +1,6 @@ +import Foundation + +public struct NetworkResponse { + public let value: T + public let response: URLResponse +} diff --git a/Sources/MicroClient/Responses/VoidRequest.swift b/Sources/MicroClient/Responses/VoidRequest.swift new file mode 100644 index 0000000..1b019ee --- /dev/null +++ b/Sources/MicroClient/Responses/VoidRequest.swift @@ -0,0 +1 @@ +public struct VoidRequest: Encodable { } diff --git a/Sources/MicroClient/Responses/VoidResponse.swift b/Sources/MicroClient/Responses/VoidResponse.swift new file mode 100644 index 0000000..356f566 --- /dev/null +++ b/Sources/MicroClient/Responses/VoidResponse.swift @@ -0,0 +1 @@ +public struct VoidResponse: Decodable { }