diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index fa6da8c..2bf3d8b 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -21,6 +21,11 @@ jobs: - name: Checkout uses: actions/checkout@v6 + - name: Lint + if: ${{ runner.os == 'macOS' }} + shell: bash + run: swiftformat --lint . --reporter github-actions-log + - name: Package Resolution shell: bash run: swift package resolve @@ -32,3 +37,4 @@ jobs: - name: Test shell: bash run: swift test + \ No newline at end of file diff --git a/Package.resolved b/Package.resolved index a48568b..8d54dcc 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "12dcbffc746ed2d6353a2a4cd30fd1cb40d74c958f9a8bc5cde1c4e2c6c2e8db", + "originHash" : "4ed52ad35390bafcb2ee6b1a0349c7a3dde293cf3f1917f38939b88d1327f9c9", "pins" : [ { "identity" : "asyncplus", @@ -19,6 +19,15 @@ "version" : "1.6.2" } }, + { + "identity" : "swift-mutex", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swhitty/swift-mutex.git", + "state" : { + "revision" : "1770152df756b54c28ef1787df1e957d93cc62d5", + "version" : "0.0.6" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 6b8f122..221ebdb 100644 --- a/Package.swift +++ b/Package.swift @@ -26,6 +26,7 @@ let package = Package( .package(url: "https://github.com/swiftlang/swift-testing.git", branch: "swift-6.2-RELEASE"), .package(url: "https://github.com/richardpiazza/AsyncPlus.git", .upToNextMajor(from: "0.3.2")), .package(url: "https://github.com/apple/swift-log.git", from: "1.6.2"), + .package(url: "https://github.com/swhitty/swift-mutex.git", from: "0.0.6"), ], targets: [ .target( @@ -33,6 +34,7 @@ let package = Package( dependencies: [ .product(name: "AsyncPlus", package: "AsyncPlus"), .product(name: "Logging", package: "swift-log"), + .product(name: "Mutex", package: "swift-mutex"), ], ), .target( diff --git a/Sources/SessionPlus/Implementation/AbsoluteURLSessionClient.swift b/Sources/SessionPlus/Implementation/AbsoluteURLSessionClient.swift index d9bcaff..6a2239f 100644 --- a/Sources/SessionPlus/Implementation/AbsoluteURLSessionClient.swift +++ b/Sources/SessionPlus/Implementation/AbsoluteURLSessionClient.swift @@ -7,16 +7,36 @@ import Logging /// A `Client` implementation that operates expecting all requests use _absolute_ urls. open class AbsoluteURLSessionClient: Client { - public var verboseLogging: Bool = false + @available(*, deprecated) + public var verboseLogging: Bool { + get { logLevel.value == .trace } + set { setLogLevel(newValue ? .trace : .debug) } + } + public let session: URLSession private let logger: Logger = .sessionPlus + private let logLevel: ProtectedState = ProtectedState() + + public init( + sessionConfiguration: URLSessionConfiguration = .default, + sessionDelegate: (any URLSessionDelegate)? = nil, + ) { + session = URLSession( + configuration: sessionConfiguration, + delegate: sessionDelegate, delegateQueue: nil, + ) + } + + public var logLevelStream: AsyncStream { + logLevel.asyncStream + } - public init(sessionConfiguration: URLSessionConfiguration = .default, sessionDelegate: (any URLSessionDelegate)? = nil) { - session = URLSession(configuration: sessionConfiguration, delegate: sessionDelegate, delegateQueue: nil) + public func setLogLevel(_ level: Logger.Level) { + logLevel.setValue(level) } public func performRequest(_ request: any Request) async throws -> any Response { - if verboseLogging { + if logLevel.value > .trace { logger.debug("HTTP Request", metadata: request.verboseMetadata) } else { logger.trace("HTTP Request", metadata: request.metadata) @@ -46,7 +66,7 @@ open class AbsoluteURLSessionClient: Client { body: data, ) - if verboseLogging { + if logLevel.value > .trace { logger.debug("HTTP Response", metadata: response.verboseMetadata) } else { logger.trace("HTTP Response", metadata: response.metadata) diff --git a/Sources/SessionPlus/Implementation/BaseURLSessionClient.swift b/Sources/SessionPlus/Implementation/BaseURLSessionClient.swift index 4cdc847..611e7e9 100644 --- a/Sources/SessionPlus/Implementation/BaseURLSessionClient.swift +++ b/Sources/SessionPlus/Implementation/BaseURLSessionClient.swift @@ -7,10 +7,16 @@ import Logging /// A `Client` implementation that operates with a _base_ URL which all requests use to form the address. open class BaseURLSessionClient: Client { + @available(*, deprecated) + public var verboseLogging: Bool { + get { logLevel.value == .trace } + set { setLogLevel(newValue ? .trace : .debug) } + } + open var baseURL: URL - public var verboseLogging: Bool = false public let session: URLSession private let logger: Logger = .sessionPlus + private let logLevel: ProtectedState = ProtectedState() public init( baseURL: URL, @@ -25,8 +31,16 @@ open class BaseURLSessionClient: Client { ) } + public var logLevelStream: AsyncStream { + logLevel.asyncStream + } + + public func setLogLevel(_ level: Logger.Level) { + logLevel.setValue(level) + } + public func performRequest(_ request: any Request) async throws -> any Response { - if verboseLogging { + if logLevel.value > .trace { logger.debug("HTTP Request", metadata: request.verboseMetadata) } else { logger.trace("HTTP Request", metadata: request.metadata) @@ -56,7 +70,7 @@ open class BaseURLSessionClient: Client { body: data, ) - if verboseLogging { + if logLevel.value > .trace { logger.debug("HTTP Response", metadata: response.verboseMetadata) } else { logger.trace("HTTP Response", metadata: response.metadata) diff --git a/Sources/SessionPlus/Implementation/ProtectedState.swift b/Sources/SessionPlus/Implementation/ProtectedState.swift new file mode 100644 index 0000000..6b1e354 --- /dev/null +++ b/Sources/SessionPlus/Implementation/ProtectedState.swift @@ -0,0 +1,59 @@ +import Foundation +import Logging +import Mutex + +package class ProtectedState: @unchecked Sendable { + + private let state: Mutex + private let subscribers: Mutex<[UUID: AsyncStream.Continuation]> = Mutex([:]) + + package init(_ initialValue: T) { + state = Mutex(initialValue) + } + + package var value: T { + state.withLock { $0 } + } + + package var asyncStream: AsyncStream { + let id = UUID() + let asyncStream = AsyncStream.makeStream(of: T.self) + asyncStream.continuation.onTermination = { [weak self] _ in + self?.removeSubscriber(id) + } + addSubscriber(id, continuation: asyncStream.continuation) + + defer { + let level = state.withLock { $0 } + asyncStream.continuation.yield(level) + } + + return asyncStream.stream + } + + package func setValue(_ value: T) { + state.withLock { $0 = value } + let subscribers = subscribers.withLock { $0 } + for (_, continuation) in subscribers { + continuation.yield(value) + } + } + + private func addSubscriber(_ id: UUID, continuation: AsyncStream.Continuation) { + subscribers.withLock { + $0[id] = continuation + } + } + + private func removeSubscriber(_ id: UUID) { + subscribers.withLock { + $0[id] = nil + } + } +} + +package extension ProtectedState where T == Logger.Level { + convenience init(level: Logger.Level = .debug) { + self.init(level) + } +} diff --git a/Sources/SessionPlus/Interface/Client.swift b/Sources/SessionPlus/Interface/Client.swift index 984536b..d918419 100644 --- a/Sources/SessionPlus/Interface/Client.swift +++ b/Sources/SessionPlus/Interface/Client.swift @@ -1,9 +1,20 @@ import Foundation import Logging +// TODO: `Sendable` Conformance public protocol Client { + @available(*, deprecated, message: "Direct state access should be avoided.") var verboseLogging: Bool { get set } + /// Provides an `AsyncStream` with the clients `Logger.Level` state. + var logLevelStream: AsyncStream { get } + + /// Requests an adjustment to the `Client` logging level. + /// + /// The client implementations provided by this package primarily observe + /// `.trace`, `.debug`, & `.info`. + func setLogLevel(_ level: Logger.Level) + /// Perform a network `Request`. /// /// - parameters: @@ -19,8 +30,11 @@ public extension Client { /// - request: The details of the request to perform. /// - decoder: The `JSONDecoder` that should be used to deserialize the result data. /// - returns: The decoded `Response` value. - func performRequest(_ request: any Request, using decoder: JSONDecoder = JSONDecoder()) async throws -> Value where Value: Decodable { + func performRequest( + _ request: any Request, + using decoder: JSONDecoder = JSONDecoder(), + ) async throws -> Content where Content: Decodable { let response = try await performRequest(request) - return try decoder.decode(Value.self, from: response.body) + return try decoder.decode(Content.self, from: response.body) } } diff --git a/Sources/SessionPlusEmulation/EmulatedClient.swift b/Sources/SessionPlusEmulation/EmulatedClient.swift index d8bd432..bd0c8b9 100644 --- a/Sources/SessionPlusEmulation/EmulatedClient.swift +++ b/Sources/SessionPlusEmulation/EmulatedClient.swift @@ -1,4 +1,5 @@ import Foundation +import Logging import SessionPlus open class EmulatedClient: Client { @@ -35,32 +36,20 @@ open class EmulatedClient: Client { queryItems = request.queryItems body = request.body } - - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - address = try container.decode(Address.self, forKey: .address) - method = try container.decode(Method.self, forKey: .method) - headers = try container.decodeIfPresent([String: String].self, forKey: .headers) - queryItems = try container.decodeIfPresent([QueryItem].self, forKey: .queryItems) - body = try container.decodeIfPresent(Data.self, forKey: .body) - } - - public func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(address, forKey: .address) - try container.encode(method, forKey: .method) - try container.encodeIfPresent(headers, forKey: .headers) - try container.encodeIfPresent(queryItems, forKey: .queryItems) - try container.encodeIfPresent(body, forKey: .body) - } } public struct NotFound: Error {} public typealias Cache = [EmulatedRequest.ID: Result] - public var verboseLogging: Bool = false + @available(*, deprecated) + public var verboseLogging: Bool { + get { logLevel.value == .trace } + set { setLogLevel(newValue ? .trace : .debug) } + } + public var responseCache: Cache + private var logLevel: ProtectedState = ProtectedState() public init(responseCache: Cache = [:]) { self.responseCache = responseCache @@ -83,6 +72,14 @@ open class EmulatedClient: Client { responseCache[emulatedRequest.id] = .failure(error) } + public var logLevelStream: AsyncStream { + logLevel.asyncStream + } + + public func setLogLevel(_ level: Logger.Level) { + logLevel.setValue(level) + } + public func performRequest(_ request: any Request) async throws -> any Response { let id = EmulatedRequest(request).id guard let result = responseCache[id] else {