Skip to content

Commit

Permalink
feat: initial API implementation
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
m4tx committed Jul 28, 2024
1 parent 15694a1 commit f6a0923
Show file tree
Hide file tree
Showing 6 changed files with 348 additions and 10 deletions.
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
3 changes: 3 additions & 0 deletions examples/hello-world/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
30 changes: 29 additions & 1 deletion examples/hello-world/src/main.rs
Original file line number Diff line number Diff line change
@@ -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<Response, Error> {
Ok(Response::new_html(
StatusCode::OK,
Body::fixed("<h1>Hello Flareon!</h1>".as_bytes().to_vec()),
))
}

Check warning on line 12 in examples/hello-world/src/main.rs

View check run for this annotation

Codecov / codecov/patch

examples/hello-world/src/main.rs#L7-L12

Added lines #L7 - L12 were not covered by tests

#[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();
}

Check warning on line 29 in examples/hello-world/src/main.rs

View check run for this annotation

Codecov / codecov/patch

examples/hello-world/src/main.rs#L15-L29

Added lines #L15 - L29 were not covered by tests
8 changes: 8 additions & 0 deletions flareon/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
309 changes: 300 additions & 9 deletions flareon/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<Response, Error>;
}

#[derive(Clone, Debug)]
pub struct Router {
urls: Vec<Route>,
}

impl Router {
#[must_use]
pub fn with_urls<T: Into<Vec<Route>>>(urls: T) -> Self {
Self { urls: urls.into() }
}

Check warning on line 31 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L29-L31

Added lines #L29 - L31 were not covered by tests

async fn route(&self, request: Request, request_path: &str) -> Result<Response, Error> {
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

Check warning on line 40 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L33-L40

Added lines #L33 - L40 were not covered by tests
}
}
}

Check warning on line 43 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L43

Added line #L43 was not covered by tests
}

unimplemented!("404 handler is not implemented yet")
}

Check warning on line 47 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L46-L47

Added lines #L46 - L47 were not covered by tests
}

#[async_trait]
impl RequestHandler for Router {
async fn handle(&self, request: Request) -> Result<Response, Error> {
let path = request.uri().path().to_owned();
self.route(request, &path).await
}

Check warning on line 55 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L52-L55

Added lines #L52 - L55 were not covered by tests
}

#[async_trait]
impl<T> RequestHandler for T
where
T: Fn(Request) -> Result<Response, Error> + Send + Sync,
{
async fn handle(&self, request: Request) -> Result<Response, Error> {
self(request)
}

Check warning on line 65 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L63-L65

Added lines #L63 - L65 were not covered by tests
}

/// 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)]

Check warning on line 82 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L82

Added line #L82 was not covered by tests
#[builder(setter(into))]
pub struct FlareonApp {
router: Router,
}

impl FlareonApp {
#[must_use]
pub fn builder() -> FlareonAppBuilder {
FlareonAppBuilder::default()
}

Check warning on line 92 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L90-L92

Added lines #L90 - L92 were not covered by tests
}

impl FlareonAppBuilder {
#[allow(unused_mut)]
pub fn urls<T: Into<Vec<Route>>>(&mut self, urls: T) -> &mut Self {
let mut new = self;
new.router = Some(Router::with_urls(urls.into()));
new
}

Check warning on line 101 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L97-L101

Added lines #L97 - L101 were not covered by tests
}

#[derive(Clone)]
pub struct Route {
url: String,
view: RouteInner,
}

impl Route {
#[must_use]
pub fn with_handler<T: Into<String>>(
url: T,
view: Arc<Box<dyn RequestHandler + Send + Sync>>,
) -> Self {
Self {
url: url.into(),
view: RouteInner::Handler(view),
}
}

Check warning on line 120 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L112-L120

Added lines #L112 - L120 were not covered by tests

#[must_use]
pub fn with_router<T: Into<String>>(url: T, router: Router) -> Self {
Self {
url: url.into(),
view: RouteInner::Router(router),
}
}

Check warning on line 128 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L123-L128

Added lines #L123 - L128 were not covered by tests
}

#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone)]
enum RouteInner {
Handler(Arc<Box<dyn RequestHandler + Send + Sync>>),
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(),

Check warning on line 141 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L138-L141

Added lines #L138 - L141 were not covered by tests
}
}

