-
Notifications
You must be signed in to change notification settings - Fork 0
0.17.1 #94
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
0.17.1 #94
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| # Changelog | ||
|
|
||
| All notable changes to PlayolaPlayer are documented here. This project follows | ||
| [Semantic Versioning](https://semver.org/). Versions correspond to git tags, | ||
| which Swift Package Manager consumers pin to. | ||
|
|
||
| ## 0.18.0 | ||
|
|
||
| ### Added | ||
|
|
||
| - **Network logging seam** for observing the library's JSON API traffic | ||
| (binary audio downloads are intentionally excluded). New public API in | ||
| `PlayolaCore`: | ||
| - `PlayolaNetworkLogEvent` — a `Sendable` value describing a single | ||
| request/response (method, url, request headers/body, status code, | ||
| response body, duration, and any error description). Events are raw and | ||
| unredacted; the consuming app is responsible for redacting sensitive | ||
| values such as `Authorization` headers before storing them. | ||
| - `PlayolaNetworkLogger.handler` — a thread-safe, `Sendable` hook the | ||
| consuming app sets at startup to receive events. `nil` (the default) | ||
| disables logging, leaving behavior and performance unchanged. | ||
| - `PlayolaNetworkLoggingSession` — a transparent `URLSessionProtocol` | ||
| wrapper that times each call and emits an event on both success and | ||
| thrown error without altering return values or rethrown errors. | ||
|
|
||
| The schedule fetch (`PlayolaStationPlayer`) and the listening-session | ||
| reporter (`ListeningSessionReporter`) now route their JSON API calls through | ||
| this wrapper by default. No breaking API changes: existing initializers and | ||
| call sites compile unchanged. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| // | ||
| // PlayolaNetworkLogger.swift | ||
| // PlayolaPlayer | ||
| // | ||
| // Created by Brian D Keane on 6/4/26. | ||
| // | ||
| import Foundation | ||
| import os | ||
|
|
||
| /// A single JSON API request/response observed by the library. | ||
| /// | ||
| /// The library emits these raw — it does **not** redact, persist, or format | ||
| /// anything. A consuming app is responsible for redacting sensitive values | ||
| /// (e.g. `Authorization` headers) before storing or displaying them. | ||
| public struct PlayolaNetworkLogEvent: Sendable { | ||
| /// The time the request started. | ||
| public let timestamp: Date | ||
| /// The HTTP method (e.g. `GET`, `POST`). | ||
| public let method: String | ||
| /// The request URL, if available. | ||
| public let url: URL? | ||
| /// All HTTP header fields sent with the request, unredacted. | ||
| public let requestHeaders: [String: String] | ||
| /// The raw request body, if any. | ||
| public let requestBody: Data? | ||
| /// The HTTP status code of the response, or `nil` if the request threw. | ||
| public let statusCode: Int? | ||
| /// The raw response body, or `nil` if the request threw. | ||
| public let responseBody: Data? | ||
| /// The wall-clock duration of the request in milliseconds. | ||
| public let durationMS: Int? | ||
| /// A description of the thrown error, or `nil` on success. | ||
| public let errorDescription: String? | ||
|
|
||
| public init( | ||
| timestamp: Date, | ||
| method: String, | ||
| url: URL?, | ||
| requestHeaders: [String: String], | ||
| requestBody: Data?, | ||
| statusCode: Int?, | ||
| responseBody: Data?, | ||
| durationMS: Int?, | ||
| errorDescription: String? | ||
| ) { | ||
| self.timestamp = timestamp | ||
| self.method = method | ||
| self.url = url | ||
| self.requestHeaders = requestHeaders | ||
| self.requestBody = requestBody | ||
| self.statusCode = statusCode | ||
| self.responseBody = responseBody | ||
| self.durationMS = durationMS | ||
| self.errorDescription = errorDescription | ||
| } | ||
| } | ||
|
|
||
| /// A logging seam for the library's JSON API traffic. | ||
| /// | ||
| /// A consuming app sets ``handler`` at startup to observe the library's JSON | ||
| /// API calls (binary audio downloads are intentionally excluded). When | ||
| /// ``handler`` is `nil` (the default) no logging occurs and behavior and | ||
| /// performance are unchanged. | ||
| public enum PlayolaNetworkLogger { | ||
| private static let _handler = | ||
| OSAllocatedUnfairLock<(@Sendable (PlayolaNetworkLogEvent) -> Void)?>(initialState: nil) | ||
|
|
||
| /// The handler invoked after each JSON API request completes. | ||
| /// | ||
| /// Set by the consuming app at startup. `nil` (the default) disables logging. | ||
| /// Access is thread-safe. | ||
| public static var handler: (@Sendable (PlayolaNetworkLogEvent) -> Void)? { | ||
| get { _handler.withLock { $0 } } | ||
| set { _handler.withLock { $0 = newValue } } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| // | ||
| // PlayolaNetworkLoggingSession.swift | ||
| // PlayolaPlayer | ||
| // | ||
| // Created by Brian D Keane on 6/4/26. | ||
| // | ||
| import Foundation | ||
|
|
||
| /// A ``URLSessionProtocol`` wrapper that times each call and emits a | ||
| /// ``PlayolaNetworkLogEvent`` to ``PlayolaNetworkLogger/handler`` on both | ||
| /// success and thrown error. | ||
| /// | ||
| /// It is a transparent pass-through: it returns exactly what the wrapped | ||
| /// session returns and rethrows any error unchanged — it never swallows or | ||
| /// alters the result. When ``PlayolaNetworkLogger/handler`` is `nil` no event | ||
| /// is built and the wrapper adds negligible overhead. | ||
| public struct PlayolaNetworkLoggingSession: URLSessionProtocol, Sendable { | ||
| private let base: any URLSessionProtocol & Sendable | ||
| private let dateProvider: DateProviderProtocol | ||
|
|
||
| public init( | ||
| wrapping base: any URLSessionProtocol & Sendable, | ||
| dateProvider: DateProviderProtocol = DateProvider.shared | ||
| ) { | ||
| self.base = base | ||
| self.dateProvider = dateProvider | ||
| } | ||
|
|
||
| public func data(for request: URLRequest) async throws -> (Data, URLResponse) { | ||
| let start = dateProvider.now() | ||
| do { | ||
| let (data, response) = try await base.data(for: request) | ||
| emit(request: request, start: start, data: data, response: response, error: nil) | ||
| return (data, response) | ||
| } catch { | ||
| emit(request: request, start: start, data: nil, response: nil, error: error) | ||
| throw error | ||
| } | ||
| } | ||
|
|
||
| private func emit( | ||
| request: URLRequest, | ||
| start: Date, | ||
| data: Data?, | ||
| response: URLResponse?, | ||
| error: Error? | ||
| ) { | ||
| guard let handler = PlayolaNetworkLogger.handler else { return } | ||
| let durationMS = Int(dateProvider.now().timeIntervalSince(start) * 1000) | ||
| let event = PlayolaNetworkLogEvent( | ||
| timestamp: start, | ||
| method: request.httpMethod ?? "GET", | ||
| url: request.url, | ||
| requestHeaders: request.allHTTPHeaderFields ?? [:], | ||
| requestBody: request.httpBody, | ||
| statusCode: (response as? HTTPURLResponse)?.statusCode, | ||
| responseBody: data, | ||
| durationMS: durationMS, | ||
| errorDescription: error?.localizedDescription | ||
| ) | ||
| handler(event) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -402,7 +402,8 @@ final public class PlayolaStationPlayer: ObservableObject { | |
| let url = createScheduleURL(for: stationId) | ||
|
|
||
| do { | ||
| let (data, response) = try await tls12Session.data(from: url) | ||
| let loggingSession = PlayolaNetworkLoggingSession(wrapping: tls12Session) | ||
| let (data, response) = try await loggingSession.data(for: URLRequest(url: url)) | ||
|
Comment on lines
+405
to
+406
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
A fresh Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
| let httpResponse = try validateHTTPResponse(response, url: url) | ||
| try await validateStatusCode(httpResponse, data: data, stationId: stationId) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| // | ||
| // PlayolaNetworkLoggingSessionTests.swift | ||
| // PlayolaPlayer | ||
| // | ||
| // Created by Brian D Keane on 6/4/26. | ||
| // | ||
| import Foundation | ||
| import PlayolaCore | ||
| import Testing | ||
| import os | ||
|
|
||
| @testable import PlayolaPlayer | ||
|
|
||
| /// Records events emitted to `PlayolaNetworkLogger.handler` in a thread-safe way. | ||
| private final class EventRecorder: Sendable { | ||
| private let storage = OSAllocatedUnfairLock<[PlayolaNetworkLogEvent]>(initialState: []) | ||
| func record(_ event: PlayolaNetworkLogEvent) { storage.withLock { $0.append(event) } } | ||
| var events: [PlayolaNetworkLogEvent] { storage.withLock { $0 } } | ||
| } | ||
|
|
||
| /// A `DateProviderProtocol` that returns queued dates in order, falling back to | ||
| /// `Date()` once exhausted. Lets tests assert an exact `durationMS`. | ||
| private final class QueueDateProvider: Sendable, DateProviderProtocol { | ||
| private let dates: OSAllocatedUnfairLock<[Date]> | ||
| init(_ dates: [Date]) { self.dates = OSAllocatedUnfairLock(initialState: dates) } | ||
| func now() -> Date { | ||
| dates.withLock { $0.isEmpty ? Date() : $0.removeFirst() } | ||
| } | ||
| } | ||
|
|
||
| /// A session that always throws, to verify error pass-through. | ||
| private struct ThrowingURLSession: URLSessionProtocol { | ||
| let error: any Error & Sendable | ||
| func data(for request: URLRequest) async throws -> (Data, URLResponse) { throw error } | ||
| } | ||
|
|
||
| private struct StubError: Error, Equatable { | ||
| let id: Int | ||
| } | ||
|
|
||
| @Suite("PlayolaNetworkLoggingSession", .serialized) | ||
| struct PlayolaNetworkLoggingSessionTests { | ||
|
|
||
| @Test("Emits an event on success with correct method, url, status, and duration") | ||
| func emitsEventOnSuccess() async throws { | ||
| let recorder = EventRecorder() | ||
| PlayolaNetworkLogger.handler = { recorder.record($0) } | ||
| defer { PlayolaNetworkLogger.handler = nil } | ||
|
|
||
| let url = URL(string: "https://test.com/v1/listeningSessions")! | ||
| let requestBody = Data("{\"deviceId\":\"abc\"}".utf8) | ||
| let responseBody = Data("{\"ok\":true}".utf8) | ||
|
|
||
| var request = URLRequest(url: url) | ||
| request.httpMethod = "POST" | ||
| request.httpBody = requestBody | ||
| request.addValue("Bearer secret-token", forHTTPHeaderField: "Authorization") | ||
|
|
||
| let base = MockURLSession() | ||
| base.addResponse(data: responseBody, statusCode: 200, url: url) | ||
|
|
||
| let start = Date(timeIntervalSince1970: 1000) | ||
| let session = PlayolaNetworkLoggingSession( | ||
| wrapping: base, | ||
| dateProvider: QueueDateProvider([start, start.addingTimeInterval(0.25)])) | ||
|
|
||
| _ = try await session.data(for: request) | ||
|
|
||
| let events = recorder.events | ||
| #expect(events.count == 1) | ||
| let event = try #require(events.first) | ||
| #expect(event.method == "POST") | ||
| #expect(event.url == url) | ||
| #expect(event.statusCode == 200) | ||
| #expect(event.durationMS == 250) | ||
| #expect(event.timestamp == start) | ||
| #expect(event.requestBody == requestBody) | ||
| #expect(event.responseBody == responseBody) | ||
| #expect(event.requestHeaders["Authorization"] == "Bearer secret-token") | ||
| #expect(event.errorDescription == nil) | ||
| } | ||
|
|
||
| @Test("Transparently passes through the wrapped session's data and response") | ||
| func passesThroughData() async throws { | ||
| PlayolaNetworkLogger.handler = nil | ||
|
|
||
| let url = URL(string: "https://test.com/v1/stations/123/schedule")! | ||
| let responseBody = Data("schedule-json".utf8) | ||
|
|
||
| let base = MockURLSession() | ||
| base.addResponse(data: responseBody, statusCode: 201, url: url) | ||
|
|
||
| let session = PlayolaNetworkLoggingSession(wrapping: base) | ||
| let (data, response) = try await session.data(for: URLRequest(url: url)) | ||
|
|
||
| #expect(data == responseBody) | ||
| #expect((response as? HTTPURLResponse)?.statusCode == 201) | ||
| #expect(base.requestCallCount == 1) | ||
| } | ||
|
|
||
| @Test("Emits an event on a thrown error and rethrows the original error unchanged") | ||
| func emitsEventOnErrorAndRethrows() async throws { | ||
| let recorder = EventRecorder() | ||
| PlayolaNetworkLogger.handler = { recorder.record($0) } | ||
| defer { PlayolaNetworkLogger.handler = nil } | ||
|
|
||
| let url = URL(string: "https://test.com/v1/listeningSessions")! | ||
| let thrownError = StubError(id: 42) | ||
| let session = PlayolaNetworkLoggingSession(wrapping: ThrowingURLSession(error: thrownError)) | ||
|
|
||
| var caught: Error? | ||
| do { | ||
| _ = try await session.data(for: URLRequest(url: url)) | ||
| } catch { | ||
| caught = error | ||
| } | ||
|
|
||
| #expect(caught as? StubError == thrownError) | ||
|
|
||
| let events = recorder.events | ||
| #expect(events.count == 1) | ||
| let event = try #require(events.first) | ||
| #expect(event.statusCode == nil) | ||
| #expect(event.responseBody == nil) | ||
| #expect(event.errorDescription != nil) | ||
| #expect(event.url == url) | ||
| } | ||
|
|
||
| @Test("Does not emit when no handler is set") | ||
| func doesNotEmitWithoutHandler() async throws { | ||
| PlayolaNetworkLogger.handler = nil | ||
|
|
||
| let url = URL(string: "https://test.com")! | ||
| let base = MockURLSession() | ||
| base.addResponse(data: Data(), statusCode: 200, url: url) | ||
|
|
||
| let session = PlayolaNetworkLoggingSession(wrapping: base) | ||
| _ = try await session.data(for: URLRequest(url: url)) | ||
|
|
||
| #expect(base.requestCallCount == 1) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The changelog entry is
## 0.18.0but the PR is titled0.17.1. Since this change introduces three new public types (PlayolaNetworkLogEvent,PlayolaNetworkLogger,PlayolaNetworkLoggingSession) it qualifies as a minor version bump under SemVer, making0.18.0the semantically correct version — but the PR title and any git tag should match. One of the two needs to be updated before the tag is pushed, otherwise consumers pinning to the version string will get mismatched release notes.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!