diff --git a/Package.swift b/Package.swift index 6926ad0..aac0428 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "swift-core", platforms: [ - .macOS(.v10_15) + .macOS(.v12) ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. diff --git a/Sources/YData/Client/Internal.swift b/Sources/YData/Client/Internal.swift new file mode 100644 index 0000000..593a1ff --- /dev/null +++ b/Sources/YData/Client/Internal.swift @@ -0,0 +1,4 @@ +import Foundation + +// Defines the `Internal` namespace +public enum Internal {} diff --git a/Sources/YData/Client/InternalClient+Concurrency.swift b/Sources/YData/Client/InternalClient+Concurrency.swift new file mode 100644 index 0000000..2bc8c1d --- /dev/null +++ b/Sources/YData/Client/InternalClient+Concurrency.swift @@ -0,0 +1,49 @@ +import Foundation +import Vapor + +public extension InternalClient { + func send(_ request: Request) async throws -> Resp + where Request.Content: Encodable { + return try await self.send(request).get() + } + + func send(_ request: Request) async throws -> Resp { + return try await self.send(request).get() + } + + func send(_ request: Request) async throws -> Resp + where Request.Content: Encodable { + try await (send(request) as EventLoopFuture).get().mapToInternalModel() + } + + func send(_ request: Request) async throws -> Resp { + try await (send(request) as EventLoopFuture).get().mapToInternalModel() + } +} + +private extension ClientResponse { + func mapToInternalModel() async throws -> R where R: InternalModel { + switch status.code { + case (100..<400): + do { + return try content.decode(R.self) + } catch { + throw Internal.ErrorResponse(headers: [:], + status: .internalServerError, + message: "failed to decode response \(error)") + } + default: + do { + let contentError = try content.decode(Internal.ServiceError.self) + throw Internal.ErrorResponse(headers: headers, + status: status, + message:contentError.message) + } catch { + throw Internal.ErrorResponse(headers: [:], + status: .internalServerError, + message: "failed to decode response with error \(error)") + } + } + } +} + diff --git a/Sources/YData/Client/InternalClient.swift b/Sources/YData/Client/InternalClient.swift new file mode 100644 index 0000000..7200160 --- /dev/null +++ b/Sources/YData/Client/InternalClient.swift @@ -0,0 +1,94 @@ +import Foundation +import Vapor + +public protocol InternalClient { + var scheme: URI.Scheme { get } + var host: String { get } + var port: Int? { get } + var basePath: String? { get } + + var httpClient: Vapor.Client { get } + var logger: Logger { get } + + func send(_ request: Req) -> EventLoopFuture +} + +public enum InternalClientError: Error { + case encode(Error) +} + +public extension InternalClient { + var scheme: URI.Scheme { URI.Scheme("http") } + var basePath: String? { nil } + + func send(_ request: Request) -> EventLoopFuture + where Request.Content: Encodable { + + var clientRequest = buildClientRequest(for: request) + + do { + try request.content.flatMap { try clientRequest.content.encode($0, as: .json) } + } catch { + return httpClient.eventLoop.makeFailedFuture(InternalClientError.encode(error)) + } + + return httpClient.send(clientRequest) + .always { self.logger.info("response for request \(clientRequest.url): \($0)") } + .mapToInternalResponse() + } + + func send(_ request: Request) -> EventLoopFuture { + + let clientRequest = buildClientRequest(for: request) + + return httpClient.send(clientRequest) + .always { self.logger.info("response for request \(clientRequest.url): \($0)") } + .mapToInternalResponse() + } + + internal func buildClientRequest(for request: R) -> ClientRequest { + let path = basePath.flatMap { base in request.path.flatMap { "\(base)/\($0)" } ?? base } ?? request.path ?? "" + + let query = request.query.flatMap { queries in + queries.compactMap { query -> String? in + guard let value = query.value else { return nil } + + guard let escapedValue = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { + return nil + } + + return String(format: "@%=@%", query.name, escapedValue) + }.joined(separator: "&") + } + + let url = URI(scheme: scheme, host: host, port: port, path: path, query: query) + + var clientRequest = ClientRequest() + request.headers.flatMap { clientRequest.headers = .init($0.map { (key, value) in (key, value) }) } + clientRequest.method = request.method + clientRequest.url = url + return clientRequest + } +} + +private extension EventLoopFuture where Value == ClientResponse { + func mapToInternalResponse() -> EventLoopFuture where R: Response { + return self.flatMapResult { response -> Result in + switch response.status.code { + case (100..<400): + return .success(R(headers: response.headers, status: response.status, body: response.body)) + default: + do { + let contentError = try response.content.decode(Internal.ServiceError.self) + return .failure(Internal.ErrorResponse(headers: response.headers, + status: response.status, + message:contentError.message)) + } catch { + return .failure(Internal.ErrorResponse(headers: [:], + status: .internalServerError, + message: "failed to decode response with error \(error)")) + } + } + } + } +} diff --git a/Sources/YData/Client/InternalModel.swift b/Sources/YData/Client/InternalModel.swift new file mode 100644 index 0000000..35a9fa7 --- /dev/null +++ b/Sources/YData/Client/InternalModel.swift @@ -0,0 +1,3 @@ +import Foundation + +public typealias InternalModel = Codable diff --git a/Sources/YData/Client/InternalRequest.swift b/Sources/YData/Client/InternalRequest.swift new file mode 100644 index 0000000..d4fd1a3 --- /dev/null +++ b/Sources/YData/Client/InternalRequest.swift @@ -0,0 +1,53 @@ +import Vapor + +public protocol InternalRequest { + associatedtype Content + + var method: HTTPMethod { get } + var path: String? { get } + var headers: HTTPHeaders? { get } + var query: [URLQueryItem]? { get } + var content: Content? { get } +} + +public extension Internal { + struct NoContentRequest: InternalRequest { + public typealias Content = Optional + + public let method: HTTPMethod + public let path: String? + public let headers: HTTPHeaders? + public let query: [URLQueryItem]? + public let content: Content? = nil + + public init(method: HTTPMethod, + path: String? = nil, + headers: HTTPHeaders? = nil, + query: [URLQueryItem]? = nil) { + self.method = method + self.path = path + self.headers = headers + self.query = query + } + } + + struct ContentRequest: InternalRequest { + public let method: HTTPMethod + public let path: String? + public let headers: HTTPHeaders? + public let query: [URLQueryItem]? + public let content: Content? + + public init(method: HTTPMethod, + path: String? = nil, + headers: HTTPHeaders? = nil, + query: [URLQueryItem]? = nil, + content: Content? = nil) { + self.method = method + self.path = path + self.headers = headers + self.query = query + self.content = content + } + } +} diff --git a/Sources/YData/Client/InternalResponse.swift b/Sources/YData/Client/InternalResponse.swift new file mode 100644 index 0000000..cd805cc --- /dev/null +++ b/Sources/YData/Client/InternalResponse.swift @@ -0,0 +1,58 @@ +import Vapor + +public extension Internal { + struct ErrorResponse: Error { + public let headers: HTTPHeaders + public let status: HTTPResponseStatus + public let message: String + } + + struct SuccessResponse: Response { + public var headers: HTTPHeaders + public let status: HTTPResponseStatus + public var body: ByteBuffer? + + public init(headers: HTTPHeaders, status: HTTPResponseStatus, body: ByteBuffer?) { + self.headers = headers + self.status = status + self.body = body + } + } +} + +extension Internal.ErrorResponse: AbortError { + public var reason: String { message } +} + +public extension Internal.SuccessResponse { + private struct _ContentContainer: ContentContainer { + var body: ByteBuffer? + var headers: HTTPHeaders + + var contentType: HTTPMediaType? { headers.contentType } + + func decode(_ decodable: D.Type, using decoder: ContentDecoder) throws -> D where D : Decodable { + guard let body = self.body else { + throw Abort(.lengthRequired) + } + return try decoder.decode(D.self, from: body, headers: self.headers) + } + + mutating func encode(_ encodable: E, using encoder: ContentEncoder) throws where E : Encodable { + var body = ByteBufferAllocator().buffer(capacity: 0) + try encoder.encode(encodable, to: &body, headers: &self.headers) + self.body = body + } + } + + var content: ContentContainer { + get { + return _ContentContainer(body: self.body, headers: self.headers) + } + set { + let container = (newValue as! _ContentContainer) + self.body = container.body + self.headers = container.headers + } + } +} diff --git a/Sources/YData/Client/InternalServiceError.swift b/Sources/YData/Client/InternalServiceError.swift new file mode 100644 index 0000000..8988677 --- /dev/null +++ b/Sources/YData/Client/InternalServiceError.swift @@ -0,0 +1,8 @@ +import Foundation +import Vapor + +public extension Internal { + struct ServiceError: Decodable { + public let message: String + } +} diff --git a/Sources/YData/Client/Response.swift b/Sources/YData/Client/Response.swift new file mode 100644 index 0000000..5a0beda --- /dev/null +++ b/Sources/YData/Client/Response.swift @@ -0,0 +1,55 @@ +import Vapor + +public protocol Response: ResponseEncodable { + var headers: HTTPHeaders { get set } + var status: HTTPResponseStatus { get } + var body: ByteBuffer? { get set } + + var content: ContentContainer { get set } + + init(headers: HTTPHeaders, status: HTTPResponseStatus, body: ByteBuffer?) +} + +public extension Response { + func encodeResponse(for request: Request) -> EventLoopFuture { + let response = Vapor.Response(status: status, headers: headers) + response.body ?= body.flatMap(Vapor.Response.Body.init) + return request.eventLoop.makeSucceededFuture(response) + } +} + +public extension Response { + @inlinable + func map(_ callback: (ContentContainer) throws -> (NewValue)) throws -> Self where NewValue: Content { + let newValue = try callback(content) + + var newResponse = Self.init(headers: headers, status: status, body: nil) + try newResponse.content.encode(newValue) + + return newResponse + } + + @inlinable + func map(_ callback: (ContentContainer) throws -> (Void)) throws -> Self { + let _ = try callback(content) + + return Self.init(headers: headers, status: status, body: nil) + } +} + +public extension EventLoopFuture where Value: Response { + func mapContent(_ callback: @escaping (ContentContainer) throws -> (NewValue)) + -> EventLoopFuture where NewValue: Content { flatMapThrowing { try $0.map(callback) } } + + func flatMapContentThrowing(_ callback: @escaping (ContentContainer) throws -> (NewValue)) + -> EventLoopFuture { flatMapThrowing { try callback($0.content) } } + + func flatMapContentResult(_ callback: @escaping (ContentContainer) -> Result) + -> EventLoopFuture where NewValue: Content, E: Error { flatMapResult { callback($0.content) } } +} + +extension Vapor.ClientResponse: Response { + public init(headers: HTTPHeaders, status: HTTPResponseStatus, body: ByteBuffer?) { + self.init(status: status, headers: headers, body: body) + } +}