rainier-framework 1.1.0

A Laravel-shaped MVC framework for Rust, built on the Rainier ORM DBAL.
Documentation
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
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
//! A test harness — [`TestApp`] and [`TestResponse`].
//!
//! ```ignore
//! let app = TestApp::new(boot(Mode::Testing).await?)?;
//!
//! app.get("/health/ready").await
//!     .assert_ok()
//!     .assert_json_path("database.status", "ok");
//!
//! let user: UserView = app.get("/api/me").await.json();
//! ```
//!
//! Laravel's `$this->get(..)->assertOk()->assertJsonPath(..)`, which is the
//! shape because it is the right shape: a feature test is three lines — make a
//! request, assert the status, assert something about the body — and anything
//! longer is the harness leaking.
//!
//! # It scopes the facades to *this* test
//!
//! The container facades resolve through is process-wide, so booting one
//! application per test used to race: two tests boot, the second wins, and the
//! first resolves out of a container that has been replaced. The symptom is
//! `nothing is bound for rainier_config::repository::Config` on a random
//! subset of tests on a random subset of runs.
//!
//! [`TestApp`] holds a [`FacadeScope`], which
//! overrides the global one **for the thread the test runs on**. So each test
//! gets its own application and its own configuration, and they do not
//! interfere.
//!
//! `#[tokio::test]` uses a current-thread runtime, so a test and everything it
//! awaits stay on that thread. Under `#[tokio::test(flavor = "multi_thread")]`
//! a future can move, and after the move a facade resolves through whatever
//! was installed globally — so a multi-threaded test wanting its own container
//! should keep the work on one task, or install its application globally and
//! not run in parallel with another that does.
//!
//! [`FacadeScope`]: rainier_container::FacadeScope

use std::sync::Arc;

use serde::de::DeserializeOwned;
use serde_json::Value;

use rainier_container::{scope_facade_application, Application, FacadeScope};
use rainier_http::{Method, Request, Response, StatusCode};
use rainier_server::Kernel;
use rainier_support::Result;

/// A booted application, ready to be asked for responses.
///
/// Not `Send`: it holds a thread-local facade scope, and moving it to another
/// thread would unscope on one and pop on another.
pub struct TestApp {
    app: Arc<Application>,
    kernel: Arc<Kernel>,
    /// Dropped with the harness, which is what ends the scope.
    _scope: FacadeScope,
    /// Headers added to every request — a bearer token, usually.
    defaults: Vec<(String, String)>,
}

impl TestApp {
    /// Wrap an application your own `boot` produced.
    ///
    /// Takes the booted application rather than booting one, because what a
    /// test wants exercised is *your* bootstrap — its providers, its
    /// configuration, its routes — not a generic one this crate could build.
    pub fn new(app: Arc<Application>) -> Result<Self> {
        let kernel = app.resolve::<Kernel>()?;
        let scope = scope_facade_application(Arc::clone(&app));

        Ok(Self { app, kernel, _scope: scope, defaults: Vec::new() })
    }

    /// The application, for resolving a service to assert on.
    pub fn app(&self) -> &Arc<Application> {
        &self.app
    }

    /// Resolve a service — `app.resolve::<PostRepository>()?`.
    pub fn resolve<T: Send + Sync + 'static>(&self) -> Result<Arc<T>> {
        self.app.resolve::<T>()
    }

    /// Send this header on every subsequent request.
    ///
    /// ```ignore
    /// let app = app.with_header("authorization", format!("Bearer {token}"));
    /// ```
    #[must_use = "this returns a configured harness rather than configuring in place"]
    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
        self.defaults.push((name.into(), value.into()));
        self
    }

    /// Authenticate every subsequent request with a bearer token.
    #[must_use = "this returns a configured harness rather than configuring in place"]
    pub fn with_token(self, token: impl std::fmt::Display) -> Self {
        self.with_header("authorization", format!("Bearer {token}"))
    }

    /// `GET uri`.
    pub async fn get(&self, uri: &str) -> TestResponse {
        self.send(self.request(Method::GET, uri).build()).await
    }

    /// `DELETE uri`.
    pub async fn delete(&self, uri: &str) -> TestResponse {
        self.send(self.request(Method::DELETE, uri).build()).await
    }

    /// `POST uri` with a JSON body.
    pub async fn post(&self, uri: &str, body: &impl serde::Serialize) -> TestResponse {
        self.send(self.request(Method::POST, uri).json(body).build()).await
    }

    /// `PUT uri` with a JSON body.
    pub async fn put(&self, uri: &str, body: &impl serde::Serialize) -> TestResponse {
        self.send(self.request(Method::PUT, uri).json(body).build()).await
    }

    /// `PATCH uri` with a JSON body.
    pub async fn patch(&self, uri: &str, body: &impl serde::Serialize) -> TestResponse {
        self.send(self.request(Method::PATCH, uri).json(body).build()).await
    }

    /// `POST uri` with no body — a form action, a publish, a logout.
    pub async fn post_empty(&self, uri: &str) -> TestResponse {
        self.send(self.request(Method::POST, uri).build()).await
    }

    /// A request builder carrying this harness's default headers.
    ///
    /// For the request the helpers above do not cover — a form encoding, an
    /// odd header, a deliberately malformed body.
    pub fn request(&self, method: Method, uri: &str) -> rainier_http::RequestBuilder {
        let mut builder = Request::builder().method(method).uri(uri);
        for (name, value) in &self.defaults {
            builder = builder.header(name.as_str(), value.as_str());
        }
        builder
    }

    /// Send a request you built yourself, through the whole kernel.
    ///
    /// The real pipeline: global middleware, routing, route middleware, the
    /// handler, and the exception renderer. A test that bypassed it would be
    /// asserting on something no client can reach.
    pub async fn send(&self, request: Request) -> TestResponse {
        TestResponse::of(self.kernel.handle_request(request).await).await
    }
}