Check warning on line 143 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L143

Added line #L143 was not covered by tests
}

pub type Request = axum::extract::Request;

type HeadersMap = IndexMap<String, String>;

#[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,
}
}

Check warning on line 168 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L162-L168

Added lines #L162 - L168 were not covered by tests

#[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

Check warning on line 174 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L171-L174

Added lines #L171 - L174 were not covered by tests
}
}

pub enum Body {
Fixed(Bytes),
Streaming(Box<dyn Read>),
}

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(),

Check warning on line 187 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L184-L187

Added lines #L184 - L187 were not covered by tests
}
}

Check warning on line 189 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L189

Added line #L189 was not covered by tests
}

impl Body {
#[must_use]
pub fn empty() -> Self {
Self::Fixed(Bytes::new())
}

Check warning on line 196 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L194-L196

Added lines #L194 - L196 were not covered by tests

#[must_use]
pub fn fixed<T: Into<Bytes>>(data: T) -> Self {
Self::Fixed(data.into())
}

Check warning on line 201 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L199-L201

Added lines #L199 - L201 were not covered by tests
}

#[derive(Debug, thiserror::Error)]

Check warning on line 204 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L204

Added line #L204 was not covered by tests
pub enum Error {
#[error("Could not create a response object: {0}")]
ResponseBuilder(#[from] axum::http::Error),
}

#[derive(Clone, Debug)]
pub struct FlareonProject {
apps: Vec<FlareonApp>,
router: Router,
}

#[derive(Debug)]
pub struct FlareonProjectBuilder {
apps: Vec<FlareonApp>,
urls: Vec<Route>,
}

impl FlareonProjectBuilder {
#[must_use]
pub fn new() -> Self {
Self {
apps: Vec::new(),
urls: Vec::new(),
}
}

Check warning on line 229 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L224-L229

Added lines #L224 - L229 were not covered by tests

#[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
}

Check warning on line 240 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L232-L240

Added lines #L232 - L240 were not covered by tests

pub fn build(&self) -> Result<FlareonProject, Error> {
Ok(FlareonProject {
apps: self.apps.clone(),
router: Router::with_urls(self.urls.clone()),
})
}

Check warning on line 247 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L242-L247

Added lines #L242 - L247 were not covered by tests
}

impl Default for FlareonProjectBuilder {
fn default() -> Self {
Self::new()
}

Check warning on line 253 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L251-L253

Added lines #L251 - L253 were not covered by tests
}

impl FlareonProject {
#[must_use]
pub fn builder() -> FlareonProjectBuilder {
FlareonProjectBuilder::default()
}

Check warning on line 260 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L258-L260

Added lines #L258 - L260 were not covered by tests
}

pub async fn run(mut project: FlareonProject, address_str: &str) -> Result<(), Error> {
for app in &mut project.apps {
info!("Initializing app: {:?}", app);

Check warning on line 265 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L263-L265

Added lines #L263 - L265 were not covered by tests
}

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(())
}

Check warning on line 280 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L268-L280

Added lines #L268 - L280 were not covered by tests

async fn pass_to_axum(
project: &FlareonProject,
request: axum::extract::Request,
) -> Result<axum::response::Response, Error> {
let response = project.router.handle(request).await?;

Check warning on line 286 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L282-L286

Added lines #L282 - L286 were not covered by tests

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!(),

Check warning on line 294 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L288-L294

Added lines #L288 - L294 were not covered by tests
});

match axum_response {
Ok(response) => Ok(response),
Err(error) => Err(Error::ResponseBuilder(error)),

Check warning on line 299 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L297-L299

Added lines #L297 - L299 were not covered by tests
}
}

Check warning on line 301 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L301

Added line #L301 was not covered by tests

fn handle_response_error(_error: Error) -> axum::response::Response {
unimplemented!("500 error handler is not implemented yet")

Check warning on line 304 in flareon/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

flareon/src/lib.rs#L303-L304

Added lines #L303 - L304 were not covered by tests
}
3 changes: 3 additions & 0 deletions flareon/src/prelude.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pub use crate::{
Body, Error, FlareonApp, FlareonProject, Request, RequestHandler, Response, Route, StatusCode,
};

0 comments on commit f6a0923

Please sign in to comment.