diff --git a/Cargo.toml b/Cargo.toml index 45f1a34..abda5b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,3 +37,6 @@ tracing-opentelemetry = "0.28.0" serde = "1.0.215" itertools = "0.13.0" once_cell = "1.20.2" +chrono = { version = "0.4.39", features = ["serde"] } +toml = "0.8.19" +parking_lot = "0.12.3" diff --git a/README.md b/README.md index 91319d7..781fd05 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,9 @@ [![Please don't upload to GitHub](https://nogithub.codeberg.page/badge.svg)](https://nogithub.codeberg.page) crusto is a git forge written in Rust, that uses: -- vespid, a custom built in-tree (for now) SSR framework +- vespid, a custom built in-tree (for now) SSR-only web framework - htmx, as the HATEOAS client layer - axum, as the web server -- sleep deprivation, as the motivation ## how to run @@ -19,6 +18,7 @@ You can set `RUST_LOG=crusto=trace` to see traces with TRACE and up. By default ## roadmap -- [ ] repo shepherding - [ ] auth and users +- [ ] repo list +- [ ] git send-pack & receive-pack - [ ] repo creation diff --git a/infra/config.toml b/infra/config.toml new file mode 100644 index 0000000..2e457ce --- /dev/null +++ b/infra/config.toml @@ -0,0 +1,8 @@ +#otel_endpoint = "http://localhost:4318" # FIXME: ERROR opentelemetry_sdk: name="BatchSpanProcessor.Flush.ExportError" reason="ExportFailed(Status { code: Unknown, message: \", detailed error message: h2 protocol error: http2 error tonic::transport::Error(Transport, hyper::Error(Http2, Error { kind: GoAway(b\\\"\\\", FRAME_SIZE_ERROR, Library) }))\" })" Failed during the export process + +repositories_home = "run/repos" + +[surrealdb] +endpoint = "localhost:9867" +ns = "crusto" +db = "crusto" diff --git a/magic/src/card.rs b/magic/src/card.rs index 89b6779..448175c 100644 --- a/magic/src/card.rs +++ b/magic/src/card.rs @@ -1,5 +1,5 @@ use tailwind_fuse::tw_merge; -use vespid::{MaybeText, component, view}; +use vespid::prelude::{MaybeText, component, view}; pub const CARD: &str = "rounded-lg border bg-card text-card-foreground shadow-sm"; pub const CARD_HEADER: &str = "flex flex-col gap-2 p-6"; diff --git a/magic/src/spinner.rs b/magic/src/spinner.rs index 5cfca58..13710f3 100644 --- a/magic/src/spinner.rs +++ b/magic/src/spinner.rs @@ -1,5 +1,5 @@ use tailwind_fuse::tw_merge; -use vespid::{MaybeText, component, view}; +use vespid::prelude::{MaybeText, component, view}; #[component] pub async fn Spinner(#[builder(default, setter(into))] class: MaybeText) -> String { diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..8f164f7 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,34 @@ +#[derive(Debug, serde::Deserialize)] +pub struct Config { + #[serde(default)] + pub otel_endpoint: Option, + pub repositories_home: String, + pub surrealdb: SurrealdbConfig, +} + +#[derive(Debug, serde::Deserialize)] +pub struct SurrealdbConfig { + pub endpoint: String, + pub ns: String, + pub db: String, +} + +impl Default for Config { + fn default() -> Self { + Self { + otel_endpoint: None, + repositories_home: "run/repos".to_string(), + surrealdb: SurrealdbConfig::default(), + } + } +} + +impl Default for SurrealdbConfig { + fn default() -> Self { + Self { + endpoint: "localhost:9867".to_string(), + ns: "crusto".to_string(), + db: "crusto".to_string(), + } + } +} diff --git a/src/db.rs b/src/db.rs index eb19fb7..24d7760 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,6 +1,268 @@ -use once_cell::sync::Lazy; -use surrealdb::{engine::remote::ws, Surreal}; +use std::{ + borrow::Cow, + fmt::{Debug, Display}, + future::IntoFuture, + hash::Hash, + marker::PhantomData, + sync::Arc, +}; -pub mod flake; +use eyre::eyre; +use itertools::Itertools; +use once_cell::sync::Lazy; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use surrealdb::{ + engine::remote::ws, + sql::{Id, Thing}, + Surreal, +}; +use tracing::{trace_span, Instrument}; +use vespid::oco::Oco; pub static DB: Lazy> = Lazy::new(Surreal::init); + +pub trait Refable: DeserializeOwned { + const TABLE: &'static str; +} + +#[macro_export] +macro_rules! refable { + (@base $name:ident, $table:literal) => { + impl $crate::db::Refable for $name { + const TABLE: &'static str = $table; + } + impl $crate::db::AsFlake<$name> for $name { + fn as_flake(&self) -> std::borrow::Cow<'_, str> { + std::borrow::Cow::Borrowed(self.id.flake()) + } + } + + impl core::ops::Deref for $name { + type Target = $crate::db::Flake<$name>; + + fn deref(&self) -> &Self::Target { + &self.id + } + } + }; + ($name:ident, $table:literal) => { + $crate::refable!(@base $name, $table); + $crate::db_helpers!($name); + }; +} + +#[macro_export] +macro_rules! db_helpers { + ($name:ident) => { + #[allow(unused)] + impl $name { + #[tracing::instrument(level = "trace", skip(id), fields(id = AsRef::::as_ref(&id), otel.name = concat!(stringify!($name), "::get"), otel.kind = "client", peer.service = "surrealdb"))] + pub async fn get(id: impl AsRef) -> crate::Result<$name> { + use $crate::db::{Refable, DB}; + let data: Option<$name> = DB.select(($name::TABLE, id.as_ref())).await?; + data.ok_or_else(|| eyre::eyre!("{}:{} not found", Self::TABLE, id.as_ref()).into()) + } + + #[tracing::instrument(level = "trace", fields(otel.name = concat!(stringify!($name), "::all"), otel.kind = "client", peer.service = "surrealdb"))] + pub async fn all() -> crate::Result> { + use $crate::db::{Refable, DB}; + let data: Vec<$name> = DB.select($name::TABLE).await?; + Ok(data) + } + + #[tracing::instrument(level = "trace", skip_all, fields(otel.name = concat!(stringify!($name), "::delete"), otel.kind = "client", id = self.id.flake(), peer.service = "surrealdb"))] + pub async fn delete(&self) -> crate::Result<()> { + use $crate::db::{Refable, DB}; + let _: Option = + DB.delete(($name::TABLE, self.id.flake())).await?; + Ok(()) + } + } + }; +} + +pub use refable; + +impl Refable for () { + const TABLE: &'static str = ""; +} + +pub struct BorrowedFlake<'a, T: Refable>(pub &'a str, PhantomData); +impl<'a, T: Refable> BorrowedFlake<'a, T> { + pub fn new(s: &'a str) -> Self { + Self(s, PhantomData) + } +} + +impl<'a, T: Refable> AsFlake for BorrowedFlake<'a, T> { + fn as_flake(&self) -> Cow<'_, str> { + Cow::Borrowed(self.0) + } +} + +pub trait AsFlake { + fn as_flake(&self) -> Cow<'_, str>; +} + +impl AsFlake for Flake { + fn as_flake(&self) -> Cow<'_, str> { + Cow::Borrowed(&self.0) + } +} + +impl> AsFlake for &F { + fn as_flake(&self) -> Cow<'_, str> { + (**self).as_flake() + } +} + +pub struct Flake(pub Oco<'static, str>, PhantomData); + +impl Debug for Flake { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Flake({}:{})", T::TABLE, self.flake()) + } +} + +impl Hash for Flake { + fn hash(&self, state: &mut H) { + self.flake().hash(state) + } +} + +impl PartialEq for Flake { + fn eq(&self, other: &Self) -> bool { + self.flake() == other.flake() + } +} + +impl Eq for Flake {} + +impl Clone for Flake { + fn clone(&self) -> Self { + Self(self.0.clone(), PhantomData) + } +} + +impl Flake { + pub fn flake(&self) -> &str { + &self.0 + } +} + +impl Flake { + pub fn borrowed(s: &str) -> BorrowedFlake { + BorrowedFlake::new(s) + } + + pub fn new(s: impl Into>) -> Self { + Self(s.into(), PhantomData) + } + + pub fn into_thing(self) -> Thing { + Thing::from((T::TABLE, self.flake())) + } + + pub fn from_thing_format(thing: &str) -> Self { + let mut parts = thing.split(':'); + let flake_or_table = parts.next().unwrap(); + + Self::new(Oco::Counted(Arc::from( + parts.next().unwrap_or(flake_or_table), + ))) + } + + pub fn from_data(data: &T) -> Self + where + T: AsFlake, + { + Self::new(Oco::Counted(Arc::from(data.as_flake().as_ref()))) + } + + pub async fn fetch(&self) -> eyre::Result { + let data: Option = DB + .select::>((T::TABLE, self.flake())) + .into_future() + .instrument(trace_span!( + "Flake::fetch", + otel.kind = "client", + otel.name = format!( + "{}::get", + std::any::type_name::().trim_start_matches("chat::") + ), + peer.service = "surrealdb", + id = self.flake() + )) + .await?; + data.ok_or_else(|| eyre!("{}:{} not found", T::TABLE, self.flake())) + } + + pub fn cast(self) -> Flake { + Flake(self.0, PhantomData) + } +} + +impl Display for Flake { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}", T::TABLE, self.flake()) + } +} + +impl AsRef for Flake { + fn as_ref(&self) -> &str { + self.flake() + } +} + +impl<'de, T: Refable> Deserialize<'de> for Flake { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct V(PhantomData); + + impl<'de, T: Refable> serde::de::Visitor<'de> for V { + type Value = Flake; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + writeln!(formatter, "a string or Thing") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + Ok(Flake::from_thing_format(v)) + } + + fn visit_map(self, map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + use serde::Deserialize; + + Ok(Flake( + Oco::Owned( + Thing::deserialize(serde::de::value::MapAccessDeserializer::new(map))? + .id + .to_raw(), + ), + PhantomData, + )) + } + } + + deserializer.deserialize_any(V::(PhantomData)) + } +} + +impl Serialize for Flake { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.flake().serialize(serializer) + //Thing::from((String::from(T::TABLE), Id::String(self.flake().to_string()))) + // .serialize(serializer) + } +} diff --git a/src/db/flake.rs b/src/db/flake.rs deleted file mode 100644 index 5d462c9..0000000 --- a/src/db/flake.rs +++ /dev/null @@ -1,262 +0,0 @@ -use std::{ - borrow::Cow, - fmt::{Debug, Display}, - future::IntoFuture, - hash::Hash, - marker::PhantomData, - sync::Arc, -}; - -use eyre::eyre; -use itertools::Itertools; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; -use surrealdb::sql::{Id, Thing}; -use tracing::{trace_span, Instrument}; -use vespid::Oco; -use super::DB; - -pub trait Refable: DeserializeOwned { - const TABLE: &'static str; -} - -#[macro_export] -macro_rules! refable { - (@base $name:ident, $table:literal) => { - impl $crate::db::Refable for $name { - const TABLE: &'static str = $table; - } - impl $crate::db::AsFlake<$name> for $name { - fn as_flake(&self) -> std::borrow::Cow<'_, str> { - std::borrow::Cow::Borrowed(self.id.flake()) - } - } - - impl core::ops::Deref for $name { - type Target = $crate::db::Flake<$name>; - - fn deref(&self) -> &Self::Target { - &self.id - } - } - }; - ($name:ident, $table:literal) => { - $crate::refable!(@base $name, $table); - $crate::db_helpers!($name); - }; -} - -#[macro_export] -macro_rules! db_helpers { - ($name:ident) => { - #[allow(unused)] - impl $name { - #[tracing::instrument(level = "trace", skip(id), fields(id = AsRef::::as_ref(&id), otel.name = concat!(stringify!($name), "::get"), otel.kind = "client", peer.service = "surrealdb"))] - pub async fn get(id: impl AsRef) -> crate::Result<$name> { - use $crate::db::{Refable, DB}; - let data: Option<$name> = DB.select(($name::TABLE, id.as_ref())).await?; - data.ok_or_else(|| eyre!("{}:{} not found", Self::TABLE, id.as_ref()).into()) - } - - #[tracing::instrument(level = "trace", fields(otel.name = concat!(stringify!($name), "::all"), otel.kind = "client", peer.service = "surrealdb"))] - pub async fn all() -> crate::Result> { - use $crate::db::{Refable, DB}; - let data: Vec<$name> = DB.select($name::TABLE).await?; - Ok(data) - } - - #[tracing::instrument(level = "trace", skip_all, fields(otel.name = concat!(stringify!($name), "::delete"), otel.kind = "client", id = self.id.flake(), peer.service = "surrealdb"))] - pub async fn delete(&self) -> crate::Result<()> { - use $crate::db::{Refable, DB}; - let _: Option = - DB.delete(($name::TABLE, self.id.flake())).await?; - Ok(()) - } - } - }; -} - -pub use refable; - -impl Refable for () { - const TABLE: &'static str = ""; -} - -pub struct BorrowedFlake<'a, T: Refable>(pub &'a str, PhantomData); -impl<'a, T: Refable> BorrowedFlake<'a, T> { - pub fn new(s: &'a str) -> Self { - Self(s, PhantomData) - } -} - -impl<'a, T: Refable> AsFlake for BorrowedFlake<'a, T> { - fn as_flake(&self) -> Cow<'_, str> { - Cow::Borrowed(self.0) - } -} - -pub trait AsFlake { - fn as_flake(&self) -> Cow<'_, str>; -} - -impl AsFlake for Flake { - fn as_flake(&self) -> Cow<'_, str> { - Cow::Borrowed(&self.0) - } -} - -impl> AsFlake for &F { - fn as_flake(&self) -> Cow<'_, str> { - (**self).as_flake() - } -} - -pub struct Flake(pub Oco<'static, str>, PhantomData); - -impl Debug for Flake { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "Flake({}:{})", T::TABLE, self.flake()) - } -} - -impl Hash for Flake { - fn hash(&self, state: &mut H) { - self.flake().hash(state) - } -} - -impl PartialEq for Flake { - fn eq(&self, other: &Self) -> bool { - self.flake() == other.flake() - } -} - -impl Eq for Flake {} - -impl Clone for Flake { - fn clone(&self) -> Self { - Self(self.0.clone(), PhantomData) - } -} - -impl Flake { - pub fn flake(&self) -> &str { - &self.0 - } -} - -impl Flake { - pub fn borrowed(s: &str) -> BorrowedFlake { - BorrowedFlake::new(s) - } - - pub fn new(s: impl Into>) -> Self { - Self(s.into(), PhantomData) - } - - pub fn into_thing(self) -> Thing { - Thing::from((T::TABLE, self.flake())) - } - - pub fn from_thing_format(thing: &str) -> Self { - let mut parts = thing.split(':'); - let flake_or_table = parts.next().unwrap(); - - Self::new(Oco::Counted(Arc::from( - parts.next().unwrap_or(flake_or_table), - ))) - } - - pub fn from_data(data: &T) -> Self - where - T: AsFlake, - { - Self::new(Oco::Counted(Arc::from(data.as_flake().as_ref()))) - } - - pub async fn fetch(&self) -> eyre::Result { - let data: Option = DB - .select::>((T::TABLE, self.flake())) - .into_future() - .instrument(trace_span!( - "Flake::fetch", - otel.kind = "client", - otel.name = format!( - "{}::get", - std::any::type_name::().trim_start_matches("chat::") - ), - peer.service = "surrealdb", - id = self.flake() - )) - .await?; - data.ok_or_else(|| eyre!("{}:{} not found", T::TABLE, self.flake())) - } - - pub fn cast(self) -> Flake { - Flake(self.0, PhantomData) - } -} - -impl Display for Flake { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}:{}", T::TABLE, self.flake()) - } -} - -impl AsRef for Flake { - fn as_ref(&self) -> &str { - self.flake() - } -} - -impl<'de, T: Refable> Deserialize<'de> for Flake { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - struct V(PhantomData); - - impl<'de, T: Refable> serde::de::Visitor<'de> for V { - type Value = Flake; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - writeln!(formatter, "a string or Thing") - } - - fn visit_str(self, v: &str) -> Result - where - E: serde::de::Error, - { - Ok(Flake::from_thing_format(v)) - } - - fn visit_map(self, map: A) -> Result - where - A: serde::de::MapAccess<'de>, - { - use serde::Deserialize; - - Ok(Flake( - Oco::Owned( - Thing::deserialize(serde::de::value::MapAccessDeserializer::new(map))? - .id - .to_raw(), - ), - PhantomData, - )) - } - } - - deserializer.deserialize_any(V::(PhantomData)) - } -} - -impl Serialize for Flake { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - self.flake().serialize(serializer) - //Thing::from((String::from(T::TABLE), Id::String(self.flake().to_string()))) - // .serialize(serializer) - } -} diff --git a/src/http.rs b/src/http.rs new file mode 100644 index 0000000..84cb56b --- /dev/null +++ b/src/http.rs @@ -0,0 +1,14 @@ +use vespid::axum::handler; + +use super::*; + +mod repo; + +pub fn app() -> axum::Router { + axum::Router::new() + .route("/", get(handler(ui::index::Index))) + .route("/widget", get(handler(ui::Widget))) + .route("/:owner", get(handler(ui::Owner))) + .route("/:owner/", get(handler(ui::Owner))) + .nest("/:owner/:repo", repo::app()) +} diff --git a/src/http/repo.rs b/src/http/repo.rs new file mode 100644 index 0000000..4939567 --- /dev/null +++ b/src/http/repo.rs @@ -0,0 +1,10 @@ +use super::*; + +pub fn app() -> axum::Router { + axum::Router::new() + .route("/", get(handler(ui::repo::Index))) + //.route("/git-upload-pack", post(upload_pack)) + //.route("/git-receive-pack", post(receive_pack)) +} + +async fn git_rpc(service: &str) {} diff --git a/src/main.rs b/src/main.rs index 0d33422..b011735 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,83 +5,48 @@ extern crate tracing; use std::sync::{atomic::AtomicU64, Arc}; -use axum::{response::Html, routing::get}; +use axum::{ + response::Html, + routing::{get, post}, +}; use magic::prelude::*; use surrealdb::{engine::remote::ws::Ws, opt::auth::Root}; -use vespid::axum::render; +mod config; mod db; +mod model; use db::DB; +mod http; mod tracing_stuff; +mod ui; -#[component] -fn Shell(children: String) -> String { - info!("Index"); - view! { - - - - - Crusto - - - - - {children} - - - } -} +type Result = std::result::Result; -async fn index() -> Html { - render(async move { - view! { - -

"Hello to Crusto!"

-

"Index"

- -
- -
-
- } - }) - .await -} +pub static CONFIG: once_cell::sync::Lazy> = + once_cell::sync::Lazy::new(|| parking_lot::RwLock::new(config::Config::default())); #[tokio::main] async fn main() -> eyre::Result<()> { color_eyre::install()?; - let _otel_guard = tracing_stuff::init_tracing_subscriber(); - let url = std::env::var("CRUSTO_DB_URL").unwrap_or_else(|_| "localhost:9867".to_string()); - debug!(%url, "Database connecting"); - DB.connect::(url).await?; + let config_path = std::env::args() + .nth(1) + .unwrap_or_else(|| "config.toml".to_string()); + let config = toml::from_str::(&std::fs::read_to_string(&config_path)?)?; + let _otel_guard = tracing_stuff::init_tracing_subscriber(config.otel_endpoint.clone()); + debug!(?config.surrealdb, "Database connecting"); + DB.connect::(&config.surrealdb.endpoint).await?; debug!("Database connected"); DB.signin(Root { username: "root", password: "root", }) .await?; - DB.use_ns("crusto").await?; - DB.use_db("crusto").await?; + DB.use_ns(&config.surrealdb.ns).await?; + DB.use_db(&config.surrealdb.db).await?; + *CONFIG.write() = config; info!("Database ready"); - let amount_of_refreshes = Arc::new(AtomicU64::new(0)); - - let app = axum::Router::new().route("/", get(index)).route("/widget", get(|| async move { - render(async move { - view! { - - - "Widget" - - -

