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
7 changes: 7 additions & 0 deletions Sources/JSON/JSON.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
//

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

/// A type-safe representation of any JSON value.
///
Expand Down Expand Up @@ -1104,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(
Expand Down
80 changes: 80 additions & 0 deletions Sources/JSON/URLSession+Compatibility.swift
Original file line number Diff line number Diff line change
@@ -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
181 changes: 181 additions & 0 deletions Tests/JSONTests/JSONFetchTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
//
// 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)")
}
}

// 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
}
Loading