/// One response, with its body already read.
///
/// The body is collected on construction so every assertion can be `&self` and
/// chain. A streaming body can only be read once, and a harness that made you
/// think about that would be a harness that made you think about the wrong
/// thing.
pub struct TestResponse {
    status: StatusCode,
    headers: Vec<(String, String)>,
    body: String,
}

impl TestResponse {
    /// Read `response` into an assertable one.
    pub async fn of(response: Response) -> Self {
        let status = response.status();
        let headers = response
            .headers()
            .iter()
            .filter_map(|(name, value)| {
                Some((name.as_str().to_string(), value.to_str().ok()?.to_string()))
            })
            .collect();

        let body = response.into_string().await.unwrap_or_default();

        Self { status, headers, body }
    }

    /// The status.
    pub fn status(&self) -> StatusCode {
        self.status
    }

    /// The body, as text.
    pub fn text(&self) -> &str {
        &self.body
    }

    /// A header's value.
    pub fn header(&self, name: &str) -> Option<&str> {
        self.headers
            .iter()
            .find(|(header, _)| header.eq_ignore_ascii_case(name))
            .map(|(_, value)| value.as_str())
    }

    /// The body as JSON.
    ///
    /// # Panics
    ///
    /// If it will not parse — printing what arrived, because that is nearly
    /// always an error response the test did not expect.
    pub fn json(&self) -> Value {
        serde_json::from_str(&self.body).unwrap_or_else(|e| {
            panic!(
                "the response body is not JSON: {e}\n  status: {}\n  body: {}",
                self.status, self.body
            )
        })
    }

    /// The body deserialised into your own type.
    ///
    /// # Panics
    ///
    /// If it will not deserialise, printing the body.
    pub fn json_as<T: DeserializeOwned>(&self) -> T {
        serde_json::from_str(&self.body).unwrap_or_else(|e| {
            panic!("the response body is not the expected shape: {e}\n  body: {}", self.body)
        })
    }

    // --- assertions --------------------------------------------------------
    //
    // Every one takes and returns `&Self`, so they chain and the response is
    // still there afterwards to read.

    /// Panic unless the status is exactly this.
    ///
    /// # Panics
    ///
    /// If it is not — printing the body, which is where the reason is.
    pub fn assert_status(&self, expected: StatusCode) -> &Self {
        assert_eq!(
            self.status, expected,
            "expected {expected}, got {}\n  body: {}",
            self.status, self.body
        );
        self
    }

    /// Panic unless the status is `2xx`.
    ///
    /// # Panics
    ///
    /// If it is not.
    pub fn assert_ok(&self) -> &Self {
        assert!(
            self.status.is_success(),
            "expected a 2xx, got {}\n  body: {}",
            self.status,
            self.body
        );
        self
    }

    /// Panic unless the status is `201`.
    ///
    /// # Panics
    ///
    /// If it is not.
    pub fn assert_created(&self) -> &Self {
        self.assert_status(StatusCode::CREATED)
    }

    /// Panic unless the status is `204`.
    ///
    /// # Panics
    ///
    /// If it is not.
    pub fn assert_no_content(&self) -> &Self {
        self.assert_status(StatusCode::NO_CONTENT)
    }

    /// Panic unless the status is `404`.
    ///
    /// # Panics
    ///
    /// If it is not.
    pub fn assert_not_found(&self) -> &Self {
        self.assert_status(StatusCode::NOT_FOUND)
    }

    /// Panic unless the status is `401`.
    ///
    /// # Panics
    ///
    /// If it is not.
    pub fn assert_unauthorized(&self) -> &Self {
        self.assert_status(StatusCode::UNAUTHORIZED)
    }

    /// Panic unless the status is `403`.
    ///
    /// # Panics
    ///
    /// If it is not.
    pub fn assert_forbidden(&self) -> &Self {
        self.assert_status(StatusCode::FORBIDDEN)
    }

