First one #8

Merged
phoenix merged 42 commits from get_it_going into v0.2.0 2026-06-13 19:49:15 -04:00
16 changed files with 695 additions and 91 deletions
+7 -6
View File
@@ -1,10 +1,11 @@
JWT_SECRET=NULqYIzgt28bTiyziCd7IOO7b6LnWDW! JWT_SECRET=NULqYIzgt28bTiyziCd7IOO7b6LnWDW!
DB_NAME=textsender_db DB_MAIN_NAME=textsender_db
DB_USER=textsender DB_MAIN_USER=textsender
DB_PASSWORD=password DB_MAIN_PASSWORD=password
DB_HOST=main_db DB_MAIN_HOST=main_db
DB_PORT=5432 DB_MAIN_PORT=5432
DB_SSLMODE=disable DB_MAIN_SSLMODE=disable
DATABASE_URL=postgres://${DB_MAIN_USER}:${DB_MAIN_PASSWORD}@${DB_MAIN_HOST}:${DB_MAIN_PORT}/${DB_MAIN_NAME}
TWILIO_AUTH_SID=9M438C93R943U4329MCU43C34U TWILIO_AUTH_SID=9M438C93R943U4329MCU43C34U
TWILIO_SERVICE_SID=9M4J3X8439U398NUVT3342MC349C348T TWILIO_SERVICE_SID=9M4J3X8439U398NUVT3342MC349C348T
TWILIO_AUTH_TOKEN="f4a1f2b0b79ea3735078c2d8ee9684e1" TWILIO_AUTH_TOKEN="f4a1f2b0b79ea3735078c2d8ee9684e1"
+7 -6
View File
@@ -1,10 +1,11 @@
JWT_SECRET=NULqYIzgt28bTiyziCd7IOO7b6LnWDW! JWT_SECRET=NULqYIzgt28bTiyziCd7IOO7b6LnWDW!
DB_NAME=textsender_db DB_MAIN_NAME=textsender_db
DB_USER=textsender DB_MAIN_USER=textsender
DB_PASSWORD=password DB_MAIN_PASSWORD=password
DB_HOST=localhost DB_MAIN_HOST=localhost
DB_PORT=5432 DB_MAIN_PORT=5432
DB_SSLMODE=disable DB_MAIN_SSLMODE=disable
DATABASE_URL=postgres://${DB_MAIN_USER}:${DB_MAIN_PASSWORD}@${DB_MAIN_HOST}:${DB_MAIN_PORT}/${DB_MAIN_NAME}
TWILIO_AUTH_SID=9M438C93R943U4329MCU43C34U TWILIO_AUTH_SID=9M438C93R943U4329MCU43C34U
TWILIO_SERVICE_SID=9M4J3X8439U398NUVT3342MC349C348T TWILIO_SERVICE_SID=9M4J3X8439U398NUVT3342MC349C348T
TWILIO_AUTH_TOKEN="f4a1f2b0b79ea3735078c2d8ee9684e1" TWILIO_AUTH_TOKEN="f4a1f2b0b79ea3735078c2d8ee9684e1"
+1 -1
View File
@@ -75,7 +75,7 @@ jobs:
# Define DATABASE_URL for tests to use # Define DATABASE_URL for tests to use
DATABASE_URL: postgresql://${{ secrets.DB_TEST_USER || 'testuser' }}:${{ secrets.DB_TEST_PASSWORD || 'testpassword' }}@postgres:${{ secrets.DB_PORT || 5432 }}/${{ secrets.DB_TEST_NAME || 'testdb' }} DATABASE_URL: postgresql://${{ secrets.DB_TEST_USER || 'testuser' }}:${{ secrets.DB_TEST_PASSWORD || 'testpassword' }}@postgres:${{ secrets.DB_PORT || 5432 }}/${{ secrets.DB_TEST_NAME || 'testdb' }}
RUST_LOG: info # Optional: configure test log level RUST_LOG: info # Optional: configure test log level
SECRET_KEY: ${{ secrets.SECRET_KEY }} SECRET_MAIN_KEY: ${{ secrets.SECRET_KEY }}
# Make SSH agent available if tests fetch private dependencies # Make SSH agent available if tests fetch private dependencies
SSH_AUTH_SOCK: ${{ env.SSH_AUTH_SOCK }} SSH_AUTH_SOCK: ${{ env.SSH_AUTH_SOCK }}
ENABLE_REGISTRATION: 'TRUE' ENABLE_REGISTRATION: 'TRUE'
Generated
+3 -3
View File
@@ -2295,7 +2295,7 @@ dependencies = [
[[package]] [[package]]
name = "textsender_api" name = "textsender_api"
version = "0.1.3" version = "0.1.4"
dependencies = [ dependencies = [
"axum", "axum",
"axum-extra", "axum-extra",
@@ -2325,8 +2325,8 @@ dependencies = [
[[package]] [[package]]
name = "textsender_models" name = "textsender_models"
version = "0.3.0" version = "0.3.3"
source = "git+ssh://git@git.kundeng.us/phoenix/textsender_models.git?tag=v0.3.0#d504108745b7b97a02eac24e16763cdb5e731f2b" source = "git+ssh://git@git.kundeng.us/phoenix/textsender_models.git?tag=v0.3.3#c09fa36b15601b37f65b8baac0d479c30d2c1e44"
dependencies = [ dependencies = [
"const_format", "const_format",
"dotenvy", "dotenvy",
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "textsender_api" name = "textsender_api"
version = "0.1.3" version = "0.1.4"
edition = "2024" edition = "2024"
rust-version = "1.95" rust-version = "1.95"
@@ -25,7 +25,7 @@ jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] }
josekit = { version = "0.10.3" } josekit = { version = "0.10.3" }
utoipa = { version = "5.5.0", features = ["axum_extras"] } utoipa = { version = "5.5.0", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] } utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
textsender_models = { git = "ssh://git@git.kundeng.us/phoenix/textsender_models.git", tag = "v0.3.0" } textsender_models = { git = "ssh://git@git.kundeng.us/phoenix/textsender_models.git", tag = "v0.3.3" }
[dev-dependencies] [dev-dependencies]
common-multipart-rfc7578 = { version = "0.7.0" } common-multipart-rfc7578 = { version = "0.7.0" }
+24 -24
View File
@@ -19,7 +19,7 @@ services:
- textsender_api-network - textsender_api-network
restart: unless-stopped # Optional: Restart policy restart: unless-stopped # Optional: Restart policy
# --- Web API auth --- # --- Auth API ---
auth_api: auth_api:
build: build:
# Might want to change the naming convention at some point for the repo names. textsender-models, textsender_auth, textesender-api, etc # Might want to change the naming convention at some point for the repo names. textsender-models, textsender_auth, textesender-api, etc
@@ -44,23 +44,23 @@ services:
# volumes: # volumes:
# - ./path/to/your/web-api-repo:/app # - ./path/to/your/web-api-repo:/app
# --- songparser service --- # --- catapult service ---
catapult: # catapult:
build: # build:
context: ../catapult # context: ../catapult
ssh: ["default"] # ssh: ["default"]
dockerfile: Dockerfile # dockerfile: Dockerfile
container_name: catapult # container_name: catapult
restart: unless-stopped # restart: unless-stopped
env_file: # env_file:
- ../catapult/.env # - ../catapult/.env
depends_on: # depends_on:
- api # - api
- main_db # - main_db
- auth_api # - auth_api
- auth_db # - auth_db
networks: # networks:
- textsender_api-network # - textsender_api-network
# PostgreSQL Database Service # PostgreSQL Database Service
@@ -70,9 +70,9 @@ services:
container_name: textsender_api_db # Optional: Give the container a specific name container_name: textsender_api_db # Optional: Give the container a specific name
environment: environment:
# These MUST match the user, password, and database name in the DATABASE_URL above # These MUST match the user, password, and database name in the DATABASE_URL above
POSTGRES_USER: ${POSTGRES_MAIN_USER:-textsender_api} POSTGRES_USER: ${DB_MAIN_USER:-textsender_api}
POSTGRES_PASSWORD: ${POSTGRES_MAIN_PASSWORD:-password} POSTGRES_PASSWORD: ${DB_MAIN_PASSWORD:-password}
POSTGRES_DB: ${POSTGRES_MAIN_DB:-textsender_api_db} POSTGRES_DB: ${DB_MAIN_NAME:-textsender_api_db}
volumes: volumes:
# Persist database data using a named volume # Persist database data using a named volume
- postgres_data:/var/lib/postgresql - postgres_data:/var/lib/postgresql
@@ -96,9 +96,9 @@ services:
container_name: textsender_api_auth_db # Optional: Give the container a specific name container_name: textsender_api_auth_db # Optional: Give the container a specific name
environment: environment:
# These MUST match the user, password, and database name in the DATABASE_URL above # These MUST match the user, password, and database name in the DATABASE_URL above
POSTGRES_USER: ${POSTGRES_AUTH_USER:-textsender_api_op} POSTGRES_USER: ${DB_AUTH_USER:-textsender_auth}
POSTGRES_PASSWORD: ${POSTGRES_AUTH_PASSWORD:-password} POSTGRES_PASSWORD: ${DB_AUTH_PASSWORD:-password}
POSTGRES_DB: ${POSTGRES_AUTH_DB:-textsender_api_auth_db} POSTGRES_DB: ${DB_AUTH_NAME:-textsender_auth_db}
volumes: volumes:
# Persist database data using a named volume # Persist database data using a named volume
- postgres_data_auth:/var/lib/postgresql - postgres_data_auth:/var/lib/postgresql
+12
View File
@@ -0,0 +1,12 @@
-- Add migration script here
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE IF NOT EXISTS "contacts" (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
phone_number TEXT NOT NULL,
user_id UUID NOT NULL,
firstname TEXT NULL,
lastname TEXT NULL,
nickname TEXT NULL
);
+49 -49
View File
@@ -1,49 +1,49 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
--
DROP TABLE IF EXISTS contacts CASCADE; -- DROP TABLE IF EXISTS contacts CASCADE;
DROP TABLE IF EXISTS messages CASCADE; -- DROP TABLE IF EXISTS messages CASCADE;
DROP TABLE IF EXISTS scheduled_messages CASCADE; -- DROP TABLE IF EXISTS scheduled_messages CASCADE;
DROP TABLE IF EXISTS scheduled_message_events CASCADE; -- DROP TABLE IF EXISTS scheduled_message_events CASCADE;
DROP TABLE IF EXISTS message_event_responses CASCADE; -- DROP TABLE IF EXISTS message_event_responses CASCADE;
--
CREATE TABLE IF NOT EXISTS contacts ( -- CREATE TABLE IF NOT EXISTS contacts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), -- id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
phone_number TEXT NOT NULL, -- phone_number TEXT NOT NULL,
user_id UUID NOT NULL, -- user_id UUID NOT NULL,
first_name TEXT NULL, -- first_name TEXT NULL,
last_name TEXT NULL, -- last_name TEXT NULL,
nickname TEXT NULL -- nickname TEXT NULL
); -- );
--
CREATE TABLE IF NOT EXISTS messages ( -- CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), -- id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
content TEXT NOT NULL, -- content TEXT NOT NULL,
user_id UUID NOT NULL -- user_id UUID NOT NULL
); -- );
--
CREATE TABLE IF NOT EXISTS scheduled_messages ( -- CREATE TABLE IF NOT EXISTS scheduled_messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), -- id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
scheduled timestamptz NOT NULL, -- scheduled timestamptz NOT NULL,
created timestamptz DEFAULT now(), -- created timestamptz DEFAULT now(),
status TEXT CHECK (status IN ('PENDING', 'READY', 'PROCESSING', 'DONE')), -- status TEXT CHECK (status IN ('PENDING', 'READY', 'PROCESSING', 'DONE')),
user_id UUID NOT NULL -- user_id UUID NOT NULL
); -- );
--
CREATE TABLE IF NOT EXISTS scheduled_message_events ( -- CREATE TABLE IF NOT EXISTS scheduled_message_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), -- id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
contact_id UUID NOT NULL, -- contact_id UUID NOT NULL,
message_id UUID NOT NULL, -- message_id UUID NOT NULL,
scheduled_message_id UUID NOT NULL, -- scheduled_message_id UUID NOT NULL,
created timestamptz DEFAULT now() -- created timestamptz DEFAULT now()
); -- );
--
CREATE TABLE IF NOT EXISTS message_event_responses ( -- CREATE TABLE IF NOT EXISTS message_event_responses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), -- id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
scheduled_message_event_id UUID NULL, -- scheduled_message_event_id UUID NULL,
response JSONB NOT NULL, -- response JSONB NOT NULL,
user_id UUID NOT NULL, -- user_id UUID NOT NULL,
sent timestamptz NOT NULL, -- sent timestamptz NOT NULL,
contact_id UUID NULL, -- contact_id UUID NULL,
message_id UUID NULL, -- message_id UUID NULL,
status TEXT CHECK (status IN ('INSTANT', 'SCHEDULED')) -- status TEXT CHECK (status IN ('INSTANT', 'SCHEDULED'))
); -- );
+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() { async fn main() {
// initialize tracing // initialize tracing
tracing_subscriber::fmt::init(); 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),
}
}
}
+230
View File
@@ -0,0 +1,230 @@
// use std::io::Write;
// use common_multipart_rfc7578::client::multipart::{Body as MultipartBody, Form as MultipartForm};
// use tower::ServiceExt;
use textsender_api::db;
mod db_mgr {
use std::str::FromStr;
pub const LIMIT: usize = 6;
pub async fn get_pool() -> Result<sqlx::PgPool, sqlx::Error> {
let tm_db_url = textsender_models::envy::environment::get_db_url()
.await
.value;
let tm_options = sqlx::postgres::PgConnectOptions::from_str(&tm_db_url).unwrap();
sqlx::PgPool::connect_with(tm_options).await
}
pub async fn generate_db_name() -> String {
let db_name =
get_database_name().await.unwrap() + &"_" + &uuid::Uuid::new_v4().to_string()[..LIMIT];
db_name
}
pub async fn connect_to_db(db_name: &str) -> Result<sqlx::PgPool, sqlx::Error> {
let db_url = textsender_models::envy::environment::get_db_url()
.await
.value;
let options = sqlx::postgres::PgConnectOptions::from_str(&db_url)?.database(db_name);
sqlx::PgPool::connect_with(options).await
}
pub async fn create_database(
template_pool: &sqlx::PgPool,
db_name: &str,
) -> Result<(), sqlx::Error> {
let create_query = format!("CREATE DATABASE {}", db_name);
match sqlx::query(&create_query).execute(template_pool).await {
Ok(_) => Ok(()),
Err(e) => Err(e),
}
}
// Function to drop a database
pub async fn drop_database(
template_pool: &sqlx::PgPool,
db_name: &str,
) -> Result<(), sqlx::Error> {
let drop_query = format!("DROP DATABASE IF EXISTS {} WITH (FORCE)", db_name);
sqlx::query(&drop_query).execute(template_pool).await?;
Ok(())
}
pub async fn get_database_name() -> Result<String, Box<dyn std::error::Error>> {
let database_url = textsender_models::envy::environment::get_db_url()
.await
.value;
let parsed_url = url::Url::parse(&database_url)?;
if parsed_url.scheme() == "postgres" || parsed_url.scheme() == "postgresql" {
match parsed_url
.path_segments()
.and_then(|segments| segments.last().map(|s| s.to_string()))
{
Some(sss) => Ok(sss),
None => Err("Error parsing".into()),
}
} else {
// Handle other database types if needed
Err("Error parsing".into())
}
}
/*
pub async fn migrations(pool: &sqlx::PgPool) {
// Run migrations using the sqlx::migrate! macro
// Assumes your test migrations are in a ./test_migrations folder relative to Cargo.toml
sqlx::migrate!("./migrations")
.run(pool)
.await
.expect("Failed to run migrations");
}
*/
}
mod init {
pub async fn app(pool: sqlx::PgPool) -> axum::Router {
textsender_api::config::init::routes()
.await
.layer(axum::Extension(pool))
.layer(axum::extract::DefaultBodyLimit::max(1024 * 1024 * 1024))
.layer(tower_http::timeout::TimeoutLayer::with_status_code(
axum::http::StatusCode::OK,
std::time::Duration::from_secs(300),
))
}
}
mod util {
pub async fn resp_to_bytes(
response: axum::response::Response,
) -> Result<axum::body::Bytes, axum::Error> {
axum::body::to_bytes(response.into_body(), std::usize::MAX).await
}
pub async fn get_resp_data<Data>(response: axum::response::Response) -> Data
where
Data: for<'a> serde::Deserialize<'a>,
{
let body = resp_to_bytes(response).await.unwrap();
serde_json::from_slice(&body).unwrap()
}
/*
pub async fn format_url_with_value(endpoint: &str, value: &uuid::Uuid) -> String {
let last = endpoint.len() - 5;
format!("{}/{value}", &endpoint[0..last])
}
*/
}
pub fn token_fields() -> (String, String, String) {
(
String::from("What a twist!"),
String::from("textsender_test"),
String::from("textsender"),
)
}
pub const TEST_USER_ID: uuid::Uuid = uuid::uuid!("cc938368-615a-4694-b2ca-6e122fa31c52");
pub async fn test_token() -> Result<String, josekit::JoseError> {
let key: String = textsender_models::envy::environment::get_secret_main_key()
.await
.value;
let (message, issuer, audience) = token_fields();
let token_resource = textsender_models::token::TokenResource {
message: message,
issuer: issuer,
audiences: vec![audience],
user_id: TEST_USER_ID,
};
match textsender_models::token::create_token(&key, &token_resource, time::Duration::hours(1)) {
Ok((access_token, _some_time)) => Ok(access_token),
Err(err) => Err(err),
}
}
pub async fn bearer_auth() -> String {
let token = match test_token().await {
Ok(access_token) => access_token,
Err(err) => {
assert!(false, "Error: {err:?}");
String::new()
}
};
format!("Bearer {token}")
}
mod request {
// use common_multipart_rfc7578::client::multipart::{
// Body as MultipartBody, Form as MultipartForm,
// };
use tower::ServiceExt;
pub async fn create_contact(
app: &axum::Router,
) -> Result<axum::response::Response, std::convert::Infallible> {
let payload = serde_json::json!({
"phone_number": super::TEST_CONTACT_PHONE_NUMBER,
"user_id": super::TEST_USER_ID,
});
let req = axum::http::Request::builder()
.method(axum::http::Method::POST)
.uri(textsender_api::caller::endpoints::ADD_CONTACT)
.header(axum::http::header::CONTENT_TYPE, "application/json")
.header(
axum::http::header::AUTHORIZATION,
super::bearer_auth().await,
)
.body(axum::body::Body::from(payload.to_string()))
.unwrap();
app.clone().oneshot(req).await
}
}
/// Test contact phone number
pub const TEST_CONTACT_PHONE_NUMBER: &str = "+10123456789";
#[tokio::test]
async fn test_create_contact() {
let tm_pool = db_mgr::get_pool().await.unwrap();
let db_name = db_mgr::generate_db_name().await;
match db_mgr::create_database(&tm_pool, &db_name).await {
Ok(_) => {
println!("Success");
}
Err(err) => {
assert!(false, "Error: {:?}", err);
}
}
let pool = db_mgr::connect_to_db(&db_name).await.unwrap();
db::migrations(&pool).await;
let app = init::app(pool).await;
// Send request
match request::create_contact(&app).await {
Ok(response) => {
let resp = util::get_resp_data::<
textsender_api::caller::contact::response::AddContactResponse,
>(response)
.await;
assert_eq!(false, resp.data.is_empty(), "Should not be empty");
}
Err(err) => {
assert!(false, "Error: {:?}", err);
}
};
let _ = db_mgr::drop_database(&tm_pool, &db_name).await;
}