Skip to content

Commit

Permalink
feat(request): Add a Request type (#2)
Browse files Browse the repository at this point in the history
  • Loading branch information
pjechris authored Jan 28, 2022
1 parent a4374d6 commit 594c2c4
Show file tree
Hide file tree
Showing 16 changed files with 310 additions and 22 deletions.
23 changes: 23 additions & 0 deletions Sources/Pinata/DataCoder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Foundation

/// A encoder suited to encode to Data
public protocol DataEncoder {
func encode<T: Encodable>(_ value: T) throws -> Data
}

/// A decoder suited to decode Data
public protocol DataDecoder {
func decode<T: Decodable>(_ 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 }
}
9 changes: 9 additions & 0 deletions Sources/Pinata/Foundation/Encodable+Existential.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 5 additions & 0 deletions Sources/Pinata/Foundation/JSONEncoder+DataEncoder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import Foundation

extension JSONEncoder: ContentDataEncoder {
public static let contentType = HTTPContentType.json
}
36 changes: 36 additions & 0 deletions Sources/Pinata/Foundation/URL+Request.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Foundation

#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

extension URL {
init<Output>(from request: Request<Output>) 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) }
}
}
22 changes: 0 additions & 22 deletions Sources/Pinata/Foundation/URLRequest+Encode.swift

This file was deleted.

22 changes: 22 additions & 0 deletions Sources/Pinata/Foundation/URLRequest/URLRequest+Encode.swift
Original file line number Diff line number Diff line change
@@ -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])
}

}
13 changes: 13 additions & 0 deletions Sources/Pinata/Foundation/URLRequest/URLRequest+HTTPHeader.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
18 changes: 18 additions & 0 deletions Sources/Pinata/HTTP/HTTPContentType.swift
Original file line number Diff line number Diff line change
@@ -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"
}
30 changes: 30 additions & 0 deletions Sources/Pinata/HTTP/HTTPHeader.swift
Original file line number Diff line number Diff line change
@@ -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"
}
14 changes: 14 additions & 0 deletions Sources/Pinata/Request/Path.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
21 changes: 21 additions & 0 deletions Sources/Pinata/Request/Request+URLRequest.swift
Original file line number Diff line number Diff line change
@@ -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
}
}

55 changes: 55 additions & 0 deletions Sources/Pinata/Request/Request.swift
Original file line number Diff line number Diff line change
@@ -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<Output> {

/// 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
}
}
26 changes: 26 additions & 0 deletions Tests/PinataTests/Foundation/URLTests+Request.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Foundation
import XCTest
@testable import Pinata

class URLRequestTests: XCTestCase {
func test_initFromRequest_pathIsSetted() throws {
XCTAssertEqual(
try URL(from: Request<Void>.get("test")).path,
"test"
)
}

func test_initFromRequest_pathHasQueryItems_urlQueryIsSetted() throws {
XCTAssertEqual(
try URL(from: Request<Void>.get("hello/world?test=1")).query,
"test=1"
)
}

func test_initFromRequest_whenPathHasQueryItems_urlPathHasNoQuery() throws {
XCTAssertEqual(
try URL(from: Request<Void>.get("hello/world?test=1")).path,
"hello/world"
)
}
}
38 changes: 38 additions & 0 deletions Tests/PinataTests/Request/RequestTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import XCTest
@testable import Pinata

class RequestTests: XCTestCase {
enum TestEndpoint: String, Path {
case test
}

func test_init_withPathAsString() {
XCTAssertEqual(Request<Void>.get("hello_world").path, "hello_world")
}

func test_toURLRequest_itSetHttpMethod() throws {
let request = try Request<Void>.post(TestEndpoint.test, body: nil)
.toURLRequest(encoder: JSONEncoder())

XCTAssertEqual(request.httpMethod, "POST")
}

func test_toURLRequest_itEncodeBody() throws {
let request = try Request<Void>.post(TestEndpoint.test, body: Body())
.toURLRequest(encoder: JSONEncoder())

XCTAssertEqual(request.httpBody, try JSONEncoder().encode(Body()))
}

func test_toURLRequest_itFillDefaultHeaders() throws {
let request = try Request<Void>.post(TestEndpoint.test, body: Body())
.toURLRequest(encoder: JSONEncoder())

XCTAssertEqual(request.allHTTPHeaderFields?["Content-Type"], "application/json")
}

}

private struct Body: Encodable {

}

0 comments on commit 594c2c4

Please sign in to comment.