First one (#8)
Reviewed-on: phoenix/textsender_api#8
This commit was merged in pull request #8.
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
use axum::{
|
||||
Json,
|
||||
http::{Request, StatusCode},
|
||||
middleware::Next,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use jsonwebtoken::{DecodingKey, Validation, decode};
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub status: &'static str,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub async fn auth<B>(
|
||||
cookie_jar: CookieJar,
|
||||
req: Request<axum::body::Body>,
|
||||
next: Next,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
|
||||
let token = cookie_jar
|
||||
.get("token")
|
||||
.map(|cookie| cookie.value().to_string())
|
||||
.or_else(|| {
|
||||
req.headers()
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|auth_header| auth_header.to_str().ok())
|
||||
.and_then(|auth_value| auth_value.strip_prefix("Bearer ").map(String::from))
|
||||
});
|
||||
|
||||
let token = token.ok_or_else(|| {
|
||||
let json_error = ErrorResponse {
|
||||
status: "fail",
|
||||
message: "You are not logged in, please provide token".to_string(),
|
||||
};
|
||||
(StatusCode::UNAUTHORIZED, Json(json_error))
|
||||
})?;
|
||||
|
||||
let secret_key = textsender_models::envy::environment::get_secret_main_key()
|
||||
.await
|
||||
.value;
|
||||
|
||||
let mut validation = Validation::new(jsonwebtoken::Algorithm::HS256);
|
||||
validation.set_audience(&["textsender"]); // Must match exactly what's in the token
|
||||
let _claims = decode::<textsender_models::token::Claims>(
|
||||
&token,
|
||||
&DecodingKey::from_secret(secret_key.as_ref()),
|
||||
&validation,
|
||||
)
|
||||
.map_err(|err| {
|
||||
eprintln!("Error: {err:?}");
|
||||
let json_error = ErrorResponse {
|
||||
status: "fail",
|
||||
message: "Invalid token - Error decoding claims".to_string(),
|
||||
};
|
||||
(StatusCode::UNAUTHORIZED, Json(json_error))
|
||||
})?
|
||||
.claims;
|
||||
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
pub mod endpoints {
|
||||
pub const ROOT: &str = "/";
|
||||
|
||||
/// Constant for adding Contact endpoint
|
||||
pub const ADD_CONTACT: &str = "/api/v1/contact/new";
|
||||
}
|
||||
|
||||
pub mod response {
|
||||
pub const SUCCESSFUL: &str = "SUCCESSFUL";
|
||||
}
|
||||
|
||||
/// Basic handler that responds with a static string
|
||||
pub async fn root() -> &'static str {
|
||||
"Hello, World!"
|
||||
}
|
||||
|
||||
pub mod contact {
|
||||
pub mod request {
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, ToSchema)]
|
||||
pub struct AddContactRequest {
|
||||
pub phone_number: String,
|
||||
pub firstname: Option<String>,
|
||||
pub lastname: Option<String>,
|
||||
pub nickname: Option<String>,
|
||||
pub user_id: uuid::Uuid,
|
||||
}
|
||||
|
||||
impl AddContactRequest {
|
||||
pub fn is_valid(&self) -> bool {
|
||||
!self.phone_number.is_empty() || !self.user_id.is_nil()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod response {
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize, ToSchema)]
|
||||
pub struct AddContactResponse {
|
||||
pub message: String,
|
||||
pub data: Vec<textsender_models::contact::Contact>,
|
||||
}
|
||||
}
|
||||
|
||||
pub mod endpoint {
|
||||
// use axum::{Json, response::IntoResponse};
|
||||
|
||||
use crate::repo::contact as contact_repo;
|
||||
|
||||
/// Endpoint to create Contact
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = super::super::endpoints::ADD_CONTACT,
|
||||
request_body(
|
||||
content = super::request::AddContactRequest,
|
||||
description = "Data needed to create a Contact",
|
||||
content_type = "application/json"
|
||||
),
|
||||
responses(
|
||||
(status = 201, description = "Contact created", body = super::response::AddContactResponse),
|
||||
(status = 400, description = "Error", body = super::response::AddContactResponse),
|
||||
(status = 500, description = "Error creating Contact", body = super::response::AddContactResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn create_contact(
|
||||
axum::Extension(pool): axum::Extension<sqlx::PgPool>,
|
||||
axum::Json(payload): axum::Json<super::request::AddContactRequest>,
|
||||
) -> (
|
||||
axum::http::StatusCode,
|
||||
axum::Json<super::response::AddContactResponse>,
|
||||
) {
|
||||
let mut response = super::response::AddContactResponse::default();
|
||||
|
||||
if payload.is_valid() {
|
||||
let mut contact = textsender_models::contact::Contact {
|
||||
firstname: payload.firstname,
|
||||
lastname: payload.lastname,
|
||||
phone_number: payload.phone_number,
|
||||
user_id: Some(payload.user_id),
|
||||
..Default::default()
|
||||
};
|
||||
match contact_repo::insert(&pool, &contact).await {
|
||||
Ok(id) => {
|
||||
contact.id = Some(id);
|
||||
response.message = String::from(super::super::response::SUCCESSFUL);
|
||||
response.data.push(contact);
|
||||
|
||||
(axum::http::StatusCode::CREATED, axum::Json(response))
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("Error: {err:?}");
|
||||
response.message = String::from("Contact not created");
|
||||
|
||||
(
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
axum::Json(response),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
response.message = String::from("Request body is not valid");
|
||||
(axum::http::StatusCode::BAD_REQUEST, axum::Json(response))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
pub mod host {
|
||||
pub const PORT: i64 = 9081;
|
||||
|
||||
pub fn get_full() -> String {
|
||||
let address: &str = "0.0.0.0";
|
||||
format!("{}:{}", address, PORT)
|
||||
}
|
||||
}
|
||||
|
||||
pub mod init {
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::routing::post;
|
||||
use tower_http::timeout::TimeoutLayer;
|
||||
use utoipa::OpenApi;
|
||||
|
||||
use crate::caller::contact as contact_caller;
|
||||
use contact_caller::endpoint as contact_endpoints;
|
||||
// use contact_caller::response as contact_responses;
|
||||
|
||||
mod cors {
|
||||
pub async fn configure_cors() -> tower_http::cors::CorsLayer {
|
||||
let cors = tower_http::cors::CorsLayer::new()
|
||||
.allow_methods([
|
||||
axum::http::Method::GET,
|
||||
axum::http::Method::POST,
|
||||
axum::http::Method::PUT,
|
||||
axum::http::Method::DELETE,
|
||||
]) // Specify allowed methods:cite[2]
|
||||
.allow_headers([
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
axum::http::header::AUTHORIZATION,
|
||||
]) // Specify allowed headers:cite[2]
|
||||
.allow_credentials(true) // If you need to send cookies or authentication headers:cite[2]
|
||||
.max_age(std::time::Duration::from_secs(3600)); // Cache the preflight response for 1 hour:cite[2]
|
||||
|
||||
// Dynamically set the allowed origin based on the environment
|
||||
match std::env::var(textsender_models::envy::keys::APP_ENV).as_deref() {
|
||||
Ok("production") => {
|
||||
// In production, allow only your specific, trusted origins
|
||||
let allowed_origins_env =
|
||||
textsender_models::envy::environment::get_allowed_origins().await;
|
||||
match textsender_models::envy::utility::delimitize(&allowed_origins_env) {
|
||||
Ok(alwd) => {
|
||||
let allowed_origins: Vec<axum::http::HeaderValue> = alwd
|
||||
.into_iter()
|
||||
.map(|a| a.parse::<axum::http::HeaderValue>().unwrap())
|
||||
.collect();
|
||||
cors.allow_origin(allowed_origins)
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("Error getting allowed origins: Error: {err:?}");
|
||||
std::process::exit(-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Development (default): Allow localhost origins
|
||||
cors.allow_origin(vec![
|
||||
format!("http://localhost:{}", super::super::host::PORT)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
format!("http://127.0.0.1:{}", super::super::host::PORT)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
"http://localhost:4200".parse().unwrap(),
|
||||
"http://127.0.0.1:4200".parse().unwrap(),
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(utoipa::OpenApi)]
|
||||
#[openapi(
|
||||
paths(contact_endpoints::create_contact,),
|
||||
components(schemas(contact_caller::response::AddContactResponse)),
|
||||
tags(
|
||||
(name = "textsender API", description = "Web API to manage texting")
|
||||
)
|
||||
)]
|
||||
struct ApiDoc;
|
||||
|
||||
pub async fn routes() -> axum::Router {
|
||||
axum::Router::new()
|
||||
.route(
|
||||
crate::caller::endpoints::ADD_CONTACT,
|
||||
post(contact_endpoints::create_contact).route_layer(axum::middleware::from_fn(
|
||||
crate::auth::auth::<axum::body::Body>,
|
||||
)),
|
||||
)
|
||||
.layer(cors::configure_cors().await)
|
||||
}
|
||||
|
||||
pub async fn app() -> axum::Router {
|
||||
let pool = crate::db::create_pool()
|
||||
.await
|
||||
.expect("Failed to create pool");
|
||||
|
||||
crate::db::migrations(&pool).await;
|
||||
|
||||
let cors = cors::configure_cors().await;
|
||||
|
||||
routes()
|
||||
.await
|
||||
.merge(
|
||||
utoipa_swagger_ui::SwaggerUi::new("/swagger-ui")
|
||||
.url("/api-docs/openapi.json", ApiDoc::openapi()),
|
||||
)
|
||||
.layer(axum::Extension(pool))
|
||||
.layer(axum::extract::DefaultBodyLimit::max(1024 * 1024 * 1024))
|
||||
.layer(TimeoutLayer::with_status_code(
|
||||
axum::http::StatusCode::OK,
|
||||
Duration::from_secs(300),
|
||||
))
|
||||
.layer(cors)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
|
||||
pub mod connection_settings {
|
||||
pub const MAXCONN: u32 = 10;
|
||||
}
|
||||
|
||||
pub async fn create_pool() -> Result<sqlx::PgPool, sqlx::Error> {
|
||||
let database_url = textsender_models::envy::environment::get_db_url()
|
||||
.await
|
||||
.value;
|
||||
println!("Database url: {database_url}");
|
||||
|
||||
PgPoolOptions::new()
|
||||
.max_connections(connection_settings::MAXCONN)
|
||||
.connect(&database_url)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn migrations(pool: &sqlx::PgPool) {
|
||||
// Run migrations using the sqlx::migrate! macro
|
||||
// Assumes your migrations are in a ./migrations folder relative to Cargo.toml
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(pool)
|
||||
.await
|
||||
.expect("Failed to run migrations");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod auth;
|
||||
pub mod caller;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod repo;
|
||||
+11
@@ -2,4 +2,15 @@
|
||||
async fn main() {
|
||||
// initialize tracing
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
match tokio::net::TcpListener::bind(textsender_api::config::host::get_full()).await {
|
||||
Ok(listener) => {
|
||||
// build our application with routes
|
||||
let app = textsender_api::config::init::app().await;
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("Error: {err:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
pub mod contact {
|
||||
use sqlx::Row;
|
||||
|
||||
pub async fn insert(
|
||||
pool: &sqlx::PgPool,
|
||||
contact: &textsender_models::contact::Contact,
|
||||
) -> Result<uuid::Uuid, sqlx::Error> {
|
||||
match sqlx::query(
|
||||
r#"
|
||||
INSERT INTO "contacts" (firstname, lastname, nickname, phone_number, user_id)
|
||||
VALUES($1, $2, $3, $4, $5) RETURNING id;
|
||||
"#,
|
||||
)
|
||||
.bind(&contact.firstname)
|
||||
.bind(&contact.lastname)
|
||||
.bind(&contact.nickname)
|
||||
.bind(&contact.phone_number)
|
||||
.bind(contact.user_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
{
|
||||
Ok(row) => {
|
||||
let id: uuid::Uuid = row.try_get("id")?;
|
||||
Ok(id)
|
||||
}
|
||||
Err(_) => Err(sqlx::Error::RowNotFound),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user