update
This commit is contained in:
parent
3299d3cc4c
commit
5f9eec00bf
13 changed files with 484 additions and 192 deletions
15
Cargo.lock
generated
15
Cargo.lock
generated
|
@ -155,6 +155,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"bcrypt",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"data-encoding",
|
||||
"env_logger",
|
||||
"futures",
|
||||
|
@ -396,6 +397,20 @@ dependencies = [
|
|||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
"hashbrown 0.14.5",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.6.0"
|
||||
|
|
|
@ -24,3 +24,7 @@ maud = "0.26.0"
|
|||
rand = "0.8.5"
|
||||
data-encoding = "2.6.0"
|
||||
bcrypt = "0.16.0"
|
||||
dashmap = "6.1.0"
|
||||
|
||||
[features]
|
||||
cache = []
|
||||
|
|
45
src/auth/mod.rs
Normal file
45
src/auth/mod.rs
Normal file
|
@ -0,0 +1,45 @@
|
|||
use data_encoding::HEXUPPER;
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
mod user;
|
||||
pub use user::User;
|
||||
|
||||
fn gen_token(token_length: usize) -> String {
|
||||
let mut token_bytes = vec![0u8; token_length];
|
||||
|
||||
rand::thread_rng().fill_bytes(&mut token_bytes);
|
||||
|
||||
HEXUPPER.encode(&token_bytes)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Session {
|
||||
/// The unique ID of the session token
|
||||
pub id: uuid::Uuid,
|
||||
/// The generated session token
|
||||
pub token: String,
|
||||
/// The username associated with the session token
|
||||
pub user: String,
|
||||
}
|
||||
|
||||
/// A macro to check if a user has admin privileges.
|
||||
///
|
||||
/// This macro checks whether the provided user has admin privileges by calling the `is_admin` method on it.
|
||||
/// If the user is not an admin, it returns a `Forbidden` error with a message indicating the restriction.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `$u` - The user to check.
|
||||
///
|
||||
/// # Returns
|
||||
/// The macro does not return a value directly but controls the flow of execution. If the user is not an admin,
|
||||
/// it returns a `Forbidden` error immediately and prevents further execution.
|
||||
#[macro_export]
|
||||
macro_rules! check_admin {
|
||||
($u:ident) => {
|
||||
if !$u.is_admin() {
|
||||
return Err(api_error("Forbidden"));
|
||||
}
|
||||
};
|
||||
}
|
|
@ -1,20 +1,11 @@
|
|||
use data_encoding::HEXUPPER;
|
||||
use rand::RngCore;
|
||||
use rocket::{http::Status, outcome::Outcome, request::FromRequest, Request};
|
||||
use rocket::{Request, http::Status, outcome::Outcome, request::FromRequest};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sqlx::FromRow;
|
||||
|
||||
use super::{Session, gen_token};
|
||||
use crate::{get_pg, request::api::ToAPI};
|
||||
|
||||
fn gen_token(token_length: usize) -> String {
|
||||
let mut token_bytes = vec![0u8; token_length];
|
||||
|
||||
rand::thread_rng().fill_bytes(&mut token_bytes);
|
||||
|
||||
HEXUPPER.encode(&token_bytes)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct User {
|
||||
/// The username chosen by the user
|
||||
|
@ -35,6 +26,11 @@ pub enum UserRole {
|
|||
}
|
||||
|
||||
impl User {
|
||||
// Get a user from session ID
|
||||
pub async fn from_session(session: &str) -> Option<Self> {
|
||||
sqlx::query_as("SELECT * FROM users WHERE username = (SELECT \"user\" FROM user_session WHERE token = $1)").bind(session).fetch_optional(get_pg!()).await.unwrap()
|
||||
}
|
||||
|
||||
/// Find a user by their username
|
||||
pub async fn find(username: &str) -> Option<Self> {
|
||||
sqlx::query_as("SELECT * FROM users WHERE username = $1")
|
||||
|
@ -143,34 +139,22 @@ impl ToAPI for User {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Session {
|
||||
/// The unique ID of the session token
|
||||
pub id: uuid::Uuid,
|
||||
/// The generated session token
|
||||
pub token: String,
|
||||
/// The username associated with the session token
|
||||
pub user: String,
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! check_admin {
|
||||
($u:ident) => {
|
||||
if !$u.is_admin() {
|
||||
return Err(api_error("Forbidden"));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for User {
|
||||
type Error = ();
|
||||
|
||||
async fn from_request(request: &'r Request<'_>) -> rocket::request::Outcome<Self, Self::Error> {
|
||||
// todo : cookie auth
|
||||
if let Some(session_id) = request.cookies().get("session_id") {
|
||||
if let Some(user) = User::from_session(session_id.value()).await {
|
||||
return Outcome::Success(user);
|
||||
} else {
|
||||
return Outcome::Error((Status::Unauthorized, ()));
|
||||
}
|
||||
}
|
||||
|
||||
match request.headers().get_one("token") {
|
||||
Some(key) => {
|
||||
if let Some(user) = sqlx::query_as("SELECT * FROM users WHERE username = (SELECT \"user\" FROM user_session WHERE token = $1)").bind(key).fetch_optional(get_pg!()).await.unwrap() {
|
||||
if let Some(user) = User::from_session(key).await {
|
||||
Outcome::Success(user)
|
||||
} else {
|
||||
Outcome::Error((Status::Unauthorized, ()))
|
||||
|
@ -180,4 +164,3 @@ impl<'r> FromRequest<'r> for User {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,72 @@
|
|||
|
||||
|
||||
|
||||
/// Formats a `chrono::NaiveDate` into a representable `String`.
|
||||
///
|
||||
/// This function converts a `NaiveDate` object into a human-readable string representation.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `date` - A reference to a `chrono::NaiveDate` instance.
|
||||
///
|
||||
/// # Returns
|
||||
/// A `String` representation of the date.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use based::format::format_date;
|
||||
///
|
||||
/// let date = chrono::NaiveDate::from_ymd(2023, 12, 18);
|
||||
/// let formatted = format_date(&date);
|
||||
/// assert_eq!(formatted, "2023-12-18");
|
||||
/// ```
|
||||
pub fn format_date(date: &chrono::NaiveDate) -> String {
|
||||
// TODO : Implement
|
||||
// TODO : Implement custom formatting
|
||||
date.to_string()
|
||||
}
|
||||
|
||||
/// Formats an integer into a representable `String`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `num` - An integer to format.
|
||||
///
|
||||
/// # Returns
|
||||
/// A `String` representation of the number.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use based::format::format_number;
|
||||
///
|
||||
/// let number = 12345;
|
||||
/// let formatted = format_number(number);
|
||||
/// assert_eq!(formatted, "12345");
|
||||
/// ```
|
||||
pub fn format_number(num: i32) -> String {
|
||||
// TODO : Implement
|
||||
// TODO : Implement custom formatting
|
||||
num.to_string()
|
||||
}
|
||||
|
||||
fn format_seconds_to_hhmmss(seconds: f64) -> String {
|
||||
/// Converts a number of seconds into a formatted string in `HH:MM:SS` or `MM:SS` format.
|
||||
///
|
||||
/// This function takes a floating-point number representing seconds and formats it
|
||||
/// into a human-readable time string. If the duration is less than one hour, the
|
||||
/// output is in `MM:SS` format. Otherwise, it is in `HH:MM:SS` format.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `seconds` - A floating-point number representing the duration in seconds.
|
||||
///
|
||||
/// # Returns
|
||||
/// A `String` formatted as `HH:MM:SS` or `MM:SS` depending on the input value.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use based::format::format_seconds_to_hhmmss;
|
||||
///
|
||||
/// let duration = 3661.5; // 1 hour, 1 minute, and 1.5 seconds
|
||||
/// let formatted = format_seconds_to_hhmmss(duration);
|
||||
/// assert_eq!(formatted, "01:01:01");
|
||||
///
|
||||
/// let short_duration = 59.9; // Less than a minute
|
||||
/// let formatted = format_seconds_to_hhmmss(short_duration);
|
||||
/// assert_eq!(formatted, "00:59");
|
||||
/// ```
|
||||
pub fn format_seconds_to_hhmmss(seconds: f64) -> String {
|
||||
let total_seconds = seconds as u64;
|
||||
let hours = total_seconds / 3600;
|
||||
let minutes = (total_seconds % 3600) / 60;
|
||||
|
|
18
src/lib.rs
18
src/lib.rs
|
@ -1,14 +1,26 @@
|
|||
use tokio::sync::OnceCell;
|
||||
|
||||
pub mod result;
|
||||
pub mod request;
|
||||
pub mod user;
|
||||
pub mod auth;
|
||||
pub mod format;
|
||||
pub mod page;
|
||||
pub mod request;
|
||||
pub mod result;
|
||||
|
||||
// Postgres
|
||||
|
||||
pub static PG: OnceCell<sqlx::PgPool> = OnceCell::const_new();
|
||||
|
||||
/// A macro to retrieve or initialize the PostgreSQL connection pool.
|
||||
///
|
||||
/// This macro provides a convenient way to access the `PgPool`. If the pool is not already initialized,
|
||||
/// it creates a new pool using the connection string from the `$DATABASE_URL` environment variable.
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use based::get_pg;
|
||||
///
|
||||
/// let pool = get_pg!();
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! get_pg {
|
||||
() => {
|
||||
|
|
185
src/page/mod.rs
185
src/page/mod.rs
|
@ -1,14 +1,124 @@
|
|||
use core::num;
|
||||
use maud::{PreEscaped, html};
|
||||
|
||||
use maud::{html, PreEscaped};
|
||||
use crate::request::{RequestContext, StringResponse};
|
||||
|
||||
use crate::{request::context::RequestContext, user::User};
|
||||
use rocket::http::{ContentType, Status};
|
||||
|
||||
use rocket::{
|
||||
http::{ContentType, Status},
|
||||
request::{self, FromRequest, Request},
|
||||
/// Represents the HTML structure of a page shell, including the head, body class, and body content.
|
||||
///
|
||||
/// This structure is used to construct the overall HTML structure of a page, making it easier to generate consistent HTML pages dynamically.
|
||||
pub struct Shell {
|
||||
/// The HTML content for the `<head>` section of the page.
|
||||
head: PreEscaped<String>,
|
||||
/// An optional class attribute for the `<body>` element.
|
||||
body_class: Option<String>,
|
||||
/// The HTML content for the static body portion.
|
||||
body_content: PreEscaped<String>,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
/// Constructs a new `Shell` instance with the given head content, body content, and body class.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `head` - The HTML content for the page's head.
|
||||
/// * `body_content` - The HTML content for the body of the page.
|
||||
/// * `body_class` - An optional class to apply to the `<body>` element.
|
||||
///
|
||||
/// # Returns
|
||||
/// A `Shell` instance encapsulating the provided HTML content and attributes.
|
||||
pub fn new(
|
||||
head: PreEscaped<String>,
|
||||
body_content: PreEscaped<String>,
|
||||
body_class: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
head,
|
||||
body_class,
|
||||
body_content,
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the full HTML page using the shell structure, with additional content and a title.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `content` - The additional HTML content to render inside the main content div.
|
||||
/// * `title` - The title of the page, rendered inside the `<title>` element.
|
||||
///
|
||||
/// # Returns
|
||||
/// A `PreEscaped<String>` containing the full HTML page content.
|
||||
pub fn render(&self, content: PreEscaped<String>, title: &str) -> PreEscaped<String> {
|
||||
html! {
|
||||
html {
|
||||
head {
|
||||
title { (title) };
|
||||
(self.head)
|
||||
};
|
||||
@if self.body_class.is_some() {
|
||||
body class=(self.body_class.as_ref().unwrap()) {
|
||||
(self.body_content);
|
||||
|
||||
div id="main_content" {
|
||||
(content)
|
||||
};
|
||||
};
|
||||
} @else {
|
||||
body {
|
||||
(self.body_content);
|
||||
|
||||
div id="main_content" {
|
||||
(content)
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a full page or an HTMX-compatible fragment based on the request context.
|
||||
///
|
||||
/// If the request is not an HTMX request, this function uses the provided shell to generate
|
||||
/// a full HTML page. If it is an HTMX request, only the provided content is rendered.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `content` - The HTML content to render.
|
||||
/// * `title` - The title of the page for full-page rendering.
|
||||
/// * `ctx` - The `RequestContext` containing request metadata.
|
||||
/// * `shell` - The `Shell` instance used for full-page rendering.
|
||||
///
|
||||
/// # Returns
|
||||
/// A `StringResponse`
|
||||
pub async fn render_page(
|
||||
content: PreEscaped<String>,
|
||||
title: &str,
|
||||
ctx: RequestContext,
|
||||
shell: &Shell,
|
||||
) -> StringResponse {
|
||||
if !ctx.is_htmx {
|
||||
(
|
||||
Status::Ok,
|
||||
(
|
||||
ContentType::HTML,
|
||||
shell.render(content, title).into_string(),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(Status::Ok, (ContentType::HTML, content.into_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates an HTML link with HTMX attributes for dynamic behavior.
|
||||
///
|
||||
/// This function creates an `<a>` element with attributes that enable HTMX behavior for navigation without reload.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `url` - The URL to link to.
|
||||
/// * `class` - The CSS class for styling the link.
|
||||
/// * `onclick` - The JavaScript `onclick` handler for the link.
|
||||
/// * `content` - The content inside the link element.
|
||||
///
|
||||
/// # Returns
|
||||
/// A `PreEscaped<String>` containing the rendered HTML link element.
|
||||
pub fn htmx_link(
|
||||
url: &str,
|
||||
class: &str,
|
||||
|
@ -22,6 +132,16 @@ pub fn htmx_link(
|
|||
)
|
||||
}
|
||||
|
||||
/// Generates a `<script>` element containing the provided JavaScript code.
|
||||
///
|
||||
/// This function wraps the provided JavaScript code in a `<script>` tag,
|
||||
/// allowing for easy inclusion of custom scripts in the rendered HTML.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `script` - The JavaScript code to include.
|
||||
///
|
||||
/// # Returns
|
||||
/// A `PreEscaped<String>` containing the rendered `<script>` element.
|
||||
pub fn script(script: &str) -> PreEscaped<String> {
|
||||
html!(
|
||||
script {
|
||||
|
@ -29,56 +149,3 @@ pub fn script(script: &str) -> PreEscaped<String> {
|
|||
};
|
||||
)
|
||||
}
|
||||
|
||||
pub fn shell(content: PreEscaped<String>, title: &str, user: Option<User>) -> PreEscaped<String> {
|
||||
html! {
|
||||
html {
|
||||
head {
|
||||
title { (title) };
|
||||
script src="https://cdn.tailwindcss.com" {};
|
||||
script src="https://unpkg.com/htmx.org@2.0.3" integrity="sha384-0895/pl2MU10Hqc6jd4RvrthNlDiE9U1tWmX7WRESftEDRosgxNsQG/Ze9YMRzHq" crossorigin="anonymous" {};
|
||||
meta name="viewport" content="width=device-width, initial-scale=1.0";
|
||||
};
|
||||
body class="bg-black text-white" {
|
||||
header class="bg-gray-800 text-white shadow-md py-2" {
|
||||
|
||||
div class="flex justify-between px-6" {
|
||||
|
||||
a href="/" class="flex items-center space-x-2" {
|
||||
img src="/favicon" alt="Logo" class="w-10 h-10 rounded-md";
|
||||
span class="font-semibold text-xl" { "WatchDogs" };
|
||||
};
|
||||
|
||||
@if user.is_some() {
|
||||
p { (user.unwrap().username) };
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
};
|
||||
|
||||
div id="main_content" {
|
||||
(content)
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn render_page(
|
||||
htmx: RequestContext,
|
||||
content: PreEscaped<String>,
|
||||
title: &str,
|
||||
user: Option<User>,
|
||||
) -> (Status, (ContentType, String)) {
|
||||
if !htmx.is_htmx {
|
||||
(
|
||||
Status::Ok,
|
||||
(ContentType::HTML, shell(content, title, user).into_string()),
|
||||
)
|
||||
} else {
|
||||
(Status::Ok, (ContentType::HTML, content.into_string()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -1,12 +1,36 @@
|
|||
use rocket::http::{ContentType, Status};
|
||||
use rocket::response::status::BadRequest;
|
||||
use serde_json::json;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// API error response with a JSON payload.
|
||||
pub type ApiError = BadRequest<serde_json::Value>;
|
||||
|
||||
/// Fallible API response.
|
||||
///
|
||||
/// This type represents the result of an API operation that can either succeed with a
|
||||
/// JSON payload (`serde_json::Value`) or fail with an `ApiError`.
|
||||
pub type FallibleApiResponse = Result<serde_json::Value, ApiError>;
|
||||
|
||||
/// A trait to generate a Model API representation in JSON format.
|
||||
///
|
||||
/// This trait is intended for data models that need to be serialized
|
||||
/// into a JSON representation suitable for public APIs.
|
||||
pub trait ToAPI: Sized {
|
||||
/// Generate public API JSON
|
||||
/// Generate a JSON representation of the model for public API use.
|
||||
fn api(&self) -> impl std::future::Future<Output = serde_json::Value>;
|
||||
}
|
||||
|
||||
/// Converts a slice of items implementing the `ToAPI` trait into a `Vec` of JSON values.
|
||||
///
|
||||
/// This function is asynchronous and iterates over a slice of items, calling the `api`
|
||||
/// method on each to generate its JSON representation. The results are collected into
|
||||
/// a `Vec<serde_json::Value>`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `items` - A slice of items implementing the `ToAPI` trait.
|
||||
///
|
||||
/// # Returns
|
||||
/// An asynchronous computation that resolves to a `Vec` of JSON values.
|
||||
pub async fn vec_to_api(items: &[impl ToAPI]) -> Vec<serde_json::Value> {
|
||||
let mut ret = Vec::with_capacity(items.len());
|
||||
|
||||
|
@ -17,9 +41,43 @@ pub async fn vec_to_api(items: &[impl ToAPI]) -> Vec<serde_json::Value> {
|
|||
ret
|
||||
}
|
||||
|
||||
pub fn api_response(json: &serde_json::Value) -> (Status, (ContentType, String)) {
|
||||
(
|
||||
Status::Ok,
|
||||
(ContentType::JSON, serde_json::to_string(json).unwrap()),
|
||||
)
|
||||
/// Converts a string into a `Uuid`.
|
||||
///
|
||||
/// This function attempts to parse a string as a UUID. If the parsing fails,
|
||||
/// it returns an `ApiError` indicating the failure.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - A string slice representing the UUID to be parsed.
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(uuid::Uuid)` if the string is a valid UUID.
|
||||
/// * `Err(ApiError)` if the string is not a valid UUID, containing a descriptive error message.
|
||||
pub fn to_uuid(id: &str) -> Result<uuid::Uuid, ApiError> {
|
||||
uuid::Uuid::from_str(id).map_err(|_| no_uuid_error())
|
||||
}
|
||||
|
||||
/// Generates an `ApiError` indicating that the input is not a valid UUID.
|
||||
///
|
||||
/// This function is used internally to standardize error responses for invalid UUIDs.
|
||||
///
|
||||
/// # Returns
|
||||
/// * `ApiError` - A `BadRequest` error with a JSON payload describing the issue.
|
||||
pub fn no_uuid_error() -> ApiError {
|
||||
api_error("No valid UUID")
|
||||
}
|
||||
|
||||
/// Generates a custom `ApiError` with a given message.
|
||||
///
|
||||
/// This function creates a `BadRequest` response with a JSON payload containing
|
||||
/// an error message, making it useful for consistent error reporting.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `msg` - A string slice containing the error message to be included in the response.
|
||||
///
|
||||
/// # Returns
|
||||
/// * `ApiError` - A `BadRequest` error with a JSON payload describing the issue.
|
||||
pub fn api_error(msg: &str) -> ApiError {
|
||||
BadRequest(json!({
|
||||
"error": msg
|
||||
}))
|
||||
}
|
||||
|
|
|
@ -1,11 +1,4 @@
|
|||
use rocket::{
|
||||
fs::NamedFile,
|
||||
get,
|
||||
http::{ContentType, Status},
|
||||
State,
|
||||
};
|
||||
|
||||
use tokio::{fs::File, io::AsyncReadExt};
|
||||
// TODO : Implement
|
||||
|
||||
/*
|
||||
|
||||
|
|
|
@ -1,7 +1,22 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use rocket::tokio::sync::RwLock;
|
||||
use dashmap::DashMap;
|
||||
|
||||
/// A macro to simplify using a cache for API responses.
|
||||
///
|
||||
/// This macro checks if a value for a given route and ID exists in the cache.
|
||||
/// If the value is found, it deserializes it into JSON and immediately returns it.
|
||||
///
|
||||
/// # Parameters
|
||||
/// * `$route` - A literal string representing the route.
|
||||
/// * `$id` - A variable or literal string representing the unique identifier for the cached value.
|
||||
/// * `$cache` - The cache instance to query.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// use based::use_api_cache;
|
||||
///
|
||||
/// use_api_cache!("/user", user_id, cache);
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! use_api_cache {
|
||||
($route:literal, $id:ident, $cache:ident) => {
|
||||
|
@ -16,49 +31,72 @@ macro_rules! use_api_cache {
|
|||
};
|
||||
}
|
||||
|
||||
/// A structure for managing cached API responses in memory.
|
||||
///
|
||||
/// The cache uses a nested `HashMap` structure to store values. Each route
|
||||
/// maps to another `HashMap` where keys are IDs and values are cached results.
|
||||
/// The cache supports asynchronous reads and writes using `RwLock`.
|
||||
pub struct RouteCache {
|
||||
inner: RwLock<HashMap<String, HashMap<String, Option<String>>>>,
|
||||
// Route cache with outer HashMap storing routes, inner HashMap storing IDs
|
||||
inner: DashMap<String, DashMap<String, Option<String>>>,
|
||||
}
|
||||
|
||||
impl RouteCache {
|
||||
/// Creates a new `RouteCache` instance.
|
||||
///
|
||||
/// # Returns
|
||||
/// A new `RouteCache` instance with an empty cache.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: RwLock::new(HashMap::new()),
|
||||
inner: DashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieves a cached value or generates and caches it if not found.
|
||||
///
|
||||
/// This method first checks if the value exists in the cache. If not, it uses the provided
|
||||
/// generator function to compute the value, caches it, and then returns it.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `route` - The route associated with the cached value.
|
||||
/// * `id` - The unique identifier for the value.
|
||||
/// * `generator` - A function that generates the value if it's not in the cache.
|
||||
///
|
||||
/// # Returns
|
||||
/// A `String` containing the cached or generated value.
|
||||
pub async fn get<F, Fut>(&self, route: &str, id: &str, generator: F) -> String
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = String>,
|
||||
{
|
||||
{
|
||||
// Try to get a read lock first.
|
||||
let lock = self.inner.read().await;
|
||||
if let Some(inner_map) = lock.get(route) {
|
||||
if let Some(inner_map) = self.inner.get(route) {
|
||||
if let Some(cached_value) = inner_map.get(id) {
|
||||
log::info!("Using cached value for {route} / {id}");
|
||||
return cached_value.clone().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the value was not found, acquire a write lock to insert the computed value.
|
||||
let mut lock = self.inner.write().await;
|
||||
|
||||
log::info!("Computing value for {route} / {id}");
|
||||
let computed = generator().await;
|
||||
|
||||
lock.entry(route.to_string())
|
||||
.or_insert_with(HashMap::new)
|
||||
self.inner
|
||||
.entry(route.to_string())
|
||||
.or_insert_with(DashMap::new)
|
||||
.insert(id.to_string(), Some(computed.clone()));
|
||||
|
||||
computed
|
||||
}
|
||||
|
||||
/// Retrieves a cached value if it exists, without generating a new one.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `route` - The route associated with the cached value.
|
||||
/// * `id` - The unique identifier for the value.
|
||||
///
|
||||
/// # Returns
|
||||
/// An `Option<String>` containing the cached value, or `None` if not found.
|
||||
pub async fn get_only(&self, route: &str, id: &str) -> Option<String> {
|
||||
let lock = self.inner.read().await;
|
||||
if let Some(inner_map) = lock.get(route) {
|
||||
if let Some(inner_map) = self.inner.get(route) {
|
||||
if let Some(cached_value) = inner_map.get(id) {
|
||||
log::info!("Using cached value for {route} / {id}");
|
||||
return cached_value.clone();
|
||||
|
@ -68,53 +106,68 @@ impl RouteCache {
|
|||
None
|
||||
}
|
||||
|
||||
/// Retrieves a cached value or generates it conditionally if not found.
|
||||
///
|
||||
/// This method works similarly to `get`, but the generator can return an
|
||||
/// `Option<String>` to handle cases where the value might not always be computable.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `route` - The route associated with the cached value.
|
||||
/// * `id` - The unique identifier for the value.
|
||||
/// * `generator` - A function that generates the value if it's not in the cache.
|
||||
///
|
||||
/// # Returns
|
||||
/// An `Option<String>` containing the cached or generated value.
|
||||
pub async fn get_option<F, Fut>(&self, route: &str, id: &str, generator: F) -> Option<String>
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = Option<String>>,
|
||||
{
|
||||
{
|
||||
// Try to get a read lock first.
|
||||
let lock = self.inner.read().await;
|
||||
if let Some(inner_map) = lock.get(route) {
|
||||
if let Some(inner_map) = self.inner.get(route) {
|
||||
if let Some(cached_value) = inner_map.get(id) {
|
||||
log::info!("Using cached value for {route} / {id}");
|
||||
return cached_value.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the value was not found, acquire a write lock to insert the computed value.
|
||||
let mut lock = self.inner.write().await;
|
||||
|
||||
log::info!("Computing value for {route} / {id}");
|
||||
let computed = generator().await;
|
||||
|
||||
lock.entry(route.to_string())
|
||||
.or_insert_with(HashMap::new)
|
||||
self.inner
|
||||
.entry(route.to_string())
|
||||
.or_insert_with(DashMap::new)
|
||||
.insert(id.to_string(), computed.clone());
|
||||
|
||||
computed
|
||||
}
|
||||
|
||||
/// Inserts a value into the cache.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `route` - The route associated with the value.
|
||||
/// * `id` - The unique identifier for the value.
|
||||
/// * `value` - The value to insert into the cache.
|
||||
pub async fn insert(&self, route: &str, id: &str, value: String) {
|
||||
let mut lock = self.inner.write().await;
|
||||
|
||||
log::info!("Inserting value for {route} / {id}");
|
||||
|
||||
lock.entry(route.to_string())
|
||||
.or_insert_with(HashMap::new)
|
||||
self.inner
|
||||
.entry(route.to_string())
|
||||
.or_insert_with(DashMap::new)
|
||||
.insert(id.to_string(), Some(value));
|
||||
}
|
||||
|
||||
/// Invalidates a cached value, removing it from the cache.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `route` - The route associated with the value to remove.
|
||||
/// * `id` - The unique identifier for the value to remove.
|
||||
pub async fn invalidate(&self, route: &str, id: &str) {
|
||||
let mut lock = self.inner.write().await;
|
||||
if let Some(inner_map) = lock.get_mut(route) {
|
||||
if let Some(inner_map) = self.inner.get_mut(route) {
|
||||
inner_map.remove(id);
|
||||
|
||||
// If the inner map is empty, remove the route entry as well.
|
||||
if inner_map.is_empty() {
|
||||
lock.remove(route);
|
||||
self.inner.remove(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,14 @@
|
|||
use rocket::{request::{self, FromRequest}, Request};
|
||||
|
||||
use crate::user::{Session, User};
|
||||
use rocket::{
|
||||
Request,
|
||||
request::{self, FromRequest},
|
||||
};
|
||||
|
||||
/// Represents contextual information about an HTTP request.
|
||||
pub struct RequestContext {
|
||||
pub is_htmx: bool
|
||||
/// A flag indicating if the request is an HTMX request.
|
||||
///
|
||||
/// This is determined by checking the presence of the `HX-Request` header.
|
||||
pub is_htmx: bool,
|
||||
}
|
||||
|
||||
#[rocket::async_trait]
|
||||
|
@ -16,7 +21,7 @@ impl<'r> FromRequest<'r> for RequestContext {
|
|||
.headers()
|
||||
.get("HX-Request")
|
||||
.collect::<Vec<&str>>()
|
||||
.is_empty()
|
||||
.is_empty(),
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,27 +1,16 @@
|
|||
use std::str::FromStr;
|
||||
use rocket::http::{ContentType, Status};
|
||||
|
||||
use rocket::response::status::BadRequest;
|
||||
use serde_json::json;
|
||||
|
||||
pub mod context;
|
||||
pub mod assets;
|
||||
#[cfg(feature = "cache")]
|
||||
pub mod cache;
|
||||
|
||||
pub mod api;
|
||||
pub mod assets;
|
||||
mod context;
|
||||
|
||||
pub use context::RequestContext;
|
||||
|
||||
pub fn to_uuid(id: &str) -> Result<uuid::Uuid, ApiError> {
|
||||
uuid::Uuid::from_str(id).map_err(|_| no_uuid_error())
|
||||
}
|
||||
/// HTTP response containing a string payload.
|
||||
pub type StringResponse = (Status, (ContentType, String));
|
||||
|
||||
type ApiError = BadRequest<serde_json::Value>;
|
||||
type FallibleApiResponse = Result<serde_json::Value, ApiError>;
|
||||
|
||||
pub fn no_uuid_error() -> ApiError {
|
||||
api_error("No valid UUID")
|
||||
}
|
||||
|
||||
pub fn api_error(msg: &str) -> ApiError {
|
||||
BadRequest(json!({
|
||||
"error": msg
|
||||
}))
|
||||
}
|
||||
/// HTTP response containing raw binary data.
|
||||
pub type RawResponse = (Status, (ContentType, Vec<u8>));
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
pub trait LogAndIgnore {
|
||||
fn log_and_ignore(self, msg: &str);
|
||||
fn log_err_and_ignore(self, msg: &str);
|
||||
fn log_warn_and_ignore(self, msg: &str);
|
||||
}
|
||||
|
||||
impl<T, E: std::fmt::Debug> LogAndIgnore for Result<T, E> {
|
||||
/// Handles the result by ignoring and logging it if it contains an error.
|
||||
///
|
||||
/// If the result is `Ok`, does nothing.
|
||||
/// If the result is `Err(e)`
|
||||
/// logs the message provided (`msg`) along with the error.
|
||||
fn log_and_ignore(self, msg: &str) {
|
||||
/// If the result is `Err(e)`, logs the message provided (`msg`) along with the error as error.
|
||||
fn log_err_and_ignore(self, msg: &str) {
|
||||
match self {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
|
@ -16,4 +16,17 @@ impl<T, E: std::fmt::Debug> LogAndIgnore for Result<T, E> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the result by ignoring and logging it if it contains an error.
|
||||
///
|
||||
/// If the result is `Ok`, does nothing.
|
||||
/// If the result is `Err(e)`, logs the message provided (`msg`) along with the error as warning.
|
||||
fn log_warn_and_ignore(self, msg: &str) {
|
||||
match self {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
log::warn!("{msg} : {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue