renox 1.1.0

A batteries-included web framework for Rust, inspired by Laravel: Axum, HTMX and Alpine.js, SQLite or PostgreSQL, deployed as one binary.
Documentation
//! `#[model(form)]`: the generated `<Model>Form` and its `fill`.

use renox::chrono::NaiveDate;
use renox::db::Migration;
use renox::prelude::*;
use renox::testing::TestApp;

const SCHEMA: Migration = Migration::new("20300101000000_tasks", "", None).sqlite(
    "CREATE TABLE tasks (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL,
         done INTEGER NOT NULL DEFAULT 0, due_on TEXT, owner_id INTEGER NOT NULL DEFAULT 0,
         created_at TEXT, updated_at TEXT);",
    None,
).postgres(
    "CREATE TABLE tasks (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, title TEXT NOT NULL,
         done BOOLEAN NOT NULL DEFAULT FALSE, due_on DATE, owner_id BIGINT NOT NULL DEFAULT 0,
         created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ);",
    None,
);

#[derive(Model, serde::Serialize, Default, Debug, Clone)]
#[model(table = "tasks", form)]
struct Task {
    id: i64,
    #[form(validate(required, max = 20))]
    title: String,
    done: bool,
    due_on: Option<NaiveDate>,
    #[form(skip)]
    owner_id: i64,
    created_at: Option<DateTime>,
    updated_at: Option<DateTime>,
}

struct Tasks;

impl Module for Tasks {
    fn name(&self) -> &'static str {
        "tasks"
    }

    fn routes(&self) -> Routes {
        Routes::new().post(
            "/tasks",
            |State(state): State<AppState>, Valid(form): Valid<TaskForm>| async move {
                let mut task = Task {
                    owner_id: 7,
                    ..Default::default()
                };
                form.fill(&mut task);
                let task = Task::create(&state.db, task).await?;
                Ok::<_, Error>(task.id.to_string())
            },
        )
    }
}

async fn app() -> TestApp {
    TestApp::new(App::new().migrations(&[SCHEMA]).module(Tasks)).await
}

#[renox::test]
async fn a_valid_post_creates_a_row() {
    let app = app().await;
    app.post(
        "/tasks",
        &[("title", "Write docs"), ("due_on", "2030-05-01")],
    )
    .await
    .assert_ok();
    let tasks = Task::all(app.db()).await.unwrap();
    assert_eq!(tasks.len(), 1);
    assert_eq!(tasks[0].title, "Write docs");
    assert!(!tasks[0].done, "an unchecked box is false");
    assert_eq!(tasks[0].due_on, NaiveDate::from_ymd_opt(2030, 5, 1));
    assert_eq!(tasks[0].owner_id, 7);
}

#[renox::test]
async fn rules_apply() {
    let app = app().await;
    app.htmx()
        .post("/tasks", &[("title", "a title that is far too long")])
        .await
        .assert_invalid("title");
    assert!(Task::all(app.db()).await.unwrap().is_empty());
}

#[renox::test]
async fn skipped_fields_are_not_form_fields() {
    let app = app().await;
    app.post(
        "/tasks",
        &[("title", "Mine"), ("owner_id", "99"), ("done", "on")],
    )
    .await
    .assert_ok();
    let task = &Task::all(app.db()).await.unwrap()[0];
    assert_eq!(task.owner_id, 7);
    assert!(task.done);
}

#[test]
fn the_form_serializes() {
    let form: TaskForm = serde_json::from_value(serde_json::json!({
        "title": "x", "due_on": null
    }))
    .unwrap();
    let value = serde_json::to_value(&form).unwrap();
    assert_eq!(value["title"], "x");
    assert_eq!(value["done"], false);
    assert!(value.get("owner_id").is_none());
    assert!(value.get("id").is_none());
}

// Upload fields.

const PHOTOS: Migration = Migration::new("20300101000100_photos", "", None)
    .sqlite(
        "CREATE TABLE photos (id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT,
         created_at TEXT, updated_at TEXT);",
        None,
    )
    .postgres(
        "CREATE TABLE photos (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, path TEXT,
         created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ);",
        None,
    );

