Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
463 changes: 463 additions & 0 deletions openapi.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/api/v1/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
pub mod auth;
pub mod projects;
29 changes: 29 additions & 0 deletions src/api/v1/projects/dto.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
use rapina::schemars::{self, JsonSchema};
use serde::{Deserialize, Serialize};

#[derive(Deserialize, JsonSchema)]
pub struct CreateProjectRequest {
pub name: String,
pub slug: String,
pub logo_url: Option<String>,
pub website_url: Option<String>,
}

#[derive(Deserialize, JsonSchema)]
pub struct UpdateProjectRequest {
pub name: Option<String>,
pub slug: Option<String>,
pub logo_url: Option<String>,
pub website_url: Option<String>,
}

#[derive(Serialize, JsonSchema)]
pub struct ProjectResponse {
pub id: String,
pub name: String,
pub slug: String,
pub logo_url: Option<String>,
pub website_url: Option<String>,
pub created_at: String,
pub updated_at: String,
}
53 changes: 53 additions & 0 deletions src/api/v1/projects/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
use rapina::database::DbError;
use rapina::prelude::*;

pub enum ProjectError {
DbError(DbError),
NotFound,
Forbidden,
SlugTaken,
}

impl IntoApiError for ProjectError {
fn into_api_error(self) -> Error {
match self {
ProjectError::DbError(e) => e.into_api_error(),
ProjectError::NotFound => Error::not_found("project not found"),
ProjectError::Forbidden => Error::forbidden("you do not own this project"),
ProjectError::SlugTaken => Error::conflict("slug already taken"),
}
}
}

impl DocumentedError for ProjectError {
fn error_variants() -> Vec<ErrorVariant> {
vec![
ErrorVariant {
status: 404,
code: "NOT_FOUND",
description: "Project not found",
},
ErrorVariant {
status: 403,
code: "FORBIDDEN",
description: "User does not own this project",
},
ErrorVariant {
status: 409,
code: "CONFLICT",
description: "Slug already taken",
},
ErrorVariant {
status: 500,
code: "INTERNAL_ERROR",
description: "Internal server error",
},
]
}
}

impl From<DbError> for ProjectError {
fn from(e: DbError) -> Self {
ProjectError::DbError(e)
}
}
204 changes: 204 additions & 0 deletions src/api/v1/projects/handlers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
use rapina::database::{Db, DbError};
use rapina::prelude::*;
use rapina::sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use uuid::Uuid;

use crate::db::entities::project::{ActiveModel, Column, Entity as Project};
use crate::db::entities::user::{Column as UserColumn, Entity as User};

use super::dto::{CreateProjectRequest, ProjectResponse, UpdateProjectRequest};
use super::error::ProjectError;

fn to_response(p: crate::db::entities::project::Model) -> ProjectResponse {
ProjectResponse {
id: p.pid.to_string(),
name: p.name,
slug: p.slug,
logo_url: p.logo_url,
website_url: p.website_url,
created_at: p.created_at.to_rfc3339(),
updated_at: p.updated_at.to_rfc3339(),
}
}

async fn resolve_user_id(db: &Db, current_user: &CurrentUser) -> Result<i32> {
let pid = Uuid::parse_str(&current_user.id)
.map_err(|_| Error::unauthorized("invalid user id in token"))?;

let user = User::find()
.filter(UserColumn::Pid.eq(pid))
.one(db.conn())
.await
.map_err(DbError)?
.ok_or_else(|| Error::unauthorized("user not found"))?;

Ok(user.id)
}

#[get("/api/v1/projects")]
#[errors(ProjectError)]
pub async fn list_projects(
db: Db,
current_user: CurrentUser,
) -> Result<Json<Vec<ProjectResponse>>> {
let user_id = resolve_user_id(&db, &current_user).await?;

let projects = Project::find()
.filter(Column::UserId.eq(user_id))
.all(db.conn())
.await
.map_err(DbError)?;

let response: Vec<ProjectResponse> = projects.into_iter().map(to_response).collect();
Ok(Json(response))
}

#[post("/api/v1/projects")]
#[errors(ProjectError)]
pub async fn create_project(
db: Db,
current_user: CurrentUser,
body: Json<CreateProjectRequest>,
) -> Result<(StatusCode, Json<ProjectResponse>)> {
let user_id = resolve_user_id(&db, &current_user).await?;
let req = body.into_inner();

let existing = Project::find()
.filter(Column::Slug.eq(&req.slug))
.one(db.conn())
.await
.map_err(DbError)?;

if existing.is_some() {
return Err(ProjectError::SlugTaken.into_api_error());
}

let pid = Uuid::new_v4();

let new_project = ActiveModel {
pid: Set(pid),
user_id: Set(user_id),
name: Set(req.name),
slug: Set(req.slug),
logo_url: Set(req.logo_url),
website_url: Set(req.website_url),
..Default::default()
};

let project = new_project.insert(db.conn()).await.map_err(DbError)?;

Ok((StatusCode::CREATED, Json(to_response(project))))
}

