Код
November 24, 2023

Rust Fullstack: Rocket, SeaORM, Dioxus

Для написания фуллстек-приложений на Rust понадобится недюжиное понимание того, как устроены библиотеки и фреймворки и как удобно их использовать. Но есть и плюс: я уже для вас разобрался.

Полный список используемых крейтов для фронтенда и бэкенда - в конце статьи.

В листингах кода полно комментариев.

Структура репозитория

Начать следует с создания Cargo.toml в корне репозитория:

[workspace]
members = [
    "./frontend",
    "./backend",
    "./backend/migration",
    "./data_types"
]
resolver = "2"

[profile.dev]
incremental = true

[profile.release]
opt-level = 3
lto = true
panic = 'abort'

Как видно, Cargo позволяет объединить несколько подпроектов в один через рабочие пространства. Помимо бэкенда и фронтенда, виден также подпроект миграций SQL и подпроект типов данных.

Строка resolver = "2" нужна для корректного управления подпроектами.

Настройки профилей можете оставить без изменений, можете дописать под себя. Для разработки включена инкрементальная сборка, для выкатки в релиз - LTO-оптимизация.

Миграции SQL

В проекте используется SeaORM. Грубо говоря, это - способ управления сущностями через базу данных. Поэтому, чтобы начать разработку, нужно сперва определить типы данных, которые понадобятся для проекта.

Для этого в папке backend выполните следующие команды:

cargo install sea-orm-cli # устанавливает клиент управления миграциями

sea-orm-cli migrate init # инициализирует подпроект миграций

Миграции - это обновление структуры базы данных с целью её расширения без потерь информации в автоматическом режиме. Всё, что нужно, - это написать код.

Типичный файл миграции выглядит так:

use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {

  /// Эта функция выполняется при запуске миграции. Если база данных пуста,
  /// и это первая миграция, - то в ней нужно создать таблицы для хранения
  /// информации.
  async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
    manager
      .create_table(
        Table::create()
          .table(Users::Table)
          .if_not_exists()
          .col(ColumnDef::new(Users::Id)
            .big_integer().not_null().auto_increment()
            .primary_key().unique_key()
          )
          .col(ColumnDef::new(Users::Login)
            .string().not_null().unique_key()
          )
          .col(ColumnDef::new(Users::Salt).string().not_null())
          .col(ColumnDef::new(Users::PHash).string().not_null())
          .col(ColumnDef::new(Users::Settings).string().not_null())
          .to_owned()
      ).await
  }

  /// Эта функция используется, чтобы откатить нежелательные изменения при
  /// необходимости. Грубо говоря, здесь нужно писать код, который вернёт
  /// всё, как раньше. Но не прям в 2007.
  async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
    manager.drop_table(Table::drop().table(Users::Table).to_owned()).await
  }
}

/// В перечислении мы определяем поля идентификаторов.
#[derive(Iden)]
enum Users {
  Table, // Да, среди полей таблицы затесалась сама таблица.
         // Подробнее: https://docs.rs/sea-query#iden
  Id,
  Login,
  Salt,
  PHash,
  Settings,
}

Миграции перечисляются в backend/migrations/lib.rs:

pub use sea_orm_migration::prelude::*;

mod m20231007_000001_create_tables; // Указываем файл с миграцией

pub struct Migrator;

#[async_trait::async_trait]
impl MigratorTrait for Migrator {
  fn migrations() -> Vec<Box<dyn MigrationTrait>> {
    vec![
    
      // И добавляем миграции сюда.
      Box::new(m20231007_000001_create_tables::Migration),
      
    ]
  }
}

Для запуска миграций вам необходимо создать пустую базу данных и из папки backend выполнить команду:

sea-orm-cli migrate -u postgres://login:pass@localhost/dbname

Затем, чтобы сгенерировать сущности, которые вы сможете использовать в коде на бэкенде, необходимо выполнить следующую команду:

sea-orm-cli generate entity -o ./src/entities

Вуаля, вы можете теперь подключить сущности через mod entities; в main.rs и использовать их примерно следующим образом:

