Skip to content

Commit

Permalink
[feat] Add persistent headers to API (ex: auth)
Browse files Browse the repository at this point in the history
- Linux builds (FoundationNetworking)
  • Loading branch information
jtouzy committed Jan 9, 2023
1 parent 35c5be1 commit 369a264
Show file tree
Hide file tree
Showing 9 changed files with 172 additions and 15 deletions.
17 changes: 16 additions & 1 deletion Sources/Spyder/API+Invoking.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

// MARK: API invoking functions

extension API {
public func invokeAndForget<Input>(request: Input) async throws
where Input: URLRequestBuilder {
let urlRequest = try request.urlRequest(for: self)
logInvoke(for: urlRequest)
do {
let response = try await invoker(urlRequest)
logResponse(for: urlRequest, response: response)
Expand All @@ -17,6 +21,7 @@ extension API {
public func invokeWaitingResponse<Input, Output>(request: Input) async throws -> Output
where Input: URLRequestBuilder, Output: Decodable {
let urlRequest = try request.urlRequest(for: self)
logInvoke(for: urlRequest)
let response: HTTPResponse = try await {
do {
var response = try await invoker(urlRequest)
Expand Down Expand Up @@ -47,7 +52,17 @@ public enum Invoker {
guard let httpResponse = response as? HTTPURLResponse else {
throw Invoker.DefaultHTTPInvokerError.missingHTTPResponse
}
return .init(statusCode: httpResponse.statusCode, data: data)
return .init(
statusCode: httpResponse.statusCode,
headers: httpResponse.allHeaderFields.compactMap { element in
guard
let stringKey = element.key as? String,
let value = element.value as? String
else { return .none }
return .init(name: stringKey, value: value)
},
data: data
)
}
}
extension Invoker {
Expand Down
27 changes: 27 additions & 0 deletions Sources/Spyder/API+Logging.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

extension API {
internal func logInvoke(for urlRequest: URLRequest) {
logger(
"🕸️ invoke",
[
"method=[\(urlRequest.httpMethod ?? "GET")]",
"absolute_url=[\(urlRequest.url?.absoluteString ?? "nil")]",
"headers=[\(urlRequest.allHTTPHeaderFields ?? [:])]",
"body=[\(urlRequest.httpBody?.jsonString(options: .prettyPrinted) ?? "nil")]"
].joined(separator: ", ")
)
}
internal func logResponse(for urlRequest: URLRequest, response: HTTPResponse) {
let isSuccess = (200...299).contains(response.statusCode)
logNetworkingEvent(
Expand Down Expand Up @@ -40,3 +54,16 @@ extension API {
logger(message, complementary)
}
}

private extension Data {
func jsonString(options: JSONSerialization.WritingOptions = []) -> String? {
guard
let object = try? JSONSerialization.jsonObject(with: self, options: []),
let data = try? JSONSerialization.data(withJSONObject: object, options: options),
let jsonString = String(data: data, encoding: .utf8)
else {
return nil
}
return jsonString
}
}
10 changes: 10 additions & 0 deletions Sources/Spyder/API+Public.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
extension API {
public func addHeader(_ header: Header) {
persistentHeaders.insert(header)
}
public var allHeaders: Set<Header> {
var dynamicHeaders = headersBuilder()
persistentHeaders.forEach { dynamicHeaders.insert($0) }
return dynamicHeaders
}
}
19 changes: 12 additions & 7 deletions Sources/Spyder/API+Types.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,32 @@ public enum HTTPMethod: String {

public struct HTTPResponse {
public let statusCode: Int
public let headers: [Header]
public let data: Data

public init(statusCode: Int, data: Data) {
public init(statusCode: Int, headers: [Header], data: Data) {
self.statusCode = statusCode
self.headers = headers
self.data = data
}
}

public struct Header: Hashable {
let name: String
let value: String
public let name: String
public let value: String

public init(name: String, value: String) {
self.name = name
self.value = value
}
}

extension Header {
public static func authorization(bearer value: String) -> Self {
.init(name: "Authorization", value: "Bearer \(value)")
}
}

extension Header {
public enum ContentType: String {
case image = "image/jpeg"
Expand All @@ -32,9 +40,6 @@ extension Header {
}

public static func contentType(_ value: Header.ContentType) -> Self {
.init(
name: "Content-Type",
value: value.rawValue
)
.init(name: "Content-Type", value: value.rawValue)
}
}
8 changes: 7 additions & 1 deletion Sources/Spyder/API.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public struct API<ConstrainedType> {
public class API<ConstrainedType> {
public typealias HeadersBuilder = () -> Set<Header>
public typealias Invoker = (URLRequest) async throws -> HTTPResponse
public typealias Logger = (_ message: String, _ complementaryMessage: String) -> Void
Expand All @@ -10,6 +13,7 @@ public struct API<ConstrainedType> {
public let jsonEncoder: JSONEncoder
public let jsonDecoder: JSONDecoder
let headersBuilder: HeadersBuilder
var persistentHeaders: Set<Header>
let invoker: Invoker
let logger: Logger
let responseMiddlewares: [ResponseMiddleware]
Expand All @@ -19,6 +23,7 @@ public struct API<ConstrainedType> {
jsonEncoder: JSONEncoder = .init(),
jsonDecoder: JSONDecoder = .init(),
headersBuilder: @escaping HeadersBuilder = { .init() },
persistentHeaders: Set<Header> = [],
invoker: @escaping Invoker,
logger: @escaping Logger = { _, _ in },
responseMiddlewares: [ResponseMiddleware] = []
Expand All @@ -29,6 +34,7 @@ public struct API<ConstrainedType> {
self.jsonEncoder = jsonEncoder
self.jsonDecoder = jsonDecoder
self.headersBuilder = headersBuilder
self.persistentHeaders = persistentHeaders
self.invoker = invoker
self.logger = logger
self.responseMiddlewares = responseMiddlewares
Expand Down
5 changes: 4 additions & 1 deletion Sources/Spyder/Builders/URLRequestBuilder.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public protocol URLRequestBuilder {
static var method: HTTPMethod { get }
Expand All @@ -13,7 +16,7 @@ extension URLRequestBuilder {
func urlRequest<ConstrainedType>(for api: API<ConstrainedType>) throws -> URLRequest {
let mirror = Mirror(reflecting: self)
var evaluatedPath = Self.path
var headers: Set<Header> = api.headersBuilder()
var headers: Set<Header> = api.allHeaders
var queryItems: [URLQueryItem] = []
var httpBody: Data?
for child in mirror.children {
Expand Down
33 changes: 29 additions & 4 deletions Tests/SpyderTests/APITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,50 @@ final class APITests: XCTestCase {

private enum TestInvoker {
public static let successInvoker: API.Invoker = { request in
.init(statusCode: 200, data: "{\"name\":\"Spyder\"}".data(using: .utf8)!)
.init(statusCode: 200, headers: [], data: "{\"name\":\"Spyder\"}".data(using: .utf8)!)
}
public static let jsonDecodingFailureInvoker: API.Invoker = { request in
.init(statusCode: 200, data: "{}".data(using: .utf8)!)
.init(statusCode: 200, headers: [], data: "{}".data(using: .utf8)!)
}
}

// ========================================================================
// MARK: Test builders
// ========================================================================

private func createSUT(invoker: @escaping API.Invoker = TestInvoker.successInvoker) -> GitHubAPI {
GitHubAPI.build(using: invoker)
private func createSUT(
invoker: @escaping API.Invoker = TestInvoker.successInvoker,
headersBuilder: @escaping API.HeadersBuilder = { .init() }
) -> GitHubAPI {
GitHubAPI.build(
using: invoker,
headersBuilder: headersBuilder
)
}

// ========================================================================
// MARK: Tests
// ========================================================================

extension APITests {
func test_headers_scenarios() {
// Given
let builderHeaders: Set<Header> = .init(
[.init(name: "spyder-hd-builder", value: "spyder-hd-builder-value")]
)
let sut = createSUT(headersBuilder: { builderHeaders })
// When/Then: Before adding headers
XCTAssertEqual(sut.allHeaders, builderHeaders)
// When/Then: After adding headers
let additionalHeader: Header = .init(name: "spyder-additional-header", value: "spyder-additional-value")
sut.addHeader(additionalHeader)
XCTAssertEqual(sut.allHeaders, .init([
.init(name: "spyder-hd-builder", value: "spyder-hd-builder-value"),
additionalHeader
]))
}
}

extension APITests {
func test_invoking_happyPath() async throws {
// Given
Expand Down
6 changes: 5 additions & 1 deletion Tests/SpyderTests/GitHubAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,16 @@ extension GitHubAPI {
}

extension GitHubAPI {
static func build(using invoker: @escaping API.Invoker) -> Self {
static func build(
using invoker: @escaping API.Invoker,
headersBuilder: @escaping API.HeadersBuilder = { .init() }
) -> GitHubAPI {
.init(
baseURLComponents: { components in
components.scheme = "https"
components.host = "api.github.com"
},
headersBuilder: headersBuilder,
invoker: invoker
)
}
Expand Down
62 changes: 62 additions & 0 deletions Tests/SpyderTests/URLRequestBuilderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ final class URLRequestBuilderTests: XCTestCase {

private func XCTAssertURLRequestEquals(
_ request: URLRequest,
headers: [Header] = [],
url: String,
httpMethod: HTTPMethod,
body: Data?
Expand All @@ -22,6 +23,18 @@ private func XCTAssertURLRequestEquals(
XCTFail("Generated HTTPMethod is missing on \(request)")
return
}
if let requestHeaders = request.allHTTPHeaderFields {
let generatedHeaderKeys = requestHeaders.keys.sorted(by: { $0 < $1 })
let expectedHeaderKeys = headers.map(\.name).sorted(by: { $0 < $1 })
XCTAssertEqual(generatedHeaderKeys, expectedHeaderKeys)
expectedHeaderKeys.forEach { expectedHeaderKey in
let expectedHeader = headers.first(where: { $0.name == expectedHeaderKey })!
let generatedHeader = requestHeaders[expectedHeaderKey]!
XCTAssertEqual(expectedHeader.value, generatedHeader, "Header \(expectedHeader.name) is different")
}
} else if headers.isEmpty == false {
XCTFail("Generated headers are different expected headers \(request)")
}
XCTAssertEqual(generatedURL, url)
XCTAssertEqual(generatedHttpMethod.lowercased(), httpMethod.rawValue)
XCTAssertEqual(request.httpBody, body)
Expand Down Expand Up @@ -71,6 +84,55 @@ extension URLRequestBuilderTests {
}
}

// ========================================================================
// MARK: URLRequestBuilderTests: Headers tests
// ========================================================================

extension URLRequestBuilderTests {
struct NoQueryNoPathGetWithHeadersRequestExample: URLRequestBuilder {
static let method: HTTPMethod = .get
static let path: String = "/api/v1/getRequestExample"
@RequestHeader(name: "spyder-auth") var auth: String
init(auth: String) { self.auth = auth }
}
}

extension URLRequestBuilderTests {
func test_noQueryNoPathGetRequestHeadersExample() throws {
// Given
let builder = NoQueryNoPathGetWithHeadersRequestExample(auth: "auth-value")
// When
let urlRequest = try builder.urlRequest(for: GitHubAPI.build(using: Invoker.defaultHTTPInvoker))
// Then
XCTAssertURLRequestEquals(
urlRequest,
headers: [.init(name: "spyder-auth", value: "auth-value")],
url: "https://api.github.com/api/v1/getRequestExample",
httpMethod: .get,
body: .none
)
}
func test_noQueryNoPathGetRequestHeadersAndApiHeadersExample() throws {
// Given
let builder = NoQueryNoPathGetWithHeadersRequestExample(auth: "auth-value")
let api = GitHubAPI.build(using: Invoker.defaultHTTPInvoker)
api.addHeader(.init(name: "spyder-additional-header", value: "Value!"))
// When
let urlRequest = try builder.urlRequest(for: api)
// Then
XCTAssertURLRequestEquals(
urlRequest,
headers: [
.init(name: "spyder-auth", value: "auth-value"),
.init(name: "spyder-additional-header", value: "Value!")
],
url: "https://api.github.com/api/v1/getRequestExample",
httpMethod: .get,
body: .none
)
}
}

// ========================================================================
// MARK: URLRequestBuilderTests: Query parameters tests
// ========================================================================
Expand Down

0 comments on commit 369a264

Please sign in to comment.