diff options
author | Santo Cariotti <santo@dcariotti.me> | 2021-03-16 11:22:42 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-03-16 11:22:42 +0100 |
commit | b950072a3109d2c13881611a3950baa191caf097 (patch) | |
tree | ccfc5c2c26c56a496d0f34b3f4db0965c713e7bb /src | |
parent | 48a9ac895b6e8b01622810ec4bf2f3a423426ca3 (diff) | |
parent | 6350610ef5f7d73680853d39898094f2bf15febb (diff) |
Merge pull request #11 from gico-net/feat/add-repositories
Add CRUD for repository
Diffstat (limited to 'src')
-rw-r--r-- | src/db.rs | 9 | ||||
-rw-r--r-- | src/errors.rs | 84 | ||||
-rw-r--r-- | src/helpers.rs | 26 | ||||
-rw-r--r-- | src/main.rs | 17 | ||||
-rw-r--r-- | src/repository/mod.rs | 2 | ||||
-rw-r--r-- | src/repository/models.rs | 199 | ||||
-rw-r--r-- | src/repository/routes.rs | 114 |
7 files changed, 442 insertions, 9 deletions
diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..5367288 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,9 @@ +use crate::errors::AppError; +use deadpool_postgres::{Client, Pool, PoolError}; + +/// Return a valid `Client` to make SQL queries +pub async fn get_client(pool: Pool) -> Result<Client, AppError> { + pool.get() + .await + .map_err(|err: PoolError| AppError::from(err)) +} diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..8140cfe --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,84 @@ +use actix_web::{error::ResponseError, http::StatusCode, HttpResponse}; +use deadpool_postgres::PoolError; +use serde::Serialize; +use std::fmt; +use tokio_postgres::error::Error; + +#[derive(Debug)] +pub enum AppErrorType { + DbError, + NotFoundError, + AuthorizationError, +} + +#[derive(Debug)] +pub struct AppError { + pub message: Option<String>, + pub cause: Option<String>, + pub error_type: AppErrorType, +} + +impl AppError { + pub fn message(&self) -> String { + match &*self { + AppError { + message: Some(message), + .. + } => message.clone(), + AppError { + message: None, + error_type: AppErrorType::NotFoundError, + .. + } => "The requested item was not found".to_string(), + _ => "An unexpected error has occurred".to_string(), + } + } +} + +impl From<PoolError> for AppError { + fn from(error: PoolError) -> AppError { + AppError { + message: None, + cause: Some(error.to_string()), + error_type: AppErrorType::DbError, + } + } +} + +impl From<Error> for AppError { + fn from(error: Error) -> AppError { + AppError { + message: None, + cause: Some(error.to_string()), + error_type: AppErrorType::DbError, + } + } +} + +impl fmt::Display for AppError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { + write!(f, "{:?}", self) + } +} + +#[derive(Serialize)] +pub struct AppErrorResponse { + pub detail: String, +} + +impl ResponseError for AppError { + fn status_code(&self) -> StatusCode { + match self.error_type { + AppErrorType::DbError => StatusCode::INTERNAL_SERVER_ERROR, + AppErrorType::NotFoundError => StatusCode::NOT_FOUND, + AppErrorType::AuthorizationError => StatusCode::UNAUTHORIZED, + } + } + + /// Returns a JSON response with "detail" as key + fn error_response(&self) -> HttpResponse { + HttpResponse::build(self.status_code()).json(AppErrorResponse { + detail: self.message(), + }) + } +} diff --git a/src/helpers.rs b/src/helpers.rs new file mode 100644 index 0000000..2dbacfd --- /dev/null +++ b/src/helpers.rs @@ -0,0 +1,26 @@ +use regex::Regex; +use uuid::Uuid; + +/// Returns a valid Uuid if `id` is not a valid Uuid +pub fn uuid_from_string(id: &String) -> Uuid { + return match Uuid::parse_str(&id) { + Ok(x) => x, + Err(_) => Uuid::parse_str("00000000000000000000000000000000").unwrap(), + }; +} + +/// Check if a path is into the "valid git repositories" and returns the name +pub fn name_of_git_repository(url: &String) -> Option<String> { + const GITHUB_RE: &str = r"^(http(s)?://)?(www.)?github.com/(?P<username>[a-zA-Z0-9-]+)/(?P<repository>[a-zA-Z0-9-]+)"; + let re = Regex::new(GITHUB_RE).unwrap(); + + if !re.is_match(&url) { + return None; + } + + let captures = re.captures(&url).unwrap(); + let name = captures.name("username").unwrap().as_str(); + let repo = captures.name("repository").unwrap().as_str(); + + Some(format!("{}/{}", name, repo)) +} diff --git a/src/main.rs b/src/main.rs index 0b3d67f..2c5532a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,17 +1,16 @@ mod config; +mod db; +mod errors; +mod helpers; -use actix_web::{middleware, web, App, HttpServer}; +mod repository; + +use actix_web::{middleware, App, HttpServer}; use dotenv::dotenv; use slog::info; use tokio_postgres::NoTls; -use crate::config::{Config, AppState}; - -async fn index(state: web::Data<AppState>) -> &'static str { - info!(state.log, "GET `/` page"); - - "Hello from Rust!" -} +use crate::config::{AppState, Config}; #[actix_rt::main] async fn main() -> std::io::Result<()> { @@ -35,7 +34,7 @@ async fn main() -> std::io::Result<()> { log: log.clone(), }) .wrap(middleware::Logger::default()) - .route("/", web::get().to(index)) + .configure(repository::routes::config) }) .bind(format!("{}:{}", config.server.host, config.server.port))? .run() diff --git a/src/repository/mod.rs b/src/repository/mod.rs new file mode 100644 index 0000000..a0e1883 --- /dev/null +++ b/src/repository/mod.rs @@ -0,0 +1,2 @@ +pub mod models; +pub mod routes; diff --git a/src/repository/models.rs b/src/repository/models.rs new file mode 100644 index 0000000..1cbf3bb --- /dev/null +++ b/src/repository/models.rs @@ -0,0 +1,199 @@ +use crate::db::get_client; +use crate::errors::{AppError, AppErrorType}; +use crate::helpers::name_of_git_repository; + +use chrono::NaiveDateTime; +use deadpool_postgres::{Client, Pool}; +use serde::{Deserialize, Serialize}; +use tokio_pg_mapper::FromTokioPostgresRow; +use tokio_pg_mapper_derive::PostgresMapper; +use uuid::Uuid; + +use std::net::SocketAddr; + +#[derive(Serialize, Deserialize, PostgresMapper)] +#[pg_mapper(table = "repository")] +/// Repository model +pub struct Repository { + pub id: Uuid, + pub url: String, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub uploader_ip: String, +} + +/// Struct used to create a new repository +#[derive(Serialize, Deserialize)] +pub struct RepositoryData { + pub url: String, +} + +impl Repository { + /// Find all repositories inside the database. + /// Make a select query and order the repositories by descrescent updated + /// datetime + pub async fn find_all(pool: Pool) -> Result<Vec<Repository>, AppError> { + let client = get_client(pool.clone()).await.unwrap(); + let statement = client + .prepare("SELECT * FROM repository ORDER BY updated_at DESC") + .await?; + + let repos = client + .query(&statement, &[]) + .await? + .iter() + .map(|row| Repository::from_row_ref(row).unwrap()) + .collect::<Vec<Repository>>(); + + Ok(repos) + } + + /// Find a repository with an `id` equals to an Uuid element + pub async fn find(pool: Pool, id: &Uuid) -> Result<Repository, AppError> { + let client = get_client(pool.clone()).await.unwrap(); + let statement = client + .prepare("SELECT * FROM repository WHERE id = $1") + .await?; + + let repo = client + .query_opt(&statement, &[&id]) + .await? + .map(|row| Repository::from_row_ref(&row).unwrap()); + + match repo { + Some(repo) => Ok(repo), + None => Err(AppError { + error_type: AppErrorType::NotFoundError, + cause: None, + message: Some("Repository not found".to_string()), + }), + } + } + + /// Find a repository and delete it, but before check if "Authorization" + /// matches with SECRET_KEY + pub async fn delete( + pool: Pool, + id: &Uuid, + ) -> Result<Repository, AppError> { + let client = get_client(pool.clone()).await.unwrap(); + let statement = client + .prepare( + " + DELETE FROM repository + WHERE id=$1 + RETURNING * + ", + ) + .await?; + + let repo = client + .query_opt(&statement, &[&id]) + .await? + .map(|row| Repository::from_row_ref(&row).unwrap()); + + match repo { + Some(repo) => Ok(repo), + None => Err(AppError { + error_type: AppErrorType::NotFoundError, + cause: None, + message: Some("Repository not found".to_string()), + }), + } + } + + /// Search a repository by its url + async fn search( + client: &Client, + url: String, + ) -> Result<Repository, AppError> { + let statement = client + .prepare("SELECT * FROM repository WHERE url=$1") + .await?; + + let repo = client + .query_opt(&statement, &[&url]) + .await? + .map(|row| Repository::from_row_ref(&row).unwrap()); + + match repo { + Some(repo) => Ok(repo), + None => Err(AppError { + error_type: AppErrorType::NotFoundError, + cause: None, + message: Some("Repository not found".to_string()), + }), + } + } + + /// Create a new repository. It uses RepositoryData as support struct + pub async fn create( + pool: Pool, + data: &RepositoryData, + uploader_ip: Option<SocketAddr>, + ) -> Result<Repository, AppError> { + let client = get_client(pool.clone()).await.unwrap(); + + let repo_name: String = match name_of_git_repository(&data.url) { + Some(path) => path, + None => { + return Err(AppError { + message: Some("Repository not found".to_string()), + cause: Some("".to_string()), + error_type: AppErrorType::NotFoundError, + }); + } + }; + + // Search a repository that matches with that url, because if it's + // exists, the server do not create a clone + let repo_search = Repository::search(&client, repo_name.clone()).await; + match repo_search { + Ok(_) => { + return Err(AppError { + message: Some("Repository already exists".to_string()), + cause: Some("".to_string()), + error_type: AppErrorType::AuthorizationError, + }); + } + Err(_) => {} + }; + + let statement = client + .prepare(" + INSERT INTO repository(id, url, uploader_ip) VALUES($1, $2, $3) RETURNING * + ").await?; + + // Create a new UUID v4 + let uuid = Uuid::new_v4(); + + // Match the uploader ip + let user_ip = match uploader_ip { + Some(ip) => ip.to_string(), + None => { + return Err(AppError { + message: Some("Failed to fetch uploader ip".to_string()), + cause: Some("".to_string()), + error_type: AppErrorType::AuthorizationError, + }) + } + }; + + let repo = client + .query(&statement, &[&uuid, &repo_name, &user_ip]) + .await? + .iter() + .map(|row| Repository::from_row_ref(row).unwrap()) + .collect::<Vec<Repository>>() + .pop(); + + match repo { + Some(repo) => Ok(repo), + None => Err(AppError { + message: Some("Error creating a new repository".to_string()), + cause: Some("Unknown error".to_string()), + error_type: AppErrorType::DbError, + }), + } + } +} diff --git a/src/repository/routes.rs b/src/repository/routes.rs new file mode 100644 index 0000000..a0f4db5 --- /dev/null +++ b/src/repository/routes.rs @@ -0,0 +1,114 @@ +use crate::config::AppState; +use crate::errors::{AppError, AppErrorResponse, AppErrorType}; +use crate::helpers::uuid_from_string; +use crate::repository::models::{Repository, RepositoryData}; +use actix_web::http::header; +use actix_web::{web, HttpRequest, HttpResponse, Responder}; +use slog::info; +use std::env; +use uuid::Uuid; + +/// Endpoint used for retrieve all repositories +async fn index(state: web::Data<AppState>) -> impl Responder { + let result = Repository::find_all(state.pool.clone()).await; + info!(state.log, "GET /repo/"); + + // If raises an `Err`, returns an error in JSON format + match result { + Ok(repos) => HttpResponse::Ok().json(repos), + _ => HttpResponse::BadRequest().json(AppErrorResponse { + detail: "Error trying to read all repositories from database" + .to_string(), + }), + } +} + +/// Endpoint used for retrieve a repository that matches with an `id`. +/// It is a String, casted in an Uuid format. +async fn get_repo( + state: web::Data<AppState>, + id: web::Path<(String,)>, +) -> impl Responder { + // I have to match the &id.0 because if it's not a valid Uuid, the server + // must response "Repository not found". + // If I pass a not valid Uuid to Repository::find() it raises an error. + let uuid: Uuid = uuid_from_string(&id.0); + + let result = Repository::find(state.pool.clone(), &uuid).await; + info!(state.log, "GET /repo/{}/", id.0); + + // `map_err` is also used when repo is not found + result + .map(|repo| HttpResponse::Ok().json(repo)) + .map_err(|e| e) +} + +/// Endpoint used for delete repository. +/// It uses a SECRET_KEY used like an API key +async fn delete_repo( + req: HttpRequest, + state: web::Data<AppState>, + id: web::Path<(String,)>, +) -> impl Responder { + let uuid: Uuid = uuid_from_string(&id.0); + match req.headers().get(header::AUTHORIZATION) { + Some(x) + if x.to_str().unwrap() + != env::var("SECRET_KEY").unwrap_or("".to_string()) => + { + info!(state.log, "DELETE /repo/{}/ 401", id.0); + return Err(AppError { + error_type: AppErrorType::AuthorizationError, + message: Some( + "You must provide a valid Authorization".to_string(), + ), + cause: None, + }); + } + Some(_) => {} + None => { + info!(state.log, "DELETE /repo/{}/ 400", id.0); + return Ok(HttpResponse::BadRequest().body("")); + } + }; + + let result = Repository::delete(state.pool.clone(), &uuid).await; + info!(state.log, "DELETE /repo/{}/", id.0); + + result + .map(|_| HttpResponse::NoContent().body("")) + .map_err(|e| e) +} + +async fn create_repo( + req: HttpRequest, + payload: web::Json<RepositoryData>, + state: web::Data<AppState>, +) -> impl Responder { + info!(state.log, "POST /repo/"); + let request_from_ip = HttpRequest::peer_addr(&req); + let result = + Repository::create(state.pool.clone(), &payload, request_from_ip) + .await; + + result + .map(|repo| HttpResponse::Ok().json(repo)) + .map_err(|e| e) +} + +/// Routes for repository. TODO: create endpoint for UPDATE method +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/repo") + .service( + web::resource("{_:/?}") + .route(web::get().to(index)) + .route(web::post().to(create_repo)), + ) + .service( + web::resource("/{id}{_:/?}") + .route(web::get().to(get_repo)) + .route(web::delete().to(delete_repo)), + ), + ); +} |