| 1 | import Foundation |
| 2 | import RastrilloApple |
| 3 | |
| 4 | /// A `NativeTransport` that answers each call from a script, in order. |
| 5 | /// |
| 6 | /// It replaces the Python fixture servers apps spin up to reach an offline |
| 7 | /// or ended-session path: the failure is one line of script instead of a |
| 8 | /// process, and "the server applied it but the answer was lost" can be said |
| 9 | /// at all. Statuses go through the same mapping as `NativeHTTP`, so a |
| 10 | /// scripted 401 fails `request` with `.unauthorized` exactly as a real one would. |
| 11 | /// |
| 12 | /// The script is either a queue of steps, answered in call order, or a |
| 13 | /// responder that answers each call by what it asks for, for callers whose |
| 14 | /// requests race (a stream and a save, say). A call with no step left, or |
| 15 | /// one the responder declines, throws `ScriptedTransport.Unscripted`, so a |
| 16 | /// test that makes a request it did not plan for fails loudly. |
| 17 | public final class ScriptedTransport: NativeTransport, @unchecked Sendable { |
| 18 | public enum Step: Sendable { |
| 19 | /// The server answers. For `stream`, a 200 body is framed into lines |
| 20 | /// exactly as `NativeHTTP` frames the network; for `download`, a 200 |
| 21 | /// body is the file. |
| 22 | case respond(status: Int, body: Data = Data(), headers: [String: String] = [:]) |
| 23 | /// The request never reaches the server. |
| 24 | case offline(URLError.Code = .notConnectedToInternet) |
| 25 | /// No answer until the calling task is cancelled; then `CancellationError`. |
| 26 | case hang |
| 27 | /// The server applies the request (it is recorded in `committed`), then |
| 28 | /// the connection drops before the answer arrives. |
| 29 | case commitThenLoseResponse(URLError.Code = .networkConnectionLost) |
| 30 | /// Any other failure, thrown as given. |
| 31 | case fail(any Error & Sendable) |
| 32 | } |
| 33 | |
| 34 | public struct Call: Sendable, Equatable { |
| 35 | public enum Kind: Sendable, Equatable { case request, stream, download } |
| 36 | public let kind: Kind |
| 37 | public let path: String |
| 38 | public let method: String |
| 39 | public let body: Data? |
| 40 | public init(kind: Kind, path: String, method: String, body: Data?) { |
| 41 | self.kind = kind; self.path = path; self.method = method; self.body = body |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | public struct Unscripted: LocalizedError, Sendable { |
| 46 | public let call: Call |
| 47 | public var errorDescription: String? { "ScriptedTransport has no step left for \(call.method) \(call.path)." } |
| 48 | } |
| 49 | |
| 50 | public let origin: URL |
| 51 | private let lock = NSLock() |
| 52 | private var steps: [Step] |
| 53 | private let responder: (@Sendable (Call) -> Step?)? |
| 54 | private var recordedCalls: [Call] = [] |
| 55 | private var recordedCommits: [Call] = [] |
| 56 | |
| 57 | public init(origin: URL = URL(string: "https://scripted.invalid")!, _ steps: [Step] = []) { |
| 58 | self.origin = origin |
| 59 | self.steps = steps |
| 60 | self.responder = nil |
| 61 | } |
| 62 | |
| 63 | public init(origin: URL = URL(string: "https://scripted.invalid")!, responder: @escaping @Sendable (Call) -> Step?) { |
| 64 | self.origin = origin |
| 65 | self.steps = [] |
| 66 | self.responder = responder |
| 67 | } |
| 68 | |
| 69 | public func append(_ more: Step...) { lock.withLock { steps.append(contentsOf: more) } } |
| 70 | /// Every call made, in order, including ones that failed. |
| 71 | public var calls: [Call] { lock.withLock { recordedCalls } } |
| 72 | /// The calls the scripted server applied: every 2xx answer, and every |
| 73 | /// `commitThenLoseResponse`, whose caller saw only a failure. |
| 74 | public var committed: [Call] { lock.withLock { recordedCommits } } |
| 75 | public var remainingSteps: Int { lock.withLock { steps.count } } |
| 76 | |
| 77 | private func take(_ call: Call) throws -> Step { |
| 78 | if let responder { |
| 79 | lock.withLock { recordedCalls.append(call) } |
| 80 | guard let step = responder(call) else { throw Unscripted(call: call) } |
| 81 | return step |
| 82 | } |
| 83 | return try lock.withLock { |
| 84 | recordedCalls.append(call) |
| 85 | guard !steps.isEmpty else { throw Unscripted(call: call) } |
| 86 | return steps.removeFirst() |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | private func commit(_ call: Call) { lock.withLock { recordedCommits.append(call) } } |
| 91 | |
| 92 | /// Resolves a step to a response, or throws the failure it scripts. |
| 93 | private func answer(_ call: Call) async throws -> NativeResponse { |
| 94 | switch try take(call) { |
| 95 | case .respond(let status, let body, let headers): |
| 96 | if (200..<300).contains(status) { commit(call) } |
| 97 | return NativeResponse(data: body, status: status, headers: headers) |
| 98 | case .offline(let code): |
| 99 | throw NativeClientError.offline(code) |
| 100 | case .hang: |
| 101 | while true { try await Task.sleep(nanoseconds: 60_000_000_000) } |
| 102 | case .commitThenLoseResponse(let code): |
| 103 | commit(call) |
| 104 | throw NativeClientError.offline(code) |
| 105 | case .fail(let error): |
| 106 | throw error |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | public func responseDetails(_ path: String, method: String, body: Data?, |
| 111 | contentType: String, maxBytes: Int) async throws -> NativeResponse { |
| 112 | let response = try await answer(Call(kind: .request, path: path, method: method, body: body)) |
| 113 | guard response.data.count <= maxBytes else { throw NativeClientError.message("The server response is too large.") } |
| 114 | return response |
| 115 | } |
| 116 | |
| 117 | public func stream(_ path: String, onOpen: @Sendable () async throws -> Void, |
| 118 | onLine: @Sendable (String) async throws -> Void) async throws { |
| 119 | let response = try await answer(Call(kind: .stream, path: path, method: "GET", body: nil)) |
| 120 | try response.requireSuccess() |
| 121 | guard response.status == 200 else { |
| 122 | throw NativeClientError.message("The live connection was refused. Reconnect to refresh your session.") |
| 123 | } |
| 124 | try await onOpen() |
| 125 | // The same byte-level framing as NativeHTTP: CR, LF and CRLF all end a |
| 126 | // line, limits and invalid UTF-8 throw, and an unterminated final line |
| 127 | // is never delivered. |
| 128 | var parser = NativeStreamLines() |
| 129 | for byte in response.data { |
| 130 | try Task.checkCancellation() |
| 131 | if let line = try parser.append(byte) { try await onLine(line) } |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | public func download(_ path: String, to destination: URL) async throws { |
| 136 | let response = try await answer(Call(kind: .download, path: path, method: "GET", body: nil)) |
| 137 | try response.requireSuccess() |
| 138 | guard response.status == 200 else { |
| 139 | throw NativeClientError.message("The export could not be completed. Refresh your session and try again.") |
| 140 | } |
| 141 | guard !FileManager.default.fileExists(atPath: destination.path) else { |
| 142 | throw CocoaError(.fileWriteFileExists) |
| 143 | } |
| 144 | try response.data.write(to: destination, options: .withoutOverwriting) |
| 145 | } |
| 146 | } |
| 147 | |