rastrillo / native Public

Clone
git clone https://amadan.net/rastrillo/native

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2import 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.
17public 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