let new_user = users::ActiveModel {
  login: ActiveValue::Set(data.login.clone()),
  salt: ActiveValue::Set(salt.clone()),
  p_hash: ActiveValue::Set(base64.encode(p_hash)),
  settings: ActiveValue::Set(serde_json::to_string(&settings)?),
  ..Default::default()
};
Users::insert(new_user).exec(db as &DbPool).await?;

Общие структуры данных

Чтобы использовать все преимущества строгой типизации в Rust, необходимо задать общие типы данных, которые планируется использовать на бэкенде и фронтенде.

Файл data_types/src/lib.rs:

use chrono::{DateTime, Utc, serde::ts_seconds};
use serde::{Serialize, Deserialize};

// Авторизация и управление аккаунтами:
#[derive(Deserialize, Serialize)]
pub struct SignUpRequestData {
  pub login: String,
  pub name: String,
  pub password: String,
}

#[derive(Deserialize, Serialize)]
pub struct SignInRequestData {
  pub login: String,
  pub password: String,
}

#[derive(Serialize, Deserialize)]
pub struct IsUserValid {
  pub valid: bool,
}

#[derive(Deserialize, Serialize)]
pub struct UserToken {
  pub user_id: i64,
  pub token_str: String,
  #[serde(with = "ts_seconds")]
  pub birth: DateTime<Utc>,
}

Чтобы использовать подпроект в бэкенде и фронтенде, укажите data_types как зависимость в их Cargo.toml:

data_types = { path = "../data_types" }

Структура бэкенда

Часть 1. Управление ошибками

Можно было бы использовать anyhow::Error или подобные крейты, но самописные решения предоставляют определённую гибкость:

use base64::DecodeError;
use bb8_redis::redis::RedisError;
use bb8_redis::bb8::RunError;
use sea_orm::DbErr;
use rocket::{
  request::Request,
  response::{self, Response, Responder},
  http::{ContentType, Status},
};
use std::{io, fmt, num::ParseIntError, string::FromUtf8Error};
use serde::{Serialize, Deserialize};
use serde_json::Error as JsonError;

/// Структура данных ошибок.
#[derive(Serialize, Deserialize, Debug)]
pub struct ErrorResponder {
  message: String,
}

/// Мы воплощаем трейт Responder, чтобы указать,
/// как Rocket будет эту ошибку возвращать клиенту.
#[rocket::async_trait]
impl<'r> Responder<'r, 'static> for ErrorResponder {
  fn respond_to(self, _: &'r Request<'_>) -> response::Result<'static> {
    let json_answer = serde_json::to_string(&self).unwrap();
    Response::build()
      .status(Status::Unauthorized)
      .header(ContentType::JSON)
      .sized_body(json_answer.len(), io::Cursor::new(json_answer))
      .ok()
  }
}

impl fmt::Display for ErrorResponder {
  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
    write!(f, "{}", self.message)
  }
}

impl From<DbErr> for ErrorResponder {
  fn from(err: DbErr) -> ErrorResponder {
    ErrorResponder { message: err.to_string() }
  }
}

impl From<String> for ErrorResponder {
  fn from(string: String) -> ErrorResponder {
    ErrorResponder { message: string }
  }
}

impl From<&str> for ErrorResponder {
  fn from(str: &str) -> ErrorResponder {
    str.to_owned().into()
  }
}

impl From<io::Error> for ErrorResponder {
  fn from(err: io::Error) -> ErrorResponder {
    err.to_string().into()
  }
}

impl From<JsonError> for ErrorResponder {
  fn from(err: JsonError) -> ErrorResponder {
    err.to_string().into()
  }
}

impl From<ParseIntError> for ErrorResponder {
  fn from(err: ParseIntError) -> ErrorResponder {
    err.to_string().into()
  }
}

impl From<RedisError> for ErrorResponder {
  fn from(err: RedisError) -> ErrorResponder {
    err.to_string().into()
  }
}

impl From<RunError<RedisError>> for ErrorResponder {
  fn from(err: RunError<RedisError>) -> ErrorResponder {
    err.to_string().into()
  }
}

impl From<DecodeError> for ErrorResponder {
  fn from(err: DecodeError) -> ErrorResponder {
    err.to_string().into()
  }
}

