first
This commit is contained in:
2257
Cargo.lock
generated
Normal file
2257
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
Executable file
16
Cargo.toml
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "api_rust"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = "0.6"
|
||||||
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
|
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "postgres"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
argon2 = "0.4"
|
||||||
|
jsonwebtoken = "8.1"
|
||||||
|
chrono = "0.4"
|
||||||
|
dotenv = "0.15"
|
||||||
|
tower-http = { version = "0.4", features = ["cors"] }
|
||||||
13
Dockerfile
Normal file
13
Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM rust:latest AS builder
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
COPY . .
|
||||||
|
RUN cargo install cargo-watch
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
WORKDIR /usr/local/bin
|
||||||
|
COPY --from=builder /usr/src/app/target/release/api_rust .
|
||||||
|
EXPOSE 9552
|
||||||
|
CMD ["./api_rust"]
|
||||||
15
Dockerfile.dev
Normal file
15
Dockerfile.dev
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Development stage for Rust API
|
||||||
|
FROM rust:latest AS builder
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
COPY . .
|
||||||
|
RUN cargo install cargo-watch
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
# Runtime stage for development
|
||||||
|
FROM rust:latest
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
COPY --from=builder /usr/src/app/target/release/api_rust /usr/local/bin/api_rust
|
||||||
|
COPY --from=builder /usr/local/cargo/bin/cargo-watch /usr/local/bin/cargo-watch
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 9552
|
||||||
|
CMD ["cargo-watch", "-x", "run"]
|
||||||
5
schema.sql
Executable file
5
schema.sql
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL
|
||||||
|
);
|
||||||
100
src/auth/auth.rs
Executable file
100
src/auth/auth.rs
Executable file
@@ -0,0 +1,100 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{State, Json},
|
||||||
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::post,
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use crate::auth::structs::{AppState, User, RegisterRequest, LoginRequest, Claims};
|
||||||
|
use argon2::{
|
||||||
|
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||||
|
Argon2,
|
||||||
|
};
|
||||||
|
use jsonwebtoken::{encode, Header, EncodingKey};
|
||||||
|
|
||||||
|
// Create auth router
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/auth/register", post(register))
|
||||||
|
.route("/auth/login", post(login))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register endpoint
|
||||||
|
async fn register(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<RegisterRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// Check if username exists
|
||||||
|
let exists = sqlx::query("SELECT 1 FROM users WHERE username = $1")
|
||||||
|
.bind(&payload.username)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if exists.is_some() {
|
||||||
|
return (StatusCode::BAD_REQUEST, "Username already exists").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
let password_hash = argon2
|
||||||
|
.hash_password(payload.password.as_bytes(), &salt)
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Insert user
|
||||||
|
let result = sqlx::query_as::<_, User>(
|
||||||
|
"INSERT INTO users (username, password) VALUES ($1, $2) RETURNING id, username, password",
|
||||||
|
)
|
||||||
|
.bind(&payload.username)
|
||||||
|
.bind(&password_hash)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(user) => (StatusCode::CREATED, Json(user)).into_response(),
|
||||||
|
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Failed to register").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login endpoint
|
||||||
|
async fn login(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<LoginRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// Fetch user
|
||||||
|
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = $1")
|
||||||
|
.bind(&payload.username)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let user = match user {
|
||||||
|
Some(u) => u,
|
||||||
|
None => return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
let parsed_hash = PasswordHash::new(&user.password).unwrap();
|
||||||
|
if Argon2::default()
|
||||||
|
.verify_password(payload.password.as_bytes(), &parsed_hash)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user.username,
|
||||||
|
exp: (chrono::Utc::now() + chrono::Duration::hours(24)).timestamp() as usize,
|
||||||
|
};
|
||||||
|
let token = encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(state.jwt_secret.as_bytes()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
(StatusCode::OK, Json(serde_json::json!({ "token": token }))).into_response()
|
||||||
|
}
|
||||||
2
src/auth/mod.rs
Executable file
2
src/auth/mod.rs
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod structs;
|
||||||
40
src/auth/structs.rs
Executable file
40
src/auth/structs.rs
Executable file
@@ -0,0 +1,40 @@
|
|||||||
|
// src/auth/structs.rs
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: i32,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub created: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub last_login: Option<String>,
|
||||||
|
pub owner: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub db: PgPool,
|
||||||
|
pub jwt_secret: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct RegisterRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Claims {
|
||||||
|
pub sub: String,
|
||||||
|
pub exp: usize,
|
||||||
|
}
|
||||||
0
src/company/company.rs
Normal file
0
src/company/company.rs
Normal file
2
src/company/mod.rs
Normal file
2
src/company/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod company;
|
||||||
|
pub mod structs;
|
||||||
21
src/company/structs.rs
Normal file
21
src/company/structs.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// src/company/struct.rs
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct Company {
|
||||||
|
pub id: i32,
|
||||||
|
pub active: bool,
|
||||||
|
pub created: NaiveDate,
|
||||||
|
pub name: String,
|
||||||
|
pub address: Option<String>,
|
||||||
|
pub town: Option<String>,
|
||||||
|
pub state: Option<String>,
|
||||||
|
pub phone: Option<i64>,
|
||||||
|
pub owner_name: Option<String>,
|
||||||
|
pub owner_phone_number: Option<i64>,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub user_id: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
21
src/data/data.rs
Normal file
21
src/data/data.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::get,
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use crate::auth::structs::AppState;
|
||||||
|
|
||||||
|
// Define the handler for the /user/ endpoint
|
||||||
|
async fn get_user(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
// Placeholder for user data retrieval logic
|
||||||
|
// In a real application, you would query the database using state.db
|
||||||
|
(StatusCode::OK, "User data retrieved successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define the router for the data module
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/user/", get(get_user))
|
||||||
|
}
|
||||||
1
src/data/mod.rs
Normal file
1
src/data/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod data;
|
||||||
10
src/data/struct.rs
Normal file
10
src/data/struct.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// src/data/struct.rs
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct County {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub state: i32,
|
||||||
|
}
|
||||||
59
src/main.rs
Executable file
59
src/main.rs
Executable file
@@ -0,0 +1,59 @@
|
|||||||
|
use axum::{
|
||||||
|
http::{header, Method},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use std::env;
|
||||||
|
use tower_http::cors::{CorsLayer, Any};
|
||||||
|
use crate::auth::structs::AppState;
|
||||||
|
use crate::auth::auth::router as auth_router;
|
||||||
|
use crate::data::data::router as data_router;
|
||||||
|
use crate::state::data::{get_counties_by_state, get_county_by_id};
|
||||||
|
|
||||||
|
mod auth;
|
||||||
|
mod data;
|
||||||
|
mod state;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
// Load environment variables
|
||||||
|
|
||||||
|
dotenv::dotenv().ok();
|
||||||
|
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
|
let jwt_secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||||
|
let frontend_origin = env::var("FRONTEND_ORIGIN").unwrap_or_else(|_| "http://localhost:9551".to_string());
|
||||||
|
|
||||||
|
// Connect to PostgreSQL
|
||||||
|
let db = sqlx::PgPool::connect(&database_url)
|
||||||
|
.await
|
||||||
|
.expect("Failed to connect to database");
|
||||||
|
|
||||||
|
// Create app state
|
||||||
|
let state = AppState { db, jwt_secret };
|
||||||
|
|
||||||
|
// Configure CORS
|
||||||
|
let cors = CorsLayer::new()
|
||||||
|
.allow_origin(frontend_origin.parse::<header::HeaderValue>().unwrap())
|
||||||
|
.allow_methods([Method::GET, Method::POST])
|
||||||
|
.allow_headers(Any);
|
||||||
|
|
||||||
|
// Build router
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/", axum::routing::get(|| async { "API is running" }))
|
||||||
|
.merge(auth_router())
|
||||||
|
.merge(data_router())
|
||||||
|
.route("/state/:state_abbr", axum::routing::get(get_counties_by_state))
|
||||||
|
.route("/state/:state_abbr/:county_id", axum::routing::get(get_county_by_id))
|
||||||
|
.layer(cors)
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
// Print server status
|
||||||
|
// use std::io::Write;
|
||||||
|
println!("Server is running on http://0.0.0.0:9552");
|
||||||
|
// std::io::stdout().flush().unwrap();
|
||||||
|
|
||||||
|
// Run server
|
||||||
|
axum::Server::bind(&"0.0.0.0:9552".parse().unwrap())
|
||||||
|
.serve(app.into_make_service())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
78
src/state/data.rs
Normal file
78
src/state/data.rs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
response::Json,
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use crate::auth::structs::AppState; // Import AppState
|
||||||
|
|
||||||
|
use crate::state::structs::{County, ErrorResponse};
|
||||||
|
|
||||||
|
pub async fn get_counties_by_state(
|
||||||
|
State(app_state): State<AppState>, // Changed from State<PgPool> to State<AppState>
|
||||||
|
Path(state_abbr): Path<String>,
|
||||||
|
) -> Result<Json<Vec<County>>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let state_abbr_upper = state_abbr.to_uppercase(); // Ensure consistent casing for DB query
|
||||||
|
println!("Querying counties for state: {}", state_abbr_upper);
|
||||||
|
|
||||||
|
match sqlx::query_as::<_, County>("SELECT id, name, state FROM county WHERE UPPER(state) = $1 ORDER BY name ASC")
|
||||||
|
.bind(&state_abbr_upper)
|
||||||
|
.fetch_all(&app_state.db) // Use app_state.db to access the pool
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(counties) => {
|
||||||
|
if counties.is_empty() {
|
||||||
|
Err((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("No counties found for state abbreviation: {}", state_abbr_upper),
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(Json(counties))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Database error fetching counties for state {}: {}", state_abbr_upper, e);
|
||||||
|
Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "Failed to retrieve counties. Please try again later.".to_string(),
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_county_by_id(
|
||||||
|
State(app_state): State<AppState>,
|
||||||
|
Path((state_abbr, county_id)): Path<(String, i32)>,
|
||||||
|
) -> Result<Json<County>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let state_abbr_upper = state_abbr.to_uppercase();
|
||||||
|
println!("Querying county with ID: {} for state: {}", county_id, state_abbr_upper);
|
||||||
|
|
||||||
|
match sqlx::query_as::<_, County>("SELECT id, name, state FROM county WHERE UPPER(state) = $1 AND id = $2")
|
||||||
|
.bind(&state_abbr_upper)
|
||||||
|
.bind(county_id)
|
||||||
|
.fetch_one(&app_state.db)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(county) => Ok(Json(county)),
|
||||||
|
Err(sqlx::Error::RowNotFound) => Err((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("County with ID {} not found in state {}", county_id, state_abbr_upper),
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Database error fetching county with ID {} for state {}: {}", county_id, state_abbr_upper, e);
|
||||||
|
Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "Failed to retrieve county. Please try again later.".to_string(),
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/state/mod.rs
Normal file
2
src/state/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod data;
|
||||||
|
pub mod structs;
|
||||||
14
src/state/structs.rs
Normal file
14
src/state/structs.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use sqlx::FromRow;
|
||||||
|
|
||||||
|
#[derive(Serialize, FromRow)]
|
||||||
|
pub struct County {
|
||||||
|
id: i32, // Assuming county table has an integer primary key id
|
||||||
|
name: String,
|
||||||
|
state: String, // This will be the state abbreviation
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user