diff --git a/.env.docker.sample b/.env.docker.sample index 4bcc074..7600a38 100644 --- a/.env.docker.sample +++ b/.env.docker.sample @@ -1,10 +1,11 @@ JWT_SECRET=NULqYIzgt28bTiyziCd7IOO7b6LnWDW! -DB_NAME=textsender_db -DB_USER=textsender -DB_PASSWORD=password -DB_HOST=main_db -DB_PORT=5432 -DB_SSLMODE=disable +DB_MAIN_NAME=textsender_db +DB_MAIN_USER=textsender +DB_MAIN_PASSWORD=password +DB_MAIN_HOST=main_db +DB_MAIN_PORT=5432 +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_SERVICE_SID=9M4J3X8439U398NUVT3342MC349C348T TWILIO_AUTH_TOKEN="f4a1f2b0b79ea3735078c2d8ee9684e1" diff --git a/.env.local.sample b/.env.local.sample index e47c1f5..166eb33 100644 --- a/.env.local.sample +++ b/.env.local.sample @@ -1,10 +1,11 @@ JWT_SECRET=NULqYIzgt28bTiyziCd7IOO7b6LnWDW! -DB_NAME=textsender_db -DB_USER=textsender -DB_PASSWORD=password -DB_HOST=localhost -DB_PORT=5432 -DB_SSLMODE=disable +DB_MAIN_NAME=textsender_db +DB_MAIN_USER=textsender +DB_MAIN_PASSWORD=password +DB_MAIN_HOST=localhost +DB_MAIN_PORT=5432 +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_SERVICE_SID=9M4J3X8439U398NUVT3342MC349C348T TWILIO_AUTH_TOKEN="f4a1f2b0b79ea3735078c2d8ee9684e1" diff --git a/.gitea/workflows/workflow.yml b/.gitea/workflows/workflow.yml index ba4c8ca..ea96dad 100644 --- a/.gitea/workflows/workflow.yml +++ b/.gitea/workflows/workflow.yml @@ -75,7 +75,7 @@ jobs: # 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' }} 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 SSH_AUTH_SOCK: ${{ env.SSH_AUTH_SOCK }} ENABLE_REGISTRATION: 'TRUE' diff --git a/Cargo.lock b/Cargo.lock index 64b2264..2ec2379 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2295,7 +2295,7 @@ dependencies = [ [[package]] name = "textsender_api" -version = "0.1.3" +version = "0.1.4" dependencies = [ "axum", "axum-extra", @@ -2325,8 +2325,8 @@ dependencies = [ [[package]] name = "textsender_models" -version = "0.3.0" -source = "git+ssh://git@git.kundeng.us/phoenix/textsender_models.git?tag=v0.3.0#d504108745b7b97a02eac24e16763cdb5e731f2b" +version = "0.3.3" +source = "git+ssh://git@git.kundeng.us/phoenix/textsender_models.git?tag=v0.3.3#c09fa36b15601b37f65b8baac0d479c30d2c1e44" dependencies = [ "const_format", "dotenvy", diff --git a/Cargo.toml b/Cargo.toml index eb85782..9462e9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "textsender_api" -version = "0.1.3" +version = "0.1.4" edition = "2024" rust-version = "1.95" @@ -25,7 +25,7 @@ jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] } josekit = { version = "0.10.3" } utoipa = { version = "5.5.0", features = ["axum_extras"] } 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] common-multipart-rfc7578 = { version = "0.7.0" } diff --git a/docker-compose.yml b/docker-compose.yml index 56a3f79..7fe4794 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,7 +19,7 @@ services: - textsender_api-network restart: unless-stopped # Optional: Restart policy - # --- Web API auth --- + # --- Auth API --- auth_api: build: # 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: # - ./path/to/your/web-api-repo:/app - # --- songparser service --- - catapult: - build: - context: ../catapult - ssh: ["default"] - dockerfile: Dockerfile - container_name: catapult - restart: unless-stopped - env_file: - - ../catapult/.env - depends_on: - - api - - main_db - - auth_api - - auth_db - networks: - - textsender_api-network + # --- catapult service --- +# catapult: +# build: +# context: ../catapult +# ssh: ["default"] +# dockerfile: Dockerfile +# container_name: catapult +# restart: unless-stopped +# env_file: +# - ../catapult/.env +# depends_on: +# - api +# - main_db +# - auth_api +# - auth_db +# networks: +# - textsender_api-network # PostgreSQL Database Service @@ -70,9 +70,9 @@ services: container_name: textsender_api_db # Optional: Give the container a specific name environment: # These MUST match the user, password, and database name in the DATABASE_URL above - POSTGRES_USER: ${POSTGRES_MAIN_USER:-textsender_api} - POSTGRES_PASSWORD: ${POSTGRES_MAIN_PASSWORD:-password} - POSTGRES_DB: ${POSTGRES_MAIN_DB:-textsender_api_db} + POSTGRES_USER: ${DB_MAIN_USER:-textsender_api} + POSTGRES_PASSWORD: ${DB_MAIN_PASSWORD:-password} + POSTGRES_DB: ${DB_MAIN_NAME:-textsender_api_db} volumes: # Persist database data using a named volume - postgres_data:/var/lib/postgresql @@ -96,9 +96,9 @@ services: container_name: textsender_api_auth_db # Optional: Give the container a specific name environment: # These MUST match the user, password, and database name in the DATABASE_URL above - POSTGRES_USER: ${POSTGRES_AUTH_USER:-textsender_api_op} - POSTGRES_PASSWORD: ${POSTGRES_AUTH_PASSWORD:-password} - POSTGRES_DB: ${POSTGRES_AUTH_DB:-textsender_api_auth_db} + POSTGRES_USER: ${DB_AUTH_USER:-textsender_auth} + POSTGRES_PASSWORD: ${DB_AUTH_PASSWORD:-password} + POSTGRES_DB: ${DB_AUTH_NAME:-textsender_auth_db} volumes: # Persist database data using a named volume - postgres_data_auth:/var/lib/postgresql diff --git a/migrations/20260613211440_init.sql b/migrations/20260613211440_init.sql new file mode 100644 index 0000000..ef5cc2b --- /dev/null +++ b/migrations/20260613211440_init.sql @@ -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 +); diff --git a/migrations/schema.sql b/migrations/schema.sql index 23587c6..9ca3c39 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -1,49 +1,49 @@ -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - -DROP TABLE IF EXISTS contacts CASCADE; -DROP TABLE IF EXISTS messages CASCADE; -DROP TABLE IF EXISTS scheduled_messages CASCADE; -DROP TABLE IF EXISTS scheduled_message_events CASCADE; -DROP TABLE IF EXISTS message_event_responses CASCADE; - -CREATE TABLE IF NOT EXISTS contacts ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - phone_number TEXT NOT NULL, - user_id UUID NOT NULL, - first_name TEXT NULL, - last_name TEXT NULL, - nickname TEXT NULL -); - -CREATE TABLE IF NOT EXISTS messages ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - content TEXT NOT NULL, - user_id UUID NOT NULL -); - -CREATE TABLE IF NOT EXISTS scheduled_messages ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - scheduled timestamptz NOT NULL, - created timestamptz DEFAULT now(), - status TEXT CHECK (status IN ('PENDING', 'READY', 'PROCESSING', 'DONE')), - user_id UUID NOT NULL -); - -CREATE TABLE IF NOT EXISTS scheduled_message_events ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - contact_id UUID NOT NULL, - message_id UUID NOT NULL, - scheduled_message_id UUID NOT NULL, - created timestamptz DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS message_event_responses ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - scheduled_message_event_id UUID NULL, - response JSONB NOT NULL, - user_id UUID NOT NULL, - sent timestamptz NOT NULL, - contact_id UUID NULL, - message_id UUID NULL, - status TEXT CHECK (status IN ('INSTANT', 'SCHEDULED')) -); +-- CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +-- +-- DROP TABLE IF EXISTS contacts CASCADE; +-- DROP TABLE IF EXISTS messages CASCADE; +-- DROP TABLE IF EXISTS scheduled_messages CASCADE; +-- DROP TABLE IF EXISTS scheduled_message_events CASCADE; +-- DROP TABLE IF EXISTS message_event_responses CASCADE; +-- +-- CREATE TABLE IF NOT EXISTS contacts ( +-- id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), +-- phone_number TEXT NOT NULL, +-- user_id UUID NOT NULL, +-- first_name TEXT NULL, +-- last_name TEXT NULL, +-- nickname TEXT NULL +-- ); +-- +-- CREATE TABLE IF NOT EXISTS messages ( +-- id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), +-- content TEXT NOT NULL, +-- user_id UUID NOT NULL +-- ); +-- +-- CREATE TABLE IF NOT EXISTS scheduled_messages ( +-- id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), +-- scheduled timestamptz NOT NULL, +-- created timestamptz DEFAULT now(), +-- status TEXT CHECK (status IN ('PENDING', 'READY', 'PROCESSING', 'DONE')), +-- user_id UUID NOT NULL +-- ); +-- +-- CREATE TABLE IF NOT EXISTS scheduled_message_events ( +-- id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), +-- contact_id UUID NOT NULL, +-- message_id UUID NOT NULL, +-- scheduled_message_id UUID NOT NULL, +-- created timestamptz DEFAULT now() +-- ); +-- +-- CREATE TABLE IF NOT EXISTS message_event_responses ( +-- id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), +-- scheduled_message_event_id UUID NULL, +-- response JSONB NOT NULL, +-- user_id UUID NOT NULL, +-- sent timestamptz NOT NULL, +-- contact_id UUID NULL, +-- message_id UUID NULL, +-- status TEXT CHECK (status IN ('INSTANT', 'SCHEDULED')) +-- ); diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000..84b764e --- /dev/null +++ b/src/auth/mod.rs @@ -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( + cookie_jar: CookieJar, + req: Request, + next: Next, +) -> Result)> { + 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::( + &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) +} diff --git a/src/caller/mod.rs b/src/caller/mod.rs new file mode 100644 index 0000000..9839b1a --- /dev/null +++ b/src/caller/mod.rs @@ -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, + pub lastname: Option, + pub nickname: Option, + 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, + } + } + + 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, + axum::Json(payload): axum::Json, + ) -> ( + axum::http::StatusCode, + axum::Json, + ) { + 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)) + } + } + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..1bde5a1 --- /dev/null +++ b/src/config/mod.rs @@ -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 = alwd + .into_iter() + .map(|a| a.parse::().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::, + )), + ) + .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) + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..d6fe47d --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,26 @@ +use sqlx::postgres::PgPoolOptions; + +pub mod connection_settings { + pub const MAXCONN: u32 = 10; +} + +pub async fn create_pool() -> Result { + 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"); +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..09f6e1f --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,5 @@ +pub mod auth; +pub mod caller; +pub mod config; +pub mod db; +pub mod repo; diff --git a/src/main.rs b/src/main.rs index 47a4039..a6e5f5f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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:?}") + } + } } diff --git a/src/repo/mod.rs b/src/repo/mod.rs new file mode 100644 index 0000000..7434ddb --- /dev/null +++ b/src/repo/mod.rs @@ -0,0 +1,29 @@ +pub mod contact { + use sqlx::Row; + + pub async fn insert( + pool: &sqlx::PgPool, + contact: &textsender_models::contact::Contact, + ) -> Result { + 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), + } + } +} diff --git a/tests/test.rs b/tests/test.rs new file mode 100644 index 0000000..257fe59 --- /dev/null +++ b/tests/test.rs @@ -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 { + 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 { + 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> { + 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::to_bytes(response.into_body(), std::usize::MAX).await + } + + pub async fn get_resp_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 { + 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 { + 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; +}