From f6a09236f8efc70208796fedb915ea584ed5ea49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ma=C4=87kowski?= Date: Mon, 8 Jul 2024 16:13:27 -0700 Subject: [PATCH] feat: initial API implementation This is an early vision of the basic concepts and how the API overall might look like in the future. There's also a fully working Hello world example that implements a single endpoint. --- Cargo.toml | 5 + examples/hello-world/Cargo.toml | 3 + examples/hello-world/src/main.rs | 30 ++- flareon/Cargo.toml | 8 + flareon/src/lib.rs | 309 ++++++++++++++++++++++++++++++- flareon/src/prelude.rs | 3 + 6 files changed, 348 insertions(+), 10 deletions(-) create mode 100644 flareon/src/prelude.rs diff --git a/Cargo.toml b/Cargo.toml index e3ee4d6..c6fe397 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,12 +17,17 @@ license = "MIT OR Apache-2.0" [workspace.dependencies] async-trait = "0.1.80" axum = "0.7.5" +bytes = "1.6.1" chrono = { version = "0.4.38", features = ["serde"] } clap = { version = "4.5.8", features = ["derive", "env"] } +derive_builder = "0.20.0" env_logger = "0.11.3" +indexmap = "2.2.6" itertools = "0.13.0" log = "0.4.22" +regex = "1.10.5" serde = "1.0.203" slug = "0.1.5" tokio = { version = "1.38.0", features = ["macros", "rt-multi-thread"] } tower = "0.4.13" +thiserror = "1.0.61" diff --git a/examples/hello-world/Cargo.toml b/examples/hello-world/Cargo.toml index 2dbef25..05cdc2e 100644 --- a/examples/hello-world/Cargo.toml +++ b/examples/hello-world/Cargo.toml @@ -3,5 +3,8 @@ name = "example-hello-world" version = "0.1.0" publish = false description = "Hello World - Flareon example." +edition = "2021" [dependencies] +flareon = { path = "../../flareon" } +tokio = { version = "1.38.0", features = ["macros", "rt-multi-thread"] } diff --git a/examples/hello-world/src/main.rs b/examples/hello-world/src/main.rs index f328e4d..c0d5667 100644 --- a/examples/hello-world/src/main.rs +++ b/examples/hello-world/src/main.rs @@ -1 +1,29 @@ -fn main() {} +use std::sync::Arc; + +use flareon::prelude::{ + Body, Error, FlareonApp, FlareonProject, Request, Response, Route, StatusCode, +}; + +fn return_hello(_request: Request) -> Result { + Ok(Response::new_html( + StatusCode::OK, + Body::fixed("

Hello Flareon!

