1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
//! Shared response state written by the background request runner and read by
//! the TUI. The HTTP request itself is performed by the Hurl runner (see
//! [`crate::hurl::run_hurl`]).
use std::sync::Arc;
use crate::hurl::AssertOutcome;
/// Common HTTP header names offered as autocomplete suggestions for the Key
/// field of the New Request headers table. Kept in a sensible display order.
pub const COMMON_HEADERS: &[&str] = &[
"Accept",
"Accept-Charset",
"Accept-Encoding",
"Accept-Language",
"Authorization",
"Cache-Control",
"Connection",
"Content-Length",
"Content-Type",
"Cookie",
"Date",
"ETag",
"Expect",
"Host",
"If-Match",
"If-Modified-Since",
"If-None-Match",
"Origin",
"Pragma",
"Range",
"Referer",
"User-Agent",
"X-Api-Key",
"X-Content-Type-Options",
"X-Correlation-ID",
"X-CSRF-Token",
"X-Forwarded-For",
"X-Forwarded-Host",
"X-Forwarded-Proto",
"X-Frame-Options",
"X-Request-ID",
"X-Requested-With",
];
/// Common header names matching `query` (case-insensitive substring). An empty
/// query returns the full list.
pub fn filter_headers(query: &str) -> Vec<&'static str> {
let q = query.trim().to_ascii_lowercase();
if q.is_empty() {
return COMMON_HEADERS.to_vec();
}
COMMON_HEADERS
.iter()
.copied()
.filter(|h| h.to_ascii_lowercase().contains(&q))
.collect()
}
/// Response state shared between the UI thread and background request threads.
#[derive(Debug, Default, Clone)]
pub struct ApiResponse {
pub status: u16,
pub status_text: String,
/// The response body. `Arc<str>` (not `String`) so the TUI can clone it
/// for a fresh draw in O(1) — a cheap refcount bump — instead of a full
/// memcpy every frame, and so its wrap/line cache
/// (`tui::wrapcache::PanelWrap`) can detect "unchanged since last frame"
/// via a pointer comparison rather than a byte-for-byte compare. `Arc`
/// (not `Rc`) because responses are produced on a background thread and
/// sent to the UI thread across an `mpsc` channel / shared `Mutex`.
pub body: Arc<str>,
pub loading: bool,
pub error: String,
/// Response headers (name, value).
pub headers: Vec<(String, String)>,
/// Results of evaluating the run entry's `[Asserts]` against this response.
pub assert_results: Vec<AssertOutcome>,
/// Wall-clock duration of the HTTP transfer for this request, in
/// milliseconds, as reported by the Hurl runner (the same figure reports
/// surface as the per-request "Time" column). `None` when unknown — e.g. a
/// response constructed before a run completed, or a transport error with
/// no timing.
pub duration_ms: Option<u64>,
/// Why the request never left, when a `# [Gen]` row was what stopped it.
/// `error` carries the same finding as one English sentence, because the
/// runner is front-end agnostic and has no `Strings`; this is the same
/// thing structurally, so a front-end that *does* know the language can
/// say it the way the pre-flight check says it ("Generated values not
/// set: ...") rather than prefixing the English text with "Request
/// error:", which names the wrong subject -- there was no request.
pub gen_errors: Vec<crate::generators::GenError>,
}
impl ApiResponse {
/// Reset to the "in-flight" state before dispatching a new request.
pub fn begin(&mut self) {
self.loading = true;
self.status = 0;
self.status_text.clear();
self.body = Arc::from("");
self.error.clear();
self.headers.clear();
self.assert_results.clear();
self.duration_ms = None;
self.gen_errors.clear();
}
/// The error to show the reader, in their language: a generator failure is
/// the pre-flight report verbatim, anything else is the runner's own
/// message under the "Request error:" heading. Empty when the request did
/// not fail.
pub fn error_text(&self, s: &crate::i18n::Strings) -> String {
if !self.gen_errors.is_empty() {
return crate::i18n::Status::GeneratorErrors(self.gen_errors.clone()).text(s);
}
if self.error.is_empty() {
return String::new();
}
format!("{} {}", s.req_error_prefix, self.error)
}
/// The same error laid out for a panel rather than a status bar: the
/// heading on its own line and one fault per line under it.
///
/// A refused send can have several things wrong with it, and a status line
/// has to join them with semicolons because it is one line. The Response
/// panel is not; joined into a paragraph there, the faults wrap into each
/// other and the reader has to find the semicolons to tell where one ends.
/// The sentences are the ones [`Self::error_text`] uses -- the same words,
/// only stacked.
pub fn error_detail(&self, s: &crate::i18n::Strings) -> String {
if self.gen_errors.is_empty() {
return self.error_text(s);
}
let mut out = String::from(s.gen_status);
for line in crate::i18n::summarise_gen_errors(s, &self.gen_errors) {
out.push_str("\n \u{2022} ");
out.push_str(&line);
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generators::GenError;
use crate::i18n::{Language, Strings};
/// A request refused before it left is not a "Request error": there was no
/// request. The runner has no `Strings` and says so in one English
/// sentence; the front-end has both, and must say what the pre-flight
/// check says -- in the reader's language, under the same heading.
#[test]
fn a_refused_send_is_reported_the_way_the_check_reports_it() {
let errors = vec![GenError::UndefinedReference {
name: "broken".into(),
reference: "nothing_defines_this".into(),
}];
let r = ApiResponse {
error: "broken: nothing defines nothing_defines_this".into(),
gen_errors: errors.clone(),
..Default::default()
};
for lang in [Language::English, Language::French, Language::Danish] {
let s = Strings::for_language(&lang);
assert_eq!(
r.error_text(&s),
crate::i18n::Status::GeneratorErrors(errors.clone()).text(&s),
"the refusal reads exactly as the pre-flight check reads"
);
assert!(
!r.error_text(&s).contains(s.req_error_prefix),
"and not under a heading naming a request that was never made"
);
}
}
/// The Response panel is not a status bar. Joined into one paragraph the
/// faults wrap into each other and the reader has to hunt for semicolons to
/// see where one ends; stacked, the shape of the report is the shape of the
/// problem. Same sentences either way.
#[test]
fn the_panel_stacks_the_faults_the_status_bar_has_to_join() {
let errors = vec![
GenError::UndefinedReference {
name: "message".into(),
reference: "session_nonce".into(),
},
GenError::FailedDependency {
name: "as_hex".into(),
reference: "message".into(),
},
];
let r = ApiResponse {
gen_errors: errors,
..Default::default()
};
let s = Strings::for_language(&Language::English);
let detail = r.error_detail(&s);
let lines: Vec<&str> = detail.lines().collect();
assert_eq!(
lines.len(),
3,
"the heading, the cause, and the count: {detail}"
);
assert!(lines[0] == s.gen_status, "{detail}");
assert!(
lines[1].contains("session_nonce") && lines[2].starts_with(" \u{2022} 1 further row "),
"{detail}"
);
assert!(
!r.error_text(&s).contains('\n'),
"the status-bar form is still one line: {}",
r.error_text(&s)
);
// A failure that isn't a generator failure has one thing to say and
// says it the same way in both.
let plain = ApiResponse {
error: "connection refused".into(),
..Default::default()
};
assert_eq!(plain.error_detail(&s), plain.error_text(&s));
}
/// Everything else still is a runner error, and still says so.
#[test]
fn a_transport_failure_keeps_the_runner_heading() {
let r = ApiResponse {
error: "connection refused".into(),
..Default::default()
};
let s = Strings::for_language(&Language::English);
assert_eq!(r.error_text(&s), "Request error: connection refused");
assert!(
ApiResponse::default().error_text(&s).is_empty(),
"a request that has not failed has nothing to say"
);
}
/// `begin` clears the last refusal along with the last response: without
/// it a send that succeeds would still be described by the generator
/// failure that stopped the previous one.
#[test]
fn starting_a_send_forgets_the_last_refusal() {
let mut r = ApiResponse {
gen_errors: vec![GenError::Cycle { name: "a".into() }],
..Default::default()
};
r.begin();
assert!(r.gen_errors.is_empty());
}
}