    /// Panic unless the status is `422` — a failed request contract.
    ///
    /// # Panics
    ///
    /// If it is not.
    pub fn assert_invalid(&self) -> &Self {
        self.assert_status(StatusCode::UNPROCESSABLE_ENTITY)
    }

    /// Panic unless the JSON at `path` equals `expected`.
    ///
    /// `path` is dotted, and an index is a segment: `data.0.title`.
    ///
    /// # Panics
    ///
    /// If the path is absent or holds something else.
    pub fn assert_json_path(&self, path: &str, expected: impl Into<Value>) -> &Self {
        let expected = expected.into();
        let document = self.json();

        match json_path(&document, path) {
            Some(actual) => assert_eq!(
                *actual, expected,
                "at `{path}`: expected {expected}, got {actual}\n  body: {}",
                self.body
            ),
            None => panic!("`{path}` is not in the response\n  body: {}", self.body),
        }
        self
    }

    /// Panic unless there is nothing at `path`.
    ///
    /// For asserting a field was **not** serialised — a password hash, an
    /// internal id.
    ///
    /// # Panics
    ///
    /// If something is there.
    pub fn assert_json_missing(&self, path: &str) -> &Self {
        assert!(
            json_path(&self.json(), path).is_none(),
            "`{path}` should not be in the response\n  body: {}",
            self.body
        );
        self
    }

    /// Panic unless the body contains `text`.
    ///
    /// # Panics
    ///
    /// If it does not.
    pub fn assert_contains(&self, text: &str) -> &Self {
        assert!(
            self.body.contains(text),
            "the body does not contain {text:?}\n  body: {}",
            self.body
        );
        self
    }

    /// Panic unless the header is present with this value.
    ///
    /// # Panics
    ///
    /// If it is absent or different.
    pub fn assert_header(&self, name: &str, expected: &str) -> &Self {
        match self.header(name) {
            Some(actual) => assert_eq!(actual, expected, "header `{name}`"),
            None => panic!("`{name}` is not on the response — it has {:?}", self.header_names()),
        }
        self
    }

    /// Panic unless the header is absent.
    ///
    /// # Panics
    ///
    /// If it is present.
    pub fn assert_header_missing(&self, name: &str) -> &Self {
        assert!(self.header(name).is_none(), "`{name}` should not be on the response");
        self
    }

    fn header_names(&self) -> Vec<&str> {
        self.headers.iter().map(|(name, _)| name.as_str()).collect()
    }
}

/// Walk a dotted path into a JSON value. A numeric segment indexes an array.
fn json_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
    let mut current = value;

    for segment in path.split('.') {
        current = match current {
            Value::Array(items) => items.get(segment.parse::<usize>().ok()?)?,
            other => other.get(segment)?,
        };
    }
    Some(current)
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn document() -> Value {
        json!({
            "database": { "status": "ok", "latency_ms": 3 },
            "data": [{ "title": "First" }, { "title": "Second" }],
            "nothing": null,
        })
    }

    #[test]
    fn a_dotted_path_walks_objects_and_arrays() {
        let value = document();

        assert_eq!(json_path(&value, "database.status"), Some(&json!("ok")));
        assert_eq!(json_path(&value, "data.1.title"), Some(&json!("Second")));
        assert_eq!(json_path(&value, "database.latency_ms"), Some(&json!(3)));
    }

    #[test]
    fn an_absent_path_is_none_rather_than_a_panic() {
        let value = document();

        assert_eq!(json_path(&value, "database.nope"), None);
        assert_eq!(json_path(&value, "data.9.title"), None);
        assert_eq!(json_path(&value, "data.title"), None, "an array indexed by a name");
    }

    #[test]
    fn a_null_is_present_and_not_missing() {
        // `"nothing": null` is a field the API sends. `assert_json_missing`
        // must not confuse it with a field the API omits.
        assert_eq!(json_path(&document(), "nothing"), Some(&json!(null)));
    }

    #[tokio::test]
    async fn a_response_is_read_once_and_asserted_many_times() {
        let response = TestResponse::of(
            Response::ok(r#"{"database":{"status":"ok"}}"#)
                .with_header("content-type", "application/json"),
        )
        .await;

        response
            .assert_ok()
            .assert_status(StatusCode::OK)
            .assert_json_path("database.status", "ok")
            .assert_json_missing("database.error")
            .assert_header("content-type", "application/json")
            .assert_contains("status");

        assert_eq!(response.json()["database"]["status"], "ok");
    }

    #[tokio::test]
    #[should_panic(expected = "expected 404 Not Found, got 200 OK")]
    async fn a_failed_status_assertion_prints_the_body() {
        TestResponse::of(Response::ok("hello")).await.assert_not_found();
    }

    #[tokio::test]
    #[should_panic(expected = "`database.status` is not in the response")]
    async fn a_missing_path_says_which_path() {
        TestResponse::of(Response::ok("{}")).await.assert_json_path("database.status", "ok");
    }
}