diff --git a/.gitignore b/.gitignore index 484a5a0..ad51278 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ Package.resolved .swiftpm .icloud .vscode +.idea docs diff --git a/Sources/Squid/Core/Body/HttpData+Json.swift b/Sources/Squid/Core/Body/HttpData+Json.swift index d990f9e..50c5e1c 100644 --- a/Sources/Squid/Core/Body/HttpData+Json.swift +++ b/Sources/Squid/Core/Body/HttpData+Json.swift @@ -25,7 +25,7 @@ extension HttpData { /// - Parameter encoder: The JSON encoder to use for encoding. When set to `nil`, a JSON /// encoder is used where camel case attribute names are converted into /// snake case. - public init(_ value: T, encoder: JSONEncoder? = nil) { + public init(_ value: T, encoder: JSONEncoder? = SquidCoders.shared.encoder) { self.value = value self.encoder = encoder ?? snakeCaseJSONEncoder() } diff --git a/Sources/Squid/Core/Request/HttpHeader.swift b/Sources/Squid/Core/Request/HttpHeader.swift index 2cb0cbd..c714598 100644 --- a/Sources/Squid/Core/Request/HttpHeader.swift +++ b/Sources/Squid/Core/Request/HttpHeader.swift @@ -60,6 +60,16 @@ extension HttpHeader: Hashable { } } +extension HttpHeader: CustomStringConvertible { + public var description: String { + var headers: [String: String] = [:] + fields.forEach { (key: HttpHeader.Field, value: String) in + headers[key.name] = value + } + return headers.httpHeaderDescription ?? "" + } +} + extension HttpHeader: ExpressibleByDictionaryLiteral { public init(dictionaryLiteral elements: (Field, String)...) { diff --git a/Sources/Squid/Core/Request/HttpQuery.swift b/Sources/Squid/Core/Request/HttpQuery.swift index f5c97b8..daba0e3 100644 --- a/Sources/Squid/Core/Request/HttpQuery.swift +++ b/Sources/Squid/Core/Request/HttpQuery.swift @@ -61,6 +61,19 @@ extension HttpQuery: ExpressibleByDictionaryLiteral { } } +extension HttpQuery: CustomStringConvertible { + public var description: String { + parameters + .reduce([]) { (result: [String], tuple: (key: String, value: String)) -> [String] in + var res: [String] = [] + res.append(contentsOf: result) + res.append("\(tuple.key)=\(tuple.value)") + return res + } + .joined(separator: "&") + } +} + extension HttpQuery: Equatable { public static func == (lhs: HttpQuery, rhs: HttpQuery) -> Bool { diff --git a/Sources/Squid/Core/Request/HttpRoute.swift b/Sources/Squid/Core/Request/HttpRoute.swift index bef12df..14e73dd 100644 --- a/Sources/Squid/Core/Request/HttpRoute.swift +++ b/Sources/Squid/Core/Request/HttpRoute.swift @@ -52,6 +52,12 @@ extension HttpRoute: Hashable { } } +extension HttpRoute: CustomStringConvertible { + public var description: String { + paths.joined(separator: "/") + } +} + // MARK: Operators extension HttpRoute { diff --git a/Sources/Squid/Request/NetworkRequest.swift b/Sources/Squid/Request/NetworkRequest.swift index add6872..ae91945 100644 --- a/Sources/Squid/Request/NetworkRequest.swift +++ b/Sources/Squid/Request/NetworkRequest.swift @@ -7,10 +7,14 @@ import Foundation +public protocol RequestStringConvertible { + func description(with service: S) -> String where S: HttpService +} + /// The network request is a protocol that serves as a base protocol for requests on remote servers. /// The protocol is mainly used as a common base for `Request` and `StreamRequest`. You should /// never directly use this protocol as it hardly provides any functionality. -public protocol NetworkRequest { +public protocol NetworkRequest: RequestStringConvertible { // MARK: Protocol /// Whether the request makes use of the secure counterpart of the protocol (e.g. "https" for @@ -78,4 +82,14 @@ extension NetworkRequest { public var timeout: TimeInterval { return TimeInterval.infinity } + + public func description(with service: S) -> String where S: HttpService { + let urlString = "\(routes.description)\(query.description.isEmpty ? "" : "?")\(query.description)" + let headersString = [service.header.description,header.description].filter({$0.isEmpty == false}).joined(separator: "\n") + return """ + - Route: \(urlString) + - Headers: \(headersString.indent(spaces: 12, skipLines: 1)) + """ + } } + diff --git a/Sources/Squid/Request/Request.swift b/Sources/Squid/Request/Request.swift index b324b56..e63921c 100644 --- a/Sources/Squid/Request/Request.swift +++ b/Sources/Squid/Request/Request.swift @@ -131,6 +131,17 @@ extension Request { zeroBasedPageIndex: zeroBasedPageIndex, decode: decode ) } + + public func description(with service: S) -> String where S: HttpService { + let urlString = "\(routes.description)\(query.description.isEmpty ? "" : "?")\(query.description)" + let headersString = [service.header.description,header.description].filter({$0.isEmpty == false}).joined(separator: "\n") + return """ + - Method: \(method.name) + - Route: \(urlString) + - Headers: \(headersString.indent(spaces: 12, skipLines: 1)) + - Body: \(body.description.indent(spaces: 12, skipLines: 1)) + """ + } } extension Request where Result == Data { @@ -174,28 +185,13 @@ extension Request { /// working with a JSON API where the returned data is a JSON object. As a requirement, the /// request's result type must implement the `Decodable` protocol. The `decode(_:)` method is then /// synthesized automatically by using a `JSONDecoder` and decoding the raw data to the specified -/// type. `decodeSnakeCase` can further be used to modify the behavior of the aforementioned -/// decoder. -public protocol JsonRequest: Request where Result: Decodable { - - /// Defines whether the decoder decoding the raw data to the result type should consider - /// camel case in the Swift code as snake case in the JSON (i.e. `userID` would be parsed from - /// the field `user_id` if not specified explicity in the type to decode to). By default, - /// attributes are decoded using snake case attribute names. - var decodeSnakeCase: Bool { get } -} +/// type. +public protocol JsonRequest: Request where Result: Decodable { } extension JsonRequest { - public var decodeSnakeCase: Bool { - return true - } - public func decode(_ data: Data) throws -> Result { - let decoder = JSONDecoder() - if self.decodeSnakeCase { - decoder.keyDecodingStrategy = .convertFromSnakeCase - } + let decoder = SquidCoders.shared.decoder return try decoder.decode(Result.self, from: data) } } @@ -226,10 +222,7 @@ extension JsonRequest { return Paginator( base: self, service: service, chunk: chunk, zeroBasedPageIndex: zeroBasedPageIndex ) { data, _ -> P in - let decoder = JSONDecoder() - if self.decodeSnakeCase { - decoder.keyDecodingStrategy = .convertFromSnakeCase - } + let decoder = SquidCoders.shared.decoder return try decoder.decode(P.self, from: data) } } diff --git a/Sources/Squid/Scheduler/NetworkScheduler.swift b/Sources/Squid/Scheduler/NetworkScheduler.swift index 20bf0a8..8d56f09 100644 --- a/Sources/Squid/Scheduler/NetworkScheduler.swift +++ b/Sources/Squid/Scheduler/NetworkScheduler.swift @@ -100,7 +100,7 @@ internal class NetworkScheduler { let response = request .responsePublisher( service: service, session: session, socket: socket, requestId: requestId - ).handleFailureServiceHook(service.hook) + ) .tryMap { result -> Result in switch result { case .success(let message): @@ -109,6 +109,7 @@ internal class NetworkScheduler { return .failure(error) } }.mapError(Squid.Error.ensure(_:)) + .handleFailureServiceHook(service.hook, for: request) .mapError(service.mapError(_:)) .subscribe(on: queue) diff --git a/Sources/Squid/Service/Hooks/ServiceHook.swift b/Sources/Squid/Service/Hooks/ServiceHook.swift index da27d62..6a8d16f 100644 --- a/Sources/Squid/Service/Hooks/ServiceHook.swift +++ b/Sources/Squid/Service/Hooks/ServiceHook.swift @@ -46,7 +46,7 @@ public protocol ServiceHook { /// the user. /// /// - Parameter error: The error that caused a request to fail. - func onFailure(_ error: Error) + func onFailure(_ error: Error, _ urlRequest: R) where R: NetworkRequest } extension ServiceHook { @@ -64,7 +64,7 @@ extension ServiceHook { } /// By default, no operation is performed. - public func onFailure(_ error: Error) { + public func onFailure(_ error: Error, _ urlRequest: R) where R: NetworkRequest { return } } @@ -95,22 +95,22 @@ extension Publisher { hook.onSuccess(request, output.1, result: output.0.body) }, receiveCompletion: { completion in if case .failure(let error) = completion { - hook.onFailure(error) + hook.onFailure(error, request) } }) } - internal func handleFailureServiceHook( - _ hook: ServiceHook - ) -> Publishers.HandleEvents where E: Error, Output == Result { + internal func handleFailureServiceHook( + _ hook: ServiceHook, for request: R + ) -> Publishers.HandleEvents where R:StreamRequest, Output == Result { return self.handleEvents( receiveOutput: { output in if case .failure(let error) = output { - hook.onFailure(error) + hook.onFailure(error, request) } }, receiveCompletion: { completion in if case .failure(let error) = completion { - hook.onFailure(error) + hook.onFailure(error, request) } }) } diff --git a/Sources/Squid/StreamRequest/StreamRequest.swift b/Sources/Squid/StreamRequest/StreamRequest.swift index 5190b14..f9d3157 100644 --- a/Sources/Squid/StreamRequest/StreamRequest.swift +++ b/Sources/Squid/StreamRequest/StreamRequest.swift @@ -122,38 +122,18 @@ extension StreamRequest where Result == String { /// messages are always encoded to `Data` for a more efficient transmission. Messages received can /// be either `String` or `Data`: in both cases, they are decoded equally (note that `Data` messages /// might be more efficient. -public protocol JsonStreamRequest: StreamRequest where Message: Encodable, Result: Decodable { - - /// Defines whether the encoder and decoder camel case in the Swift code as snake case in the - /// JSON (i.e. `userID` would be encoded as/decoded from the field `user_id` if not specified - /// explicity in the type to decode to). By default, attributes are encoded/decoded using snake - /// case attribute names. - var decodeSnakeCase: Bool { get } -} - -extension JsonStreamRequest { - - public var decodeSnakeCase: Bool { - return true - } -} +public protocol JsonStreamRequest: StreamRequest where Message: Encodable, Result: Decodable {} extension JsonStreamRequest { public func encode(_ message: Message) throws -> URLSessionWebSocketTask.Message { - let encoder = JSONEncoder() - if self.decodeSnakeCase { - encoder.keyEncodingStrategy = .convertToSnakeCase - } + let encoder = SquidCoders.shared.encoder return .data(try encoder.encode(message)) } public func decode(_ message: URLSessionWebSocketTask.Message) throws -> Result { - let decoder = JSONDecoder() - if self.decodeSnakeCase { - decoder.keyDecodingStrategy = .convertFromSnakeCase - } + let decoder = SquidCoders.shared.decoder switch message { case .string(let string): diff --git a/Sources/Squid/Utils/SquidCoders.swift b/Sources/Squid/Utils/SquidCoders.swift new file mode 100644 index 0000000..391eaa5 --- /dev/null +++ b/Sources/Squid/Utils/SquidCoders.swift @@ -0,0 +1,9 @@ +import Foundation + +public class SquidCoders { + private init() {} + + public static let shared = SquidCoders() + public var decoder = JSONDecoder() + public var encoder = JSONEncoder() +} diff --git a/Squid.xcodeproj/project.pbxproj b/Squid.xcodeproj/project.pbxproj index 555cbe9..e32c36f 100644 --- a/Squid.xcodeproj/project.pbxproj +++ b/Squid.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 609C3596948A29D590F2BCE7 /* SquidCoders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 609C34B982B6A7563A33C91F /* SquidCoders.swift */; }; C908D40024218035008352C0 /* ServiceHookTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C908D3FF24218035008352C0 /* ServiceHookTests.swift */; }; C908D402242180A5008352C0 /* Hook.swift in Sources */ = {isa = PBXBuildFile; fileRef = C908D401242180A5008352C0 /* Hook.swift */; }; C911D3CC23E7C2570065CD7D /* Squid.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C911D3C223E7C2570065CD7D /* Squid.framework */; }; @@ -86,6 +87,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 609C34B982B6A7563A33C91F /* SquidCoders.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SquidCoders.swift; sourceTree = ""; }; C908D3FF24218035008352C0 /* ServiceHookTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceHookTests.swift; sourceTree = ""; }; C908D401242180A5008352C0 /* Hook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hook.swift; sourceTree = ""; }; C911D3C223E7C2570065CD7D /* Squid.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Squid.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -360,6 +362,7 @@ C911D41023E7C3010065CD7D /* Dictionary.swift */, C911D41123E7C3010065CD7D /* Url.swift */, C911D41223E7C3010065CD7D /* String.swift */, + 609C34B982B6A7563A33C91F /* SquidCoders.swift */, ); path = Utils; sourceTree = ""; @@ -646,6 +649,7 @@ C911D42323E7C3010065CD7D /* HttpData+Image.swift in Sources */, C9F2E25424155181007F2977 /* ServiceHook.swift in Sources */, C911D43223E7C3010065CD7D /* AnyRetrierFactory.swift in Sources */, + 609C3596948A29D590F2BCE7 /* SquidCoders.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; };