From a3956d6870a3e14a6b9bdc91c34193ab83577f29 Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Tue, 10 Dec 2024 17:33:57 +0100 Subject: [PATCH] Request code WIP --- Package.swift | 4 +- Sources/SwiftDevRant/DevRantJSONCoder.swift | 19 ++++ ...ngStrategy+OptionalFractionalSeconds.swift | 38 +++++++ Sources/SwiftDevRant/Request/Logger.swift | 3 + Sources/SwiftDevRant/Request/Request.swift | 103 +++++++++++++++++- Sources/SwiftDevRant/SwiftDevRant.swift | 5 +- 6 files changed, 164 insertions(+), 8 deletions(-) create mode 100644 Sources/SwiftDevRant/DevRantJSONCoder.swift create mode 100644 Sources/SwiftDevRant/Request/JsonDecoder.DateDecodingStrategy+OptionalFractionalSeconds.swift create mode 100644 Sources/SwiftDevRant/Request/Logger.swift diff --git a/Package.swift b/Package.swift index c0b5a29..15a5be0 100644 --- a/Package.swift +++ b/Package.swift @@ -5,11 +5,13 @@ import PackageDescription let package = Package( name: "SwiftDevRant", + platforms: [.iOS(.v13), .macOS(.v10_15), .tvOS(.v13), .watchOS(.v6), .driverKit(.v19), .macCatalyst(.v13), .visionOS(.v1)], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "SwiftDevRant", - targets: ["SwiftDevRant"]), + targets: ["SwiftDevRant"] + ), ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. diff --git a/Sources/SwiftDevRant/DevRantJSONCoder.swift b/Sources/SwiftDevRant/DevRantJSONCoder.swift new file mode 100644 index 0000000..9b38d4e --- /dev/null +++ b/Sources/SwiftDevRant/DevRantJSONCoder.swift @@ -0,0 +1,19 @@ +import Foundation + +extension JSONEncoder { + static let devRant: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.keyEncodingStrategy = .convertToSnakeCase + return encoder + }() +} + +extension JSONDecoder { + static let devRant: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601WithOptionalFractionalSeconds + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder + }() +} diff --git a/Sources/SwiftDevRant/Request/JsonDecoder.DateDecodingStrategy+OptionalFractionalSeconds.swift b/Sources/SwiftDevRant/Request/JsonDecoder.DateDecodingStrategy+OptionalFractionalSeconds.swift new file mode 100644 index 0000000..3dd49b0 --- /dev/null +++ b/Sources/SwiftDevRant/Request/JsonDecoder.DateDecodingStrategy+OptionalFractionalSeconds.swift @@ -0,0 +1,38 @@ +import Foundation + +public extension JSONDecoder.DateDecodingStrategy { + nonisolated(unsafe) private static let dateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + + nonisolated(unsafe) private static let dateFormatterWithFractionalSeconds: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + static let iso8601WithOptionalFractionalSeconds: Self = { + return .custom { (decoder) -> Date in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + + // Workaround to work with both, fractional seconds and whole seconds. + + // Try whole seconds first: + + if let date = dateFormatter.date(from: dateString) { + return date + } + + // If it fails, try fractional: + + if let date = dateFormatterWithFractionalSeconds.date(from: dateString) { + return date + } + + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Could not decode date from \(dateString).") + } + }() +} diff --git a/Sources/SwiftDevRant/Request/Logger.swift b/Sources/SwiftDevRant/Request/Logger.swift new file mode 100644 index 0000000..7216f97 --- /dev/null +++ b/Sources/SwiftDevRant/Request/Logger.swift @@ -0,0 +1,3 @@ +public protocol Logger { + func log(_ message: String) +} diff --git a/Sources/SwiftDevRant/Request/Request.swift b/Sources/SwiftDevRant/Request/Request.swift index ab7faf4..6673c8c 100644 --- a/Sources/SwiftDevRant/Request/Request.swift +++ b/Sources/SwiftDevRant/Request/Request.swift @@ -4,20 +4,22 @@ public struct Request { public enum Error: Swift.Error, CustomStringConvertible { case notHttpResponse case notFound + case noInternet case apiError(_ error: ApiError) case generalError public var description: String { switch self { - case .notHttpResponse: "response is not HTTP" + case .notHttpResponse: "Response is not HTTP" case .notFound: "Not found" + case .noInternet: "No internet connection" case .apiError(error: let error): "\(error)" case .generalError: "General error" } } } - struct EmptyError: Decodable { + public struct EmptyError: Decodable { } @@ -37,9 +39,19 @@ public struct Request { var headers: [String: String] = [:] } - var session = URLSession(configuration: .ephemeral) + let encoder: JSONEncoder + let decoder: JSONDecoder + let session: URLSession + let logger: Logger? - func makeURLRequest(config: Config, body: Data?) -> URLRequest { + public init(encoder: JSONEncoder, decoder: JSONDecoder, session: URLSession = .init(configuration: .ephemeral), logger: Logger? = nil) { + self.encoder = encoder + self.decoder = decoder + self.session = session + self.logger = logger + } + + private func makeURLRequest(config: Config, body: Data?) -> URLRequest { let urlQuery = urlEncodedQueryString(from: config.urlParameters) guard let url = URL(string: config.backend.baseURL + config.path + urlQuery) else { fatalError("Couldn't create a URL") @@ -53,7 +65,7 @@ public struct Request { return urlRequest } - func urlEncodedQueryString(from query: [String: String]) -> String { + private func urlEncodedQueryString(from query: [String: String]) -> String { guard !query.isEmpty else { return "" } var components = URLComponents() components.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) } @@ -61,4 +73,85 @@ public struct Request { let plusCorrection = absoluteString.replacingOccurrences(of: "+", with: "%2b") return plusCorrection } + + @discardableResult private func requestData(urlRequest: URLRequest, apiError: ApiError.Type = EmptyError.self) async throws(Error) -> (data: Data, headers: [AnyHashable: Any]) { + do { + let response = try await session.data(for: urlRequest) + + if let httpResponse = response.1 as? HTTPURLResponse { + let data = response.0 + + if let logger { + let logInputString = urlRequest.httpBody.flatMap { jsonString(data: $0, prettyPrinted: true) } ?? "(none)" + let logOutputString = !data.isEmpty ? jsonString(data: data, prettyPrinted: true) ?? "-" : "(none)" + logger.log("\(urlRequest.httpMethod?.uppercased() ?? "?") \(urlRequest.url?.absoluteString ?? "")\nbody: \(logInputString)\nresponse: \(logOutputString)") + } + + if (200..<300).contains(httpResponse.statusCode) { + return (data, httpResponse.allHeaderFields) + } else if httpResponse.statusCode == 404 { + throw Error.notFound + } else { + throw Error.apiError(try decoder.decode(apiError, from: data)) + } + } else { + throw Error.notHttpResponse + } + } catch { + if let error = error as? URLError, error.code == .notConnectedToInternet { + throw Error.noInternet + } else { + throw Error.generalError + } + } + } + + /// JSON Data to String converter for printing/logging purposes + private func jsonString(data: Data, prettyPrinted: Bool) -> String? { + do { + let writingOptions: JSONSerialization.WritingOptions = prettyPrinted ? [.prettyPrinted] : [] + let decoded: Data? + if String(data: data, encoding: .utf8) == "null" { + decoded = nil + } else if let string = String(data: data, encoding: .utf8), string.first == "\"", string.last == "\"" { + decoded = data + } else if let encodedDict = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { + decoded = try JSONSerialization.data(withJSONObject: encodedDict, options: writingOptions) + } else if let encodedArray = try JSONSerialization.jsonObject(with: data, options: []) as? [Any] { + decoded = try JSONSerialization.data(withJSONObject: encodedArray, options: writingOptions) + } else { + decoded = nil + } + return decoded.flatMap { String(data: $0, encoding: .utf8) } + } catch { + print(error) + return String(data: data, encoding: .utf8) + } + } + + // MARK: public + + public func requestJson(config: Config, apiError: ApiError.Type = EmptyError.self) async throws(Error) { + let urlRequest = makeURLRequest(config: config, body: nil) + try await requestData(urlRequest: urlRequest, apiError: apiError) + } + + public func requestJson(config: Config, json: In, apiError: ApiError.Type = EmptyError.self) async throws { + let inData = try encoder.encode(json) + let urlRequest = makeURLRequest(config: config, body: inData) + try await requestData(urlRequest: urlRequest, apiError: apiError) + } + + public func requestJson(config: Config, apiError: ApiError.Type = EmptyError.self) async throws -> Out { + let urlRequest = makeURLRequest(config: config, body: nil) + let outData = try await requestData(urlRequest: urlRequest, apiError: apiError).data + return try decoder.decode(Out.self, from: outData) + } + + public func requestJson(config: Config, json: In, apiError: ApiError.Type = EmptyError.self) async throws -> Out { + let inData = try encoder.encode(json) + let urlRequest = makeURLRequest(config: config, body: inData) + let outData = try await requestData(urlRequest: urlRequest, apiError: apiError).data + return try decoder.decode(Out.self, from: outData) + } } diff --git a/Sources/SwiftDevRant/SwiftDevRant.swift b/Sources/SwiftDevRant/SwiftDevRant.swift index 08b22b8..8292d50 100644 --- a/Sources/SwiftDevRant/SwiftDevRant.swift +++ b/Sources/SwiftDevRant/SwiftDevRant.swift @@ -1,2 +1,3 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book +public struct SwiftDevRant { + let request = Request(encoder: .devRant, decoder: .devRant) +}