First one (#8)
Reviewed-on: phoenix/textsender_api#8
This commit was merged in pull request #8.
This commit is contained in:
+7
-6
@@ -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"
|
||||
|
||||
+7
-6
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
|
||||
Generated
+3
-3
@@ -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",
|
||||
|
||||
+2
-2
@@ -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" }
|
||||
|
||||
+24
-24
@@ -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
|
||||
|
||||
@@ -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
@@ -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'))
|
||||
-- );
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
+230
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user