impl From<FromUtf8Error> for ErrorResponder {
  fn from(value: FromUtf8Error) -> Self {
    value.to_string().into()
  }
}

/// Делаем биндинг для удобного использования в функциях.
pub type MResult<T> = Result<T, ErrorResponder>;

Использование MResult<T> облегчает нашу жизнь таким образом:

pub async fn login(...) -> MResult<Json<UserToken>> {
  let user_data = find_by_login(&data.login, db).await?;
  let phash_db = base64.decode(user_data.p_hash.as_bytes())?;
  let salted_password = data.password.clone() + &user_data.salt;
  validate_hashes(salted_password.as_bytes(), &phash_db)?;
  let utl_name = get_user_tokens_list_name(&user_data.id);
  let cacher = cacher as &RedisPool;
  let mut cacher_conn = cacher.get().await?;
  let user_tokens_list_len: isize = cacher_conn.llen(&utl_name).await?;
  let token = generate_token(user_data.id)?;
  if user_tokens_list_len >= MAX_TOKENS_PER_USER {
    cacher_conn.ltrim(&utl_name, 0, MAX_TOKENS_PER_USER - 1).await?;
  }
  cacher_conn.lpush(&utl_name, &serde_json::to_string(&token)?).await?;
  Ok(Json(token))
}

Часть 2. Управление shared-объектами

Для начала загрузим в приложение всю необходимую информацию для его работы и инициализируем объекты для управления данными:

use bb8_redis::{bb8::Pool as Bb8Pool, RedisConnectionManager};
use dotenv::dotenv;
use sea_orm::*;

pub type RedisPool = Pool<RedisConnectionManager>;
pub type DbPool = DatabaseConnection;

pub struct AppState {
  pub db_url: String,
  pub redis_url: String,
}

pub(super) async fn load_app_state() -> MResult<(AppState, DbPool, RedisPool)> {
  dotenv().ok(); // Загружаем все переменные из файла `.env` в окружение
  let state = AppState {
    db_url: std::env::var("DATABASE_URL").expect("DATABASE_URL variable must be set"),
    redis_url: std::env::var("REDIS_URL").expect("REDIS_URL variable must be set"),
  };
  let db = connect_db(&state.db_url).await?;
  let cacher = connect_redis(&state.redis_url).await?;
  Ok((state, db, cacher))
}

async fn connect_db(db_url: &str) -> MResult<DbPool> {
  Ok(Database::connect(db_url).await?)
}

async fn connect_redis(redis_url: &str) -> MResult<RedisPool> {
  let manager = RedisConnectionManager::new(redis_url)?;
  Ok(Bb8Pool::builder().build(manager).await?)
}

А теперь напишем функцию, которая полностью заменяет fn main():

#[launch]
async fn rocket() -> _ {
  // 1. Установка уровня логгирования.
  if cfg!(debug_assertions) {
    simple_logger::init_with_level(log::Level::Info).unwrap();
  } else {
    simple_logger::init_with_level(log::Level::Warn).unwrap();
  }

  // 2. Загрузка основных параметров и установка соединений с базами данных.
  let (state, db, cacher) = match load_app_state().await {
    Ok((state, db, cacher)) => (state, db, cacher),
    Err(err) => panic!("{}", err),
  };

  // 3. Запуск сервера.
  rocket::build()
    // перечисляем управляемые объекты:
    .manage(state)
    .manage(db)
    .manage(cacher)
    // перечисляем роуты
    .mount("/", routes![
      ...
    ])
}

Часть 3. Роуты

В перечислении параметров функций-роутов можно использовать только те управляемые объекты, которые вам нужны.

pub use rocket::{State, serde::json::Json};
pub use rocket::post;

/// Создаём метод POST для пути '/login',
/// который принимает `application/json` в теле запроса.
#[post("/login", format = "json", data = "<data>")]
pub async fn login(
  data: Json<SignInRequestData>, // JSON из тела запроса
  db: &State<DbPool>,
  cacher: &State<RedisPool>,
) -> MResult<Json<UserToken>> {
  ...
  Ok(Json(token))
}

