Просмотр исходного кода

Add OpenAPI integration using utoipa and schema annotations

master
Jared Bell 1 неделю назад
Родитель
Сommit
3b7fc342b8
10 измененных файлов: 237 добавлений и 33 удалений
  1. +33
    -6
      Cargo.lock
  2. +3
    -1
      Cargo.toml
  3. +36
    -3
      src/main.rs
  4. +28
    -0
      src/openapi/api_doc.rs
  5. +1
    -0
      src/openapi/mod.rs
  6. +0
    -1
      src/repositories/proposal.rs
  7. +35
    -1
      src/routes/amendment.rs
  8. +55
    -9
      src/routes/auth.rs
  9. +38
    -5
      src/routes/proposal.rs
  10. +8
    -7
      src/types/proposal.rs

+ 33
- 6
Cargo.lock Просмотреть файл

@@ -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"


+ 3
- 1
Cargo.toml Просмотреть файл

@@ -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"

+ 36
- 3
src/main.rs Просмотреть файл

@@ -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());
}
}

+ 28
- 0
src/openapi/api_doc.rs Просмотреть файл

@@ -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;

+ 1
- 0
src/openapi/mod.rs Просмотреть файл

@@ -0,0 +1 @@
pub mod api_doc;

+ 0
- 1
src/repositories/proposal.rs Просмотреть файл

@@ -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())


+ 35
- 1
src/routes/amendment.rs Просмотреть файл

@@ -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


+ 55
- 9
src/routes/auth.rs Просмотреть файл

@@ -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


+ 38
- 5
src/routes/proposal.rs Просмотреть файл

@@ -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>,


+ 8
- 7
src/types/proposal.rs Просмотреть файл

@@ -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,


Загрузка…
Отмена
Сохранить