#[get("/api/v1/projects/:id")]
#[errors(ProjectError)]
pub async fn get_project(
id: Path<String>,
db: Db,
current_user: CurrentUser,
) -> Result<Json<ProjectResponse>> {
let user_id = resolve_user_id(&db, &current_user).await?;
let pid =
Uuid::parse_str(&id.into_inner()).map_err(|_| ProjectError::NotFound.into_api_error())?;

let project = Project::find()
.filter(Column::Pid.eq(pid))
.one(db.conn())
.await
.map_err(DbError)?
.ok_or_else(|| ProjectError::NotFound.into_api_error())?;

if project.user_id != user_id {
return Err(ProjectError::Forbidden.into_api_error());
}

Ok(Json(to_response(project)))
}

#[put("/api/v1/projects/:id")]
#[errors(ProjectError)]
pub async fn update_project(
id: Path<String>,
db: Db,
current_user: CurrentUser,
body: Json<UpdateProjectRequest>,
) -> Result<Json<ProjectResponse>> {
let user_id = resolve_user_id(&db, &current_user).await?;
let pid =
Uuid::parse_str(&id.into_inner()).map_err(|_| ProjectError::NotFound.into_api_error())?;

let project = Project::find()
.filter(Column::Pid.eq(pid))
.one(db.conn())
.await
.map_err(DbError)?
.ok_or_else(|| ProjectError::NotFound.into_api_error())?;

if project.user_id != user_id {
return Err(ProjectError::Forbidden.into_api_error());
}

let req = body.into_inner();

if let Some(ref slug) = req.slug {
let slug_taken = Project::find()
.filter(Column::Slug.eq(slug))
.filter(Column::Id.ne(project.id))
.one(db.conn())
.await
.map_err(DbError)?;

if slug_taken.is_some() {
return Err(ProjectError::SlugTaken.into_api_error());
}
}

let mut active: ActiveModel = project.into();

if let Some(name) = req.name {
active.name = Set(name);
}
if let Some(slug) = req.slug {
active.slug = Set(slug);
}
if let Some(logo_url) = req.logo_url {
active.logo_url = Set(Some(logo_url));
}
if let Some(website_url) = req.website_url {
active.website_url = Set(Some(website_url));
}

let updated = active.update(db.conn()).await.map_err(DbError)?;

Ok(Json(to_response(updated)))
}

#[delete("/api/v1/projects/:id")]
#[errors(ProjectError)]
pub async fn delete_project(
id: Path<String>,
db: Db,
current_user: CurrentUser,
) -> Result<StatusCode> {
let user_id = resolve_user_id(&db, &current_user).await?;
let pid =
Uuid::parse_str(&id.into_inner()).map_err(|_| ProjectError::NotFound.into_api_error())?;

let project = Project::find()
.filter(Column::Pid.eq(pid))
.one(db.conn())
.await
.map_err(DbError)?
.ok_or_else(|| ProjectError::NotFound.into_api_error())?;

if project.user_id != user_id {
return Err(ProjectError::Forbidden.into_api_error());
}

Project::delete_by_id(project.id)
.exec(db.conn())
.await
.map_err(DbError)?;

Ok(StatusCode::NO_CONTENT)
}
15 changes: 15 additions & 0 deletions src/api/v1/projects/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
pub mod dto;
pub mod error;
pub mod handlers;

use handlers::*;
use rapina::prelude::*;

pub fn routes() -> Router {
Router::new()
.get("/", list_projects)
.post("/", create_project)
.get("/:id", get_project)
.put("/:id", update_project)
.delete("/:id", delete_project)
}
1 change: 1 addition & 0 deletions src/db/entities/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
pub mod project;
pub mod user;
25 changes: 25 additions & 0 deletions src/db/entities/project.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
use rapina::sea_orm;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "projects")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub pid: Uuid,
pub user_id: i32,
pub name: String,
#[sea_orm(unique)]
pub slug: String,
pub logo_url: Option<String>,
pub website_url: Option<String>,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}
4 changes: 3 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use rapina::prelude::*;
use rapina::schemars;

use reeverb::api::v1::auth;
use reeverb::api::v1::projects;

#[derive(Clone, Config)]
struct AppConfig {
Expand Down Expand Up @@ -43,7 +44,8 @@ async fn main() -> std::io::Result<()> {

let router = Router::new()
.get("/health", health)
.group("/api/v1/auth", auth::routes());
.group("/api/v1/auth", auth::routes())
.group("/api/v1/projects", projects::routes());

let mut app = Rapina::new()
.with_tracing(TracingConfig::new())
Expand Down
Loading