Skip to content

Commit

Permalink
refactor(api): [torrust#183] Axum API, user context, registration
Browse files Browse the repository at this point in the history
  • Loading branch information
josecelano committed Jun 13, 2023
1 parent 9f8832b commit 79682a5
Show file tree
Hide file tree
Showing 21 changed files with 266 additions and 95 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/develop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,5 @@ jobs:
run: cargo llvm-cov nextest
- name: E2E Tests
run: ./docker/bin/run-e2e-tests.sh
env:
TORRUST_IDX_BACK_E2E_EXCLUDE_AXUM_IMPL: "true"
6 changes: 6 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,12 @@ impl Configuration {

settings_lock.website.name.clone()
}

pub async fn get_api_base_url(&self) -> Option<String> {
let settings_lock = self.settings.read().await;

settings_lock.net.base_url.clone()
}
}

/// The public backend configuration.
Expand Down
128 changes: 69 additions & 59 deletions src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,49 +146,7 @@ pub struct ErrorToResponse {

impl ResponseError for ServiceError {
fn status_code(&self) -> StatusCode {
#[allow(clippy::match_same_arms)]
match self {
ServiceError::ClosedForRegistration => StatusCode::FORBIDDEN,
ServiceError::EmailInvalid => StatusCode::BAD_REQUEST,
ServiceError::NotAUrl => StatusCode::BAD_REQUEST,
ServiceError::WrongPasswordOrUsername => StatusCode::FORBIDDEN,
ServiceError::UsernameNotFound => StatusCode::NOT_FOUND,
ServiceError::UserNotFound => StatusCode::NOT_FOUND,
ServiceError::AccountNotFound => StatusCode::NOT_FOUND,
ServiceError::ProfanityError => StatusCode::BAD_REQUEST,
ServiceError::BlacklistError => StatusCode::BAD_REQUEST,
ServiceError::UsernameCaseMappedError => StatusCode::BAD_REQUEST,
ServiceError::PasswordTooShort => StatusCode::BAD_REQUEST,
ServiceError::PasswordTooLong => StatusCode::BAD_REQUEST,
ServiceError::PasswordsDontMatch => StatusCode::BAD_REQUEST,
ServiceError::UsernameTaken => StatusCode::BAD_REQUEST,
ServiceError::UsernameInvalid => StatusCode::BAD_REQUEST,
ServiceError::EmailTaken => StatusCode::BAD_REQUEST,
ServiceError::EmailNotVerified => StatusCode::FORBIDDEN,
ServiceError::TokenNotFound => StatusCode::UNAUTHORIZED,
ServiceError::TokenExpired => StatusCode::UNAUTHORIZED,
ServiceError::TokenInvalid => StatusCode::UNAUTHORIZED,
ServiceError::TorrentNotFound => StatusCode::BAD_REQUEST,
ServiceError::InvalidTorrentFile => StatusCode::BAD_REQUEST,
ServiceError::InvalidTorrentPiecesLength => StatusCode::BAD_REQUEST,
ServiceError::InvalidFileType => StatusCode::BAD_REQUEST,
ServiceError::BadRequest => StatusCode::BAD_REQUEST,
ServiceError::InvalidCategory => StatusCode::BAD_REQUEST,
ServiceError::InvalidTag => StatusCode::BAD_REQUEST,
ServiceError::Unauthorized => StatusCode::FORBIDDEN,
ServiceError::InfoHashAlreadyExists => StatusCode::BAD_REQUEST,
ServiceError::TorrentTitleAlreadyExists => StatusCode::BAD_REQUEST,
ServiceError::TrackerOffline => StatusCode::INTERNAL_SERVER_ERROR,
ServiceError::CategoryAlreadyExists => StatusCode::BAD_REQUEST,
ServiceError::TagAlreadyExists => StatusCode::BAD_REQUEST,
ServiceError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
ServiceError::EmailMissing => StatusCode::NOT_FOUND,
ServiceError::FailedToSendVerificationEmail => StatusCode::INTERNAL_SERVER_ERROR,
ServiceError::WhitelistingError => StatusCode::INTERNAL_SERVER_ERROR,
ServiceError::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
ServiceError::CategoryNotFound => StatusCode::NOT_FOUND,
ServiceError::TagNotFound => StatusCode::NOT_FOUND,
}
http_status_code_for_service_error(self)
}

fn error_response(&self) -> HttpResponse {
Expand Down Expand Up @@ -220,22 +178,7 @@ impl From<sqlx::Error> for ServiceError {

impl From<database::Error> for ServiceError {
fn from(e: database::Error) -> Self {
#[allow(clippy::match_same_arms)]
match e {
database::Error::Error => ServiceError::InternalServerError,
database::Error::ErrorWithText(_) => ServiceError::InternalServerError,
database::Error::UsernameTaken => ServiceError::UsernameTaken,
database::Error::EmailTaken => ServiceError::EmailTaken,
database::Error::UserNotFound => ServiceError::UserNotFound,
database::Error::CategoryAlreadyExists => ServiceError::CategoryAlreadyExists,
database::Error::CategoryNotFound => ServiceError::InvalidCategory,
database::Error::TagAlreadyExists => ServiceError::TagAlreadyExists,
database::Error::TagNotFound => ServiceError::InvalidTag,
database::Error::TorrentNotFound => ServiceError::TorrentNotFound,
database::Error::TorrentAlreadyExists => ServiceError::InfoHashAlreadyExists,
database::Error::TorrentTitleAlreadyExists => ServiceError::TorrentTitleAlreadyExists,
database::Error::UnrecognizedDatabaseDriver => ServiceError::InternalServerError,
}
map_database_error_to_service_error(&e)
}
}

Expand Down Expand Up @@ -266,3 +209,70 @@ impl From<serde_json::Error> for ServiceError {
ServiceError::InternalServerError
}
}

#[must_use]
pub fn http_status_code_for_service_error(error: &ServiceError) -> StatusCode {
#[allow(clippy::match_same_arms)]
match error {
ServiceError::ClosedForRegistration => StatusCode::FORBIDDEN,
ServiceError::EmailInvalid => StatusCode::BAD_REQUEST,
ServiceError::NotAUrl => StatusCode::BAD_REQUEST,
ServiceError::WrongPasswordOrUsername => StatusCode::FORBIDDEN,
ServiceError::UsernameNotFound => StatusCode::NOT_FOUND,
ServiceError::UserNotFound => StatusCode::NOT_FOUND,
ServiceError::AccountNotFound => StatusCode::NOT_FOUND,
ServiceError::ProfanityError => StatusCode::BAD_REQUEST,
ServiceError::BlacklistError => StatusCode::BAD_REQUEST,
ServiceError::UsernameCaseMappedError => StatusCode::BAD_REQUEST,
ServiceError::PasswordTooShort => StatusCode::BAD_REQUEST,
ServiceError::PasswordTooLong => StatusCode::BAD_REQUEST,
ServiceError::PasswordsDontMatch => StatusCode::BAD_REQUEST,
ServiceError::UsernameTaken => StatusCode::BAD_REQUEST,
ServiceError::UsernameInvalid => StatusCode::BAD_REQUEST,
ServiceError::EmailTaken => StatusCode::BAD_REQUEST,
ServiceError::EmailNotVerified => StatusCode::FORBIDDEN,
ServiceError::TokenNotFound => StatusCode::UNAUTHORIZED,
ServiceError::TokenExpired => StatusCode::UNAUTHORIZED,
ServiceError::TokenInvalid => StatusCode::UNAUTHORIZED,
ServiceError::TorrentNotFound => StatusCode::BAD_REQUEST,
ServiceError::InvalidTorrentFile => StatusCode::BAD_REQUEST,
ServiceError::InvalidTorrentPiecesLength => StatusCode::BAD_REQUEST,
ServiceError::InvalidFileType => StatusCode::BAD_REQUEST,
ServiceError::BadRequest => StatusCode::BAD_REQUEST,
ServiceError::InvalidCategory => StatusCode::BAD_REQUEST,
ServiceError::InvalidTag => StatusCode::BAD_REQUEST,
ServiceError::Unauthorized => StatusCode::FORBIDDEN,
ServiceError::InfoHashAlreadyExists => StatusCode::BAD_REQUEST,
ServiceError::TorrentTitleAlreadyExists => StatusCode::BAD_REQUEST,
ServiceError::TrackerOffline => StatusCode::INTERNAL_SERVER_ERROR,
ServiceError::CategoryAlreadyExists => StatusCode::BAD_REQUEST,
ServiceError::TagAlreadyExists => StatusCode::BAD_REQUEST,
ServiceError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
ServiceError::EmailMissing => StatusCode::NOT_FOUND,
ServiceError::FailedToSendVerificationEmail => StatusCode::INTERNAL_SERVER_ERROR,
ServiceError::WhitelistingError => StatusCode::INTERNAL_SERVER_ERROR,
ServiceError::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
ServiceError::CategoryNotFound => StatusCode::NOT_FOUND,
ServiceError::TagNotFound => StatusCode::NOT_FOUND,
}
}

#[must_use]
pub fn map_database_error_to_service_error(error: &database::Error) -> ServiceError {
#[allow(clippy::match_same_arms)]
match error {
database::Error::Error => ServiceError::InternalServerError,
database::Error::ErrorWithText(_) => ServiceError::InternalServerError,
database::Error::UsernameTaken => ServiceError::UsernameTaken,
database::Error::EmailTaken => ServiceError::EmailTaken,
database::Error::UserNotFound => ServiceError::UserNotFound,
database::Error::CategoryAlreadyExists => ServiceError::CategoryAlreadyExists,
database::Error::CategoryNotFound => ServiceError::InvalidCategory,
database::Error::TagAlreadyExists => ServiceError::TagAlreadyExists,
database::Error::TagNotFound => ServiceError::InvalidTag,
database::Error::TorrentNotFound => ServiceError::TorrentNotFound,
database::Error::TorrentAlreadyExists => ServiceError::InfoHashAlreadyExists,
database::Error::TorrentTitleAlreadyExists => ServiceError::TorrentTitleAlreadyExists,
database::Error::UnrecognizedDatabaseDriver => ServiceError::InternalServerError,
}
}
13 changes: 3 additions & 10 deletions src/routes/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use crate::common::WebAppData;
use crate::errors::{ServiceError, ServiceResult};
use crate::models::response::{OkResponse, TokenResponse};
use crate::routes::API_VERSION;
use crate::web::api::v1::contexts::user::forms::RegistrationForm;

pub fn init(cfg: &mut web::ServiceConfig) {
cfg.service(
Expand All @@ -26,14 +27,6 @@ pub fn init(cfg: &mut web::ServiceConfig) {
);
}

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct RegistrationForm {
pub username: String,
pub email: Option<String>,
pub password: String,
pub confirm_password: String,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Login {
pub login: String,
Expand All @@ -56,8 +49,8 @@ pub async fn registration_handler(
app_data: WebAppData,
) -> ServiceResult<impl Responder> {
let conn_info = req.connection_info().clone();
// todo: we should add this in the configuration. It does not work is the
// server is behind a reverse proxy.
// todo: check if `base_url` option was define in settings `net->base_url`.
// It should have priority over request headers.
let api_base_url = format!("{}://{}", conn_info.scheme(), conn_info.host());

let _user_id = app_data
Expand Down
2 changes: 1 addition & 1 deletion src/services/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ use crate::errors::ServiceError;
use crate::mailer;
use crate::mailer::VerifyClaims;
use crate::models::user::{UserCompact, UserId, UserProfile};
use crate::routes::user::RegistrationForm;
use crate::utils::regex::validate_email_address;
use crate::web::api::v1::contexts::user::forms::RegistrationForm;

/// Since user email could be optional, we need a way to represent "no email"
/// in the database. This function returns the string that should be used for
Expand Down
16 changes: 5 additions & 11 deletions src/web/api/v1/contexts/about/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,9 @@ use axum::Router;
use super::handlers::{about_page_handler, license_page_handler};
use crate::common::AppData;

/// It adds the routes to the router for the [`about`](crate::web::api::v1::contexts::about) API context.
pub fn add(prefix: &str, router: Router, app_data: Arc<AppData>) -> Router {
router
.route(
&format!("{prefix}/about"),
get(about_page_handler).with_state(app_data.clone()),
)
.route(
&format!("{prefix}/about/license"),
get(license_page_handler).with_state(app_data),
)
/// Routes for the [`about`](crate::web::api::v1::contexts::about) API context.
pub fn router(app_data: Arc<AppData>) -> Router {
Router::new()
.route("/", get(about_page_handler).with_state(app_data.clone()))
.route("/license", get(license_page_handler).with_state(app_data))
}
9 changes: 9 additions & 0 deletions src/web/api/v1/contexts/user/forms.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct RegistrationForm {
pub username: String,
pub email: Option<String>,
pub password: String,
pub confirm_password: String,
}
46 changes: 46 additions & 0 deletions src/web/api/v1/contexts/user/handlers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//! API handlers for the the [`user`](crate::web::api::v1::contexts::user) API
//! context.
use std::sync::Arc;

use axum::extract::{self, Host, State};
use axum::Json;

use super::forms::RegistrationForm;
use super::responses::{self, NewUser};
use crate::common::AppData;
use crate::errors::ServiceError;
use crate::web::api::v1::responses::OkResponse;

/// It handles the registration of a new user.
///
/// # Errors
///
/// It returns an error if the user could not be registered.
#[allow(clippy::unused_async)]
pub async fn registration_handler(
State(app_data): State<Arc<AppData>>,
Host(host_from_header): Host,
extract::Json(registration_form): extract::Json<RegistrationForm>,
) -> Result<Json<OkResponse<NewUser>>, ServiceError> {
let api_base_url = app_data
.cfg
.get_api_base_url()
.await
.unwrap_or(api_base_url(&host_from_header));

match app_data
.registration_service
.register_user(&registration_form, &api_base_url)
.await
{
Ok(user_id) => Ok(responses::added_user(user_id)),
Err(error) => Err(error),
}
}

/// It returns the base API URL without the port. For example: `http://localhost`.
fn api_base_url(host: &str) -> String {
// HTTPS is not supported yet.
// See https://github.com/torrust/torrust-index-backend/issues/131
format!("http://{host}")
}
4 changes: 4 additions & 0 deletions src/web/api/v1/contexts/user/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,3 +243,7 @@
//! **WARNING**: The admin can ban themselves. If they do, they will not be able
//! to unban themselves. The only way to unban themselves is to manually remove
//! the user from the banned user list in the database.
pub mod forms;
pub mod handlers;
pub mod responses;
pub mod routes;
17 changes: 17 additions & 0 deletions src/web/api/v1/contexts/user/responses.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
use axum::Json;
use serde::{Deserialize, Serialize};

use crate::models::user::UserId;
use crate::web::api::v1::responses::OkResponse;

#[derive(Serialize, Deserialize, Debug)]
pub struct NewUser {
pub user_id: UserId,
}

/// Response after successfully creating a new user.
pub fn added_user(user_id: i64) -> Json<OkResponse<NewUser>> {
Json(OkResponse {
data: NewUser { user_id },
})
}
15 changes: 15 additions & 0 deletions src/web/api/v1/contexts/user/routes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//! API routes for the [`user`](crate::web::api::v1::contexts::user) API context.
//!
//! Refer to the [API endpoint documentation](crate::web::api::v1::contexts::user).
use std::sync::Arc;

use axum::routing::post;
use axum::Router;

use super::handlers::registration_handler;
use crate::common::AppData;

/// Routes for the [`user`](crate::web::api::v1::contexts::user) API context.
pub fn router(app_data: Arc<AppData>) -> Router {
Router::new().route("/register", post(registration_handler).with_state(app_data))
}
1 change: 1 addition & 0 deletions src/web/api/v1/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@
//! information.
pub mod auth;
pub mod contexts;
pub mod responses;
pub mod routes;
25 changes: 25 additions & 0 deletions src/web/api/v1/responses.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//! Generic responses for the API.
use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize};

use crate::databases::database;
use crate::errors::{http_status_code_for_service_error, map_database_error_to_service_error, ServiceError};

#[derive(Serialize, Deserialize, Debug)]
pub struct OkResponse<T> {
pub data: T,
}

impl IntoResponse for database::Error {
fn into_response(self) -> Response {
let service_error = map_database_error_to_service_error(&self);

(http_status_code_for_service_error(&service_error), service_error.to_string()).into_response()
}
}

impl IntoResponse for ServiceError {
fn into_response(self) -> Response {
(http_status_code_for_service_error(&self), self.to_string()).into_response()
}
}
14 changes: 5 additions & 9 deletions src/web/api/v1/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,16 @@ use std::sync::Arc;

use axum::Router;

use super::contexts::about;
use super::contexts::{about, user};
use crate::common::AppData;

/// Add all API routes to the router.
#[allow(clippy::needless_pass_by_value)]
pub fn router(app_data: Arc<AppData>) -> Router {
let router = Router::new();
let user_routes = user::routes::router(app_data.clone());
let about_routes = about::routes::router(app_data);

add(router, app_data)
}

/// Add the routes for the v1 API.
fn add(router: Router, app_data: Arc<AppData>) -> Router {
let v1_prefix = "/v1".to_string();
let api_routes = Router::new().nest("/user", user_routes).nest("/about", about_routes);

about::routes::add(&v1_prefix, router, app_data)
Router::new().nest("/v1", api_routes)
}
9 changes: 9 additions & 0 deletions tests/common/contexts/user/asserts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
use crate::common::asserts::assert_json_ok;
use crate::common::contexts::user::responses::AddedUserResponse;
use crate::common::responses::TextResponse;

pub fn assert_added_user_response(response: &TextResponse) {
let _added_user_response: AddedUserResponse = serde_json::from_str(&response.body)
.unwrap_or_else(|_| panic!("response {:#?} should be a AddedUserResponse", response.body));
assert_json_ok(response);
}
Loading

0 comments on commit 79682a5

Please sign in to comment.