diff --git a/Sources/Spyder/API+Invoking.swift b/Sources/Spyder/API+Invoking.swift
index 66d0a32..ab426ae 100644
--- a/Sources/Spyder/API+Invoking.swift
+++ b/Sources/Spyder/API+Invoking.swift
@@ -1,4 +1,7 @@
import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
// MARK: API invoking functions
@@ -6,6 +9,7 @@ extension API {
public func invokeAndForget(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)
@@ -17,6 +21,7 @@ extension API {
public func invokeWaitingResponse(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)
@@ -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 {
diff --git a/Sources/Spyder/API+Logging.swift b/Sources/Spyder/API+Logging.swift
index cb0648b..62d2091 100644
--- a/Sources/Spyder/API+Logging.swift
+++ b/Sources/Spyder/API+Logging.swift
@@ -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(
@@ -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
+ }
+}
diff --git a/Sources/Spyder/API+Public.swift b/Sources/Spyder/API+Public.swift
new file mode 100644
index 0000000..62e1006
--- /dev/null
+++ b/Sources/Spyder/API+Public.swift
@@ -0,0 +1,10 @@
+extension API {
+ public func addHeader(_ header: Header) {
+ persistentHeaders.insert(header)
+ }
+ public var allHeaders: Set {
+ var dynamicHeaders = headersBuilder()
+ persistentHeaders.forEach { dynamicHeaders.insert($0) }
+ return dynamicHeaders
+ }
+}
diff --git a/Sources/Spyder/API+Types.swift b/Sources/Spyder/API+Types.swift
index ba19225..794d3f9 100644
--- a/Sources/Spyder/API+Types.swift
+++ b/Sources/Spyder/API+Types.swift
@@ -6,17 +6,19 @@ 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
@@ -24,6 +26,12 @@ public struct Header: Hashable {
}
}
+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"
@@ -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)
}
}
diff --git a/Sources/Spyder/API.swift b/Sources/Spyder/API.swift
index b3c68ad..8ea46c1 100644
--- a/Sources/Spyder/API.swift
+++ b/Sources/Spyder/API.swift
@@ -1,6 +1,9 @@
import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
-public struct API {
+public class API {
public typealias HeadersBuilder = () -> Set
public typealias Invoker = (URLRequest) async throws -> HTTPResponse
public typealias Logger = (_ message: String, _ complementaryMessage: String) -> Void
@@ -10,6 +13,7 @@ public struct API {
public let jsonEncoder: JSONEncoder
public let jsonDecoder: JSONDecoder
let headersBuilder: HeadersBuilder
+ var persistentHeaders: Set
let invoker: Invoker
let logger: Logger
let responseMiddlewares: [ResponseMiddleware]
@@ -19,6 +23,7 @@ public struct API {
jsonEncoder: JSONEncoder = .init(),
jsonDecoder: JSONDecoder = .init(),
headersBuilder: @escaping HeadersBuilder = { .init() },
+ persistentHeaders: Set = [],
invoker: @escaping Invoker,
logger: @escaping Logger = { _, _ in },
responseMiddlewares: [ResponseMiddleware] = []
@@ -29,6 +34,7 @@ public struct API {
self.jsonEncoder = jsonEncoder
self.jsonDecoder = jsonDecoder
self.headersBuilder = headersBuilder
+ self.persistentHeaders = persistentHeaders
self.invoker = invoker
self.logger = logger
self.responseMiddlewares = responseMiddlewares
diff --git a/Sources/Spyder/Builders/URLRequestBuilder.swift b/Sources/Spyder/Builders/URLRequestBuilder.swift
index 3dbf5d4..0703161 100644
--- a/Sources/Spyder/Builders/URLRequestBuilder.swift
+++ b/Sources/Spyder/Builders/URLRequestBuilder.swift
@@ -1,4 +1,7 @@
import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
public protocol URLRequestBuilder {
static var method: HTTPMethod { get }
@@ -13,7 +16,7 @@ extension URLRequestBuilder {
func urlRequest(for api: API) throws -> URLRequest {
let mirror = Mirror(reflecting: self)
var evaluatedPath = Self.path
- var headers: Set = api.headersBuilder()
+ var headers: Set = api.allHeaders
var queryItems: [URLQueryItem] = []
var httpBody: Data?
for child in mirror.children {
diff --git a/Tests/SpyderTests/APITests.swift b/Tests/SpyderTests/APITests.swift
index ede69f1..183a4e2 100644
--- a/Tests/SpyderTests/APITests.swift
+++ b/Tests/SpyderTests/APITests.swift
@@ -10,10 +10,10 @@ 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)!)
}
}
@@ -21,14 +21,39 @@ private enum TestInvoker {
// 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 = .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
diff --git a/Tests/SpyderTests/GitHubAPI.swift b/Tests/SpyderTests/GitHubAPI.swift
index f29580c..aafb32c 100644
--- a/Tests/SpyderTests/GitHubAPI.swift
+++ b/Tests/SpyderTests/GitHubAPI.swift
@@ -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
)
}
diff --git a/Tests/SpyderTests/URLRequestBuilderTests.swift b/Tests/SpyderTests/URLRequestBuilderTests.swift
index 41a3db7..64a3061 100644
--- a/Tests/SpyderTests/URLRequestBuilderTests.swift
+++ b/Tests/SpyderTests/URLRequestBuilderTests.swift
@@ -10,6 +10,7 @@ final class URLRequestBuilderTests: XCTestCase {
private func XCTAssertURLRequestEquals(
_ request: URLRequest,
+ headers: [Header] = [],
url: String,
httpMethod: HTTPMethod,
body: Data?
@@ -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)
@@ -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
// ========================================================================