chore: first working version with integration test
This commit is contained in:
parent
f869b0f067
commit
122c38a0da
19 changed files with 266 additions and 1214 deletions
18
src/ctx.rs
18
src/ctx.rs
|
|
@ -1,18 +0,0 @@
|
|||
#[derive(Debug, Clone)]
|
||||
pub struct Ctx {
|
||||
user_id: u64,
|
||||
}
|
||||
|
||||
// Constructor
|
||||
impl Ctx {
|
||||
pub fn new(user_id: u64) -> Self {
|
||||
Self { user_id }
|
||||
}
|
||||
}
|
||||
|
||||
// Property Accessors.
|
||||
impl Ctx {
|
||||
pub fn user_id(&self) -> u64 {
|
||||
self.user_id
|
||||
}
|
||||
}
|
||||
67
src/error.rs
67
src/error.rs
|
|
@ -1,67 +0,0 @@
|
|||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::Serialize;
|
||||
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, strum_macros::AsRefStr)]
|
||||
#[serde(tag = "type", content = "data")]
|
||||
pub enum Error {
|
||||
LoginFail,
|
||||
|
||||
// -- Auth errors.
|
||||
AuthFailNoAuthTokenCookie,
|
||||
AuthFailTokenWrongFormat,
|
||||
AuthFailCtxNotInRequestExt,
|
||||
|
||||
// -- Model errors.
|
||||
PropertyDeleteFailIdNotFound { id: u64 },
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub fn client_status_and_error(&self) -> (StatusCode, ClientError) {
|
||||
match self {
|
||||
// -- Login.
|
||||
Self::LoginFail => (StatusCode::UNAUTHORIZED, ClientError::LOGIN_FAIL),
|
||||
|
||||
// -- Auth.
|
||||
Self::AuthFailNoAuthTokenCookie
|
||||
| Self::AuthFailTokenWrongFormat
|
||||
| Self::AuthFailCtxNotInRequestExt => (StatusCode::FORBIDDEN, ClientError::NO_AUTH),
|
||||
|
||||
// -- Model.
|
||||
Self::PropertyDeleteFailIdNotFound { .. } => {
|
||||
(StatusCode::BAD_REQUEST, ClientError::INVALID_PARAMS)
|
||||
}
|
||||
|
||||
// -- Fallback.
|
||||
_ => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ClientError::SERVICE_ERROR,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for Error {
|
||||
fn into_response(self) -> Response {
|
||||
println!("->> {:<12} - {self:?}", "INTO_RESPONSE");
|
||||
|
||||
// Create a placeholder Axum response.
|
||||
let mut response = StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
|
||||
// Insert the Error into the response.
|
||||
response.extensions_mut().insert(self);
|
||||
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, strum_macros::AsRefStr)]
|
||||
#[allow(non_camel_case_types)]
|
||||
pub enum ClientError {
|
||||
LOGIN_FAIL,
|
||||
NO_AUTH,
|
||||
INVALID_PARAMS,
|
||||
SERVICE_ERROR,
|
||||
}
|
||||
24
src/lib.rs
Normal file
24
src/lib.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#![allow(unused)]
|
||||
use axum::extract::{Path, Query};
|
||||
use axum::http::{Method, Uri};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::routing::{get, get_service, IntoMakeService};
|
||||
use axum::Server;
|
||||
use axum::{middleware, Json, Router};
|
||||
use hyper::server::conn::AddrIncoming;
|
||||
use std::net::SocketAddr;
|
||||
use std::net::TcpListener;
|
||||
|
||||
pub type App = Server<AddrIncoming, IntoMakeService<Router>>;
|
||||
|
||||
/// API routes
|
||||
fn app() -> Router {
|
||||
Router::new().route("/health_check", get(|| async {}))
|
||||
}
|
||||
|
||||
/// Start the server
|
||||
pub fn run(listener: TcpListener) -> hyper::Result<App> {
|
||||
let app = app();
|
||||
let server = Server::from_tcp(listener)?.serve(app.into_make_service());
|
||||
Ok(server)
|
||||
}
|
||||
70
src/log.rs
70
src/log.rs
|
|
@ -1,70 +0,0 @@
|
|||
use std::time::SystemTime;
|
||||
|
||||
use crate::ctx::Ctx;
|
||||
use crate::error::ClientError;
|
||||
use crate::{Error, Result};
|
||||
use axum::http::{Method, Uri};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_with::skip_serializing_none;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn log_request(
|
||||
uuid: Uuid,
|
||||
req_method: Method,
|
||||
uri: Uri,
|
||||
ctx: Option<Ctx>,
|
||||
service_error: Option<&Error>,
|
||||
client_error: Option<ClientError>,
|
||||
) -> Result<()> {
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis();
|
||||
|
||||
let error_type = service_error.map(|se| se.as_ref().to_string());
|
||||
let error_data = serde_json::to_value(service_error)
|
||||
.ok()
|
||||
.and_then(|mut v| v.get_mut("data").map(|v| v.take()));
|
||||
|
||||
// Create the RequestLogLine.
|
||||
let log_line = RequestLogLine {
|
||||
uuid: uuid.to_string(),
|
||||
timestamp: timestamp.to_string(),
|
||||
|
||||
req_path: uri.path().to_string(),
|
||||
req_method: req_method.to_string(),
|
||||
|
||||
user_id: ctx.map(|ctx| ctx.user_id()),
|
||||
|
||||
client_error_type: client_error.map(|e| e.as_ref().to_string()),
|
||||
|
||||
error_type,
|
||||
error_data,
|
||||
};
|
||||
|
||||
println!(" ->> log request: \n{}", json!(log_line));
|
||||
|
||||
// TODO: Send to cloud-watch or something.
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[skip_serializing_none]
|
||||
#[derive(Serialize)]
|
||||
struct RequestLogLine {
|
||||
uuid: String, // uuid string formatted
|
||||
timestamp: String, // (should be iso8601)
|
||||
|
||||
// -- User and context attributes.
|
||||
user_id: Option<u64>,
|
||||
|
||||
// -- http request attributes.
|
||||
req_path: String,
|
||||
req_method: String,
|
||||
|
||||
// -- Errors attributes.
|
||||
client_error_type: Option<String>,
|
||||
error_type: Option<String>,
|
||||
error_data: Option<Value>,
|
||||
}
|
||||
111
src/main.rs
111
src/main.rs
|
|
@ -1,108 +1,9 @@
|
|||
#![allow(unused)]
|
||||
|
||||
use crate::model::ModelController;
|
||||
|
||||
use self::ctx::Ctx;
|
||||
pub use self::error::{Error, Result};
|
||||
use self::log::log_request;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use axum::extract::{Path, Query};
|
||||
use axum::http::{Method, Uri};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::routing::{get, get_service};
|
||||
use axum::Server;
|
||||
use axum::{middleware, Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tower_cookies::CookieManagerLayer;
|
||||
use tower_http::services::ServeDir;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod ctx;
|
||||
mod error;
|
||||
mod log;
|
||||
mod model;
|
||||
mod web;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HelloParams {
|
||||
name: Option<String>,
|
||||
}
|
||||
use learn_axum::run;
|
||||
use std::net::TcpListener;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let mc = ModelController::new().await?;
|
||||
|
||||
let routes_apis = web::routes_properties::routes(mc.clone())
|
||||
.route_layer(middleware::from_fn(web::mw_auth::mw_require_auth));
|
||||
|
||||
let routes_all = Router::new()
|
||||
.merge(Router::new().route("/health_check", get(|| async {})))
|
||||
.merge(web::routes_login::routes())
|
||||
.nest("/api", routes_apis)
|
||||
.layer(middleware::map_response(main_response_mapper))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
mc.clone(),
|
||||
web::mw_auth::mw_ctx_resolver,
|
||||
))
|
||||
.layer(CookieManagerLayer::new()) // must be above? the auth routes
|
||||
.fallback_service(routes_static());
|
||||
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
|
||||
println!("->> Listening on {addr}\n");
|
||||
Server::bind(&addr)
|
||||
.serve(routes_all.into_make_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Map the response to add headers, etc.
|
||||
///
|
||||
/// * `res`: the response to map
|
||||
async fn main_response_mapper(
|
||||
ctx: Option<Ctx>,
|
||||
uri: Uri,
|
||||
req_method: Method,
|
||||
res: Response,
|
||||
) -> Response {
|
||||
println!("->> {:<12} - main_response_mapper", "RES_MAPPER");
|
||||
let uuid = Uuid::new_v4();
|
||||
|
||||
// -- Get the eventual response error.
|
||||
let service_error = res.extensions().get::<Error>();
|
||||
let client_status_error = service_error.map(|se| se.client_status_and_error());
|
||||
|
||||
// -- If client error, build the new response
|
||||
let error_response = client_status_error
|
||||
.as_ref()
|
||||
.map(|(status_code, client_error)| {
|
||||
let client_error_body = json!({
|
||||
"error": {
|
||||
"type": client_error.as_ref(),
|
||||
"req_uuid": uuid.to_string(),
|
||||
}
|
||||
});
|
||||
|
||||
println!(" ->> client_error_body: {client_error_body}");
|
||||
|
||||
// Build the new response from the client error body.
|
||||
(*status_code, Json(client_error_body)).into_response()
|
||||
});
|
||||
|
||||
// -- Build and log the server log line.
|
||||
let client_error = client_status_error.unzip().1;
|
||||
log_request(uuid, req_method, uri, ctx, service_error, client_error).await;
|
||||
|
||||
println!();
|
||||
error_response.unwrap_or(res)
|
||||
}
|
||||
|
||||
/// Serve static files
|
||||
// FIXME: remove
|
||||
fn routes_static() -> Router {
|
||||
Router::new().nest_service("/", get_service(ServeDir::new("./")))
|
||||
async fn main() -> hyper::Result<()> {
|
||||
let addr = format!("127.0.0.1:{}", "3000");
|
||||
let listener = TcpListener::bind(addr).expect("Unable to bind to port");
|
||||
run(listener)?.await
|
||||
}
|
||||
|
|
|
|||
69
src/model.rs
69
src/model.rs
|
|
@ -1,69 +0,0 @@
|
|||
//! Simplistic model layer
|
||||
//! (with mock-store layer)
|
||||
|
||||
use crate::{ctx::Ctx, Error, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
// region: --- Property Types
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Property {
|
||||
pub id: u64,
|
||||
pub creator_id: u64,
|
||||
pub address: String,
|
||||
pub contact: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PropertyForCreate {
|
||||
pub address: String,
|
||||
pub contact: String,
|
||||
}
|
||||
|
||||
// endregion: --- Property Types
|
||||
|
||||
// region: --- Model Controller
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ModelController {
|
||||
property_store: Arc<Mutex<Vec<Option<Property>>>>,
|
||||
}
|
||||
|
||||
// Constructor
|
||||
impl ModelController {
|
||||
pub async fn new() -> Result<Self> {
|
||||
Ok(Self {
|
||||
property_store: Arc::default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CRUD implementation
|
||||
impl ModelController {
|
||||
pub async fn create_property(&self, ctx: Ctx, property: PropertyForCreate) -> Result<Property> {
|
||||
let mut store = self.property_store.lock().unwrap();
|
||||
let id = store.len() as u64;
|
||||
let property = Property {
|
||||
id,
|
||||
creator_id: ctx.user_id(),
|
||||
address: property.address,
|
||||
contact: property.contact,
|
||||
};
|
||||
store.push(Some(property.clone()));
|
||||
Ok(property)
|
||||
}
|
||||
|
||||
pub async fn list_properties(&self, ctx: Ctx) -> Result<Vec<Property>> {
|
||||
let store = self.property_store.lock().unwrap();
|
||||
let properties = store.iter().filter_map(|p| p.clone()).collect();
|
||||
Ok(properties)
|
||||
}
|
||||
|
||||
pub async fn delete_property(&self, ctx: Ctx, id: u64) -> Result<Property> {
|
||||
let mut store = self.property_store.lock().unwrap();
|
||||
let property = store.get_mut(id as usize).and_then(|p| p.take());
|
||||
property.ok_or(Error::PropertyDeleteFailIdNotFound { id })
|
||||
}
|
||||
}
|
||||
|
||||
// endregion: --- Model Controller
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
pub mod mw_auth;
|
||||
pub mod routes_login;
|
||||
pub mod routes_properties;
|
||||
|
||||
/// The cookie name for the auth token
|
||||
pub const AUTH_TOKEN: &str = "auth-token";
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
use async_trait::async_trait;
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::request::Parts;
|
||||
use axum::http::Request;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
use axum::RequestPartsExt;
|
||||
use lazy_regex::regex_captures;
|
||||
use tower_cookies::{Cookie, Cookies};
|
||||
|
||||
use crate::ctx::Ctx;
|
||||
use crate::web::AUTH_TOKEN;
|
||||
use crate::{Error, Result};
|
||||
|
||||
pub async fn mw_ctx_resolver<B>(
|
||||
cookies: Cookies,
|
||||
mut req: Request<B>,
|
||||
next: Next<B>,
|
||||
) -> Result<Response> {
|
||||
println!("->> {:<12} - mw_ctx_resolver", "MIDDLEWARE");
|
||||
|
||||
let auth_token = cookies.get(AUTH_TOKEN).map(|c| c.value().to_string());
|
||||
|
||||
// Compute Result<Ctx>.
|
||||
let result_ctx = match auth_token
|
||||
.ok_or(Error::AuthFailNoAuthTokenCookie)
|
||||
.and_then(parse_token)
|
||||
{
|
||||
Ok((user_id, _exp, _sign)) => {
|
||||
// TODO: Token components validations.
|
||||
Ok(Ctx::new(user_id))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
|
||||
// Remove the cookie if something went wrong other than NoAuthTokenCookie.
|
||||
if result_ctx.is_err() && !matches!(result_ctx, Err(Error::AuthFailNoAuthTokenCookie)) {
|
||||
cookies.remove(Cookie::named(AUTH_TOKEN))
|
||||
}
|
||||
|
||||
// Store the ctx_result in the request extension.
|
||||
req.extensions_mut().insert(result_ctx);
|
||||
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
/// Middleware to require authentication.
|
||||
pub async fn mw_require_auth<B>(
|
||||
ctx: Result<Ctx>,
|
||||
req: Request<B>,
|
||||
next: Next<B>,
|
||||
) -> Result<Response> {
|
||||
println!("->> {:<12} - mw_require_auth - {ctx:?}", "MIDDLEWARE");
|
||||
|
||||
ctx?;
|
||||
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
// region: --- Ctx Extractor
|
||||
|
||||
#[async_trait]
|
||||
impl<S: Send + Sync> FromRequestParts<S> for Ctx {
|
||||
type Rejection = Error;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self> {
|
||||
println!("->> {:<12} - Ctx", "EXTRACTOR");
|
||||
|
||||
parts
|
||||
.extensions
|
||||
.get::<Result<Ctx>>()
|
||||
.ok_or(Error::AuthFailCtxNotInRequestExt)?
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
// endregion: --- Ctx Extractor
|
||||
|
||||
/// Parse a token of format `user-[user-id].[expiration].[signature]`
|
||||
/// Returns (user-id, expiration, signature)
|
||||
fn parse_token(token: String) -> Result<(u64, String, String)> {
|
||||
let (_whole, user_id, exp, sign) = regex_captures!(
|
||||
r#"^user-(\d+)\.(.+)\.(.+)"#, // a literal regex
|
||||
&token
|
||||
)
|
||||
.ok_or(Error::AuthFailTokenWrongFormat)?;
|
||||
|
||||
let user_id: u64 = user_id
|
||||
.parse()
|
||||
.map_err(|_| Error::AuthFailTokenWrongFormat)?;
|
||||
|
||||
Ok((user_id, exp.to_string(), sign.to_string()))
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
use crate::{web, Error, Result};
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tower_cookies::{Cookie, Cookies};
|
||||
|
||||
pub fn routes() -> Router {
|
||||
Router::new().route("/api/login", post(api_login))
|
||||
}
|
||||
|
||||
async fn api_login(cookies: Cookies, payload: Json<LoginPayload>) -> Result<Json<Value>> {
|
||||
println!("->> {:<12} - api_login", "HANDLER");
|
||||
|
||||
if payload.username != "demo1" || payload.password != "demo1" {
|
||||
return Err(Error::LoginFail);
|
||||
}
|
||||
|
||||
// FIXME: Implement real auth-token generation/signature.
|
||||
cookies.add(Cookie::new(web::AUTH_TOKEN, "user-1.exp.sign"));
|
||||
|
||||
let body = Json(json!({
|
||||
"result": {
|
||||
"success": true
|
||||
}
|
||||
}));
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LoginPayload {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
use crate::ctx::Ctx;
|
||||
use crate::model::{ModelController, Property, PropertyForCreate};
|
||||
use crate::Result;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::{delete, post};
|
||||
use axum::{Json, Router};
|
||||
|
||||
pub fn routes(mc: ModelController) -> Router {
|
||||
Router::new()
|
||||
.route("/properties", post(create_property).get(list_properties))
|
||||
.route("/properties/:id", delete(delete_property))
|
||||
.with_state(mc)
|
||||
}
|
||||
|
||||
// region: --- REST Handlers
|
||||
|
||||
async fn create_property(
|
||||
State(mc): State<ModelController>,
|
||||
ctx: Ctx,
|
||||
Json(property_fc): Json<PropertyForCreate>,
|
||||
) -> Result<Json<Property>> {
|
||||
println!("->> {:<12} - create_property", "HANDLER");
|
||||
let property = mc.create_property(ctx, property_fc).await?;
|
||||
Ok(Json(property))
|
||||
}
|
||||
|
||||
async fn list_properties(
|
||||
State(mc): State<ModelController>,
|
||||
ctx: Ctx,
|
||||
) -> Result<Json<Vec<Property>>> {
|
||||
println!("->> {:<12} - list_properties", "HANDLER");
|
||||
let properties = mc.list_properties(ctx).await?;
|
||||
Ok(Json(properties))
|
||||
}
|
||||
|
||||
async fn delete_property(
|
||||
State(mc): State<ModelController>,
|
||||
ctx: Ctx,
|
||||
Path(id): Path<u64>,
|
||||
) -> Result<Json<Property>> {
|
||||
println!("->> {:<12} - delete_property", "HANDLER");
|
||||
let property = mc.delete_property(ctx, id).await?;
|
||||
Ok(Json(property))
|
||||
}
|
||||
|
||||
// endregion: --- REST Handlers
|
||||
Loading…
Add table
Add a link
Reference in a new issue