| @@ -1312,6 +1312,7 @@ checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" | |||
| dependencies = [ | |||
| "equivalent", | |||
| "hashbrown", | |||
| "serde", | |||
| ] | |||
| [[package]] | |||
| @@ -1751,6 +1752,8 @@ dependencies = [ | |||
| "serde", | |||
| "serde_json", | |||
| "stellar-strkey", | |||
| "utoipa", | |||
| "utoipa-gen", | |||
| "uuid", | |||
| ] | |||
| @@ -1862,9 +1865,9 @@ dependencies = [ | |||
| [[package]] | |||
| name = "regex" | |||
| version = "1.11.1" | |||
| version = "1.13.1" | |||
| source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" | |||
| checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" | |||
| dependencies = [ | |||
| "aho-corasick", | |||
| "memchr", | |||
| @@ -1874,9 +1877,9 @@ dependencies = [ | |||
| [[package]] | |||
| name = "regex-automata" | |||
| version = "0.4.9" | |||
| version = "0.4.18" | |||
| source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" | |||
| checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" | |||
| dependencies = [ | |||
| "aho-corasick", | |||
| "memchr", | |||
| @@ -1891,9 +1894,9 @@ checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" | |||
| [[package]] | |||
| name = "regex-syntax" | |||
| version = "0.8.5" | |||
| version = "0.8.11" | |||
| source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" | |||
| checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" | |||
| [[package]] | |||
| name = "rfc6979" | |||
| @@ -2391,6 +2394,30 @@ version = "1.0.4" | |||
| source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" | |||
| [[package]] | |||
| name = "utoipa" | |||
| version = "6.0.0" | |||
| source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| checksum = "8765fe27aeff71012a3f90fa474cf1df863a7d0dd81ed25de4e63fff2544994f" | |||
| dependencies = [ | |||
| "indexmap", | |||
| "serde", | |||
| "serde_json", | |||
| "utoipa-gen", | |||
| ] | |||
| [[package]] | |||
| name = "utoipa-gen" | |||
| version = "6.0.1" | |||
| source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| checksum = "d935f1c83fdf8b88f09bbe050d0cea78ea4afee6d7b0317403228c1200b2c8ab" | |||
| dependencies = [ | |||
| "proc-macro2", | |||
| "quote", | |||
| "regex", | |||
| "syn 3.0.6", | |||
| ] | |||
| [[package]] | |||
| name = "uuid" | |||
| version = "1.17.0" | |||
| @@ -5,6 +5,7 @@ edition = "2024" | |||
| [dependencies] | |||
| actix-web = "4.11.0" | |||
| utoipa = { version = "6.0.0", features = ["actix_extras", "chrono"] } | |||
| serde = { version = "1.0.219", features = ["derive"] } | |||
| serde_json = "1.0.142" | |||
| uuid = { version = "1.17.0", features = ["v4"] } | |||
| @@ -17,4 +18,5 @@ argon2 = "0.6.0" | |||
| base64 = "0.23.1" | |||
| ed25519-dalek = { version = "3.0.0", features = ["rand_core"] } | |||
| stellar-strkey = "0.0.18" | |||
| jsonwebtoken = { version = "11.1.0", features = ["rust_crypto"] } | |||
| jsonwebtoken = { version = "11.1.0", features = ["rust_crypto"] } | |||
| utoipa-gen = "6.0.1" | |||
| @@ -6,12 +6,15 @@ mod db; | |||
| mod types; | |||
| mod repositories; | |||
| mod utils; | |||
| mod openapi; | |||
| use crate::routes::proposal::{add_proposal, get_proposal, list_proposals, visit_proposal}; | |||
| use actix_web::{web, App, HttpServer}; | |||
| use crate::routes::amendment::{add_amendment, get_amendment, list_amendments}; | |||
| use crate::routes::auth::{register, login, login_freighter, get_nonce}; | |||
| use crate::routes::auth::{get_nonce, login, login_freighter, register}; | |||
| use crate::routes::proposal::{add_proposal, get_proposal, list_proposals, visit_proposal}; | |||
| use crate::utils::env::load_env; | |||
| use actix_web::{App, HttpResponse, HttpServer, web}; | |||
| use utoipa::OpenApi; | |||
| use openapi::api_doc::ApiDoc; | |||
| #[actix_web::main] | |||
| async fn main() -> std::io::Result<()> { | |||
| @@ -24,6 +27,11 @@ async fn main() -> std::io::Result<()> { | |||
| println!("Starting server on http://{}:{}", ip, port); | |||
| HttpServer::new(|| { | |||
| App::new() | |||
| .service( | |||
| web::resource("/openapi.json").route(web::get().to(|| async { | |||
| HttpResponse::Ok().json(ApiDoc::openapi()) | |||
| })) | |||
| ) | |||
| .service( | |||
| web::scope("/api/v1") | |||
| .service( | |||
| @@ -58,3 +66,28 @@ async fn main() -> std::io::Result<()> { | |||
| .run() | |||
| .await | |||
| } | |||
| #[cfg(test)] | |||
| mod tests { | |||
| use super::*; | |||
| use actix_web::{App, test}; | |||
| #[actix_web::test] | |||
| async fn test_openapi_endpoint() { | |||
| let app = test::init_service( | |||
| App::new().service( | |||
| web::resource("/openapi.json").route(web::get().to(|| async { | |||
| HttpResponse::Ok().json(ApiDoc::openapi()) | |||
| })) | |||
| ) | |||
| ).await; | |||
| let req = test::TestRequest::get().uri("/openapi.json").to_request(); | |||
| let resp = test::call_service(&app, req).await; | |||
| assert!(resp.status().is_success()); | |||
| let body: serde_json::Value = test::read_body_json(resp).await; | |||
| assert!(body.get("openapi").is_some()); | |||
| assert!(body.get("paths").is_some()); | |||
| } | |||
| } | |||
| @@ -0,0 +1,28 @@ | |||
| use utoipa_gen::OpenApi; | |||
| #[derive(OpenApi)] | |||
| #[openapi( | |||
| paths( | |||
| crate::routes::proposal::list_proposals, | |||
| crate::routes::proposal::get_proposal, | |||
| crate::routes::proposal::add_proposal, | |||
| crate::routes::auth::register, | |||
| crate::routes::auth::login, | |||
| crate::routes::auth::get_nonce, | |||
| crate::routes::auth::login_freighter, | |||
| crate::routes::amendment::list_amendments, | |||
| crate::routes::amendment::add_amendment, | |||
| crate::routes::amendment::get_amendment, | |||
| ), | |||
| components( | |||
| schemas( | |||
| crate::types::proposal::Proposal, | |||
| crate::types::proposal::ProposalParagraph, | |||
| crate::types::proposal::ProposalParagraphRequest, | |||
| crate::types::proposal::SelectableProposal, | |||
| crate::types::proposal::ProposalWithSummary, | |||
| crate::types::proposal::ProposalWithParagraphs, | |||
| ), | |||
| ) | |||
| )] | |||
| pub struct ApiDoc; | |||
| @@ -0,0 +1 @@ | |||
| pub mod api_doc; | |||
| @@ -33,7 +33,6 @@ pub fn paginate_proposals(offset: i64, limit: i64) -> Result<Vec<ProposalWithSum | |||
| props | |||
| .into_iter() | |||
| .map(|p| { | |||
| println!("{}", p.id); | |||
| let summary = proposal_paragraphs::table | |||
| .filter(proposal_paragraphs::proposal_id.eq(p.id)) | |||
| .order(proposal_paragraphs::index.asc()) | |||
| @@ -3,12 +3,23 @@ use crate::types::amendment::AmendmentError; | |||
| use actix_web::{HttpResponse, get, post, web}; | |||
| use serde::{Deserialize, Serialize}; | |||
| use serde_json::json; | |||
| use utoipa_gen::ToSchema; | |||
| #[derive(Deserialize)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct ListAmendmentsRequest { | |||
| pub proposal_id: i32, | |||
| } | |||
| #[utoipa::path( | |||
| get, | |||
| path = "/api/v1/amendments/list", | |||
| responses( | |||
| (status = 200, description = "List of amendments for a proposal"), | |||
| (status = 500, description = "Internal server error") | |||
| ), | |||
| tag = "amendment" | |||
| )] | |||
| #[get("list")] | |||
| async fn list_amendments(params: web::Query<ListAmendmentsRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| match get_amendments_for_proposal(params.proposal_id) { | |||
| @@ -19,7 +30,7 @@ async fn list_amendments(params: web::Query<ListAmendmentsRequest>) -> Result<Ht | |||
| } | |||
| } | |||
| #[derive(Serialize, Deserialize)] | |||
| #[derive(Serialize, Deserialize, ToSchema)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct AddAmendmentRequest { | |||
| pub proposal_id: i32, | |||
| @@ -27,6 +38,18 @@ pub struct AddAmendmentRequest { | |||
| pub content: String, | |||
| pub creator: String, | |||
| } | |||
| #[utoipa::path( | |||
| post, | |||
| path = "/api/v1/amendment/add", | |||
| request_body = AddAmendmentRequest, | |||
| responses( | |||
| (status = 200, description = "CID of the added amendment"), | |||
| (status = 400, description = "Bad request"), | |||
| (status = 500, description = "Internal server error") | |||
| ), | |||
| tag = "amendment" | |||
| )] | |||
| #[post("add")] | |||
| async fn add_amendment(params: web::Json<AddAmendmentRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| let req = params.into_inner(); | |||
| @@ -65,6 +88,17 @@ async fn add_amendment(params: web::Json<AddAmendmentRequest>) -> Result<HttpRes | |||
| pub struct GetAmendmentRequest { | |||
| pub amendment_id: i32, | |||
| } | |||
| #[utoipa::path( | |||
| get, | |||
| path = "/api/v1/amendment/get", | |||
| responses( | |||
| (status = 200, description = "Amendment details"), | |||
| (status = 400, description = "Bad request"), | |||
| (status = 500, description = "Internal server error") | |||
| ), | |||
| tag = "amendment" | |||
| )] | |||
| #[get("get")] | |||
| async fn get_amendment(request: web::Query<GetAmendmentRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| // TODO: Implement actual logic to fetch an amendment by ID | |||
| @@ -8,8 +8,9 @@ const JWT_EXPIRATION_TIME: i64 = 240; | |||
| use crate::utils::auth::{generate_freighter_nonce, generate_jwt}; | |||
| use argon2::{Argon2, PasswordHash, password_hash::{PasswordHasher, PasswordVerifier}}; | |||
| use utoipa_gen::ToSchema; | |||
| #[derive(Deserialize)] | |||
| #[derive(Deserialize, ToSchema)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct RegisterRequest { | |||
| pub username: String, | |||
| @@ -18,13 +19,17 @@ pub struct RegisterRequest { | |||
| pub email: Option<String>, | |||
| } | |||
| #[derive(Deserialize)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct LoginRequest { | |||
| pub username: String, | |||
| pub password: String, | |||
| } | |||
| #[utoipa::path( | |||
| post, | |||
| path = "/api/v1/auth/register", | |||
| request_body = RegisterRequest, | |||
| responses( | |||
| (status = 200, description = "User registered successfully"), | |||
| (status = 400, description = "Bad request"), | |||
| (status = 500, description = "Internal server error") | |||
| ), | |||
| tag = "auth" | |||
| )] | |||
| #[post("register")] | |||
| pub async fn register(req: web::Json<RegisterRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| // Basic validation | |||
| @@ -63,7 +68,25 @@ pub async fn register(req: web::Json<RegisterRequest>) -> Result<HttpResponse, a | |||
| } | |||
| } | |||
| #[derive(Deserialize, ToSchema)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct LoginRequest { | |||
| pub username: String, | |||
| pub password: String, | |||
| } | |||
| #[utoipa::path( | |||
| post, | |||
| path = "/api/v1/auth/login", | |||
| request_body = LoginRequest, | |||
| responses( | |||
| (status = 200, description = "User logged in successfully"), | |||
| (status = 400, description = "Bad request"), | |||
| (status = 401, description = "Unauthorized"), | |||
| (status = 500, description = "Internal server error") | |||
| ), | |||
| tag = "auth" | |||
| )] | |||
| #[post("login")] | |||
| pub async fn login(req: web::Json<LoginRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| // Basic validation | |||
| @@ -134,18 +157,41 @@ pub async fn login(req: web::Json<LoginRequest>) -> Result<HttpResponse, actix_w | |||
| } | |||
| } | |||
| #[derive(Deserialize)] | |||
| #[derive(Deserialize, ToSchema)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct FreighterLoginRequest { | |||
| pub stellar_address: String, | |||
| } | |||
| #[utoipa::path( | |||
| post, | |||
| path = "/api/v1/auth/nonce", | |||
| responses( | |||
| (status = 200, description = "User logged in successfully"), | |||
| (status = 400, description = "Bad request"), | |||
| (status = 401, description = "Unauthorized"), | |||
| (status = 500, description = "Internal server error") | |||
| ), | |||
| tag = "auth" | |||
| )] | |||
| #[get("nonce")] | |||
| pub async fn get_nonce() -> HttpResponse { | |||
| let nonce = generate_freighter_nonce(); | |||
| HttpResponse::Ok().json(json!({"nonce": nonce})) | |||
| } | |||
| #[utoipa::path( | |||
| post, | |||
| path = "/api/v1/auth/login/freighter", | |||
| request_body = FreighterLoginRequest, | |||
| responses( | |||
| (status = 200, description = "User logged in successfully"), | |||
| (status = 400, description = "Bad request"), | |||
| (status = 401, description = "Unauthorized"), | |||
| (status = 500, description = "Internal server error") | |||
| ), | |||
| tag = "auth" | |||
| )] | |||
| #[post("login/freighter")] | |||
| pub async fn login_freighter(req: web::Json<FreighterLoginRequest>) -> Result<HttpResponse, actix_web::Error> { | |||
| // Basic validation | |||
| @@ -5,13 +5,24 @@ use actix_web::{get, post, web, HttpResponse, HttpRequest}; | |||
| use chrono::Utc; | |||
| use serde::{Deserialize, Serialize}; | |||
| use serde_json::json; | |||
| use utoipa_gen::{IntoParams, ToSchema}; | |||
| use crate::repositories; | |||
| #[derive(Deserialize)] | |||
| struct PaginationParams { | |||
| #[derive(Deserialize, IntoParams, ToSchema)] | |||
| pub struct PaginationParams { | |||
| offset: i64, | |||
| limit: i64, | |||
| } | |||
| #[utoipa::path( | |||
| get, | |||
| path = "/api/v1/proposals/list", | |||
| responses( | |||
| (status = 200, description = "Returns a list of proposals"), | |||
| (status = 500, description = "Internal server error") | |||
| ), | |||
| params(PaginationParams), | |||
| tag = "proposal" | |||
| )] | |||
| #[get("list")] | |||
| async fn list_proposals(params: web::Query<PaginationParams>) -> Result<HttpResponse, actix_web::Error> { | |||
| match paginate_proposals(params.offset, params.limit) { | |||
| @@ -22,7 +33,7 @@ async fn list_proposals(params: web::Query<PaginationParams>) -> Result<HttpResp | |||
| } | |||
| } | |||
| #[derive(Serialize, Deserialize)] | |||
| #[derive(Serialize, Deserialize, ToSchema)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct VisitProposalRequest { | |||
| pub proposal_id: i32, | |||
| @@ -74,10 +85,21 @@ async fn visit_proposal(req: HttpRequest, payload: web::Json<VisitProposalReques | |||
| } | |||
| } | |||
| #[derive(Deserialize)] | |||
| #[derive(Deserialize, IntoParams)] | |||
| struct GetProposalParams { | |||
| pub id: i32, | |||
| } | |||
| #[utoipa::path( | |||
| get, | |||
| path = "/api/v1/proposal/get", | |||
| responses( | |||
| (status = 200, description = "Proposal retrieved successfully"), | |||
| (status = 400, description = "Invalid proposalId"), | |||
| (status = 500, description = "Failed to retrieve proposal"), | |||
| ), | |||
| params(GetProposalParams), | |||
| tag = "proposal" | |||
| )] | |||
| #[get("get")] | |||
| async fn get_proposal(params: web::Query<GetProposalParams>) -> Result<HttpResponse, actix_web::Error> { | |||
| let proposal = repositories::proposal::get_proposal(params.id); | |||
| @@ -89,13 +111,24 @@ async fn get_proposal(params: web::Query<GetProposalParams>) -> Result<HttpRespo | |||
| } | |||
| } | |||
| #[derive(Serialize, Deserialize)] | |||
| #[derive(Serialize, Deserialize, ToSchema)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct AddProposalRequest { | |||
| pub name: String, | |||
| pub creator: String, | |||
| pub paragraphs: Vec<ProposalParagraphRequest>, | |||
| } | |||
| #[utoipa::path( | |||
| post, | |||
| path = "/api/v1/proposal/add", | |||
| request_body = AddProposalRequest, | |||
| responses( | |||
| (status = 200, description = "Proposal added successfully"), | |||
| (status = 400, description = "Bad request"), | |||
| (status = 500, description = "Failed to add proposal"), | |||
| ), | |||
| tag = "proposal" | |||
| )] | |||
| #[post("add")] | |||
| async fn add_proposal( | |||
| request: web::Json<AddProposalRequest>, | |||
| @@ -4,7 +4,7 @@ use std::fmt; | |||
| use chrono::NaiveDateTime; | |||
| use diesel::{Insertable, Queryable, Selectable}; | |||
| use serde::{Deserialize, Serialize}; | |||
| use utoipa_gen::ToSchema; | |||
| use crate::schema::{proposal_paragraphs, proposals}; | |||
| #[derive(Debug)] | |||
| @@ -48,7 +48,7 @@ impl From<std::io::Error> for ProposalError { | |||
| } | |||
| } | |||
| #[derive(Queryable, Selectable, Clone, Serialize, Deserialize)] | |||
| #[derive(Queryable, Selectable, Clone, Serialize, Deserialize, ToSchema)] | |||
| #[diesel(table_name = proposals)] | |||
| #[diesel(check_for_backend(diesel::pg::Pg))] | |||
| #[serde(rename_all = "camelCase")] | |||
| @@ -60,7 +60,7 @@ pub struct SelectableProposal { | |||
| pub updated_at: Option<NaiveDateTime>, | |||
| } | |||
| #[derive(Insertable, Clone, Serialize, Deserialize)] | |||
| #[derive(Insertable, Clone, Serialize, Deserialize, ToSchema)] | |||
| #[diesel(table_name = proposals)] | |||
| #[diesel(check_for_backend(diesel::pg::Pg))] | |||
| #[serde(rename_all = "camelCase")] | |||
| @@ -83,13 +83,14 @@ impl Proposal { | |||
| } | |||
| } | |||
| #[derive(Serialize, Deserialize)] | |||
| #[derive(Serialize, Deserialize, ToSchema)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct ProposalParagraphRequest { | |||
| pub paragraph_text: String, | |||
| pub index: i32, | |||
| } | |||
| #[derive(Queryable, Insertable, Clone, Serialize, Deserialize)] | |||
| #[derive(Queryable, Insertable, Clone, Serialize, Deserialize, ToSchema)] | |||
| #[diesel(table_name = proposal_paragraphs)] | |||
| #[diesel(check_for_backend(diesel::pg::Pg))] | |||
| #[serde(rename_all = "camelCase")] | |||
| @@ -99,7 +100,7 @@ pub struct ProposalParagraph { | |||
| pub index: i32, | |||
| } | |||
| #[derive(Clone, Serialize, Deserialize)] | |||
| #[derive(Clone, Serialize, Deserialize, ToSchema)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct ProposalWithSummary { | |||
| pub id: i32, | |||
| @@ -110,7 +111,7 @@ pub struct ProposalWithSummary { | |||
| pub summary: String, | |||
| } | |||
| #[derive(Clone, Serialize, Deserialize)] | |||
| #[derive(Clone, Serialize, Deserialize, ToSchema)] | |||
| #[serde(rename_all = "camelCase")] | |||
| pub struct ProposalWithParagraphs { | |||
| pub id: i32, | |||