#[derive(Model, serde::Serialize, Default, Debug, Clone)]
#[model(table = "photos", form)]
struct Photo {
    id: i64,
    #[form(upload, validate(image))]
    path: Option<String>,
    created_at: Option<DateTime>,
    updated_at: Option<DateTime>,
}

struct Photos;

impl Module for Photos {
    fn name(&self) -> &'static str {
        "photos"
    }

    fn routes(&self) -> Routes {
        Routes::new().post(
            "/photos",
            |State(state): State<AppState>, Valid(mut form): Valid<PhotoForm>| async move {
                let mut photo = Photo::default();
                let stored = match form.path.take() {
                    Some(upload) => Some(upload.store(&state.storage, "photos").await?),
                    None => None,
                };
                form.fill(&mut photo);
                photo.path = stored;
                let photo = Photo::create(&state.db, photo).await?;
                Ok::<_, Error>(photo.id.to_string())
            },
        )
    }
}

const PNG: &[u8] = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDRsmall-but-valid-enough-for-sniffing";

#[renox::test]
async fn an_upload_field_is_stored_by_the_handler() {
    let app = TestApp::new(App::new().migrations(&[PHOTOS]).module(Photos)).await;
    app.post_multipart("/photos", &[], &[("path", "cat.png", PNG)])
        .await
        .assert_ok();
    let photos = Photo::all(app.db()).await.unwrap();
    let path = photos[0].path.clone().expect("the handler set the path");
    assert!(path.starts_with("photos/"), "{path}");
}

#[renox::test]
async fn an_upload_field_is_checked() {
    let app = TestApp::new(App::new().migrations(&[PHOTOS]).module(Photos)).await;
    app.htmx()
        .post_multipart("/photos", &[], &[("path", "notes.png", b"just text")])
        .await
        .assert_invalid("path");
    assert!(Photo::all(app.db()).await.unwrap().is_empty());
}

// Nested names.

#[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone, PartialEq)]
struct Line {
    qty: i64,
}

const ORDERS: Migration = Migration::new("20300101000200_orders", "", None).sqlite(
    "CREATE TABLE orders (id INTEGER PRIMARY KEY AUTOINCREMENT, lines TEXT NOT NULL DEFAULT '[]',
         created_at TEXT, updated_at TEXT);",
    None,
).postgres(
    "CREATE TABLE orders (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, lines JSONB NOT NULL DEFAULT '[]',
         created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ);",
    None,
);

#[derive(Model, serde::Serialize, Default, Debug, Clone)]
#[model(table = "orders", form)]
struct Order {
    id: i64,
    lines: renox::db::Json<Vec<Line>>,
    created_at: Option<DateTime>,
    updated_at: Option<DateTime>,
}

struct Orders;

impl Module for Orders {
    fn name(&self) -> &'static str {
        "orders"
    }

    fn routes(&self) -> Routes {
        Routes::new().post("/orders", |Valid(form): Valid<OrderForm>| async move {
            let mut order = Order::default();
            form.fill(&mut order);
            Ok::<_, Error>(format!("{:?}", order.lines.0))
        })
    }
}

#[renox::test]
async fn nested_names_fill_a_json_column() {
    let app = TestApp::new(App::new().migrations(&[ORDERS]).module(Orders)).await;
    let res = app
        .post("/orders", &[("lines[0][qty]", "2"), ("lines[1][qty]", "3")])
        .await;
    res.assert_ok();
    assert_eq!(res.text(), "[Line { qty: 2 }, Line { qty: 3 }]");
}

#[renox::test]
async fn a_missing_nested_value_names_its_key() {
    let app = TestApp::new(App::new().migrations(&[ORDERS]).module(Orders)).await;
    app.htmx()
        .post(
            "/orders",
            &[("lines[0][qty]", "2"), ("lines[1][other]", "3")],
        )
        .await
        .assert_invalid("lines.1.qty");
}