First one (#8)
Rust Build / Test Suite (push) Successful in 59s
Rust Build / Check (push) Successful in 1m40s
Rust Build / Rustfmt (push) Successful in 1m52s
Rust Build / Clippy (push) Successful in 1m25s
Rust Build / build (push) Successful in 2m20s

Reviewed-on: phoenix/textsender_api#8
This commit was merged in pull request #8.
This commit is contained in:
2026-06-13 19:49:14 -04:00
parent 7107a50d58
commit e940d6b159
16 changed files with 695 additions and 91 deletions
+61
View File
@@ -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)
}
+110
View File
@@ -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))
}
}
}
}
+118
View File
@@ -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)
}
}
+26
View File
@@ -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");
}
+5
View File
@@ -0,0 +1,5 @@
pub mod auth;
pub mod caller;
pub mod config;
pub mod db;
pub mod repo;
+11
View File
@@ -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:?}")
}
}
}
+29
View File
@@ -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),
}
}
}