{amount_of_refreshes.fetch_add(1, std::sync::atomic::Ordering::Relaxed)} " refreshes"

-
-
- } - }).await - })); + let app = http::app(); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; info!("listening on {}", listener.local_addr()?); diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..45c1d18 --- /dev/null +++ b/src/model.rs @@ -0,0 +1,71 @@ +use std::path::PathBuf; + +use chrono::{DateTime, Utc}; + +use super::*; +use crate::db::*; + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct User { + pub id: Flake, + pub username: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub about_me: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_logout: Option>, +} + +refable!(User, "user"); + +impl User { + #[tracing::instrument(level = "trace", fields(otel.name = "User::get_by_name", otel.kind = "client"))] + pub async fn get_by_name(name: &str) -> Result { + let query = format!("SELECT * FROM user WHERE username = \"{name}\""); + trace!(%query); + let opt_me: Option = DB.query(&query).await?.take(0)?; + opt_me.ok_or_else(|| eyre::eyre!("User not found").into()) + } +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct Repository { + pub id: Flake, + pub owner: Flake, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +refable!(Repository, "repo"); + +impl Repository { + #[tracing::instrument(level = "trace", fields(otel.name = "Repository::get_by_owner_repo", otel.kind = "client"))] + pub async fn get_by_owner_repo(owner: &Flake, repo: &str) -> Result { + let query = format!("SELECT * FROM repo WHERE owner = {owner} AND name = \"{repo}\""); + trace!(%query); + let opt_me: Option = DB.query(&query).await?.check()?.take(0)?; + opt_me.ok_or_else(|| eyre::eyre!("Repository not found").into()) + } + + #[tracing::instrument(level = "trace", fields(otel.name = "Repository::get_by_owner_name_and_repo", otel.kind = "client"))] + pub async fn get_by_owner_name_and_repo(owner: &str, repo: &str) -> Result { + let query = + format!("SELECT * FROM repo WHERE owner.username = \"{owner}\" AND name = \"{repo}\""); + trace!(%query); + let opt_me: Option = DB.query(&query).await?.check()?.take(0)?; + opt_me.ok_or_else(|| eyre::eyre!("Repository not found").into()) + } + + pub async fn open(&self) -> Result { + let id = self.id.clone(); + tokio::task::spawn_blocking(move || { + let config = crate::CONFIG.read(); + git2::Repository::open_bare(PathBuf::from(&config.repositories_home).join(id.flake())) + .map_err(|e| e.into()) + }) + .await + .expect("JoinError") + } +} diff --git a/src/tracing_stuff.rs b/src/tracing_stuff.rs index 0ca90e3..79248d6 100644 --- a/src/tracing_stuff.rs +++ b/src/tracing_stuff.rs @@ -1,4 +1,5 @@ use opentelemetry::{global, trace::TracerProvider as _, KeyValue}; +use opentelemetry_otlp::WithExportConfig; use opentelemetry_sdk::{ metrics::{MeterProviderBuilder, PeriodicReader, SdkMeterProvider}, runtime, @@ -23,9 +24,10 @@ fn resource() -> Resource { ) } -fn init_meter_provider() -> SdkMeterProvider { +fn init_meter_provider(otel_endpoint: &str) -> SdkMeterProvider { let exporter = opentelemetry_otlp::MetricExporter::builder() .with_tonic() + .with_endpoint(otel_endpoint) .with_temporality(opentelemetry_sdk::metrics::Temporality::default()) .build() .unwrap(); @@ -44,9 +46,10 @@ fn init_meter_provider() -> SdkMeterProvider { meter_provider } -fn init_tracer() -> Tracer { +fn init_tracer(otel_endpoint: &str) -> Tracer { let exporter = opentelemetry_otlp::SpanExporter::builder() .with_tonic() + .with_endpoint(otel_endpoint) .build() .unwrap(); @@ -62,36 +65,45 @@ fn init_tracer() -> Tracer { provider.tracer("crusto") } -pub fn init_tracing_subscriber() -> OtelGuard { - let meter_provider = init_meter_provider(); - let tracer = init_tracer(); - - tracing_subscriber::registry() +pub fn init_tracing_subscriber(otel_endpoint: Option) -> OtelGuard { + let registry = tracing_subscriber::registry() .with( tracing_subscriber::EnvFilter::builder() .with_default_directive(LevelFilter::INFO.into()) .from_env_lossy(), ) - //.with(funnylog::tracing::TracingLayer::new( - // funnylog::terminal::TerminalConfig::default().to_stdout().ignore_error(), - //)) .with(tracing_error::ErrorLayer::default()) - .with(tracing_subscriber::fmt::layer()) - .with(MetricsLayer::new(meter_provider.clone())) - .with(OpenTelemetryLayer::new(tracer)) - .init(); + .with(tracing_subscriber::fmt::layer()); - OtelGuard { meter_provider } + if let Some(otel_endpoint) = otel_endpoint { + let meter_provider = init_meter_provider(&otel_endpoint); + let tracer = init_tracer(&otel_endpoint); + registry + .with(MetricsLayer::new(meter_provider.clone())) + .with(OpenTelemetryLayer::new(tracer)) + .init(); + + OtelGuard { + meter_provider: Some(meter_provider), + } + } else { + registry.init(); + OtelGuard { + meter_provider: None, + } + } } pub struct OtelGuard { - meter_provider: SdkMeterProvider, + meter_provider: Option, } impl Drop for OtelGuard { fn drop(&mut self) { - if let Err(err) = self.meter_provider.shutdown() { - eprintln!("meter_provider.shutdown: {err:?}"); + if let Some(meter_provider) = self.meter_provider.take() { + if let Err(err) = meter_provider.shutdown() { + eprintln!("meter_provider.shutdown: {err:?}"); + } } opentelemetry::global::shutdown_tracer_provider(); } diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..ff4b741 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,50 @@ +use axum::{extract::Path, http::request::Parts}; +use model::User; +use vespid::axum::{extract, handler}; + +use super::*; + +pub mod index; +pub mod user; +pub mod repo; + +#[component] +fn Shell(children: String) -> String { + view! { + + + + + Crusto + + + + {children} + + } +} + +pub async fn Widget() -> crate::Result { + let parts: Parts = extract().await?; + Ok(view! { + + + "Widget" + + + {"professional goober"}

"URL: " {parts.uri.to_string()}

+
+
+ }) +} + +pub async fn Owner() -> crate::Result { + let Path(owner): Path = extract().await?; + let user = User::get_by_name(&owner).await?; + + user::Index(user).await +} diff --git a/src/ui/index.rs b/src/ui/index.rs new file mode 100644 index 0000000..6c315f9 --- /dev/null +++ b/src/ui/index.rs @@ -0,0 +1,21 @@ +use super::*; + +pub async fn Index() -> crate::Result { + Ok(view! { + +

"Hello to Crusto!"

+

"Index"

+ +
+ +
+
+ }) +} diff --git a/src/ui/repo.rs b/src/ui/repo.rs new file mode 100644 index 0000000..7d00c69 --- /dev/null +++ b/src/ui/repo.rs @@ -0,0 +1,105 @@ +use axum::extract::Path; +use model::*; + +use super::*; + +pub async fn Index() -> crate::Result { + let Path((p_owner, p_repo)): Path<(String, String)> = extract().await?; + let repo = Repository::get_by_owner_name_and_repo(&p_owner, &p_repo).await?; + trace!(?repo); + let owner = User::get(&repo.owner).await?; + let git = repo.open().await?; + let head = git.head()?; + let head_oid = head + .target() + .ok_or_else(|| eyre::eyre!("head is symbolic ref we dont like that"))?; + let head_commit = head.peel_to_commit()?; + let head_tree = head_commit.tree()?; + let mut dirs = vec![]; + let mut files = vec![]; + for entry in head_tree.into_iter() { + let kind = entry.kind(); + let name = entry + .name() + .expect("not utf8 for whatever reason") + .to_string(); + if kind.unwrap_or(git2::ObjectType::Tree) == git2::ObjectType::Tree { + dirs.push(name); + } else { + files.push(name); + } + } + + Ok(view! { + + + + {owner.username} "/" {repo.name} + + {if let Some(description) = &repo.description { + Some(view! { {description} }) + } else { + None + }} + + + +
+ +
+ {collect_fragments( + dirs + .into_iter() + .map(|dir| (dir, true)) + .chain(files.into_iter().map(|file| (file, false))) + .map(|(name, is_dir)| { + let p_owner = p_owner.clone(); + let p_repo = p_repo.clone(); + async move { + view! { +
+ + + + + {name} + + + // TODO: commit +
+ } + } + }), + ) + .await} +
+ + + + }) +} diff --git a/src/ui/user.rs b/src/ui/user.rs new file mode 100644 index 0000000..ba9bf2e --- /dev/null +++ b/src/ui/user.rs @@ -0,0 +1,14 @@ +use super::*; + +pub async fn Index(owner: User) -> crate::Result { + Ok(view! { + + + + {owner.username} + + {owner.id.flake()} + + + }) +} diff --git a/vespid/Cargo.toml b/vespid/Cargo.toml index c3ce91b..55768ac 100644 --- a/vespid/Cargo.toml +++ b/vespid/Cargo.toml @@ -12,3 +12,9 @@ vespid_macros.path = "macros" typed-builder = "0.20.0" serde = "1.0.215" thiserror = "2.0.6" +pin-project-lite = "0.2.15" +eyre = "0.6.12" +rustc-hash = "2.1.0" +parking_lot = "0.12.3" +http-body-util = "0.1.2" +futures-util = { version = "0.3.31", default-features = false, features = ["std"] } diff --git a/vespid/LICENSE b/vespid/LICENSE new file mode 100644 index 0000000..277c9be --- /dev/null +++ b/vespid/LICENSE @@ -0,0 +1,11 @@ +Anti-GitHub License (AGHL) v1 (based on the MIT license) + +Copyright (c) 2024 Ilya Borodinov + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +2. The Software shall not be published, distributed, or otherwise made available on GitHub.com or any of its subdomains or affiliated websites. Failure to comply with this condition will immediately revoke the rights granted hereunder. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vespid/README.md b/vespid/README.md new file mode 100644 index 0000000..732ed59 --- /dev/null +++ b/vespid/README.md @@ -0,0 +1,10 @@ +# vespid - an SSR-only web framework for Rust + +[![License: AGHL v1](https://badgers.space/badge/License/AGHL%20v1/blue)](./LICENSE) +[![Please don't upload to GitHub](https://nogithub.codeberg.page/badge.svg)](https://nogithub.codeberg.page) + +vespid is a Rust web framework that is SSR-only, meaning that it just renders HTML. + +vespid is very much inspired by [leptos](https://github.com/leptos-rs/leptos), and if you look at it from this perspective, it's just leptos without the WASM, and tailored for [axum](https://github.com/tokio-rs/axum). + +Most of the framework's code are just utilities, like error handling using an `` component (with a bit taken from leptos's [`throw_error`](https://docs.rs/throw_error)), an `Error` type that is just `eyre::Error` + `axum::http::StatusCode`, etc. diff --git a/vespid/macros/src/view.rs b/vespid/macros/src/view.rs index ffafdba..da41395 100644 --- a/vespid/macros/src/view.rs +++ b/vespid/macros/src/view.rs @@ -67,7 +67,7 @@ fn process_nodes<'n>( #(#errors;)* // Make sure that "enum x{};" and "let _x = crate::element;" can be used in this context #(#docs;)* - format!(#html_string, #(vespid::FormatRender::new(#values)),*) + format!(#html_string, #(vespid::render_adapter::FormatRender::new(#values)),*) } } } diff --git a/vespid/src/axum.rs b/vespid/src/axum.rs index 956820f..36199b7 100644 --- a/vespid/src/axum.rs +++ b/vespid/src/axum.rs @@ -1,6 +1,12 @@ -use std::{future::Future, sync::OnceLock, thread::available_parallelism}; +use std::{any::TypeId, future::Future, sync::OnceLock, thread::available_parallelism}; -use axum::response::Html; +use axum::{ + body::Body, + extract::{FromRequest, FromRequestParts, Request}, + http::{request::Parts, StatusCode}, + response::{Html, IntoResponse, Response}, + RequestExt, +}; use tokio_util::task::LocalPoolHandle; use crate::context; @@ -25,3 +31,70 @@ where .await .unwrap() } + +pub fn handler( + f: RenderFn, +) -> impl Fn(Request) -> std::pin::Pin + Send + 'static>> + + Clone + + Send + + 'static +where + RenderFn: Fn() -> F + Clone + Send + 'static, + F: Future> + 'static, + O: Into + Send + 'static, +{ + move |req: Request| { + let f = f.clone(); + Box::pin(async move { + get_rendering_pool() + .spawn_pinned(move || async move { + let f = f.clone(); + context::spawn_local(async move { + let f = f.clone(); + context::provide_context(req.into_parts().0); + f().await.map(|x| Html(x)).into_response() + }) + .await + .unwrap() + }) + .await + .unwrap() + }) + } +} + +pub async fn extract + Send + 'static>() -> Result +where + E::Rejection: std::fmt::Debug, +{ + extract_with_state(&()).await +} + +pub async fn extract_with_state + Send + 'static, S>( + state: &S, +) -> Result +where + E::Rejection: std::fmt::Debug, +{ + let mut parts = context::use_context::() + .ok_or_else(|| eyre::eyre!("should have had Parts provided by vespid::axum::handler"))?; + + match E::from_request_parts(&mut parts, state).await { + Ok(ok) => Ok(ok), + Err(e) => { + use http_body_util::BodyExt; + + let res = e.into_response(); + let (parts, body) = res.into_parts(); + let bytes = body.collect().await.expect("Body::collect").to_bytes(); + let s = String::from_utf8(bytes.into()).expect("String::from_utf8"); + Err(crate::error::Error::new( + StatusCode::INTERNAL_SERVER_ERROR, + eyre::eyre!( + "failed to extract {E}: {parts:?} {s}", + E = std::any::type_name::(), + ), + )) + } + } +} diff --git a/vespid/src/context.rs b/vespid/src/context.rs index 48efac3..0ac5f4c 100644 --- a/vespid/src/context.rs +++ b/vespid/src/context.rs @@ -50,3 +50,37 @@ where use_context::() .unwrap_or_else(|| panic!("Context not found for {T}", T = std::any::type_name::())) } + +pub fn apply_context(f: impl FnOnce(&T) -> U) -> Option { + CONTEXT.with(|context| { + context + .borrow() + .get(&TypeId::of::()) + .map(|any| { + any.downcast_ref::().unwrap_or_else(|| { + panic!( + "Context type mismatch for {T}", + T = std::any::type_name::() + ) + }) + }) + .map(f) + }) +} + +pub fn apply_context_mut(f: impl FnOnce(&mut T) -> U) -> Option { + CONTEXT.with(|context| { + context + .borrow_mut() + .get_mut(&TypeId::of::()) + .map(|any| { + any.downcast_mut::().unwrap_or_else(|| { + panic!( + "Context type mismatch for {T}", + T = std::any::type_name::() + ) + }) + }) + .map(f) + }) +} diff --git a/vespid/src/error.rs b/vespid/src/error.rs new file mode 100644 index 0000000..66b1a7e --- /dev/null +++ b/vespid/src/error.rs @@ -0,0 +1,11 @@ +mod hook; +pub use hook::*; + +mod errors; +pub use errors::*; + +mod error_boundary; +pub use error_boundary::*; + +mod wrapper; +pub use wrapper::*; diff --git a/vespid/src/error/error_boundary.rs b/vespid/src/error/error_boundary.rs new file mode 100644 index 0000000..b6ec677 --- /dev/null +++ b/vespid/src/error/error_boundary.rs @@ -0,0 +1,49 @@ +use std::{future::Future, sync::Arc}; + +type SyncErrors = Arc>; + +use vespid_macros::component; + +// #[derive(vespid::typed_builder::TypedBuilder)] +// #[builder(doc, crate = ::vespid::typed_builder)] +// pub struct ErrorBoundaryProps { +// pub children: String, +// pub fallback: FallbackFn, +// } +// +// #[allow(non_snake_case)] +// pub async fn ErrorBoundary< +// Fallback: Future, +// FallbackFn: Fn(super::Errors) -> Fallback, +// >( +// props: ErrorBoundaryProps +// ) -> String { +// let id = crate::next_id(); +// let errors = SyncErrors::new(parking_lot::Mutex::new(super::Errors::default())); +// let hook = Arc::new(ErrorHook { +// errors: errors.clone(), +// }); +// let hook = hook as Arc; +// let errors = errors.lock(); +// if errors.is_empty() { +// props.children +// } else { +// props.fallback(errors).await +// } +// } +// +// struct ErrorHook { +// errors: SyncErrors, +// } +// +// impl super::ErrorHook for ErrorHook { +// fn throw(&self, error: super::Error) -> super::ErrorId { +// let id = super::ErrorId(crate::next_id()); +// self.errors.lock().insert(id, error); +// id +// } +// +// fn clear(&self, id: &super::ErrorId) { +// self.errors.lock().remove(id); +// } +// } diff --git a/vespid/src/error/errors.rs b/vespid/src/error/errors.rs new file mode 100644 index 0000000..329eec4 --- /dev/null +++ b/vespid/src/error/errors.rs @@ -0,0 +1,82 @@ +//! A container for errors thrown by views, taken from [`leptos`](https://github.com/leptos-rs/leptos/blob/d9b590b8e0e49a0537b973b7083040cbb46b523c/leptos/src/error_boundary.rs#L467) + +use rustc_hash::FxHashMap; + +use super::{Error, ErrorId}; + +/// A struct to hold all the possible errors that could be provided by child +/// [`view!s`](vespid_macros::view) +#[derive(Debug, Default)] +#[repr(transparent)] +pub struct Errors(FxHashMap); + +impl Errors { + /// Returns `true` if there are no errors. + #[inline(always)] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Add an error to Errors that will be processed by `` + pub fn insert(&mut self, key: ErrorId, error: E) + where + E: Into, + { + self.0.insert(key, error.into()); + } + + /// Add an error with the default key for errors outside the reactive system + pub fn insert_with_default_key(&mut self, error: E) + where + E: Into, + { + self.0.insert(Default::default(), error.into()); + } + + /// Remove an error to Errors that will be processed by `` + pub fn remove(&mut self, key: &ErrorId) -> Option { + self.0.remove(key) + } + + /// An iterator over all the errors, in arbitrary order. + #[inline(always)] + pub fn iter(&self) -> Iter<'_> { + Iter(self.0.iter()) + } +} + +impl IntoIterator for Errors { + type IntoIter = IntoIter; + type Item = (ErrorId, Error); + + #[inline(always)] + fn into_iter(self) -> Self::IntoIter { + IntoIter(self.0.into_iter()) + } +} + +/// An owning iterator over all the errors contained in the [`Errors`] struct. +#[repr(transparent)] +pub struct IntoIter(std::collections::hash_map::IntoIter); + +impl Iterator for IntoIter { + type Item = (ErrorId, Error); + + #[inline(always)] + fn next(&mut self) -> std::option::Option<::Item> { + self.0.next() + } +} + +/// An iterator over all the errors contained in the [`Errors`] struct. +#[repr(transparent)] +pub struct Iter<'a>(std::collections::hash_map::Iter<'a, ErrorId, Error>); + +impl<'a> Iterator for Iter<'a> { + type Item = (&'a ErrorId, &'a Error); + + #[inline(always)] + fn next(&mut self) -> std::option::Option<::Item> { + self.0.next() + } +} diff --git a/vespid/src/error/hook.rs b/vespid/src/error/hook.rs new file mode 100644 index 0000000..fee13a9 --- /dev/null +++ b/vespid/src/error/hook.rs @@ -0,0 +1,121 @@ +//! Error handling utilities taken from [`throw_error`](https://github.com/leptos-rs/leptos/blob/d9b590b8e0e49a0537b973b7083040cbb46b523c/any_error/src/lib.rs), and adapted for vespid's [`Error`](crate::error::Error) type + +use std::{ + cell::RefCell, + fmt::{self, Display}, + future::Future, + mem, + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; + +use super::Error; + +/// Implements behavior that allows for global or scoped error handling. +/// +/// This allows for both "throwing" errors to register them, and "clearing" errors when they are no +/// longer valid. This is useful for something like a user interface, in which an error can be +/// "thrown" on some invalid user input, and later "cleared" if the user corrects the input. +/// Keeping a unique identifier for each error allows the UI to be updated accordingly. +pub trait ErrorHook: Send + Sync { + /// Handles the given error, returning a unique identifier. + fn throw(&self, error: crate::error::Error) -> ErrorId; + + /// Clears the error associated with the given identifier. + fn clear(&self, id: &ErrorId); +} +/// A unique identifier for an error. This is returned when you call [`throw`], which calls a +/// global error handler. +#[derive(Debug, PartialEq, Eq, Hash, Clone, Default)] +pub struct ErrorId(usize); + +impl Display for ErrorId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + Display::fmt(&self.0, f) + } +} + +impl From for ErrorId { + fn from(value: usize) -> Self { + Self(value) + } +} + +thread_local! { + static ERROR_HOOK: RefCell>> = RefCell::new(None); +} + +/// Resets the error hook to its previous state when dropped. +pub struct ResetErrorHookOnDrop(Option>); + +impl Drop for ResetErrorHookOnDrop { + fn drop(&mut self) { + ERROR_HOOK.with_borrow_mut(|this| *this = self.0.take()) + } +} + +/// Returns the current error hook. +pub fn get_error_hook() -> Option> { + ERROR_HOOK.with_borrow(Clone::clone) +} + +/// Sets the current thread-local error hook, which will be invoked when [`throw`] is called. +pub fn set_error_hook(hook: Arc) -> ResetErrorHookOnDrop { + ResetErrorHookOnDrop(ERROR_HOOK.with_borrow_mut(|this| mem::replace(this, Some(hook)))) +} + +/// Invokes the error hook set by [`set_error_hook`] with the given error. +pub fn throw(error: impl Into) -> ErrorId { + ERROR_HOOK + .with_borrow(|hook| hook.as_ref().map(|hook| hook.throw(error.into()))) + .unwrap_or_default() +} + +/// Clears the given error from the current error hook. +pub fn clear(id: &ErrorId) { + ERROR_HOOK + .with_borrow(|hook| hook.as_ref().map(|hook| hook.clear(id))) + .unwrap_or_default() +} + +pin_project_lite::pin_project! { + /// A [`Future`] that reads the error hook that is set when it is created, and sets this as the + /// current error hook whenever it is polled. + pub struct ErrorHookFuture { + hook: Option>, + #[pin] + inner: Fut + } +} + +impl ErrorHookFuture { + /// Reads the current hook and wraps the given [`Future`], returning a new `Future` that will + /// set the error hook whenever it is polled. + pub fn new(inner: Fut) -> Self { + Self { + hook: ERROR_HOOK.with_borrow(Clone::clone), + inner, + } + } + + pub fn new_with_hook(inner: Fut, hook: Arc) -> Self { + Self { hook: Some(hook), inner } + } +} + +impl Future for ErrorHookFuture +where + Fut: Future, +{ + type Output = Fut::Output; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.project(); + let _hook = this + .hook + .as_ref() + .map(|hook| set_error_hook(Arc::clone(hook))); + this.inner.poll(cx) + } +} diff --git a/vespid/src/error/wrapper.rs b/vespid/src/error/wrapper.rs new file mode 100644 index 0000000..04a3155 --- /dev/null +++ b/vespid/src/error/wrapper.rs @@ -0,0 +1,113 @@ +use std::fmt::{Debug, Display}; + +use axum::{http::StatusCode, response::IntoResponse}; + +pub type Result = std::result::Result; + +#[derive(Debug)] +pub struct Error { + pub status: StatusCode, + pub error: eyre::Error, +} + +impl Error { + #[track_caller] + pub fn new(status: StatusCode, error: impl Into) -> Self { + Self { + status, + error: error.into(), + } + } + + #[track_caller] + pub fn msg(status: StatusCode, msg: impl Display + Debug + Send + Sync + 'static) -> Self { + Self { + status, + error: eyre::Error::msg(msg), + } + } +} + +pub trait ErrorExt { + #[track_caller] + fn with_status(self, status: StatusCode) -> Error; + #[track_caller] + fn context(self, context: E) -> Error; + #[track_caller] + fn realize(self) -> Error; +} + +impl ErrorExt for Error { + fn with_status(mut self, status: StatusCode) -> Error { + self.status = status; + self + } + + fn context(mut self, context: E) -> Error { + self.error = self.error.wrap_err(context); + + self + } + + fn realize(self) -> Error { + self + } +} + +impl> ErrorExt for E { + fn with_status(self, status: StatusCode) -> Error { + Error::new(status, self) + } + + fn context(self, context: D) -> Error { + Error::new(StatusCode::INTERNAL_SERVER_ERROR, self).context(context) + } + + fn realize(self) -> Error { + Error::new(StatusCode::INTERNAL_SERVER_ERROR, self) + } +} + +pub trait ResultExt { + #[track_caller] + fn with_status(self, status: StatusCode) -> Result; + #[track_caller] + fn context(self, context: E) -> Result; + #[track_caller] + fn realize(self) -> Result; +} + +impl ResultExt for Result { + fn with_status(self, status: StatusCode) -> Result { + self.map_err(|e| e.with_status(status)) + } + + fn context(self, context: D) -> Result { + self.map_err(|e| e.context(context)) + } + + fn realize(self) -> Result { + self.map_err(|e| e.realize()) + } +} + +impl> From for Error { + #[track_caller] + fn from(value: E) -> Self { + Self::new(StatusCode::INTERNAL_SERVER_ERROR, value) + } +} + +impl std::fmt::Display for Error { + #[track_caller] + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {:?}", self.status, self.error) + } +} + +impl IntoResponse for Error { + #[track_caller] + fn into_response(self) -> axum::response::Response { + (self.status, format!("{}", self.error)).into_response() + } +} diff --git a/vespid/src/lib.rs b/vespid/src/lib.rs index e1d7121..fb38fa0 100644 --- a/vespid/src/lib.rs +++ b/vespid/src/lib.rs @@ -1,22 +1,49 @@ +// Used so that vespid_macros::component can be used here, as it uses ::vespid +extern crate self as vespid; + mod escape_attribute; +use std::future::Future; + pub use escape_attribute::*; #[doc(hidden)] pub mod support; pub use support::*; -mod render; -pub use render::Render; - -mod render_adapter; -pub use render_adapter::*; - -mod context; -pub use context::*; - -mod text; -pub use text::*; pub mod axum; +pub mod context; +pub mod error; +pub mod oco; +pub mod render; +pub mod render_adapter; + +static ID_COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +pub fn next_id() -> usize { + ID_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed) +} + +pub async fn collect_fragments>( + fragments: impl Iterator, +) -> String { + futures_util::future::join_all(fragments).await.join("") +} + +pub async fn try_collect_fragments>, E>( + fragments: impl Iterator, +) -> Result { + futures_util::future::try_join_all(fragments) + .await + .map(|v| v.join("")) +} + +pub async fn async_transpose, O>(future: Option) -> Option { + if let Some(future) = future { + Some(future.await) + } else { + None + } +} pub use vespid_macros::*; @@ -26,7 +53,9 @@ pub extern crate html_escape; pub extern crate typed_builder; pub mod prelude { - pub use {html_escape, typed_builder}; + pub use html_escape; + pub use typed_builder; pub use vespid_macros::*; - pub use crate::{context::*, render::Render, render_adapter::*, text::*}; + + pub use crate::{context::*, oco::*, render::Render, render_adapter::*, collect_fragments, try_collect_fragments, async_transpose}; } diff --git a/vespid/src/text.rs b/vespid/src/oco.rs similarity index 100% rename from vespid/src/text.rs rename to vespid/src/oco.rs diff --git a/vespid/src/render_adapter.rs b/vespid/src/render_adapter.rs index e52aa5b..de19476 100644 --- a/vespid/src/render_adapter.rs +++ b/vespid/src/render_adapter.rs @@ -1,6 +1,6 @@ use core::fmt; -use crate::Render; +use crate::render::Render; pub struct RenderDisplay(pub T); diff --git a/watch.sh b/watch.sh index 3e36525..a457342 100755 --- a/watch.sh +++ b/watch.sh @@ -1,3 +1,3 @@ #!/usr/bin/env bash -cargo watch -w src -w magic -w vespid -w Cargo.toml -s "bun tailwind:build; cargo run" +cargo watch -w src -w magic -w vespid -w Cargo.toml -s "bun tailwind:build; env RUST_LOG=crusto=trace,info cargo run infra/config.toml"