Skip to content

Commit ecd9b19

Browse files
authored
Merge pull request #94 from playola-radio/develop
0.17.1
2 parents eae75ea + 5878f7b commit ecd9b19

7 files changed

Lines changed: 315 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Changelog
2+
3+
All notable changes to PlayolaPlayer are documented here. This project follows
4+
[Semantic Versioning](https://semver.org/). Versions correspond to git tags,
5+
which Swift Package Manager consumers pin to.
6+
7+
## 0.18.0
8+
9+
### Added
10+
11+
- **Network logging seam** for observing the library's JSON API traffic
12+
(binary audio downloads are intentionally excluded). New public API in
13+
`PlayolaCore`:
14+
- `PlayolaNetworkLogEvent` — a `Sendable` value describing a single
15+
request/response (method, url, request headers/body, status code,
16+
response body, duration, and any error description). Events are raw and
17+
unredacted; the consuming app is responsible for redacting sensitive
18+
values such as `Authorization` headers before storing them.
19+
- `PlayolaNetworkLogger.handler` — a thread-safe, `Sendable` hook the
20+
consuming app sets at startup to receive events. `nil` (the default)
21+
disables logging, leaving behavior and performance unchanged.
22+
- `PlayolaNetworkLoggingSession` — a transparent `URLSessionProtocol`
23+
wrapper that times each call and emits an event on both success and
24+
thrown error without altering return values or rethrown errors.
25+
26+
The schedule fetch (`PlayolaStationPlayer`) and the listening-session
27+
reporter (`ListeningSessionReporter`) now route their JSON API calls through
28+
this wrapper by default. No breaking API changes: existing initializers and
29+
call sites compile unchanged.
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
//
2+
// PlayolaNetworkLogger.swift
3+
// PlayolaPlayer
4+
//
5+
// Created by Brian D Keane on 6/4/26.
6+
//
7+
import Foundation
8+
import os
9+
10+
/// A single JSON API request/response observed by the library.
11+
///
12+
/// The library emits these raw — it does **not** redact, persist, or format
13+
/// anything. A consuming app is responsible for redacting sensitive values
14+
/// (e.g. `Authorization` headers) before storing or displaying them.
15+
public struct PlayolaNetworkLogEvent: Sendable {
16+
/// The time the request started.
17+
public let timestamp: Date
18+
/// The HTTP method (e.g. `GET`, `POST`).
19+
public let method: String
20+
/// The request URL, if available.
21+
public let url: URL?
22+
/// All HTTP header fields sent with the request, unredacted.
23+
public let requestHeaders: [String: String]
24+
/// The raw request body, if any.
25+
public let requestBody: Data?
26+
/// The HTTP status code of the response, or `nil` if the request threw.
27+
public let statusCode: Int?
28+
/// The raw response body, or `nil` if the request threw.
29+
public let responseBody: Data?
30+
/// The wall-clock duration of the request in milliseconds.
31+
public let durationMS: Int?
32+
/// A description of the thrown error, or `nil` on success.
33+
public let errorDescription: String?
34+
35+
public init(
36+
timestamp: Date,
37+
method: String,
38+
url: URL?,
39+
requestHeaders: [String: String],
40+
requestBody: Data?,
41+
statusCode: Int?,
42+
responseBody: Data?,
43+
durationMS: Int?,
44+
errorDescription: String?
45+
) {
46+
self.timestamp = timestamp
47+
self.method = method
48+
self.url = url
49+
self.requestHeaders = requestHeaders
50+
self.requestBody = requestBody
51+
self.statusCode = statusCode
52+
self.responseBody = responseBody
53+
self.durationMS = durationMS
54+
self.errorDescription = errorDescription
55+
}
56+
}
57+
58+
/// A logging seam for the library's JSON API traffic.
59+
///
60+
/// A consuming app sets ``handler`` at startup to observe the library's JSON
61+
/// API calls (binary audio downloads are intentionally excluded). When
62+
/// ``handler`` is `nil` (the default) no logging occurs and behavior and
63+
/// performance are unchanged.
64+
public enum PlayolaNetworkLogger {
65+
private static let _handler =
66+
OSAllocatedUnfairLock<(@Sendable (PlayolaNetworkLogEvent) -> Void)?>(initialState: nil)
67+
68+
/// The handler invoked after each JSON API request completes.
69+
///
70+
/// Set by the consuming app at startup. `nil` (the default) disables logging.
71+
/// Access is thread-safe.
72+
public static var handler: (@Sendable (PlayolaNetworkLogEvent) -> Void)? {
73+
get { _handler.withLock { $0 } }
74+
set { _handler.withLock { $0 = newValue } }
75+
}
76+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
//
2+
// PlayolaNetworkLoggingSession.swift
3+
// PlayolaPlayer
4+
//
5+
// Created by Brian D Keane on 6/4/26.
6+
//
7+
import Foundation
8+
9+
/// A ``URLSessionProtocol`` wrapper that times each call and emits a
10+
/// ``PlayolaNetworkLogEvent`` to ``PlayolaNetworkLogger/handler`` on both
11+
/// success and thrown error.
12+
///
13+
/// It is a transparent pass-through: it returns exactly what the wrapped
14+
/// session returns and rethrows any error unchanged — it never swallows or
15+
/// alters the result. When ``PlayolaNetworkLogger/handler`` is `nil` no event
16+
/// is built and the wrapper adds negligible overhead.
17+
public struct PlayolaNetworkLoggingSession: URLSessionProtocol, Sendable {
18+
private let base: any URLSessionProtocol & Sendable
19+
private let dateProvider: DateProviderProtocol
20+
21+
public init(
22+
wrapping base: any URLSessionProtocol & Sendable,
23+
dateProvider: DateProviderProtocol = DateProvider.shared
24+
) {
25+
self.base = base
26+
self.dateProvider = dateProvider
27+
}
28+
29+
public func data(for request: URLRequest) async throws -> (Data, URLResponse) {
30+
let start = dateProvider.now()
31+
do {
32+
let (data, response) = try await base.data(for: request)
33+
emit(request: request, start: start, data: data, response: response, error: nil)
34+
return (data, response)
35+
} catch {
36+
emit(request: request, start: start, data: nil, response: nil, error: error)
37+
throw error
38+
}
39+
}
40+
41+
private func emit(
42+
request: URLRequest,
43+
start: Date,
44+
data: Data?,
45+
response: URLResponse?,
46+
error: Error?
47+
) {
48+
guard let handler = PlayolaNetworkLogger.handler else { return }
49+
let durationMS = Int(dateProvider.now().timeIntervalSince(start) * 1000)
50+
let event = PlayolaNetworkLogEvent(
51+
timestamp: start,
52+
method: request.httpMethod ?? "GET",
53+
url: request.url,
54+
requestHeaders: request.allHTTPHeaderFields ?? [:],
55+
requestBody: request.httpBody,
56+
statusCode: (response as? HTTPURLResponse)?.statusCode,
57+
responseBody: data,
58+
durationMS: durationMS,
59+
errorDescription: error?.localizedDescription
60+
)
61+
handler(event)
62+
}
63+
}

Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ public class ListeningSessionReporter {
6666

6767
init(
6868
stationPlayer: PlayolaStationPlayer, authProvider: PlayolaAuthenticationProvider? = nil,
69-
urlSession: URLSessionProtocol = tls12Session,
69+
urlSession: URLSessionProtocol = PlayolaNetworkLoggingSession(wrapping: tls12Session),
7070
baseURL: URL = URL(string: "https://admin-api.playola.fm")!
7171
) {
7272
self.stationPlayer = stationPlayer
@@ -307,7 +307,7 @@ public class ListeningSessionReporter {
307307
#if DEBUG
308308
internal init(
309309
authProvider: PlayolaAuthenticationProvider? = nil,
310-
urlSession: URLSessionProtocol = tls12Session,
310+
urlSession: URLSessionProtocol = PlayolaNetworkLoggingSession(wrapping: tls12Session),
311311
baseURL: URL = URL(string: "https://admin-api.playola.fm")!
312312
) {
313313
self.stationPlayer = nil

Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,8 @@ final public class PlayolaStationPlayer: ObservableObject {
402402
let url = createScheduleURL(for: stationId)
403403

404404
do {
405-
let (data, response) = try await tls12Session.data(from: url)
405+
let loggingSession = PlayolaNetworkLoggingSession(wrapping: tls12Session)
406+
let (data, response) = try await loggingSession.data(for: URLRequest(url: url))
406407
let httpResponse = try validateHTTPResponse(response, url: url)
407408
try await validateStatusCode(httpResponse, data: data, stationId: stationId)
408409

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
//
2+
// PlayolaNetworkLoggingSessionTests.swift
3+
// PlayolaPlayer
4+
//
5+
// Created by Brian D Keane on 6/4/26.
6+
//
7+
import Foundation
8+
import PlayolaCore
9+
import Testing
10+
import os
11+
12+
@testable import PlayolaPlayer
13+
14+
/// Records events emitted to `PlayolaNetworkLogger.handler` in a thread-safe way.
15+
private final class EventRecorder: Sendable {
16+
private let storage = OSAllocatedUnfairLock<[PlayolaNetworkLogEvent]>(initialState: [])
17+
func record(_ event: PlayolaNetworkLogEvent) { storage.withLock { $0.append(event) } }
18+
var events: [PlayolaNetworkLogEvent] { storage.withLock { $0 } }
19+
}
20+
21+
/// A `DateProviderProtocol` that returns queued dates in order, falling back to
22+
/// `Date()` once exhausted. Lets tests assert an exact `durationMS`.
23+
private final class QueueDateProvider: Sendable, DateProviderProtocol {
24+
private let dates: OSAllocatedUnfairLock<[Date]>
25+
init(_ dates: [Date]) { self.dates = OSAllocatedUnfairLock(initialState: dates) }
26+
func now() -> Date {
27+
dates.withLock { $0.isEmpty ? Date() : $0.removeFirst() }
28+
}
29+
}
30+
31+
/// A session that always throws, to verify error pass-through.
32+
private struct ThrowingURLSession: URLSessionProtocol {
33+
let error: any Error & Sendable
34+
func data(for request: URLRequest) async throws -> (Data, URLResponse) { throw error }
35+
}
36+
37+
private struct StubError: Error, Equatable {
38+
let id: Int
39+
}
40+
41+
@Suite("PlayolaNetworkLoggingSession", .serialized)
42+
struct PlayolaNetworkLoggingSessionTests {
43+
44+
@Test("Emits an event on success with correct method, url, status, and duration")
45+
func emitsEventOnSuccess() async throws {
46+
let recorder = EventRecorder()
47+
PlayolaNetworkLogger.handler = { recorder.record($0) }
48+
defer { PlayolaNetworkLogger.handler = nil }
49+
50+
let url = URL(string: "https://test.com/v1/listeningSessions")!
51+
let requestBody = Data("{\"deviceId\":\"abc\"}".utf8)
52+
let responseBody = Data("{\"ok\":true}".utf8)
53+
54+
var request = URLRequest(url: url)
55+
request.httpMethod = "POST"
56+
request.httpBody = requestBody
57+
request.addValue("Bearer secret-token", forHTTPHeaderField: "Authorization")
58+
59+
let base = MockURLSession()
60+
base.addResponse(data: responseBody, statusCode: 200, url: url)
61+
62+
let start = Date(timeIntervalSince1970: 1000)
63+
let session = PlayolaNetworkLoggingSession(
64+
wrapping: base,
65+
dateProvider: QueueDateProvider([start, start.addingTimeInterval(0.25)]))
66+
67+
_ = try await session.data(for: request)
68+
69+
let events = recorder.events
70+
#expect(events.count == 1)
71+
let event = try #require(events.first)
72+
#expect(event.method == "POST")
73+
#expect(event.url == url)
74+
#expect(event.statusCode == 200)
75+
#expect(event.durationMS == 250)
76+
#expect(event.timestamp == start)
77+
#expect(event.requestBody == requestBody)
78+
#expect(event.responseBody == responseBody)
79+
#expect(event.requestHeaders["Authorization"] == "Bearer secret-token")
80+
#expect(event.errorDescription == nil)
81+
}
82+
83+
@Test("Transparently passes through the wrapped session's data and response")
84+
func passesThroughData() async throws {
85+
PlayolaNetworkLogger.handler = nil
86+
87+
let url = URL(string: "https://test.com/v1/stations/123/schedule")!
88+
let responseBody = Data("schedule-json".utf8)
89+
90+
let base = MockURLSession()
91+
base.addResponse(data: responseBody, statusCode: 201, url: url)
92+
93+
let session = PlayolaNetworkLoggingSession(wrapping: base)
94+
let (data, response) = try await session.data(for: URLRequest(url: url))
95+
96+
#expect(data == responseBody)
97+
#expect((response as? HTTPURLResponse)?.statusCode == 201)
98+
#expect(base.requestCallCount == 1)
99+
}
100+
101+
@Test("Emits an event on a thrown error and rethrows the original error unchanged")
102+
func emitsEventOnErrorAndRethrows() async throws {
103+
let recorder = EventRecorder()
104+
PlayolaNetworkLogger.handler = { recorder.record($0) }
105+
defer { PlayolaNetworkLogger.handler = nil }
106+
107+
let url = URL(string: "https://test.com/v1/listeningSessions")!
108+
let thrownError = StubError(id: 42)
109+
let session = PlayolaNetworkLoggingSession(wrapping: ThrowingURLSession(error: thrownError))
110+
111+
var caught: Error?
112+
do {
113+
_ = try await session.data(for: URLRequest(url: url))
114+
} catch {
115+
caught = error
116+
}
117+
118+
#expect(caught as? StubError == thrownError)
119+
120+
let events = recorder.events
121+
#expect(events.count == 1)
122+
let event = try #require(events.first)
123+
#expect(event.statusCode == nil)
124+
#expect(event.responseBody == nil)
125+
#expect(event.errorDescription != nil)
126+
#expect(event.url == url)
127+
}
128+
129+
@Test("Does not emit when no handler is set")
130+
func doesNotEmitWithoutHandler() async throws {
131+
PlayolaNetworkLogger.handler = nil
132+
133+
let url = URL(string: "https://test.com")!
134+
let base = MockURLSession()
135+
base.addResponse(data: Data(), statusCode: 200, url: url)
136+
137+
let session = PlayolaNetworkLoggingSession(wrapping: base)
138+
_ = try await session.data(for: URLRequest(url: url))
139+
140+
#expect(base.requestCallCount == 1)
141+
}
142+
}

Tests/PlayolaPlayerTests/Test Utilities/MockUrlSession.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import Foundation
88

99
@testable import PlayolaPlayer
1010

11-
class MockURLSession: URLSessionProtocol {
11+
final class MockURLSession: URLSessionProtocol, @unchecked Sendable {
1212
var responses: [(Data, URLResponse)] = []
1313
var requestCallCount = 0
1414
var lastRequest: URLRequest?

0 commit comments

Comments
 (0)