Чтобы запрашивать через параметры роута ещё и ключ аутентификации, используйте следующий код:

/// Ключ API через заголовок аутентификации.
pub struct ApiToken(String);

#[rocket::async_trait]
impl<'r> FromRequest<'r> for ApiToken {
  type Error = ErrorResponder;

  async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
    /// Извлекаем токен из строки авторизации.
    fn extract_token(key: &str) -> String {
      key.to_owned().replace("Bearer ", "")
    }

    // Получаем текст заголовка `Authorization`:
    match req.headers().get_one("Authorization") {
      None => Outcome::Error((Status::Forbidden, "Authorization token is missing.".into())),
      Some(key) => Outcome::Success(ApiToken(extract_token(key))),
    }
  }
}

Тогда роуты принимают подобный вид:

#[get("/project/<project_id>")]
pub async fn get_project_info(
  project_id: i64, // Запрос `/project/2221` установит
                   // в переменную `project_id` значение 2221.
  db: &State<DbPool>,
  cacher: &State<RedisPool>,
  token: ApiToken, // Токен, который клиент должен передавать
                   // вместе с запросом.
) -> MResult<Json<ProjectCardInfo>> { ... }

Запуск

cd backend
cargo run --release

Структура фронтенда

Часть 1. Конфигурация приложения

Создайте Dioxus.toml с подобным содержимым:

[application]
name = "app_name"
default_platform = "web"
out_dir = "../dist"
asset_dir = "public"

[web.app]
title = "App Title"

[web.watcher]
watch_path = ["src", "public"]

[web.resource]
# Если требуется использовать TailwindCSS,
# изучите https://dioxuslabs.com/learn/0.4/cookbook/tailwind
style = ["tailwind.css"]
script = []

[web.resource.dev]
style = []
script = []

И установите dioxus-cli:

cargo install dioxus-cli

Часть 2. Облегчаем себе жизнь при работе с LocalStorage

Используем простейшие функции для получения и записи значений:

pub type MResult = Result<(), AppError>;
pub static APP_TOKEN_LOCALSTORAGE_KEY: &str = "token";

/// Чтение из локального хранилища.
pub fn get_from_storage(key: &str) -> Result<String, AppError> {
  let window = match web_sys::window() {
    None => return Err("Не удалось получить параметры окна".into()),
    Some(window) => window,
  };
  let storage = match window.local_storage() {
    Err(_) => return Err("Не удалось получить доступ к LocalStorage".into()),
    Ok(None) => return Err("LocalStorage пуст".into()),
    Ok(Some(storage)) => storage,
  };
  if let Ok(Some(value)) = storage.get_item(key) {
    Ok(value)
  } else {
    Err(format!("Не удалось получить значение по ключу \"{}\"", key).into())
  }
}

/// Запись в локальное хранилище.
pub fn put_in_storage(key: &str, val: &str) -> MResult {
  let window = match web_sys::window() {
    None => return Err("Не удалось получить параметры окна".into()),
    Some(window) => window,
  };
  let storage = match window.local_storage() {
    Err(_) => return Err("Не удалось получить доступ к LocalStorage".into()),
    Ok(None) => return Err("LocalStorage пуст".into()),
    Ok(Some(storage)) => storage,
  };
  if storage.set_item(key, val).is_err() {
    return Err("Не удалось сохранить значение в LocalStorage".into())
  }
  Ok(())
}

/// Получение заголовка авторизации.
pub fn get_authorization_token() -> Result<String, AppError> {
  get_from_storage(APP_TOKEN_LOCALSTORAGE_KEY)
}

Часть 3. Добавляем функции для запросов к серверу

pub static BASE_API_URL: &str = "schema://example.com/";
pub static LOGIN_API: &str = "login";
pub static AUTH_API: &str = "user";
pub static CHECK_USER_API: &str = "/check";

/// Осуществляет вход в аккаунт.
pub async fn login(username: String, password: String) -> MResult {
  let token: UserToken = reqwest::Client::new()
    .post(format!("{}{}", BASE_API_URL, LOGIN_API))
    .json(&SignInRequestData { login: username, password })
    .send()
    .await?
    .json::<UserToken>()
    .await?;
  put_in_storage(APP_TOKEN_LOCALSTORAGE_KEY, &serde_json::to_string(&token).unwrap())
}

