diff --git a/README.md b/README.md index 9b35edb..9f10f48 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Meet **Snowdrop** - type-safe, easy to use framework powered by Swift Macros cre - [Interceptors](#interceptors) - [Mockable](#mockable) - [JSON Injection](#json-injection) + - [Verbose](#verbose) - [Acknowledgements](#acknowledgements) ## Installation @@ -219,11 +220,11 @@ func testEmptyArrayResult() async throws { ### JSON Injection -If you'd like to test your service against mocked JSONs, you can easily do it. Just make sure you got your JSON mock somewhere in your project files, then instantiate your service with `testMode` flag set to `true` and determine for which request your mock should be injected like in the example below. +If you'd like to test your service against mocked JSONs, you can easily do it. Just make sure you got your JSON mock somewhere in your project files, then instantiate your service and determine for which request your mock should be injected like in the example below. ```Swift func testJSONMockInjectsion() async throws { - let service = MyEndpointService(baseUrl: someBaseURL, testMode: true) + let service = MyEndpointService(baseUrl: someBaseURL) service.testJSONDictionary = ["users/123/info": "MyJSONMock"] let result = try await service.getUserInfo(id: 123) @@ -232,6 +233,14 @@ func testJSONMockInjectsion() async throws { } ``` +### Verbose + +If you'd like to get see logs from Snowdrop, use `verbose` flag when creating new instance of your service. + +```Swift +let service = MyEndpointService(baseUrl: URL(string: "https://my-endpoint.com")!, verbose: true) +``` + ## Acknowledgements Retrofit was an inspiration for Snowdrop. diff --git a/Sources/Snowdrop/Core/SnowdropCore.swift b/Sources/Snowdrop/Core/SnowdropCore.swift index 9e7dd5a..67f5129 100644 --- a/Sources/Snowdrop/Core/SnowdropCore.swift +++ b/Sources/Snowdrop/Core/SnowdropCore.swift @@ -6,30 +6,27 @@ // import Foundation +import OSLog // MARK: Request perform methods public extension Snowdrop.Core { + private var logger: Logger { Logger() } + @discardableResult func performRequest( _ request: URLRequest, - baseUrl: URL, - pinning: PinningMode?, - urlsExcludedFromPinning: [String], - requestBlocks: [String: RequestHandler], - responseBlocks: [String: ResponseHandler], - testJSONDictionary: [String: String]? + service: Service ) async throws -> (Data?, HTTPURLResponse) { - let session = getSession(pinningMode: pinning, urlsExcludedFromPinning: urlsExcludedFromPinning) + let session = getSession(pinningMode: service.pinningMode, urlsExcludedFromPinning: service.urlsExcludedFromPinning) var data: Data? var urlResponse: URLResponse? var finalRequest = request - applyRequestBlocks(requestBlocks, for: &finalRequest) + applyRequestBlocks(service.requestBlocks, for: &finalRequest) do { - (data, urlResponse) = try await executeRequest(baseUrl: baseUrl, session: session, request: finalRequest, testJSONDictionary: testJSONDictionary) - session.finishTasksAndInvalidate() + (data, urlResponse) = try await executeRequest(finalRequest, session: session, service: service) } catch { throw handleError(error as NSError) } @@ -38,36 +35,24 @@ public extension Snowdrop.Core { try handleNon200Code(from: response, data: data) guard var finalData = data else { return (data, response) } - applyResponseBlocks(responseBlocks, forData: &finalData, response: &response) + applyResponseBlocks(service.responseBlocks, forData: &finalData, response: &response) + log(level: .info, message: "Request finished. Response:\n\(String(data: finalData, encoding: .utf8) ?? "")", execute: service.verbose) return (finalData, response) } func performRequestAndDecode( _ request: URLRequest, - baseUrl: URL, - decoder: JSONDecoder, - pinning: PinningMode?, - urlsExcludedFromPinning: [String], - requestBlocks: [String: RequestHandler], - responseBlocks: [String: ResponseHandler], - testJSONDictionary: [String: String]? + service: Service ) async throws -> T { - let (data, _) = try await performRequest( - request, - baseUrl: baseUrl, - pinning: pinning, - urlsExcludedFromPinning: urlsExcludedFromPinning, - requestBlocks: requestBlocks, - responseBlocks: responseBlocks, - testJSONDictionary: testJSONDictionary - ) + let (data, _) = try await performRequest(request, service: service) guard let unwrappedData = data else { throw SnowdropError(type: .unexpectedResponse) } do { - let decodedData = try decoder.decode(T.self, from: unwrappedData) + let decodedData = try service.decoder.decode(T.self, from: unwrappedData) return decodedData } catch { + log(level: .error, message: "Response decoding failed.", execute: service.verbose) throw SnowdropError( type: .failedToMapResponse, details: .init( @@ -80,14 +65,14 @@ public extension Snowdrop.Core { } } - func executeRequest(baseUrl: URL, session: URLSession, request: URLRequest, testJSONDictionary: [String: String]?) async throws -> (Data?, URLResponse?) { + func executeRequest(_ request: URLRequest, session: URLSession, service: Service) async throws -> (Data?, URLResponse?) { var data: Data? var urlResponse: URLResponse? let jsonPaths = [".json", ".JSON"].reduce([]) { $0 + Bundle.main.paths(forResourcesOfType: $1, inDirectory: nil) } - if let testJSONDictionary, + if let testJSONDictionary = service.testJSONDictionary, let requestUrl = request.url, - let key = testJSONDictionary.keys.first(where: { baseUrl.appendingPathComponent($0).absoluteString == requestUrl.absoluteString }), + let key = testJSONDictionary.keys.first(where: { service.baseUrl.appendingPathComponent($0).absoluteString == requestUrl.absoluteString }), let jsonName = testJSONDictionary[key], let jsonPath = jsonPaths.first(where: { $0.hasSuffix(jsonName + ".json") || $0.hasSuffix(jsonName + ".JSON") }), let jsonData = try? Data(contentsOf: URL(fileURLWithPath: jsonPath)) { @@ -98,9 +83,11 @@ public extension Snowdrop.Core { } do { + log(level: .info, message: "Executing request \(request.url?.absoluteString ?? "unknown").", execute: service.verbose) (data, urlResponse) = try await session.data(for: request) session.finishTasksAndInvalidate() } catch { + log(level: .error, message: "Request failed\n\(handleError(error as NSError))", execute: service.verbose) throw handleError(error as NSError) } @@ -237,6 +224,11 @@ private extension Snowdrop.Core { return $0 + ["\($1.key)=\(value)"] } } + + func log(level: OSLogType, message: String, execute: Bool) { + guard execute else { return } + logger.log(level: level, "[Snowdrop] \(message)") + } } fileprivate extension Collection where Element == String { diff --git a/Sources/Snowdrop/Service.swift b/Sources/Snowdrop/Service.swift index e92591c..2dd4d4c 100644 --- a/Sources/Snowdrop/Service.swift +++ b/Sources/Snowdrop/Service.swift @@ -18,12 +18,13 @@ public protocol Service { var decoder: JSONDecoder { get set } var pinningMode: PinningMode? { get set } var urlsExcludedFromPinning: [String] { get set } + var verbose: Bool { get } init(baseUrl: URL, pinningMode: PinningMode?, urlsExcludedFromPinning: [String], decoder: JSONDecoder, - testMode: Bool + verbose: Bool ) func addBeforeSendingBlock(for path: String?, _ block: @escaping RequestHandler) diff --git a/Sources/SnowdropMacros/ClassBuilder/ClassBuilder.swift b/Sources/SnowdropMacros/ClassBuilder/ClassBuilder.swift index b17c67f..acd6b93 100644 --- a/Sources/SnowdropMacros/ClassBuilder/ClassBuilder.swift +++ b/Sources/SnowdropMacros/ClassBuilder/ClassBuilder.swift @@ -37,20 +37,20 @@ struct ClassBuilder { \(raw: accessModifier)var decoder: JSONDecoder \(raw: accessModifier)var pinningMode: PinningMode? \(raw: accessModifier)var urlsExcludedFromPinning: [String] - private let testMode: Bool + \(raw: accessModifier)let verbose: Bool \(raw: accessModifier)required init( baseUrl: URL, pinningMode: PinningMode? = nil, urlsExcludedFromPinning: [String] = [], decoder: JSONDecoder = .init(), - testMode: Bool = false + verbose: Bool = false ) { self.baseUrl = baseUrl self.pinningMode = pinningMode self.urlsExcludedFromPinning = urlsExcludedFromPinning self.decoder = decoder - self.testMode = testMode + self.verbose = verbose } \(raw: ClassBuilder.buildBeforeSendingBlockFunc(for: type, accessModifier: accessModifier)) diff --git a/Sources/SnowdropMacros/Macros/Service/ServiceRequestBuilder.swift b/Sources/SnowdropMacros/Macros/Service/ServiceRequestBuilder.swift index b761c7c..fd497c6 100644 --- a/Sources/SnowdropMacros/Macros/Service/ServiceRequestBuilder.swift +++ b/Sources/SnowdropMacros/Macros/Service/ServiceRequestBuilder.swift @@ -60,28 +60,11 @@ struct ServiceRequestBuilder: ClassMethodBodyBuilderProtocol { if let _ = details.returnType { requestImpl += """ - return try\(details.doesThrow ? "" : "?") await Snowdrop.core.performRequestAndDecode( - request, - baseUrl: baseUrl, - decoder: decoder, - pinning: pinningMode, - urlsExcludedFromPinning: urlsExcludedFromPinning, - requestBlocks: requestBlocks, - responseBlocks: responseBlocks, - testJSONDictionary: testMode ? testJSONDictionary : nil - ) + return try\(details.doesThrow ? "" : "?") await Snowdrop.core.performRequestAndDecode(request, service: self) """ } else { requestImpl += """ - _ = try\(details.doesThrow ? "" : "?") await Snowdrop.core.performRequest( - request, - baseUrl: baseUrl, - pinning: pinningMode, - urlsExcludedFromPinning: urlsExcludedFromPinning, - requestBlocks: requestBlocks, - responseBlocks: responseBlocks, - testJSONDictionary: testMode ? testJSONDictionary : nil - ) + _ = try\(details.doesThrow ? "" : "?") await Snowdrop.core.performRequest(request, service: self) """ } diff --git a/Sources/SnowdropMacros/Utilities/PathVariableFinder.swift b/Sources/SnowdropMacros/Utilities/PathVariableFinder.swift index 9f88ce0..c534044 100644 --- a/Sources/SnowdropMacros/Utilities/PathVariableFinder.swift +++ b/Sources/SnowdropMacros/Utilities/PathVariableFinder.swift @@ -14,6 +14,7 @@ struct PathVariableFinder { private let shortCollectionPattern = #"[ ]{0,1}=[ ]{0,1}\[[a-zA-Z0-9\\.\\@\" \\/\[\]\:\(\)\!]*\]"# private let shortTupleLikePattern = #"[ ]{0,1}=[ ]{0,1}\([a-zA-Z0-9\\.\\@\" \,\\/\[\]\:\(\)\!]*\)"# + private let simpleRegex = try? NSRegularExpression(pattern: #"\{[a-z]+[a-zA-Z0-9]+\}"#) private let numericVarRegex = try? NSRegularExpression(pattern: #"\{[a-z]+[a-zA-Z0-9]+[ ]{0,1}=[ ]{0,1}[0-9a-zA-Z\\.]*\}"#) private let stringRegex = try? NSRegularExpression(pattern: #"\{[a-z]+[a-zA-Z0-9]+[ ]{0,1}=[ ]{0,1}\"[a-zA-Z0-9\\.\\@]*\"\}"#) private let instanceRegex = try? NSRegularExpression(pattern: #"\{[a-z]+[a-zA-Z0-9]+[ ]{0,1}=[ ]{0,1}[a-zA-Z0-9\\.]+\([a-zA-Z0-9\\.\\@\" \\/\[\]\:\(\)\!]+\)[\!]{0,1}\}"#) @@ -47,12 +48,13 @@ struct PathVariableFinder { guard let url else { return "" } var outcome = url - let regexes = [ + let regexes: [(String?, NSRegularExpression?)] = [ (shortNumericVarPattern, numericVarRegex), (shortStringPattern, stringRegex), (shortInstancePattern, instanceRegex), (shortCollectionPattern, collectionRegex), - (shortTupleLikePattern, tupleLikeRegex) + (shortTupleLikePattern, tupleLikeRegex), + (nil, simpleRegex) ] regexes.forEach { shortRegex, regex in @@ -70,7 +72,11 @@ struct PathVariableFinder { outcome = outcome .replacingOccurrences(of: "}", with: ")", range: range) .replacingOccurrences(of: "{", with: "\\(", range: range) - .replacingOccurrences(of: shortRegex, with: "", options: .regularExpression, range: range) + + if let shortRegex { + outcome = outcome + .replacingOccurrences(of: shortRegex, with: "", options: .regularExpression, range: range) + } } } diff --git a/Tests/SnowdropMacrosTests/SnowdropMacrosTests.swift b/Tests/SnowdropMacrosTests/SnowdropMacrosTests.swift index aa56431..877b63c 100644 --- a/Tests/SnowdropMacrosTests/SnowdropMacrosTests.swift +++ b/Tests/SnowdropMacrosTests/SnowdropMacrosTests.swift @@ -16,7 +16,7 @@ final class SnowdropMacrosTests: XCTestCase { """ @Service protocol TestEndpoint { - @GET(url: "/posts/{id=2}/comments") + @GET(url: "/posts/{id}/comments") @Headers(["Content-Type": "application/json"]) @Body("model") func getPosts(for id: Int, model: Model) async throws -> Post @@ -40,20 +40,20 @@ final class SnowdropMacrosTests: XCTestCase { var decoder: JSONDecoder var pinningMode: PinningMode? var urlsExcludedFromPinning: [String] - private let testMode: Bool + let verbose: Bool required init( baseUrl: URL, pinningMode: PinningMode? = nil, urlsExcludedFromPinning: [String] = [], decoder: JSONDecoder = .init(), - testMode: Bool = false + verbose: Bool = false ) { self.baseUrl = baseUrl self.pinningMode = pinningMode self.urlsExcludedFromPinning = urlsExcludedFromPinning self.decoder = decoder - self.testMode = testMode + self.verbose = verbose } func addBeforeSendingBlock(for path: String? = nil, _ block: @escaping RequestHandler) { @@ -80,12 +80,12 @@ final class SnowdropMacrosTests: XCTestCase { responseBlocks[key] = block } - func getPosts(for id: Int = 2, model: Model) async throws -> Post { + func getPosts(for id: Int, model: Model) async throws -> Post { let _queryItems: [QueryItem] = [] return try await getPosts(for: id, model: model, _queryItems: _queryItems) } - func getPosts(for id: Int = 2, model: Model, _queryItems: [QueryItem]) async throws -> Post { + func getPosts(for id: Int, model: Model, _queryItems: [QueryItem]) async throws -> Post { let url = baseUrl.appendingPathComponent("/posts/\\(id)/comments") let headers: [String: Any] = ["Content-Type": "application/json"] @@ -100,16 +100,7 @@ final class SnowdropMacrosTests: XCTestCase { request.httpBody = data - return try await Snowdrop.core.performRequestAndDecode( - request, - baseUrl: baseUrl, - decoder: decoder, - pinning: pinningMode, - urlsExcludedFromPinning: urlsExcludedFromPinning, - requestBlocks: requestBlocks, - responseBlocks: responseBlocks, - testJSONDictionary: testMode ? testJSONDictionary : nil - ) + return try await Snowdrop.core.performRequestAndDecode(request, service: self) } private func prepareBasicRequest(url: URL, method: String, queryItems: [QueryItem], headers: [String: Any]) -> URLRequest { @@ -172,20 +163,20 @@ final class SnowdropMacrosTests: XCTestCase { public var decoder: JSONDecoder public var pinningMode: PinningMode? public var urlsExcludedFromPinning: [String] - private let testMode: Bool + public let verbose: Bool public required init( baseUrl: URL, pinningMode: PinningMode? = nil, urlsExcludedFromPinning: [String] = [], decoder: JSONDecoder = .init(), - testMode: Bool = false + verbose: Bool = false ) { self.baseUrl = baseUrl self.pinningMode = pinningMode self.urlsExcludedFromPinning = urlsExcludedFromPinning self.decoder = decoder - self.testMode = testMode + self.verbose = verbose } public func addBeforeSendingBlock(for path: String? = nil, _ block: @escaping RequestHandler) { @@ -232,16 +223,7 @@ final class SnowdropMacrosTests: XCTestCase { request.httpBody = Snowdrop.core.dataWithBoundary(file, payloadDescription: _payloadDescription) - return try await Snowdrop.core.performRequestAndDecode( - request, - baseUrl: baseUrl, - decoder: decoder, - pinning: pinningMode, - urlsExcludedFromPinning: urlsExcludedFromPinning, - requestBlocks: requestBlocks, - responseBlocks: responseBlocks, - testJSONDictionary: testMode ? testJSONDictionary : nil - ) + return try await Snowdrop.core.performRequestAndDecode(request, service: self) } private func prepareBasicRequest(url: URL, method: String, queryItems: [QueryItem], headers: [String: Any]) -> URLRequest { @@ -304,20 +286,20 @@ final class SnowdropMacrosTests: XCTestCase { public var decoder: JSONDecoder public var pinningMode: PinningMode? public var urlsExcludedFromPinning: [String] - private let testMode: Bool + public let verbose: Bool public required init( baseUrl: URL, pinningMode: PinningMode? = nil, urlsExcludedFromPinning: [String] = [], decoder: JSONDecoder = .init(), - testMode: Bool = false + verbose: Bool = false ) { self.baseUrl = baseUrl self.pinningMode = pinningMode self.urlsExcludedFromPinning = urlsExcludedFromPinning self.decoder = decoder - self.testMode = testMode + self.verbose = verbose } public func addBeforeSendingBlock(for path: String? = nil, _ block: @escaping RequestHandler) { diff --git a/Tests/SnowdropTests/Endpoint.swift b/Tests/SnowdropTests/Endpoint.swift index a1bf3e6..975fa9b 100644 --- a/Tests/SnowdropTests/Endpoint.swift +++ b/Tests/SnowdropTests/Endpoint.swift @@ -21,8 +21,8 @@ public struct Comment: Codable { @Service @Mockable -public protocol TestEndpoint { - @GET(url: "/posts/{id=2}") +public protocol TestEndpointService { + @GET(url: "/posts/{id}") func getPost(id: Int) async throws -> Post @GET(url: "/posts/{id=4}/comments") diff --git a/Tests/SnowdropTests/SnowdropTests.swift b/Tests/SnowdropTests/SnowdropTests.swift index 4528add..2de4227 100644 --- a/Tests/SnowdropTests/SnowdropTests.swift +++ b/Tests/SnowdropTests/SnowdropTests.swift @@ -10,11 +10,11 @@ import XCTest final class SnowdropTests: XCTestCase { private let baseUrl = URL(string: "https://jsonplaceholder.typicode.com")! - private lazy var service = TestEndpointService(baseUrl: baseUrl) + private lazy var service = TestEndpointServiceImpl(baseUrl: baseUrl, verbose: true) private lazy var mock = TestEndpointServiceMock(baseUrl: baseUrl) func testGetTask() async throws { - let result = try await service.getPost() + let result = try await service.getPost(id: 2) XCTAssertTrue(result.id == 2) } @@ -81,14 +81,14 @@ final class SnowdropTests: XCTestCase { func testPositiveGetTaskMock() async throws { let post = Post(id: 1, userId: 1, title: "Mock title", body: "Mock body") mock.getPostResult = .success(post) - let result = try await mock.getPost() + let result = try await mock.getPost(id: 5) XCTAssertTrue(post.title == result.title) } func testNegativeGetTaskMock() async throws { mock.getPostResult = .failure(SnowdropError(type: .unexpectedResponse)) do { - let _ = try await mock.getPost() + let _ = try await mock.getPost(id: 4) } catch { let snowdropError = try XCTUnwrap(error as? SnowdropError) XCTAssertTrue(snowdropError.type == .unexpectedResponse)