From 5dd6bf96b7c6b67631905612af22a01183fa9bfa Mon Sep 17 00:00:00 2001 From: arferreira Date: Wed, 18 Feb 2026 17:25:51 -0500 Subject: [PATCH 1/2] Add projects CRUD with ownership enforcement and integration tests --- src/api/v1/mod.rs | 1 + src/api/v1/projects/dto.rs | 29 +++ src/api/v1/projects/error.rs | 53 ++++++ src/api/v1/projects/handlers.rs | 204 +++++++++++++++++++++ src/api/v1/projects/mod.rs | 15 ++ src/db/entities/mod.rs | 1 + src/db/entities/project.rs | 25 +++ src/main.rs | 4 +- tests/projects.rs | 313 ++++++++++++++++++++++++++++++++ 9 files changed, 644 insertions(+), 1 deletion(-) create mode 100644 src/api/v1/projects/dto.rs create mode 100644 src/api/v1/projects/error.rs create mode 100644 src/api/v1/projects/handlers.rs create mode 100644 src/api/v1/projects/mod.rs create mode 100644 src/db/entities/project.rs create mode 100644 tests/projects.rs diff --git a/src/api/v1/mod.rs b/src/api/v1/mod.rs index 0e4a05d..f37035f 100644 --- a/src/api/v1/mod.rs +++ b/src/api/v1/mod.rs @@ -1 +1,2 @@ pub mod auth; +pub mod projects; diff --git a/src/api/v1/projects/dto.rs b/src/api/v1/projects/dto.rs new file mode 100644 index 0000000..7fcc5ea --- /dev/null +++ b/src/api/v1/projects/dto.rs @@ -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, + pub website_url: Option, +} + +#[derive(Deserialize, JsonSchema)] +pub struct UpdateProjectRequest { + pub name: Option, + pub slug: Option, + pub logo_url: Option, + pub website_url: Option, +} + +#[derive(Serialize, JsonSchema)] +pub struct ProjectResponse { + pub id: String, + pub name: String, + pub slug: String, + pub logo_url: Option, + pub website_url: Option, + pub created_at: String, + pub updated_at: String, +} diff --git a/src/api/v1/projects/error.rs b/src/api/v1/projects/error.rs new file mode 100644 index 0000000..96c4690 --- /dev/null +++ b/src/api/v1/projects/error.rs @@ -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 { + 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 for ProjectError { + fn from(e: DbError) -> Self { + ProjectError::DbError(e) + } +} diff --git a/src/api/v1/projects/handlers.rs b/src/api/v1/projects/handlers.rs new file mode 100644 index 0000000..579cebe --- /dev/null +++ b/src/api/v1/projects/handlers.rs @@ -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 { + let pid = Uuid::parse_str(¤t_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>> { + let user_id = resolve_user_id(&db, ¤t_user).await?; + + let projects = Project::find() + .filter(Column::UserId.eq(user_id)) + .all(db.conn()) + .await + .map_err(DbError)?; + + let response: Vec = 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, +) -> Result<(StatusCode, Json)> { + let user_id = resolve_user_id(&db, ¤t_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, + db: Db, + current_user: CurrentUser, +) -> Result> { + let user_id = resolve_user_id(&db, ¤t_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, + db: Db, + current_user: CurrentUser, + body: Json, +) -> Result> { + let user_id = resolve_user_id(&db, ¤t_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, + db: Db, + current_user: CurrentUser, +) -> Result { + let user_id = resolve_user_id(&db, ¤t_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) +} diff --git a/src/api/v1/projects/mod.rs b/src/api/v1/projects/mod.rs new file mode 100644 index 0000000..330f121 --- /dev/null +++ b/src/api/v1/projects/mod.rs @@ -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) +} diff --git a/src/db/entities/mod.rs b/src/db/entities/mod.rs index 22d12a3..f4c2c0f 100644 --- a/src/db/entities/mod.rs +++ b/src/db/entities/mod.rs @@ -1 +1,2 @@ +pub mod project; pub mod user; diff --git a/src/db/entities/project.rs b/src/db/entities/project.rs new file mode 100644 index 0000000..5e3dd93 --- /dev/null +++ b/src/db/entities/project.rs @@ -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, + pub website_url: Option, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/main.rs b/src/main.rs index 8d3473f..e06a8d1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 { @@ -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()) diff --git a/tests/projects.rs b/tests/projects.rs new file mode 100644 index 0000000..723d75d --- /dev/null +++ b/tests/projects.rs @@ -0,0 +1,313 @@ +use rapina::auth::{AuthMiddleware, PublicRoutes}; +use rapina::database::DatabaseConfig; +use rapina::prelude::*; +use rapina::testing::TestClient; +use serde_json::json; +use tokio::sync::OnceCell; +use uuid::Uuid; + +use reeverb::api::v1::auth; +use reeverb::api::v1::projects; +use reeverb::db::migrations::Migrator; + +static MIGRATIONS: OnceCell<()> = OnceCell::const_new(); + +fn database_url() -> String { + dotenvy::dotenv().ok(); + std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for integration tests") +} + +async fn run_migrations_once() { + MIGRATIONS + .get_or_init(|| async { + let config = DatabaseConfig::new(database_url()); + let conn = config.connect().await.expect("failed to connect"); + + use rapina::sea_orm_migration::MigratorTrait; + Migrator::up(&conn, None) + .await + .expect("failed to run migrations"); + }) + .await; +} + +async fn setup() -> TestClient { + run_migrations_once().await; + + let auth_config = AuthConfig::new("test-secret", 3600); + + let mut public_routes = PublicRoutes::new(); + for (method, path) in auth::PUBLIC_ROUTES { + public_routes.add(method, path); + } + + let auth_middleware = AuthMiddleware::with_public_routes(auth_config.clone(), public_routes); + + let router = Router::new() + .group("/api/v1/auth", auth::routes()) + .group("/api/v1/projects", projects::routes()); + + let app = Rapina::new() + .with_introspection(false) + .state(auth_config) + .middleware(auth_middleware) + .with_database(DatabaseConfig::new(database_url())) + .await + .expect("failed to connect to test database") + .router(router); + + TestClient::new(app).await +} + +fn unique_email() -> String { + format!("test-{}@example.com", Uuid::new_v4()) +} + +async fn register_and_get_token(client: &TestClient) -> String { + let email = unique_email(); + let res = client + .post("/api/v1/auth/register") + .json(&json!({ + "email": email, + "password": "password123", + "name": "Test User" + })) + .send() + .await; + + let body: serde_json::Value = res.json(); + body["token"].as_str().unwrap().to_string() +} + +fn unique_slug() -> String { + format!("project-{}", Uuid::new_v4()) +} + +#[tokio::test] +async fn create_project_returns_201() { + let client = setup().await; + let token = register_and_get_token(&client).await; + let slug = unique_slug(); + + let res = client + .post("/api/v1/projects") + .header("Authorization", &format!("Bearer {}", token)) + .json(&json!({ + "name": "My Project", + "slug": slug, + "website_url": "https://example.com" + })) + .send() + .await; + + assert_eq!(res.status(), StatusCode::CREATED); + + let body: serde_json::Value = res.json(); + assert_eq!(body["name"], "My Project"); + assert_eq!(body["slug"], slug); + assert_eq!(body["website_url"], "https://example.com"); + assert!(body["id"].is_string()); + assert!(body["created_at"].is_string()); +} + +#[tokio::test] +async fn list_projects_returns_only_own() { + let client = setup().await; + let token_a = register_and_get_token(&client).await; + let token_b = register_and_get_token(&client).await; + + let slug_a = unique_slug(); + let slug_b = unique_slug(); + + client + .post("/api/v1/projects") + .header("Authorization", &format!("Bearer {}", token_a)) + .json(&json!({ "name": "Project A", "slug": slug_a })) + .send() + .await; + + client + .post("/api/v1/projects") + .header("Authorization", &format!("Bearer {}", token_b)) + .json(&json!({ "name": "Project B", "slug": slug_b })) + .send() + .await; + + let res = client + .get("/api/v1/projects") + .header("Authorization", &format!("Bearer {}", token_a)) + .send() + .await; + + assert_eq!(res.status(), StatusCode::OK); + + let body: Vec = res.json(); + assert!(body.iter().all(|p| p["slug"] != slug_b)); + assert!(body.iter().any(|p| p["slug"] == slug_a)); +} + +#[tokio::test] +async fn get_project_by_pid() { + let client = setup().await; + let token = register_and_get_token(&client).await; + let slug = unique_slug(); + + let res = client + .post("/api/v1/projects") + .header("Authorization", &format!("Bearer {}", token)) + .json(&json!({ "name": "Get Test", "slug": slug })) + .send() + .await; + + let created: serde_json::Value = res.json(); + let pid = created["id"].as_str().unwrap(); + + let res = client + .get(&format!("/api/v1/projects/{}", pid)) + .header("Authorization", &format!("Bearer {}", token)) + .send() + .await; + + assert_eq!(res.status(), StatusCode::OK); + + let body: serde_json::Value = res.json(); + assert_eq!(body["id"], pid); + assert_eq!(body["name"], "Get Test"); +} + +#[tokio::test] +async fn update_project_partial() { + let client = setup().await; + let token = register_and_get_token(&client).await; + let slug = unique_slug(); + + let res = client + .post("/api/v1/projects") + .header("Authorization", &format!("Bearer {}", token)) + .json(&json!({ "name": "Before", "slug": slug })) + .send() + .await; + + let created: serde_json::Value = res.json(); + let pid = created["id"].as_str().unwrap(); + + let res = client + .put(&format!("/api/v1/projects/{}", pid)) + .header("Authorization", &format!("Bearer {}", token)) + .json(&json!({ "name": "After" })) + .send() + .await; + + assert_eq!(res.status(), StatusCode::OK); + + let body: serde_json::Value = res.json(); + assert_eq!(body["name"], "After"); + assert_eq!(body["slug"], slug); +} + +#[tokio::test] +async fn delete_project_returns_204() { + let client = setup().await; + let token = register_and_get_token(&client).await; + let slug = unique_slug(); + + let res = client + .post("/api/v1/projects") + .header("Authorization", &format!("Bearer {}", token)) + .json(&json!({ "name": "To Delete", "slug": slug })) + .send() + .await; + + let created: serde_json::Value = res.json(); + let pid = created["id"].as_str().unwrap(); + + let res = client + .delete(&format!("/api/v1/projects/{}", pid)) + .header("Authorization", &format!("Bearer {}", token)) + .send() + .await; + + assert_eq!(res.status(), StatusCode::NO_CONTENT); + + let res = client + .get(&format!("/api/v1/projects/{}", pid)) + .header("Authorization", &format!("Bearer {}", token)) + .send() + .await; + + assert_eq!(res.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn slug_uniqueness_returns_409() { + let client = setup().await; + let token = register_and_get_token(&client).await; + let slug = unique_slug(); + + client + .post("/api/v1/projects") + .header("Authorization", &format!("Bearer {}", token)) + .json(&json!({ "name": "First", "slug": slug })) + .send() + .await; + + let res = client + .post("/api/v1/projects") + .header("Authorization", &format!("Bearer {}", token)) + .json(&json!({ "name": "Second", "slug": slug })) + .send() + .await; + + assert_eq!(res.status(), StatusCode::CONFLICT); +} + +#[tokio::test] +async fn ownership_enforcement_returns_403() { + let client = setup().await; + let token_owner = register_and_get_token(&client).await; + let token_other = register_and_get_token(&client).await; + let slug = unique_slug(); + + let res = client + .post("/api/v1/projects") + .header("Authorization", &format!("Bearer {}", token_owner)) + .json(&json!({ "name": "Private", "slug": slug })) + .send() + .await; + + let created: serde_json::Value = res.json(); + let pid = created["id"].as_str().unwrap(); + + let res = client + .get(&format!("/api/v1/projects/{}", pid)) + .header("Authorization", &format!("Bearer {}", token_other)) + .send() + .await; + + assert_eq!(res.status(), StatusCode::FORBIDDEN); + + let res = client + .put(&format!("/api/v1/projects/{}", pid)) + .header("Authorization", &format!("Bearer {}", token_other)) + .json(&json!({ "name": "Hacked" })) + .send() + .await; + + assert_eq!(res.status(), StatusCode::FORBIDDEN); + + let res = client + .delete(&format!("/api/v1/projects/{}", pid)) + .header("Authorization", &format!("Bearer {}", token_other)) + .send() + .await; + + assert_eq!(res.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn projects_without_token_returns_401() { + let client = setup().await; + + let res = client.get("/api/v1/projects").send().await; + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); +} From bba694d3b25c9b3b61b4968379a8b63cdbca4632 Mon Sep 17 00:00:00 2001 From: arferreira Date: Wed, 18 Feb 2026 17:30:44 -0500 Subject: [PATCH 2/2] Update openapi.json with projects endpoints --- openapi.json | 463 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 463 insertions(+) diff --git a/openapi.json b/openapi.json index 68e161a..e4df72d 100644 --- a/openapi.json +++ b/openapi.json @@ -338,6 +338,469 @@ "summary": "Register" } }, + "/api/v1/projects": { + "get": { + "operationId": "list_projects", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$defs": { + "ProjectResponse": { + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "logo_url": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "website_url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "name", + "slug", + "created_at", + "updated_at" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "items": { + "$ref": "#/$defs/ProjectResponse" + }, + "title": "Array_of_ProjectResponse", + "type": "array" + } + } + }, + "description": "Success" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User does not own this project" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Project not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Slug already taken" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal server error" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Error response" + } + }, + "summary": "List projects" + }, + "post": { + "operationId": "create_project", + "responses": { + "200": { + "description": "Success" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User does not own this project" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Project not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Slug already taken" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal server error" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Error response" + } + }, + "summary": "Create project" + } + }, + "/api/v1/projects/{id}": { + "delete": { + "operationId": "delete_project", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true + } + ], + "responses": { + "200": { + "description": "Success" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User does not own this project" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Project not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Slug already taken" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal server error" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Error response" + } + }, + "summary": "Delete project" + }, + "get": { + "operationId": "get_project", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "logo_url": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "website_url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "name", + "slug", + "created_at", + "updated_at" + ], + "title": "ProjectResponse", + "type": "object" + } + } + }, + "description": "Success" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User does not own this project" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Project not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Slug already taken" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal server error" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Error response" + } + }, + "summary": "Get project" + }, + "put": { + "operationId": "update_project", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "logo_url": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "website_url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "name", + "slug", + "created_at", + "updated_at" + ], + "title": "ProjectResponse", + "type": "object" + } + } + }, + "description": "Success" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "User does not own this project" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Project not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Slug already taken" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal server error" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Error response" + } + }, + "summary": "Update project" + } + }, "/health": { "get": { "operationId": "health",