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 // ========================================================================