diff options
author | Toby Vincent <tobyv@tobyvin.dev> | 2024-03-21 21:39:15 -0500 |
---|---|---|
committer | Toby Vincent <tobyv@tobyvin.dev> | 2024-03-21 21:39:15 -0500 |
commit | fd1447999d9665866d65002b2c2317b8b150225f (patch) | |
tree | dce0c5282a60fc27f65a066200fcd8aa86b4370e /src | |
parent | f977dce01be9de61a64b94aab883fb43949234b3 (diff) |
feat: impl user api endpoint
Diffstat (limited to 'src')
-rw-r--r-- | src/config.rs | 31 | ||||
-rw-r--r-- | src/error.rs | 48 | ||||
-rw-r--r-- | src/lib.rs | 3 | ||||
-rw-r--r-- | src/main.rs | 52 | ||||
-rw-r--r-- | src/model.rs | 46 | ||||
-rw-r--r-- | src/routes.rs | 119 | ||||
-rw-r--r-- | src/state.rs | 19 |
7 files changed, 243 insertions, 75 deletions
diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..dc132b8 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,31 @@ +#[derive(Debug, Default, Clone)] +pub struct Config { + pub database_url: String, + pub jwt_secret: String, + pub jwt_expires_in: String, + pub jwt_maxage: i32, +} + +impl Config { + pub fn init() -> Config { + let mut config = Config::default(); + + if let Ok(database_url) = std::env::var("DATABASE_URL") { + config.database_url = database_url; + }; + + if let Ok(jwt_secret) = std::env::var("JWT_SECRET") { + config.jwt_secret = jwt_secret; + }; + + if let Ok(jwt_expires_in) = std::env::var("JWT_EXPIRED_IN") { + config.jwt_expires_in = jwt_expires_in; + }; + + if let Ok(jwt_maxage) = std::env::var("JWT_MAXAGE") { + config.jwt_maxage = jwt_maxage.parse::<i32>().unwrap(); + }; + + config + } +} diff --git a/src/error.rs b/src/error.rs index 1f4b354..a5b48ff 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,26 +1,46 @@ -use axum::{ - http::StatusCode, - response::{IntoResponse, Response}, - Json, -}; - pub type Result<T, E = Error> = std::result::Result<T, E>; #[derive(thiserror::Error, Debug)] pub enum Error { - #[error("IO error: {0}")] + #[error(transparent)] IO(#[from] std::io::Error), - #[error("Axum Error: {0:?}")] + #[error(transparent)] + TaskJoin(#[from] tokio::task::JoinError), + + #[error(transparent)] Axum(#[from] axum::Error), + + #[error(transparent)] + Sqlx(#[from] sqlx::Error), + + #[error(transparent)] + Migration(#[from] sqlx::migrate::MigrateError), + + #[error("User not found: {0}")] + UserNotFound(uuid::Uuid), } -impl IntoResponse for Error { - fn into_response(self) -> Response { - let body = Json(serde_json::json!({ - "error": self.to_string(), - })); +impl axum::response::IntoResponse for Error { + fn into_response(self) -> axum::response::Response { + use axum::{http::StatusCode, Json}; + use serde_json::json; - (StatusCode::INTERNAL_SERVER_ERROR, body).into_response() + match self { + Error::UserNotFound(uuid) => ( + StatusCode::BAD_REQUEST, + Json(json!({ + "status": "fail", + "message": uuid, + })), + ), + err => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "error": err.to_string(), + })), + ), + } + .into_response() } } @@ -1,6 +1,7 @@ pub use error::{Error, Result}; -pub use routes::serve; +pub use routes::router; pub mod error; pub mod routes; pub mod state; +pub mod model; diff --git a/src/main.rs b/src/main.rs index 7b5d3c2..f2b81ae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,18 +1,60 @@ +use tokio::net::TcpListener; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use unnamed_server::state::AppState; + +use crate::config::Config; + +mod config; #[tokio::main] #[tracing::instrument] async fn main() -> Result<(), unnamed_server::Error> { tracing_subscriber::registry() .with( - tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { - "example_tracing_aka_logging=debug,tower_http=debug,axum::rejection=trace".into() - }), + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "unnamed_server=debug".into()), ) .with(tracing_subscriber::fmt::layer()) .init(); - let socket_addr = std::net::SocketAddr::from(([127, 0, 0, 1], 30000)); + let _ = dotenvy::dotenv(); + + let config = Config::init(); + + let state = AppState::new(config.database_url).await?; + + let app = unnamed_server::router(state); + + let listener = TcpListener::bind("127.0.0.1:30000").await?; + + tracing::info!("Server listening on http://{}", listener.local_addr()?); + + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await + .map_err(From::from) +} + +async fn shutdown_signal() { + let ctrl_c = async { + tokio::signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); - unnamed_server::serve(socket_addr).await + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } } diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..9c1bfe6 --- /dev/null +++ b/src/model.rs @@ -0,0 +1,46 @@ +use chrono::prelude::*; +use serde::{Deserialize, Serialize}; + +#[allow(non_snake_case)] +#[derive(Debug, Deserialize, sqlx::FromRow, Serialize, Clone)] +pub struct User { + pub id: uuid::Uuid, + pub name: String, + pub email: String, + pub password: String, + #[serde(rename = "createdAt")] + pub created_at: Option<DateTime<Utc>>, + #[serde(rename = "updatedAt")] + pub updated_at: Option<DateTime<Utc>>, +} + +impl User { + pub fn into_query_response(self) -> axum::Json<serde_json::Value> { + axum::Json(serde_json::json!({ + "status": "success", + "data": serde_json::json!({ + "user": self + }) + })) + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TokenClaims { + pub sub: String, + pub iat: usize, + pub exp: usize, +} + +#[derive(Debug, Deserialize)] +pub struct RegisterUserSchema { + pub name: String, + pub email: String, + pub password: String, +} + +#[derive(Debug, Deserialize)] +pub struct LoginUserSchema { + pub email: String, + pub password: String, +} diff --git a/src/routes.rs b/src/routes.rs index 3625eff..0a81317 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,69 +1,80 @@ -use axum::Router; -use axum_extra::routing::{RouterExt, TypedPath}; -use tokio::{ - net::{TcpListener, ToSocketAddrs}, - signal, +use std::sync::Arc; + +use axum::{ + extract::State, + http::{StatusCode, Uri}, + Json, }; +use axum_extra::routing::{RouterExt, TypedPath}; +use serde::Deserialize; + +use crate::{model::User, state::AppState, Error}; + +pub fn router(state: AppState) -> axum::Router { + axum::Router::new() + // .route("/api/user", get(get_user)) + .typed_get(HealthCheck::get) + .typed_get(UserId::get) + .fallback(fallback) + .with_state(Arc::new(state)) +} + +#[derive(Debug, Deserialize, TypedPath)] +#[typed_path("/api/healthcheck")] +pub struct HealthCheck; -use crate::Error; +impl HealthCheck { + pub async fn get(self) -> Json<serde_json::Value> { + const MESSAGE: &str = "Unnamed server"; -#[derive(TypedPath)] -#[typed_path("/ping")] -pub struct Ping; + let json_response = serde_json::json!({ + "status": "success", + "message": MESSAGE + }); -/// # Test endpoint -/// -/// Returns "pong" -#[tracing::instrument(ret)] -pub async fn ping(_: Ping) -> &'static str { - "pong" + Json(json_response) + } } -#[derive(TypedPath)] -#[typed_path("/")] -pub struct Root; +#[derive(Debug, Deserialize, TypedPath)] +#[typed_path("/api/user/:uuid")] +pub struct UserId { + pub uuid: uuid::Uuid, +} -#[tracing::instrument(ret)] -pub async fn root(_: Root) -> &'static str { - "Hello, World!" +impl UserId { + /// Get a user via their `id` + #[tracing::instrument(ret, skip(data))] + pub async fn get( + self, + State(data): State<Arc<AppState>>, + ) -> Result<Json<serde_json::Value>, Error> { + sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", self.uuid) + .fetch_optional(&data.db_pool) + .await? + .ok_or_else(|| Error::UserNotFound(self.uuid)) + .map(User::into_query_response) + } } -pub async fn serve<A>(addr: A) -> Result<(), Error> -where - A: ToSocketAddrs, -{ - let app = Router::new().typed_get(root).typed_get(ping); +pub async fn fallback(uri: Uri) -> (StatusCode, String) { + (StatusCode::NOT_FOUND, format!("Route not found: {uri}")) +} - let listener = TcpListener::bind(addr).await?; +#[cfg(test)] +mod tests { + use super::*; - tracing::info!("Server listening on http://{}", listener.local_addr()?); + use axum_test::TestServer; - axum::serve(listener, app) - .with_graceful_shutdown(shutdown_signal()) - .await - .map_err(From::from) -} + #[tokio::test] + async fn test_fallback() -> Result<(), Box<dyn std::error::Error>> { + let server = TestServer::new(axum::Router::new().fallback(fallback))?; + + let response = server.get("/fallback").await; + + assert_eq!(StatusCode::NOT_FOUND, response.status_code()); -async fn shutdown_signal() { - let ctrl_c = async { - signal::ctrl_c() - .await - .expect("failed to install Ctrl+C handler"); - }; - - #[cfg(unix)] - let terminate = async { - signal::unix::signal(signal::unix::SignalKind::terminate()) - .expect("failed to install signal handler") - .recv() - .await; - }; - - #[cfg(not(unix))] - let terminate = std::future::pending::<()>(); - - tokio::select! { - _ = ctrl_c => {}, - _ = terminate => {}, + Ok(()) } } diff --git a/src/state.rs b/src/state.rs index 78a6654..efe2192 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1 +1,18 @@ -pub struct AppState; +use sqlx::{postgres::PgPoolOptions, Pool, Postgres}; + +use crate::Error; + +pub struct AppState { + pub db_pool: Pool<Postgres>, +} + +impl AppState { + pub async fn new<S: AsRef<str>>(db_url: S) -> Result<Self, Error> { + let db_pool = PgPoolOptions::new() + .max_connections(10) + .connect(db_url.as_ref()) + .await?; + + Ok(Self { db_pool }) + } +} |