From 594c2c4541ab0bccff88ac3f2a47d6fe1dfcb3ed Mon Sep 17 00:00:00 2001 From: pjechris Date: Fri, 28 Jan 2022 13:46:26 +0100 Subject: [PATCH] feat(request): Add a Request type (#2) --- Sources/Pinata/DataCoder.swift | 23 ++++++++ .../Foundation/Encodable+Existential.swift | 9 +++ .../Foundation/JSONEncoder+DataEncoder.swift | 5 ++ Sources/Pinata/Foundation/URL+Request.swift | 36 ++++++++++++ .../Pinata/Foundation/URLRequest+Encode.swift | 22 -------- .../URLRequest/URLRequest+Encode.swift | 22 ++++++++ .../URLRequest/URLRequest+HTTPHeader.swift | 13 +++++ Sources/Pinata/HTTP/HTTPContentType.swift | 18 ++++++ Sources/Pinata/HTTP/HTTPHeader.swift | 30 ++++++++++ Sources/Pinata/Request/Path.swift | 14 +++++ .../Pinata/Request/Request+URLRequest.swift | 21 +++++++ Sources/Pinata/Request/Request.swift | 55 +++++++++++++++++++ .../URLRequestsTests+Encode.swift | 0 .../URLResponseValidateTests.swift | 0 .../Foundation/URLTests+Request.swift | 26 +++++++++ Tests/PinataTests/Request/RequestTests.swift | 38 +++++++++++++ 16 files changed, 310 insertions(+), 22 deletions(-) create mode 100644 Sources/Pinata/DataCoder.swift create mode 100644 Sources/Pinata/Foundation/Encodable+Existential.swift create mode 100644 Sources/Pinata/Foundation/JSONEncoder+DataEncoder.swift create mode 100644 Sources/Pinata/Foundation/URL+Request.swift delete mode 100644 Sources/Pinata/Foundation/URLRequest+Encode.swift create mode 100644 Sources/Pinata/Foundation/URLRequest/URLRequest+Encode.swift create mode 100644 Sources/Pinata/Foundation/URLRequest/URLRequest+HTTPHeader.swift create mode 100644 Sources/Pinata/HTTP/HTTPContentType.swift create mode 100644 Sources/Pinata/HTTP/HTTPHeader.swift create mode 100644 Sources/Pinata/Request/Path.swift create mode 100644 Sources/Pinata/Request/Request+URLRequest.swift create mode 100644 Sources/Pinata/Request/Request.swift rename Tests/PinataTests/Foundation/{ => URLRequest}/URLRequestsTests+Encode.swift (100%) rename Tests/PinataTests/Foundation/{ => URLRequest}/URLResponseValidateTests.swift (100%) create mode 100644 Tests/PinataTests/Foundation/URLTests+Request.swift create mode 100644 Tests/PinataTests/Request/RequestTests.swift diff --git a/Sources/Pinata/DataCoder.swift b/Sources/Pinata/DataCoder.swift new file mode 100644 index 0000000..ebaeb19 --- /dev/null +++ b/Sources/Pinata/DataCoder.swift @@ -0,0 +1,23 @@ +import Foundation + +/// A encoder suited to encode to Data +public protocol DataEncoder { + func encode(_ value: T) throws -> Data +} + +/// A decoder suited to decode Data +public protocol DataDecoder { + func decode(_ type: T.Type, from: Data) throws -> T +} + +/// A `DataEncoder` providing a `ContentType` +public protocol ContentDataEncoder: DataEncoder { + /// a http content type + static var contentType: HTTPContentType { get } +} + +/// A `DataDecoder` providing a `ContentType` +public protocol ContentDataDecoder: DataDecoder { + /// a http content type + static var contentType: HTTPContentType { get } +} diff --git a/Sources/Pinata/Foundation/Encodable+Existential.swift b/Sources/Pinata/Foundation/Encodable+Existential.swift new file mode 100644 index 0000000..d289269 --- /dev/null +++ b/Sources/Pinata/Foundation/Encodable+Existential.swift @@ -0,0 +1,9 @@ +import Foundation + +extension Encodable { + /// Encode the object with provided encoder. + /// This technique allow to "open" an existential, that is to use it in a context where a generic is expected + func encoded(with encoder: DataEncoder) throws -> Data { + try encoder.encode(self) + } +} diff --git a/Sources/Pinata/Foundation/JSONEncoder+DataEncoder.swift b/Sources/Pinata/Foundation/JSONEncoder+DataEncoder.swift new file mode 100644 index 0000000..578ad69 --- /dev/null +++ b/Sources/Pinata/Foundation/JSONEncoder+DataEncoder.swift @@ -0,0 +1,5 @@ +import Foundation + +extension JSONEncoder: ContentDataEncoder { + public static let contentType = HTTPContentType.json +} diff --git a/Sources/Pinata/Foundation/URL+Request.swift b/Sources/Pinata/Foundation/URL+Request.swift new file mode 100644 index 0000000..4eb7441 --- /dev/null +++ b/Sources/Pinata/Foundation/URL+Request.swift @@ -0,0 +1,36 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +extension URL { + init(from request: Request) throws { + guard var components = URLComponents(string: request.path) else { + throw URLComponents.Error.invalid(path: request.path) + } + + let queryItems = (components.queryItems ?? []) + request.parameters.queryItems + + components.queryItems = queryItems.isEmpty ? nil : queryItems + + guard let url = components.url else { + throw URLComponents.Error.cannotGenerateURL(components: components) + } + + self = url + } +} + +extension URLComponents { + enum Error: Swift.Error { + case invalid(path: String) + case cannotGenerateURL(components: URLComponents) + } +} + +extension Dictionary where Key == String, Value == String { + fileprivate var queryItems: [URLQueryItem] { + map { URLQueryItem(name: $0.key, value: $0.value) } + } +} diff --git a/Sources/Pinata/Foundation/URLRequest+Encode.swift b/Sources/Pinata/Foundation/URLRequest+Encode.swift deleted file mode 100644 index aab59ee..0000000 --- a/Sources/Pinata/Foundation/URLRequest+Encode.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Foundation - -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -public extension URLRequest { - func encodedBody(_ body: T, encoder: JSONEncoder) throws -> Self { - var request = self - - try request.encodeBody(body, encoder: encoder) - - return request - } - - /// Use a `JSONEncoder` object as request body and set the "Content-Type" header associated to the encoder - mutating func encodeBody(_ body: T, encoder: JSONEncoder) throws { - httpBody = try encoder.encode(body) - setValue("Content-Type", forHTTPHeaderField: "application/json") - } - -} diff --git a/Sources/Pinata/Foundation/URLRequest/URLRequest+Encode.swift b/Sources/Pinata/Foundation/URLRequest/URLRequest+Encode.swift new file mode 100644 index 0000000..91fde6a --- /dev/null +++ b/Sources/Pinata/Foundation/URLRequest/URLRequest+Encode.swift @@ -0,0 +1,22 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public extension URLRequest { + func encodedBody(_ body: Encodable, encoder: ContentDataEncoder) throws -> Self { + var request = self + + try request.encodeBody(body, encoder: encoder) + + return request + } + + /// Use a `Encodable` object as request body and set the "Content-Type" header associated to the encoder + mutating func encodeBody(_ body: Encodable, encoder: ContentDataEncoder) throws { + httpBody = try body.encoded(with: encoder) + setHeaders([.contentType: type(of: encoder).contentType.value]) + } + +} diff --git a/Sources/Pinata/Foundation/URLRequest/URLRequest+HTTPHeader.swift b/Sources/Pinata/Foundation/URLRequest/URLRequest+HTTPHeader.swift new file mode 100644 index 0000000..e15d2aa --- /dev/null +++ b/Sources/Pinata/Foundation/URLRequest/URLRequest+HTTPHeader.swift @@ -0,0 +1,13 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +extension URLRequest { + public mutating func setHeaders(_ headers: HTTPHeaderFields) { + for (header, value) in headers { + setValue(value, forHTTPHeaderField: header.key) + } + } +} diff --git a/Sources/Pinata/HTTP/HTTPContentType.swift b/Sources/Pinata/HTTP/HTTPContentType.swift new file mode 100644 index 0000000..b443cf6 --- /dev/null +++ b/Sources/Pinata/HTTP/HTTPContentType.swift @@ -0,0 +1,18 @@ +import Foundation + +/// A struct representing a http header content type value +public struct HTTPContentType: Hashable, ExpressibleByStringLiteral { + let value: String + + public init(value: String) { + self.value = value + } + + public init(stringLiteral value: StringLiteralType) { + self.value = value + } +} + +extension HTTPContentType { + public static let json: Self = "application/json" +} diff --git a/Sources/Pinata/HTTP/HTTPHeader.swift b/Sources/Pinata/HTTP/HTTPHeader.swift new file mode 100644 index 0000000..608f790 --- /dev/null +++ b/Sources/Pinata/HTTP/HTTPHeader.swift @@ -0,0 +1,30 @@ +import Foundation + +/// HTTP headers `Dictionary` and their associated value +public typealias HTTPHeaderFields = [HTTPHeader: String] + +/// A struct representing a http request header key +public struct HTTPHeader: Hashable, ExpressibleByStringLiteral { + public let key: String + + public init(stringLiteral value: StringLiteralType) { + self.key = value + } +} + +extension HTTPHeader { + public static let accept: Self = "Accept" + public static let authentication: Self = "Authentication" + public static let contentType: Self = "Content-Type" +} + +@available(*, unavailable, message: "This is a reserved header. See https://developer.apple.com/documentation/foundation/nsurlrequest#1776617") +extension HTTPHeader { + public static let authorization: Self = "Authorization" + public static let connection: Self = "Connection" + public static let contentLength: Self = "Content-Length" + public static let host: Self = "Host" + public static let proxyAuthenticate: Self = "Proxy-Authenticate" + public static let proxyAuthorization: Self = "Proxy-Authorization" + public static let wwwAuthenticate: Self = "WWW-Authenticate" +} diff --git a/Sources/Pinata/Request/Path.swift b/Sources/Pinata/Request/Path.swift new file mode 100644 index 0000000..b874783 --- /dev/null +++ b/Sources/Pinata/Request/Path.swift @@ -0,0 +1,14 @@ +import Foundation + +/// A Type representing a URL path +public protocol Path { + var path: String { get } +} + +extension Path where Self: RawRepresentable, RawValue == String { + public var path: String { rawValue } +} + +extension String: Path { + public var path: String { self } +} diff --git a/Sources/Pinata/Request/Request+URLRequest.swift b/Sources/Pinata/Request/Request+URLRequest.swift new file mode 100644 index 0000000..02783e3 --- /dev/null +++ b/Sources/Pinata/Request/Request+URLRequest.swift @@ -0,0 +1,21 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +extension Request { + func toURLRequest(encoder: ContentDataEncoder) throws -> URLRequest { + var urlRequest = try URLRequest(url: URL(from: self)) + + urlRequest.httpMethod = method.rawValue.uppercased() + urlRequest.setHeaders(headers) + + if let body = body { + try urlRequest.encodeBody(body, encoder: encoder) + } + + return urlRequest + } +} + diff --git a/Sources/Pinata/Request/Request.swift b/Sources/Pinata/Request/Request.swift new file mode 100644 index 0000000..bbfbca7 --- /dev/null +++ b/Sources/Pinata/Request/Request.swift @@ -0,0 +1,55 @@ +import Foundation + +public enum Method: String { + case get + case post + case put + case delete +} + +/// A Http request expecting an `Output` response +/// +/// Highly inspired by https://swiftwithmajid.com/2021/02/10/building-type-safe-networking-in-swift/ +public struct Request { + + /// request relative path + public let path: String + public let method: Method + public let body: Encodable? + public let parameters: [String: String] + public private(set) var headers: HTTPHeaderFields = [:] + + public static func get(_ path: Path, parameters: [String: String] = [:]) -> Self { + self.init(path: path, method: .get, parameters: parameters, body: nil) + } + + public static func post(_ path: Path, body: Encodable?, parameters: [String: String] = [:]) + -> Self { + self.init(path: path, method: .post, parameters: parameters, body: body) + } + + public static func put(_ path: Path, body: Encodable, parameters: [String: String] = [:]) + -> Self { + self.init(path: path, method: .put, parameters: parameters, body: body) + } + + public static func delete(_ path: Path, parameters: [String: String] = [:]) -> Self { + self.init(path: path, method: .delete, parameters: parameters, body: nil) + } + + private init(path: Path, method: Method, parameters: [String: String] = [:], body: Encodable?) { + self.path = path.path + self.method = method + self.body = body + self.parameters = parameters + } + + /// add headers to the request + public func headers(_ newHeaders: [HTTPHeader: String]) -> Self { + var request = self + + request.headers.merge(newHeaders) { $1 } + + return request + } +} diff --git a/Tests/PinataTests/Foundation/URLRequestsTests+Encode.swift b/Tests/PinataTests/Foundation/URLRequest/URLRequestsTests+Encode.swift similarity index 100% rename from Tests/PinataTests/Foundation/URLRequestsTests+Encode.swift rename to Tests/PinataTests/Foundation/URLRequest/URLRequestsTests+Encode.swift diff --git a/Tests/PinataTests/Foundation/URLResponseValidateTests.swift b/Tests/PinataTests/Foundation/URLRequest/URLResponseValidateTests.swift similarity index 100% rename from Tests/PinataTests/Foundation/URLResponseValidateTests.swift rename to Tests/PinataTests/Foundation/URLRequest/URLResponseValidateTests.swift diff --git a/Tests/PinataTests/Foundation/URLTests+Request.swift b/Tests/PinataTests/Foundation/URLTests+Request.swift new file mode 100644 index 0000000..8777ea4 --- /dev/null +++ b/Tests/PinataTests/Foundation/URLTests+Request.swift @@ -0,0 +1,26 @@ +import Foundation +import XCTest +@testable import Pinata + +class URLRequestTests: XCTestCase { + func test_initFromRequest_pathIsSetted() throws { + XCTAssertEqual( + try URL(from: Request.get("test")).path, + "test" + ) + } + + func test_initFromRequest_pathHasQueryItems_urlQueryIsSetted() throws { + XCTAssertEqual( + try URL(from: Request.get("hello/world?test=1")).query, + "test=1" + ) + } + + func test_initFromRequest_whenPathHasQueryItems_urlPathHasNoQuery() throws { + XCTAssertEqual( + try URL(from: Request.get("hello/world?test=1")).path, + "hello/world" + ) + } +} diff --git a/Tests/PinataTests/Request/RequestTests.swift b/Tests/PinataTests/Request/RequestTests.swift new file mode 100644 index 0000000..94baacd --- /dev/null +++ b/Tests/PinataTests/Request/RequestTests.swift @@ -0,0 +1,38 @@ +import XCTest +@testable import Pinata + +class RequestTests: XCTestCase { + enum TestEndpoint: String, Path { + case test + } + + func test_init_withPathAsString() { + XCTAssertEqual(Request.get("hello_world").path, "hello_world") + } + + func test_toURLRequest_itSetHttpMethod() throws { + let request = try Request.post(TestEndpoint.test, body: nil) + .toURLRequest(encoder: JSONEncoder()) + + XCTAssertEqual(request.httpMethod, "POST") + } + + func test_toURLRequest_itEncodeBody() throws { + let request = try Request.post(TestEndpoint.test, body: Body()) + .toURLRequest(encoder: JSONEncoder()) + + XCTAssertEqual(request.httpBody, try JSONEncoder().encode(Body())) + } + + func test_toURLRequest_itFillDefaultHeaders() throws { + let request = try Request.post(TestEndpoint.test, body: Body()) + .toURLRequest(encoder: JSONEncoder()) + + XCTAssertEqual(request.allHTTPHeaderFields?["Content-Type"], "application/json") + } + +} + +private struct Body: Encodable { + +}