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!
|
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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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";
|
-- 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'))
|
||||||
);
|
-- );
|
||||||
|
|||||||
@@ -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() {
|
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:?}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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