117 lines
3.9 KiB
Swift
117 lines
3.9 KiB
Swift
|
|
import Foundation
|
||
|
|
|
||
|
|
/// Test double for `URLProtocol`. Intercepts every request on a `URLSession`
|
||
|
|
/// configured with it, records the outgoing `URLRequest`, and replays a canned
|
||
|
|
/// response or error supplied by the test.
|
||
|
|
///
|
||
|
|
/// Register via:
|
||
|
|
/// ```
|
||
|
|
/// let cfg = URLSessionConfiguration.ephemeral
|
||
|
|
/// cfg.protocolClasses = [StubURLProtocol.self]
|
||
|
|
/// let session = URLSession(configuration: cfg)
|
||
|
|
/// ```
|
||
|
|
final class StubURLProtocol: URLProtocol, @unchecked Sendable {
|
||
|
|
|
||
|
|
struct Stub {
|
||
|
|
var statusCode: Int = 200
|
||
|
|
var headers: [String: String] = ["Content-Type": "application/json"]
|
||
|
|
var body: Data = Data()
|
||
|
|
var error: Error?
|
||
|
|
/// Bytes reported through `URLSession`'s upload progress, in order.
|
||
|
|
var uploadProgressChunks: [Int] = []
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Test-facing state (guarded)
|
||
|
|
|
||
|
|
private static let lock = NSLock()
|
||
|
|
// Access is serialised through `lock`; the unsafe opt-out is the documented
|
||
|
|
// pattern for lock-guarded mutable statics under strict concurrency.
|
||
|
|
nonisolated(unsafe) private static var _stub = Stub()
|
||
|
|
nonisolated(unsafe) private static var _capturedRequests: [URLRequest] = []
|
||
|
|
nonisolated(unsafe) private static var _capturedBodies: [Data] = []
|
||
|
|
|
||
|
|
static func setStub(_ stub: Stub) {
|
||
|
|
lock.lock(); defer { lock.unlock() }
|
||
|
|
_stub = stub
|
||
|
|
_capturedRequests = []
|
||
|
|
_capturedBodies = []
|
||
|
|
}
|
||
|
|
|
||
|
|
static func reset() { setStub(Stub()) }
|
||
|
|
|
||
|
|
static var capturedRequests: [URLRequest] {
|
||
|
|
lock.lock(); defer { lock.unlock() }
|
||
|
|
return _capturedRequests
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Body of the last intercepted request. `URLProtocol` strips `httpBody` for
|
||
|
|
/// stream bodies, so this reads `httpBodyStream` when needed.
|
||
|
|
static var lastCapturedBody: Data? {
|
||
|
|
lock.lock(); defer { lock.unlock() }
|
||
|
|
return _capturedBodies.last
|
||
|
|
}
|
||
|
|
|
||
|
|
static var requestCount: Int {
|
||
|
|
lock.lock(); defer { lock.unlock() }
|
||
|
|
return _capturedRequests.count
|
||
|
|
}
|
||
|
|
|
||
|
|
private static func currentStub() -> Stub {
|
||
|
|
lock.lock(); defer { lock.unlock() }
|
||
|
|
return _stub
|
||
|
|
}
|
||
|
|
|
||
|
|
private static func record(_ request: URLRequest, body: Data) {
|
||
|
|
lock.lock(); defer { lock.unlock() }
|
||
|
|
_capturedRequests.append(request)
|
||
|
|
_capturedBodies.append(body)
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - URLProtocol
|
||
|
|
|
||
|
|
override class func canInit(with request: URLRequest) -> Bool { true }
|
||
|
|
|
||
|
|
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||
|
|
|
||
|
|
override func startLoading() {
|
||
|
|
let stub = Self.currentStub()
|
||
|
|
Self.record(request, body: Self.bodyData(from: request))
|
||
|
|
|
||
|
|
guard let client = client else { return }
|
||
|
|
|
||
|
|
if let error = stub.error {
|
||
|
|
client.urlProtocol(self, didFailWithError: error)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
let url = request.url ?? URL(string: "https://stub.invalid")!
|
||
|
|
let response = HTTPURLResponse(url: url,
|
||
|
|
statusCode: stub.statusCode,
|
||
|
|
httpVersion: "HTTP/1.1",
|
||
|
|
headerFields: stub.headers)!
|
||
|
|
client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||
|
|
client.urlProtocol(self, didLoad: stub.body)
|
||
|
|
client.urlProtocolDidFinishLoading(self)
|
||
|
|
}
|
||
|
|
|
||
|
|
override func stopLoading() {}
|
||
|
|
|
||
|
|
// MARK: - Body extraction
|
||
|
|
|
||
|
|
private static func bodyData(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()
|
||
|
|
let bufferSize = 64 * 1024
|
||
|
|
var buffer = [UInt8](repeating: 0, count: bufferSize)
|
||
|
|
while stream.hasBytesAvailable {
|
||
|
|
let read = stream.read(&buffer, maxLength: bufferSize)
|
||
|
|
if read <= 0 { break }
|
||
|
|
data.append(buffer, count: read)
|
||
|
|
}
|
||
|
|
return data
|
||
|
|
}
|
||
|
|
}
|