import Foundation import RastrilloApple /// A `NativeTransport` that answers each call from a script, in order. /// /// It replaces the Python fixture servers apps spin up to reach an offline /// or ended-session path: the failure is one line of script instead of a /// process, and "the server applied it but the answer was lost" can be said /// at all. Statuses go through the same mapping as `NativeHTTP`, so a /// scripted 401 fails `request` with `.unauthorized` exactly as a real one would. /// /// The script is either a queue of steps, answered in call order, or a /// responder that answers each call by what it asks for, for callers whose /// requests race (a stream and a save, say). A call with no step left, or /// one the responder declines, throws `ScriptedTransport.Unscripted`, so a /// test that makes a request it did not plan for fails loudly. public final class ScriptedTransport: NativeTransport, @unchecked Sendable { public enum Step: Sendable { /// The server answers. For `stream`, a 200 body is framed into lines /// exactly as `NativeHTTP` frames the network; for `download`, a 200 /// body is the file. case respond(status: Int, body: Data = Data(), headers: [String: String] = [:]) /// The request never reaches the server. case offline(URLError.Code = .notConnectedToInternet) /// No answer until the calling task is cancelled; then `CancellationError`. case hang /// The server applies the request (it is recorded in `committed`), then /// the connection drops before the answer arrives. case commitThenLoseResponse(URLError.Code = .networkConnectionLost) /// Any other failure, thrown as given. case fail(any Error & Sendable) } public struct Call: Sendable, Equatable { public enum Kind: Sendable, Equatable { case request, stream, download } public let kind: Kind public let path: String public let method: String public let body: Data? public init(kind: Kind, path: String, method: String, body: Data?) { self.kind = kind; self.path = path; self.method = method; self.body = body } } public struct Unscripted: LocalizedError, Sendable { public let call: Call public var errorDescription: String? { "ScriptedTransport has no step left for \(call.method) \(call.path)." } } public let origin: URL private let lock = NSLock() private var steps: [Step] private let responder: (@Sendable (Call) -> Step?)? private var recordedCalls: [Call] = [] private var recordedCommits: [Call] = [] public init(origin: URL = URL(string: "https://scripted.invalid")!, _ steps: [Step] = []) { self.origin = origin self.steps = steps self.responder = nil } public init(origin: URL = URL(string: "https://scripted.invalid")!, responder: @escaping @Sendable (Call) -> Step?) { self.origin = origin self.steps = [] self.responder = responder } public func append(_ more: Step...) { lock.withLock { steps.append(contentsOf: more) } } /// Every call made, in order, including ones that failed. public var calls: [Call] { lock.withLock { recordedCalls } } /// The calls the scripted server applied: every 2xx answer, and every /// `commitThenLoseResponse`, whose caller saw only a failure. public var committed: [Call] { lock.withLock { recordedCommits } } public var remainingSteps: Int { lock.withLock { steps.count } } private func take(_ call: Call) throws -> Step { if let responder { lock.withLock { recordedCalls.append(call) } guard let step = responder(call) else { throw Unscripted(call: call) } return step } return try lock.withLock { recordedCalls.append(call) guard !steps.isEmpty else { throw Unscripted(call: call) } return steps.removeFirst() } } private func commit(_ call: Call) { lock.withLock { recordedCommits.append(call) } } /// Resolves a step to a response, or throws the failure it scripts. private func answer(_ call: Call) async throws -> NativeResponse { switch try take(call) { case .respond(let status, let body, let headers): if (200..<300).contains(status) { commit(call) } return NativeResponse(data: body, status: status, headers: headers) case .offline(let code): throw NativeClientError.offline(code) case .hang: while true { try await Task.sleep(nanoseconds: 60_000_000_000) } case .commitThenLoseResponse(let code): commit(call) throw NativeClientError.offline(code) case .fail(let error): throw error } } public func responseDetails(_ path: String, method: String, body: Data?, contentType: String, maxBytes: Int) async throws -> NativeResponse { let response = try await answer(Call(kind: .request, path: path, method: method, body: body)) guard response.data.count <= maxBytes else { throw NativeClientError.message("The server response is too large.") } return response } public func stream(_ path: String, onOpen: @Sendable () async throws -> Void, onLine: @Sendable (String) async throws -> Void) async throws { let response = try await answer(Call(kind: .stream, path: path, method: "GET", body: nil)) try response.requireSuccess() guard response.status == 200 else { throw NativeClientError.message("The live connection was refused. Reconnect to refresh your session.") } try await onOpen() // The same byte-level framing as NativeHTTP: CR, LF and CRLF all end a // line, limits and invalid UTF-8 throw, and an unterminated final line // is never delivered. var parser = NativeStreamLines() for byte in response.data { try Task.checkCancellation() if let line = try parser.append(byte) { try await onLine(line) } } } public func download(_ path: String, to destination: URL) async throws { let response = try await answer(Call(kind: .download, path: path, method: "GET", body: nil)) try response.requireSuccess() guard response.status == 200 else { throw NativeClientError.message("The export could not be completed. Refresh your session and try again.") } guard !FileManager.default.fileExists(atPath: destination.path) else { throw CocoaError(.fileWriteFileExists) } try response.data.write(to: destination, options: .withoutOverwriting) } }