".as_bytes().to_vec()), + )) +} + +#[tokio::main] +async fn main() { + let hello_app = FlareonApp::builder() + .urls([Route::with_handler("", Arc::new(Box::new(return_hello)))]) + .build() + .unwrap(); + + let flareon_project = FlareonProject::builder() + .register_app_with_views(hello_app, "") + .build() + .unwrap(); + + flareon::run(flareon_project, "127.0.0.1:8000") + .await + .unwrap(); +} diff --git a/flareon/Cargo.toml b/flareon/Cargo.toml index 73215d2..25d8064 100644 --- a/flareon/Cargo.toml +++ b/flareon/Cargo.toml @@ -6,3 +6,11 @@ license.workspace = true description = "Modern web framework focused on speed and ease of use." [dependencies] +async-trait.workspace = true +axum.workspace = true +bytes.workspace = true +derive_builder.workspace = true +indexmap.workspace = true +log.workspace = true +thiserror.workspace = true +tokio.workspace = true diff --git a/flareon/src/lib.rs b/flareon/src/lib.rs index b93cf3f..1b562fb 100644 --- a/flareon/src/lib.rs +++ b/flareon/src/lib.rs @@ -1,14 +1,305 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right +pub mod prelude; + +use std::fmt::{Debug, Formatter}; +use std::io::Read; +use std::sync::Arc; + +use async_trait::async_trait; +use axum::handler::HandlerWithoutStateExt; +use bytes::Bytes; +use derive_builder::Builder; +use indexmap::IndexMap; +use log::info; +use thiserror::Error; + +pub type StatusCode = axum::http::StatusCode; + +#[async_trait] +pub trait RequestHandler { + async fn handle(&self, request: Request) -> Result; +} + +#[derive(Clone, Debug)] +pub struct Router { + urls: Vec, +} + +impl Router { + #[must_use] + pub fn with_urls>>(urls: T) -> Self { + Self { urls: urls.into() } + } + + async fn route(&self, request: Request, request_path: &str) -> Result { + for route in &self.urls { + if request_path.starts_with(&route.url) { + let request_path = &request_path[route.url.len()..]; + match &route.view { + RouteInner::Handler(handler) => return handler.handle(request).await, + RouteInner::Router(router) => { + return Box::pin(router.route(request, request_path)).await + } + } + } + } + + unimplemented!("404 handler is not implemented yet") + } +} + +#[async_trait] +impl RequestHandler for Router { + async fn handle(&self, request: Request) -> Result { + let path = request.uri().path().to_owned(); + self.route(request, &path).await + } +} + +#[async_trait] +impl RequestHandler for T +where + T: Fn(Request) -> Result + Send + Sync, +{ + async fn handle(&self, request: Request) -> Result { + self(request) + } +} + +/// A building block for a Flareon project. +/// +/// A Flareon app is a part (ideally, reusable) of a Flareon project that is +/// responsible for its own set of functionalities. Examples of apps could be: +/// * admin panel +/// * user authentication +/// * blog +/// * message board +/// * session management +/// * etc. +/// +/// Each app can have its own set of URLs that it can handle which can be +/// mounted on the project's router, its own set of middleware, database +/// migrations (which can depend on other apps), etc. +#[derive(Clone, Debug, Builder)] +#[builder(setter(into))] +pub struct FlareonApp { + router: Router, +} + +impl FlareonApp { + #[must_use] + pub fn builder() -> FlareonAppBuilder { + FlareonAppBuilder::default() + } +} + +impl FlareonAppBuilder { + #[allow(unused_mut)] + pub fn urls>>(&mut self, urls: T) -> &mut Self { + let mut new = self; + new.router = Some(Router::with_urls(urls.into())); + new + } +} + +#[derive(Clone)] +pub struct Route { + url: String, + view: RouteInner, +} + +impl Route { + #[must_use] + pub fn with_handler>( + url: T, + view: Arc>, + ) -> Self { + Self { + url: url.into(), + view: RouteInner::Handler(view), + } + } + + #[must_use] + pub fn with_router>(url: T, router: Router) -> Self { + Self { + url: url.into(), + view: RouteInner::Router(router), + } + } } -#[cfg(test)] -mod tests { - use super::*; +#[derive(Clone)] +enum RouteInner { + Handler(Arc>), + Router(Router), +} + +impl Debug for Route { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match &self.view { + RouteInner::Handler(_) => f.debug_tuple("Handler").field(&"handler(...)").finish(), + RouteInner::Router(router) => f.debug_tuple("Router").field(router).finish(), + } + } +} + +pub type Request = axum::extract::Request; + +type HeadersMap = IndexMap; + +#[derive(Debug)] +pub struct Response { + status: StatusCode, + headers: HeadersMap, + body: Body, +} + +const CONTENT_TYPE_HEADER: &str = "Content-Type"; +const HTML_CONTENT_TYPE: &str = "text/html"; + +impl Response { + #[must_use] + pub fn new_html(status: StatusCode, body: Body) -> Self { + Self { + status, + headers: Self::html_headers(), + body, + } + } - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); + #[must_use] + fn html_headers() -> HeadersMap { + let mut headers = HeadersMap::new(); + headers.insert(CONTENT_TYPE_HEADER.to_owned(), HTML_CONTENT_TYPE.to_owned()); + headers } } + +pub enum Body { + Fixed(Bytes), + Streaming(Box), +} + +impl Debug for Body { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Body::Fixed(data) => f.debug_tuple("Fixed").field(data).finish(), + Body::Streaming(_) => f.debug_tuple("Streaming").field(&"...").finish(), + } + } +} + +impl Body { + #[must_use] + pub fn empty() -> Self { + Self::Fixed(Bytes::new()) + } + + #[must_use] + pub fn fixed>(data: T) -> Self { + Self::Fixed(data.into()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Could not create a response object: {0}")] + ResponseBuilder(#[from] axum::http::Error), +} + +#[derive(Clone, Debug)] +pub struct FlareonProject { + apps: Vec, + router: Router, +} + +#[derive(Debug)] +pub struct FlareonProjectBuilder { + apps: Vec, + urls: Vec, +} + +impl FlareonProjectBuilder { + #[must_use] + pub fn new() -> Self { + Self { + apps: Vec::new(), + urls: Vec::new(), + } + } + + #[must_use] + pub fn register_app_with_views(&mut self, app: FlareonApp, url_prefix: &str) -> &mut Self { + let new = self; + new.urls.push(Route::with_handler( + url_prefix, + Arc::new(Box::new(app.router.clone())), + )); + new.apps.push(app); + new + } + + pub fn build(&self) -> Result { + Ok(FlareonProject { + apps: self.apps.clone(), + router: Router::with_urls(self.urls.clone()), + }) + } +} + +impl Default for FlareonProjectBuilder { + fn default() -> Self { + Self::new() + } +} + +impl FlareonProject { + #[must_use] + pub fn builder() -> FlareonProjectBuilder { + FlareonProjectBuilder::default() + } +} + +pub async fn run(mut project: FlareonProject, address_str: &str) -> Result<(), Error> { + for app in &mut project.apps { + info!("Initializing app: {:?}", app); + } + + let listener = tokio::net::TcpListener::bind(address_str).await.unwrap(); + + let handler = |request: axum::extract::Request| async move { + pass_to_axum(&project, request) + .await + .unwrap_or_else(handle_response_error) + }; + axum::serve(listener, handler.into_make_service()) + .await + .unwrap(); + + Ok(()) +} + +async fn pass_to_axum( + project: &FlareonProject, + request: axum::extract::Request, +) -> Result { + let response = project.router.handle(request).await?; + + let mut builder = axum::http::Response::builder().status(response.status); + for (key, value) in response.headers { + builder = builder.header(key, value); + } + let axum_response = builder.body(match response.body { + Body::Fixed(data) => axum::body::Body::from(data), + Body::Streaming(_) => unimplemented!(), + }); + + match axum_response { + Ok(response) => Ok(response), + Err(error) => Err(Error::ResponseBuilder(error)), + } +} + +fn handle_response_error(_error: Error) -> axum::response::Response { + unimplemented!("500 error handler is not implemented yet") +} diff --git a/flareon/src/prelude.rs b/flareon/src/prelude.rs new file mode 100644 index 0000000..6c2ec5f --- /dev/null +++ b/flareon/src/prelude.rs @@ -0,0 +1,3 @@ +pub use crate::{ + Body, Error, FlareonApp, FlareonProject, Request, RequestHandler, Response, Route, StatusCode, +};