/// Осуществляет проверку пользовательского токена.
pub async fn check_user_token() -> MResult {
  let res: IsUserValid = reqwest::Client::new()
    .post(format!("{}{}{}", BASE_API_URL, AUTH_API, CHECK_USER_API))
    .bearer_auth(get_authorization_token()?)
    .send()
    .await?
    .json()
    .await?;
  if !res.valid { return Err("Пользователь не авторизован".into()) }
  Ok(())
}

Часть 4. Подготовка реактивных состояний приложения

Чтобы Dioxus был способен динамически отрисовывать веб-страницу в зависимости от изменения данных, их необходимо задать заранее:

/// Структура данных, которая отвечает за отображение необходимого раздела.
#[derive(Clone, Debug, PartialEq)]
pub enum InAppLocation {
  Unset,
  Auth,
  Register,
  Projects,
  ProjectSurvey(ProjectID),
  PrepareRecommendationsByTest(ProjectID),
  // ...
}

/// Вспомогательная структура для принудительной отрисовки DOM.
#[derive(Clone, Debug, PartialEq)]
pub enum LocalUpdate {
  NotScheduled,
  Scheduled,
}

Далее мы создаём структуру данных, которую будем передавать во все компоненты приложения по необходимости:

#[derive(Copy, Clone)]
pub struct AppHooks<'a> {
  pub app_location: &'a UseState<InAppLocation>,
  pub update_scheduler: &'a UseState<LocalUpdate>,
  pub project_selected: &'a UseState<ProjectID>,
  pub get_summaries_fut: &'a UseFuture<Result<Vec<ProjectLineSummary>, AppError>>,
  pub get_project_card_fut: &'a UseFuture<Result<ProjectCardInfo, AppError>>,
  pub get_project_card_st: &'a UseState<ProjectID>,
  // ...
}

impl<'a> AppHooks<'a> {
  pub fn new(cx: Scope<'a>) -> Self {
    // Состояния приложения.
    let app_location = use_state(cx, || InAppLocation::Unset);
    let update_scheduler = use_state(cx, || LocalUpdate::NotScheduled);

    // Задание опционального хука
    let project_selected = use_state(cx, || 0);
    let get_project_card_fut = use_future(cx, (), |_| {
      let project_id = project_selected.to_owned();

      async move { get_project_info(*project_id).await }
    });

    // Задание опционального хука
    // Мы хотим, чтобы замыкание-футура выполнялась только тогда,
    // когда `get_project_survey_st` установлен в `true`.
    let get_project_survey_st = use_state(cx, || false);
    let get_project_survey_fut = use_future(cx, (), |_| {
      let survey_loaded = get_project_survey_st.to_owned();

      async move {
        if survey_loaded == true {
          let res = get_project_survey().await;
          if res.is_ok() { survey_loaded.set(true); }
          res
        } else { Err("Ожидание запроса.".into()) }
      }
    });

    // Отдаём хуки
    AppHooks {
      // Состояния
      app_location,
      update_scheduler,
      project_selected,
      // Исполняемые замыкания
      get_summaries_fut: use_future(cx, (), |_| async move { get_summaries().await }),
      get_project_card_fut,
      get_project_card_st: use_state(cx, || 0),
      // ...
    }
  }
}

Часть 5. Подготавливаем главный компонент приложения и запуск

Главный компонент приложения играет роль роутера, раскидывая пользователя по компонентам в зависимости от состояния приложения AppLocation.

fn main() { dioxus_web::launch(App); }
use dioxus::core::{Element, Scope};
use dioxus::prelude::*;

