From 7fd62c1ace77e31aa26980f0bbbb112547f5b76e Mon Sep 17 00:00:00 2001 From: Reid Chatham Date: Mon, 5 Oct 2026 11:44:44 -0700 Subject: [PATCH 1/2] fix(json): support FoundationNetworking on Linux --- Sources/JSON/JSON.swift | 3 + Tests/JSONTests/JSONFetchTests.swift | 160 +++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 Tests/JSONTests/JSONFetchTests.swift diff --git a/Sources/JSON/JSON.swift b/Sources/JSON/JSON.swift index fa077a8..fd00e95 100644 --- a/Sources/JSON/JSON.swift +++ b/Sources/JSON/JSON.swift @@ -6,6 +6,9 @@ // import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif /// A type-safe representation of any JSON value. /// diff --git a/Tests/JSONTests/JSONFetchTests.swift b/Tests/JSONTests/JSONFetchTests.swift new file mode 100644 index 0000000..eb73946 --- /dev/null +++ b/Tests/JSONTests/JSONFetchTests.swift @@ -0,0 +1,160 @@ +// +// JSONFetchTests.swift +// JSONTests +// + +import XCTest +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +@testable import JSON + +// MARK: - Mock URLProtocol (zero static state, fixed routes) + +private final class MockJSONURLProtocol: URLProtocol { + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func stopLoading() {} + + override func startLoading() { + guard let url = request.url else { + client?.urlProtocol(self, didFailWithError: URLError(.badURL)) + return + } + switch url.path { + case "/success": respond(200, #"{"name":"Alice","age":30}"#) + case "/post": respondEcho() + case "/missing": respond(404, #"{"error":"not_found"}"#) + case "/invalid": respond(200, "plain text, not json") + default: client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + } + } + + private func respond(_ status: Int, _ body: String) { + guard let resp = HTTPURLResponse(url: request.url!, statusCode: status, + httpVersion: "HTTP/1.1", headerFields: ["Content-Type": "application/json"]) + else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + client?.urlProtocol(self, didReceive: resp, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(body.utf8)) + client?.urlProtocolDidFinishLoading(self) + } + + private func respondEcho() { + let bodyData = Self.readBody(from: request) + let jsonBody: Any + if let obj = try? JSONSerialization.jsonObject(with: bodyData) { + jsonBody = obj + } else { + jsonBody = String(data: bodyData, encoding: .utf8) ?? bodyData.base64EncodedString() + } + let echo: [String: Any] = [ + "method": request.httpMethod ?? "", + "body": jsonBody, + "headers": request.allHTTPHeaderFields ?? [:], + ] + guard let data = try? JSONSerialization.data(withJSONObject: echo), + let resp = HTTPURLResponse(url: request.url!, statusCode: 201, + httpVersion: "HTTP/1.1", headerFields: ["Content-Type": "application/json"]) + else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + client?.urlProtocol(self, didReceive: resp, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } + + private static func readBody(from request: URLRequest) -> Data { + if let body = request.httpBody { return body } + guard let stream = request.httpBodyStream else { return Data() } + stream.open(); defer { stream.close() } + var data = Data() + var buf = [UInt8](repeating: 0, count: 4096) + while stream.hasBytesAvailable { + let n = stream.read(&buf, maxLength: buf.count) + guard n > 0 else { break } + data.append(buf, count: n) + } + return data + } +} + +// MARK: - Tests + +final class JSONFetchTests: XCTestCase { + + private func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockJSONURLProtocol.self] + return URLSession(configuration: config) + } + + func test_fetch_get_success() async throws { + let session = makeSession() + defer { session.invalidateAndCancel() } + let json = try await JSON.fetch(from: URL(string: "https://test/success")!, session: session) + XCTAssertEqual(json["name"], "Alice") + XCTAssertEqual(json["age"], .number(30)) + } + + func test_fetch_post_echo() async throws { + var req = URLRequest(url: URL(string: "https://test/post")!) + req.httpMethod = "POST" + req.httpBody = Data(#"{"key":"value"}"#.utf8) + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("Bearer t", forHTTPHeaderField: "Authorization") + let session = makeSession() + defer { session.invalidateAndCancel() } + let json = try await JSON.fetch(request: req, session: session) + XCTAssertEqual(json["method"], "POST") + XCTAssertEqual(json["body"]?["key"], "value") + XCTAssertEqual(json["headers"]?["Content-Type"], "application/json") + XCTAssertEqual(json["headers"]?["Authorization"], "Bearer t") + } + + func test_fetch_404_http_error() async { + let session = makeSession() + defer { session.invalidateAndCancel() } + do { + _ = try await JSON.fetch(from: URL(string: "https://test/missing")!, session: session) + XCTFail("Expected httpError") + } catch let error as JSONError { + guard case .httpError(404, let body) = error else { + return XCTFail("Expected .httpError(404, _), got \(error)") + } + XCTAssertEqual(body?["error"], "not_found") + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func test_fetch_200_invalid_json_body_throws_decoding_error() async { + let session = makeSession() + defer { session.invalidateAndCancel() } + do { + _ = try await JSON.fetch(from: URL(string: "https://test/invalid")!, session: session) + XCTFail("Expected DecodingError") + } catch is DecodingError { + // Expected: invalid JSON body + } catch { + XCTFail("Expected DecodingError, got \(type(of: error)): \(error)") + } + } + + func test_fetch_unmapped_route_propagates_url_error_code() async { + let session = makeSession() + defer { session.invalidateAndCancel() } + do { + _ = try await JSON.fetch(from: URL(string: "https://test/unknown")!, session: session) + XCTFail("Expected URLError") + } catch let error as URLError { + XCTAssertEqual(error.code, .badServerResponse) + } catch { + XCTFail("Expected URLError, got \(type(of: error)): \(error)") + } + } +} \ No newline at end of file From f80d29f5113b5a3ed0a47e6afa908eab07ef024c Mon Sep 17 00:00:00 2001 From: Reid Chatham Date: Mon, 5 Oct 2026 12:03:38 -0700 Subject: [PATCH 2/2] fix(json): bridge async fetch on Linux Swift 5.10 --- Sources/JSON/JSON.swift | 4 ++ Sources/JSON/URLSession+Compatibility.swift | 80 +++++++++++++++++++++ Tests/JSONTests/JSONFetchTests.swift | 23 +++++- 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 Sources/JSON/URLSession+Compatibility.swift diff --git a/Sources/JSON/JSON.swift b/Sources/JSON/JSON.swift index fd00e95..36dc315 100644 --- a/Sources/JSON/JSON.swift +++ b/Sources/JSON/JSON.swift @@ -1107,7 +1107,11 @@ extension JSON { request: URLRequest, session: URLSession = .shared ) async throws -> JSON { +#if os(Linux) && compiler(<6.0) + let (data, response) = try await session.jsonData(for: request) +#else let (data, response) = try await session.data(for: request) +#endif if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { throw JSONError.httpError( diff --git a/Sources/JSON/URLSession+Compatibility.swift b/Sources/JSON/URLSession+Compatibility.swift new file mode 100644 index 0000000..ec5faed --- /dev/null +++ b/Sources/JSON/URLSession+Compatibility.swift @@ -0,0 +1,80 @@ +// +// URLSession+Compatibility.swift +// JSON +// + +#if os(Linux) && compiler(<6.0) +import Foundation +import FoundationNetworking + +extension URLSession { + + /// Async wrapper around the callback-based `dataTask(with:completionHandler:)`, + /// bridging the gap on Linux Swift 5.x where `data(for:)` is not available. + /// + /// - Parameter request: The `URLRequest` to execute. + /// - Returns: A tuple of the response `Data` and the `URLResponse`. + /// - Throws: `CancellationError` if the task was cancelled before starting; + /// the `dataTask` completion error or `URLError(.badServerResponse)` otherwise. + func jsonData(for request: URLRequest) async throws -> (Data, URLResponse) { + try Task.checkCancellation() + let holder = TaskHolder() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let task = self.dataTask(with: request) { data, response, error in + if let error = error { + continuation.resume(throwing: error) + } else if let data = data, let response = response { + continuation.resume(returning: (data, response)) + } else { + continuation.resume(throwing: URLError(.badServerResponse)) + } + } + holder.install(task: task) + } + } onCancel: { + holder.cancel() + } + } +} + +// MARK: - Cancellation helper + +/// Holds a reference to the in-flight `URLSessionDataTask` so it can be +/// cancelled when the Swift concurrency task is cancelled. +/// +/// Marked `@unchecked Sendable` because all access to `task` and `cancelled` +/// is serialised behind `lock`; the class never escapes the scope of a single +/// `jsonData(for:)` call. +private final class TaskHolder: @unchecked Sendable { + private let lock = NSLock() + private var task: URLSessionDataTask? + private var cancelled = false + + deinit { task?.cancel() } + + /// Installs the data task, handling a race where cancellation arrived + /// between task creation and storage. Always resumes the task so the + /// completion handler fires — even if cancelled — guaranteeing the + /// continuation is resumed exactly once. + func install(task t: URLSessionDataTask) { + lock.lock() + let wasCancelled = cancelled + if !wasCancelled { task = t } + lock.unlock() + if wasCancelled { t.cancel() } + t.resume() + } + + /// Cancels the stored task (if any) under the lock, then clears the + /// reference so `deinit` won't double-cancel. + func cancel() { + lock.lock() + cancelled = true + let t = task + task = nil + lock.unlock() + t?.cancel() + } +} +#endif \ No newline at end of file diff --git a/Tests/JSONTests/JSONFetchTests.swift b/Tests/JSONTests/JSONFetchTests.swift index eb73946..85ab2ce 100644 --- a/Tests/JSONTests/JSONFetchTests.swift +++ b/Tests/JSONTests/JSONFetchTests.swift @@ -157,4 +157,25 @@ final class JSONFetchTests: XCTestCase { XCTFail("Expected URLError, got \(type(of: error)): \(error)") } } -} \ No newline at end of file + + // Only the compatibility shim promises a preflight cancellation check. + // Native URLSession may finish an immediate mock response before cancelling it. + #if os(Linux) && compiler(<6.0) + func test_fetch_compatibility_checks_preexisting_cancellation() async { + let session = makeSession() + defer { session.invalidateAndCancel() } + let task = Task { + withUnsafeCurrentTask { $0?.cancel() } + try await JSON.fetch(from: URL(string: "https://test/success")!, session: session) + } + do { + _ = try await task.value + XCTFail("Expected cancellation error") + } catch is CancellationError { + // The compatibility path checks cancellation before creating a data task. + } catch { + XCTFail("Expected CancellationError, got \(type(of: error)): \(error)") + } + } + #endif +}