Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/swift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,3 +37,4 @@ jobs:
- name: Test
shell: bash
run: swift test

11 changes: 10 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@ 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(
name: "SessionPlus",
dependencies: [
.product(name: "AsyncPlus", package: "AsyncPlus"),
.product(name: "Logging", package: "swift-log"),
.product(name: "Mutex", package: "swift-mutex"),
],
),
.target(
Expand Down
30 changes: 25 additions & 5 deletions Sources/SessionPlus/Implementation/AbsoluteURLSessionClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Logger.Level> {
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)
Expand Down Expand Up @@ -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)
Expand Down
20 changes: 17 additions & 3 deletions Sources/SessionPlus/Implementation/BaseURLSessionClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,8 +31,16 @@ open class BaseURLSessionClient: Client {
)
}

public var logLevelStream: AsyncStream<Logger.Level> {
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)
Expand Down Expand Up @@ -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)
Expand Down
59 changes: 59 additions & 0 deletions Sources/SessionPlus/Implementation/ProtectedState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import Foundation
import Logging
import Mutex

package class ProtectedState<T: Sendable>: @unchecked Sendable {

private let state: Mutex<T>
private let subscribers: Mutex<[UUID: AsyncStream<T>.Continuation]> = Mutex([:])

package init(_ initialValue: T) {
state = Mutex(initialValue)
}

package var value: T {
state.withLock { $0 }
}

package var asyncStream: AsyncStream<T> {
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<T>.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)
}
}
18 changes: 16 additions & 2 deletions Sources/SessionPlus/Interface/Client.swift
Original file line number Diff line number Diff line change
@@ -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<Logger.Level> { 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:
Expand All @@ -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<Value>(_ request: any Request, using decoder: JSONDecoder = JSONDecoder()) async throws -> Value where Value: Decodable {
func performRequest<Content>(
_ 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)
}
}
35 changes: 16 additions & 19 deletions Sources/SessionPlusEmulation/EmulatedClient.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import Logging
import SessionPlus

open class EmulatedClient: Client {
Expand Down Expand Up @@ -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<any Response, any Error>]

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
Expand All @@ -83,6 +72,14 @@ open class EmulatedClient: Client {
responseCache[emulatedRequest.id] = .failure(error)
}

public var logLevelStream: AsyncStream<Logger.Level> {
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 {
Expand Down