/// Главный компонент веб-приложения.
fn App(cx: Scope) -> Element {
  // Создание хранилища состояний
  let hx = AppHooks::new(cx);

  // Замыкание для проверки аутентификации
  let apploc_fut = use_future(cx, (), |_| async move { check_user_token().await });

  // Раскладка для отображения состояния загрузки
  let loading_lt = rsx!( div { "Загрузка..." });

  // Отрисовка приложения
  // Поскольку отрисовка условная, все состояния и хуки должны 
  // выноситься в безусловный блок. Следовательно, данные до компонентов
  // должны прокидываться из главного компонента.
  cx.render({

    // 1. Получение текущего местоположения
    let curr_location = (**hx.app_location).clone();

    // 2. Отрисовка страницы в соответствии с местоположением
    match curr_location {

      // Если положение не установлено -
      // сравниваем с результатом валидации токена.
      InAppLocation::Unset => {
        match apploc_fut.value() {
          Some(Ok(_)) => { hx.app_location.set(InAppLocation::Projects); },
          Some(Err(_)) => { hx.app_location.set(InAppLocation::Auth); },
          None => {},
        };
        loading_lt
      },

      // Страницы:
      InAppLocation::Auth => rsx!(Auth(cx, hx)),
      InAppLocation::Register => rsx!(Register(cx, hx.app_location)),
      InAppLocation::Projects => rsx!(Projects(cx, hx)),
      InAppLocation::ProjectSurvey(project_id) => rsx!(ProjectSurvey(cx, hx, project_id)),
      // ...
    }
  })
}

Немного пояснений:

  1. После установки значений или обновления состояния замыканий-футур будет выполнен рендеринг VirtualDOM, и изменения будут при необходимости отрисованы.
  2. Внутри блока cx.render({ ... }) необходимо писать код, который должен будет выполняться каждую перерисовку, а перед ним - код, который должен будет выполниться лишь один раз.
  3. При этом нельзя в компонентах использовать любые хуки (функции Dioxus, которые начинаются на use_), если эти компоненты будут отрисованы условно (см. правила хуков по ссылке ниже). Например, InAppLocation::Projects будет отрисован только после того, как отрисуется Auth, и будет выполнена авторизация. Это - причина, почему хуки лучше выносить в отдельную структуру данных - AppHooks.

Дополнительная информация про стейты, правила хуков, футуры, динамический рендеринг и футуры, которые будут выполняться по какому-то событию.

Часть 6. Компоненты

Код компонента страницы авторизации выглядит приблизительно так:

use dioxus::prelude::*;
use dioxus_html::input_data::keyboard_types::Code as KeyCode;
use log::{error, debug};

use crate::requests::auth::login;
use crate::states::{AppHooks, InAppLocation, LocalUpdate};

/// Страница авторизации пользователя.
pub fn Auth<'a>(cx: Scope<'a>, hx: AppHooks<'a>) -> Element<'a> {
  // Создаём хуки, которые будут использоваться только в этом компоненте
  let email = use_state(cx, || "".to_string());
  let password = use_state(cx, || "".to_string());
  
  // Вход в аккаунт
  // Это замыкание будет выполнено по нажатию на кнопку "Войти"
  let auth_fut = move |_| {
    cx.spawn({
      to_owned![email, password];
      let app_location = hx.app_location.to_owned();
      let update_scheduler = hx.update_scheduler.to_owned();
      async move {
        match login(
          email.current().to_string().clone(),
          password.current().to_string().clone()
        ).await {
          Ok(_) => {
            debug!("Токен получен.");
            app_location.set(InAppLocation::Projects);
            update_scheduler.set(LocalUpdate::Scheduled);
          },
          Err(e) => error!("{}", e),
        }
      }
    });
  };
  
  // Вход в аккаунт
  // Это замыкание будет выполено по нажатию на кнопку `Enter` на поле пароля
  //
  // Q: Почему нужно два одинаковых внешне замыкания?
  // A: Из-за различия в типах событий по нажатию кнопки мыши или клавиатуры.
  let auth_by_enter_fut = move |_| {
    cx.spawn({
      to_owned![email, password];
      let app_location = hx.app_location.to_owned();
      let update_scheduler = hx.update_scheduler.to_owned();
      async move {
        match login(
          email.current().to_string().clone(),
          password.current().to_string().clone()
        ).await {
          Ok(_) => {
            debug!("Токен получен.");
            app_location.set(InAppLocation::Projects);
            update_scheduler.set(LocalUpdate::Scheduled);
          },
          Err(e) => error!("{}", e),
        }
      }
    });
  };
  
  // Отрисовка страницы
  cx.render(rsx! {
    div {
      class: "flex mx-auto justify-center items-center h-screen w-2/5",
      div {
        p {
          class: "text-center font-medium mb-2",
          "Авторизация",
        },
        input {
          class: "border-b-2 w-full hover:outline-2 outline-offset-2 mb-1 rounded-sm",
          placeholder: "Электронная почта",
          r#type: "email", // Так мы можем использовать зарезервированные
                           // в языке Rust слова, которые используются в HTML.
          value: "{email}", // Значение будет соответствовать значению хука `email`.
          oninput: move |evt| email.set(evt.value.clone()),
        },
        input {
          class: "border-b-2 w-full hover:outline-2 outline-offset-2 mb-1 rounded-sm",
          r#type: "password",
          placeholder: "Пароль",
          value: "{password}",
          oninput: move |evt| password.set(evt.value.clone()),
          onkeypress: move |evt| {
            if evt.code() == KeyCode::Enter { auth_by_enter_fut(evt); }
          }
        },
        div {
          class: "flex flex-row columns-2 block gap-4",
          button {
            class: "w-full rounded-xl shadow-md px-4 py-1 text-white bg-orange-500",
            onclick: auth_fut,
            "Войти"
          },
          button {
            class: "w-full rounded-xl shadow-md px-4 py-1 text-white bg-teal-800",
            onclick: move |_| { hx.app_location.set(InAppLocation::Register); },
            "Зарегистрироваться"
          },
        },
      }
    }
  })
}

