a native ios client for gitbay

client ios swift

https://gitbay.org

gitbayTests/StubProtocol.swift

163 lines · 5552 bytes

  1import Foundation
  2
  3/// URLProtocol stub: tests enqueue responses, the client sees a real
  4/// URLSession. Recorded envelopes go through here.
  5///
  6/// Swift Testing runs suites in parallel, so state is keyed per box: each
  7/// `box()` gets its own queue and request log, matched up by a header the
  8/// box's session adds to every request.
  9nonisolated final class StubProtocol: URLProtocol, @unchecked Sendable {
 10
 11    struct Stub: Sendable {
 12        let status: Int
 13        let headers: [String: String]
 14        let body: Data
 15        /// When set, this stub only answers requests whose URL contains
 16        /// it  needed when the code under test issues requests
 17        /// concurrently and arrival order is not deterministic.
 18        let match: String?
 19
 20        init(status: Int, headers: [String: String] = [:], json: String, match: String? = nil) {
 21            self.status = status
 22            var headers = headers
 23            headers["Content-Type"] = headers["Content-Type"] ?? "application/json"
 24            self.headers = headers
 25            self.body = Data(json.utf8)
 26            self.match = match
 27        }
 28
 29        init(status: Int, headers: [String: String] = [:], body: Data = Data(), match: String? = nil) {
 30            self.status = status
 31            self.headers = headers
 32            self.body = body
 33            self.match = match
 34        }
 35    }
 36
 37    /// A request as the stub saw it, for asserting on method, URL, headers
 38    /// and body.
 39    struct Seen: Sendable {
 40        let url: URL
 41        let method: String
 42        let headers: [String: String]
 43        let body: Data
 44    }
 45
 46    /// One test's private stub queue and request log.
 47    final class Box: Sendable {
 48        fileprivate let id = UUID().uuidString
 49        private let queue = Mutex<[Stub]>([])
 50        private let log = Mutex<[Seen]>([])
 51
 52        func enqueue(_ stub: Stub) {
 53            queue.withLock { $0.append(stub) }
 54        }
 55
 56        var seen: [Seen] {
 57            log.withLock { $0 }
 58        }
 59
 60        /// The stubbed session for this box. The marker header is how the
 61        /// shared protocol class finds its way back here.
 62        func session() -> URLSession {
 63            let configuration = URLSessionConfiguration.ephemeral
 64            configuration.protocolClasses = [StubProtocol.self]
 65            configuration.httpAdditionalHeaders = [StubProtocol.marker: id]
 66            return URLSession(configuration: configuration)
 67        }
 68
 69        fileprivate func next(for url: URL) -> Stub? {
 70            queue.withLock { stubs in
 71                let index = stubs.firstIndex {
 72                    $0.match.map { url.absoluteString.contains($0) } ?? true
 73                }
 74                guard let index else { return nil }
 75                return stubs.remove(at: index)
 76            }
 77        }
 78
 79        fileprivate func record(_ request: Seen) {
 80            log.withLock { $0.append(request) }
 81        }
 82    }
 83
 84    private static let marker = "X-Stub-Box"
 85    private static let boxes = Mutex<[String: Box]>([:])
 86
 87    static func box() -> Box {
 88        let box = Box()
 89        boxes.withLock { $0[box.id] = box }
 90        return box
 91    }
 92
 93    // MARK: - URLProtocol
 94
 95    override static func canInit(with _: URLRequest) -> Bool { true }
 96
 97    override static func canonicalRequest(for request: URLRequest) -> URLRequest { request }
 98
 99    override func startLoading() {
100        guard let id = request.value(forHTTPHeaderField: Self.marker),
101              let box = Self.boxes.withLock({ $0[id] }) else {
102            client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable))
103            return
104        }
105
106        // httpBody is transformed into a stream by URLSession; read it back.
107        var body = Data()
108        if let stream = request.httpBodyStream {
109            stream.open()
110            defer { stream.close() }
111            let size = 4096
112            let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: size)
113            defer { buffer.deallocate() }
114            while stream.hasBytesAvailable {
115                let read = stream.read(buffer, maxLength: size)
116                guard read > 0 else { break }
117                body.append(buffer, count: read)
118            }
119        }
120        var headers = request.allHTTPHeaderFields ?? [:]
121        headers.removeValue(forKey: Self.marker)
122        box.record(Seen(
123            url: request.url!,
124            method: request.httpMethod ?? "GET",
125            headers: headers,
126            body: body
127        ))
128
129        guard let stub = box.next(for: request.url!) else {
130            client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable))
131            return
132        }
133        let response = HTTPURLResponse(
134            url: request.url!,
135            statusCode: stub.status,
136            httpVersion: "HTTP/1.1",
137            headerFields: stub.headers
138        )!
139        client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
140        if !stub.body.isEmpty {
141            client?.urlProtocol(self, didLoad: stub.body)
142        }
143        client?.urlProtocolDidFinishLoading(self)
144    }
145
146    override func stopLoading() {}
147}
148
149/// Minimal lock wrapper; os.OSAllocatedUnfairLock spelled test-side.
150nonisolated final class Mutex<Value>: @unchecked Sendable {
151    private var value: Value
152    private let lock = NSLock()
153
154    init(_ value: Value) {
155        self.value = value
156    }
157
158    func withLock<R>(_ body: (inout Value) -> R) -> R {
159        lock.lock()
160        defer { lock.unlock() }
161        return body(&value)
162    }
163}