Код компонента страницы проектов:

pub fn Projects<'a>(cx: Scope<'a>, hx: AppHooks<'a>) -> Element<'a> {
  // Создание проекта
  let new_project_fut = move |_| {
    cx.spawn({
      let update_scheduler = hx.update_scheduler.clone();
      async move {
        match new_project().await {
          Ok(_) => {
            debug!("Проект создан.");
            update_scheduler.set(LocalUpdate::Scheduled);
          },
          Err(e) => error!("{}", e),
        }
      }
    });
  };

  // Отрисовка страницы
  cx.render({

    // 1. Проверка планировщика (если добавили/удалили проект)
    let curr_summaries_scheduler_state = hx.update_scheduler.clone();

    if curr_summaries_scheduler_state == LocalUpdate::Scheduled {
      hx.update_scheduler.set(LocalUpdate::NotScheduled);
      hx.get_summaries_fut.restart(); // так можно перезапускать футуры
                                      // и ожидать от них новых значений
    }

    // 2. Обновление значений из корутины
    let mut summaries_list = Vec::new();
    if hx.get_summaries_fut.value().is_some() {
      let res = hx.get_summaries_fut.value().unwrap();
      if res.is_ok() {
        let summaries_vec = res.as_ref().unwrap();
        for summary in summaries_vec {
          summaries_list.push(rsx!(ProjectLine(cx, summary.clone(), hx.update_scheduler, hx.project_selected)))
        }
      }
    }

    // 3. Отрисовка превью проекта
    let project_card = match **hx.project_selected {
      0 => None,
      _ => Some(rsx!(ProjectCard(cx, hx))),
    };

    // 4. Финальная отрисовка
    rsx!{
      SideBar {
        app_location: hx.app_location.clone()
      },
      div {
        class: "sm:pl-80 md:pl-80 lg:pl-80 pl-12 pr-12 pt-8 pb-8",
        p {
          class: "font-medium text-xl select-none pb-4",
          "Мои проекты"
        },
        summaries_list.into_iter(), // будет последовательно отрисован
                                    // список компонентов
        div {
          class: "pt-4 py-4",
          button {
            class: "animate-slide_in pl-8 pr-8 shadow-md rounded-full px-4 py-1 text-white bg-[#607a79] hover:bg-[#719190]",
            onclick: new_project_fut,
            "Начать новый проект"
          }
        },
        project_card, // отметим, что можно передавать Option<LazyNodes<..>>
      }
    }
  })
}

Сборка

Для сборки необходимо выполнить следующую команду:

dx build --release

Встраивание фронта в бэк

Добавьте следующие функции в backend/main.rs:

// Файлы для фронтенда.
#[get("/")]
async fn frontend() -> Result<NamedFile, NotFound<String>> {
  let path = Path::new("../dist/index.html");
  NamedFile::open(&path).await.map_err(|e| NotFound(e.to_string()))
}

// Если используете TailwindCSS
#[get("/tailwind.css")]
async fn get_tw_css() -> Result<NamedFile, NotFound<String>> {
  let path = Path::new("../dist/tailwind.css");
  NamedFile::open(&path).await.map_err(|e| NotFound(e.to_string()))
}

#[get("/assets/<path..>")]
async fn get_dx_app_internals(path: PathBuf) -> Option<NamedFile> {
  NamedFile::open(Path::new("../dist/assets/").join(path)).await.ok()
}

И измените функцию async fn rocket():

rocket::build()
  // перечисляем роуты
  .mount("/", routes![
    frontend, get_dx_app_internals, get_tw_css,
    // ...
  ])

Возможные проблемы

1. /favicon.ico error

Проблема с favicon.ico? Да, бывает. Измените frontend/index.html:

<head>
  <!-- ... -->
  <link rel="shortcut icon" type="image/avif" href="favicon.avif">
  <!-- ... -->
</head>

Сконвертируйте иконку в AVIF и добавьте ещё один роут в backend:

#[get("/favicon.avif")]
async fn get_favicon() -> Result<NamedFile, NotFound<String>> {
  let path = Path::new("../dist/favicon.avif");
  NamedFile::open(&path).await.map_err(|e| NotFound(e.to_string()))
}

2. Разрешение проблем с CORS

Если вы хотите на сервере разрешить CORS, добавьте следующий код:

/// Разрешает CORS.
pub struct CorsAllower;

#[rocket::async_trait]
impl Fairing for CorsAllower {
  fn info(&self) -> Info {
    Info {
      name: "Add CORS headers to responses",
      kind: Kind::Response
    }
  }

  async fn on_response<'r>(&self, _request: &'r Request<'_>, response: &mut Response<'r>) {
    response.set_header(Header::new("Access-Control-Allow-Origin", "*"));
    response.set_header(Header::new("Access-Control-Allow-Methods", "POST, PUT, DELETE, PATCH, GET, OPTIONS"));
    response.set_header(Header::new("Access-Control-Allow-Headers", "Authorization, Content-Type"));
    response.set_header(Header::new("Access-Control-Allow-Credentials", "true"));
  }
}

/// Разрешает запросы OPTIONS.
#[allow(dead_code)]
#[options("/<_path..>")]
pub async fn cors_options_preflight(_path: std::path::PathBuf) -> MResult<()> { Ok(()) }

И измените async fn rocket():

rocket::build()
  .mount("/", routes![
    cors_options_preflight, // <- позволяет отвечать на OPTIONS-запросы
    // ..
  ])
  .attach(CorsAllower); // <- добавляет разрешающие заголовки

Полезные библиотеки

1. Бэкенд

[dependencies]
base64 = "0.21"
bb8-redis = "0.13"
chrono = { version = "0.4", features = ["serde"] }
data_types = { path = "../data_types" }
dotenv = "0.15.0"
log = "0.4.20"
passwords = "3.1"
rocket = { version = "0.5.0-rc.4", features = ["tls", "json"] }
sea-orm = { version = "0.12", features = ["sqlx-postgres", "sqlx-sqlite", "runtime-tokio-native-tls", "macros", "mock"] }
sea-orm-migration = "0.12"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha3 = "0.10"
tokio = { version = "1.33.0", features = ["sync"] }
uuid = { version = "1.5.0", features = ["v4"] }

2. Фронтенд

[dependencies]
async-recursion = "1.0"
chrono = { version = "0.4", features = ["serde"] }
console_log = "1"
data_types = { path = "../data_types" }
dioxus = "0.4"
dioxus-free-icons = { version = "0.7", features = ["bootstrap"] }
dioxus-html = "0.4"
dioxus-web = "0.4"
futures = "0.3"
log = "0.4"
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
web-sys = { version = "0.3", features = ["Storage", "Window"] }