refactor (v2): better arch
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
24
crates/presentation/Cargo.toml
Normal file
24
crates/presentation/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "presentation"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
api-types = { workspace = true }
|
||||
auth = { workspace = true, features = ["jwt"] }
|
||||
|
||||
axum = "0.8"
|
||||
tower-http = { version = "0.6", features = ["cors", "fs", "trace"] }
|
||||
utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] }
|
||||
utoipa-scalar = { version = "0.3", features = ["axum"], default-features = false }
|
||||
utoipa-swagger-ui = { version = "9", features = ["axum", "vendored"] }
|
||||
|
||||
async-trait = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
69
crates/presentation/src/error.rs
Normal file
69
crates/presentation/src/error.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use domain::errors::DomainError;
|
||||
|
||||
use api_types::errors::ErrorResponse;
|
||||
|
||||
pub type ApiResult<T> = Result<T, ApiError>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ApiError {
|
||||
NotFound(String),
|
||||
Forbidden(String),
|
||||
Conflict(String),
|
||||
Validation(String),
|
||||
Unauthorized,
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn internal(msg: impl Into<String>) -> Self {
|
||||
Self::Internal(msg.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DomainError> for ApiError {
|
||||
fn from(e: DomainError) -> Self {
|
||||
match e {
|
||||
DomainError::NotFound(msg) => Self::NotFound(msg),
|
||||
DomainError::Forbidden(msg) => Self::Forbidden(msg),
|
||||
DomainError::Conflict(msg) => Self::Conflict(msg),
|
||||
DomainError::Validation(msg) => Self::Validation(msg),
|
||||
DomainError::Repository(msg) => {
|
||||
tracing::error!("repository error: {msg}");
|
||||
Self::Internal("database error".into())
|
||||
}
|
||||
DomainError::Infrastructure(msg) => {
|
||||
tracing::error!("infrastructure error: {msg}");
|
||||
Self::Internal("service unavailable".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match self {
|
||||
Self::NotFound(msg) => (StatusCode::NOT_FOUND, ErrorResponse::not_found(msg)),
|
||||
Self::Forbidden(msg) => (StatusCode::FORBIDDEN, ErrorResponse::forbidden(msg)),
|
||||
Self::Conflict(msg) => (StatusCode::CONFLICT, ErrorResponse::conflict(msg)),
|
||||
Self::Validation(msg) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
ErrorResponse::validation(msg),
|
||||
),
|
||||
Self::Unauthorized => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
ErrorResponse::new("UNAUTHORIZED", "authentication required"),
|
||||
),
|
||||
Self::Internal(msg) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ErrorResponse::internal(msg),
|
||||
),
|
||||
};
|
||||
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
59
crates/presentation/src/extractors.rs
Normal file
59
crates/presentation/src/extractors.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::FromRequestParts,
|
||||
http::{StatusCode, header, request::Parts},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use api_types::errors::ErrorResponse;
|
||||
use domain::user::entity::UserId;
|
||||
|
||||
use crate::state::PresentationState;
|
||||
|
||||
/// Extracts the authenticated user from `Authorization: Bearer <jwt>`.
|
||||
/// Returns `401 Unauthorized` if the header is absent, malformed, or the token is invalid.
|
||||
pub struct CurrentUser(pub domain::user::entity::User);
|
||||
|
||||
impl FromRequestParts<PresentationState> for CurrentUser {
|
||||
type Rejection = (StatusCode, Json<ErrorResponse>);
|
||||
|
||||
fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &PresentationState,
|
||||
) -> impl std::future::Future<Output = Result<Self, Self::Rejection>> + Send {
|
||||
let validator = state.jwt_validator.clone();
|
||||
let user_repo = Arc::clone(&state.ctx.repos.user);
|
||||
let auth_header = parts.headers.get(header::AUTHORIZATION).cloned();
|
||||
|
||||
async move {
|
||||
let header_val = auth_header.ok_or_else(unauthorized)?;
|
||||
let s = header_val.to_str().map_err(|_| unauthorized())?;
|
||||
let token = s.strip_prefix("Bearer ").ok_or_else(unauthorized)?;
|
||||
|
||||
let claims = validator
|
||||
.validate_token(token.trim())
|
||||
.map_err(|_| unauthorized())?;
|
||||
let uuid = Uuid::parse_str(&claims.sub).map_err(|_| unauthorized())?;
|
||||
|
||||
let user = user_repo
|
||||
.find_by_id(&UserId::from_uuid(uuid))
|
||||
.await
|
||||
.map_err(|_| unauthorized())?
|
||||
.ok_or_else(unauthorized)?;
|
||||
|
||||
Ok(CurrentUser(user))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unauthorized() -> (StatusCode, Json<ErrorResponse>) {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ErrorResponse::new(
|
||||
"UNAUTHORIZED",
|
||||
"authentication required",
|
||||
)),
|
||||
)
|
||||
}
|
||||
64
crates/presentation/src/lib.rs
Normal file
64
crates/presentation/src/lib.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
pub mod error;
|
||||
pub mod extractors;
|
||||
pub mod mapping;
|
||||
pub mod openapi;
|
||||
pub mod routes;
|
||||
pub mod state;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use axum::Router;
|
||||
use axum::http::{HeaderValue, Method, header};
|
||||
use tower_http::{
|
||||
cors::CorsLayer,
|
||||
services::{ServeDir, ServeFile},
|
||||
trace::TraceLayer,
|
||||
};
|
||||
|
||||
pub use state::PresentationState;
|
||||
|
||||
/// Build the Axum router with all API routes and OpenAPI docs.
|
||||
///
|
||||
/// `spa_dir` — when `Some`, the built frontend is served at `/` as a fallback
|
||||
/// so client-side routing works. API routes and docs take priority.
|
||||
/// Set `SPA_DIR` env var in bootstrap to configure at runtime.
|
||||
pub fn router(state: PresentationState, spa_dir: Option<PathBuf>) -> Router {
|
||||
let mut app = Router::new()
|
||||
.nest("/api/v1", routes::api_router())
|
||||
.with_state(state);
|
||||
|
||||
// OpenAPI docs at /docs and /scalar
|
||||
app = openapi::serve(app);
|
||||
|
||||
// Serve the SPA at root — must be last so API routes take priority.
|
||||
if let Some(dir) = spa_dir {
|
||||
let index = dir.join("index.html");
|
||||
tracing::info!("serving SPA from {}", dir.display());
|
||||
app = app.fallback_service(ServeDir::new(dir).fallback(ServeFile::new(index)));
|
||||
}
|
||||
|
||||
app
|
||||
}
|
||||
|
||||
/// Apply CORS and request tracing middleware.
|
||||
pub fn apply_middleware(app: Router, cors_origins: Vec<String>) -> Router {
|
||||
let mut cors = CorsLayer::new()
|
||||
.allow_methods([
|
||||
Method::GET,
|
||||
Method::POST,
|
||||
Method::PUT,
|
||||
Method::PATCH,
|
||||
Method::DELETE,
|
||||
Method::OPTIONS,
|
||||
])
|
||||
.allow_headers([header::AUTHORIZATION, header::ACCEPT, header::CONTENT_TYPE])
|
||||
.allow_credentials(true);
|
||||
|
||||
let origins: Vec<HeaderValue> = cors_origins.iter().filter_map(|o| o.parse().ok()).collect();
|
||||
|
||||
if !origins.is_empty() {
|
||||
cors = cors.allow_origin(origins);
|
||||
}
|
||||
|
||||
app.layer(TraceLayer::new_for_http()).layer(cors)
|
||||
}
|
||||
59
crates/presentation/src/mapping.rs
Normal file
59
crates/presentation/src/mapping.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use api_types::{
|
||||
auth::UserResponse,
|
||||
notes::{NoteLinkResponse, NoteResponse, NoteVersionResponse},
|
||||
tags::TagResponse,
|
||||
};
|
||||
use domain::{
|
||||
note::entity::{Note, NoteLink, NoteVersion},
|
||||
tag::entity::Tag,
|
||||
user::entity::User,
|
||||
};
|
||||
|
||||
pub fn tag_response(t: Tag) -> TagResponse {
|
||||
TagResponse {
|
||||
id: t.id.as_uuid(),
|
||||
name: t.name.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn note_response(n: Note) -> NoteResponse {
|
||||
NoteResponse {
|
||||
id: n.id.as_uuid(),
|
||||
user_id: n.user_id.as_uuid(),
|
||||
title: n.title.map(|t| t.into_inner()),
|
||||
content: n.content,
|
||||
color: n.color.into_inner(),
|
||||
is_pinned: n.is_pinned,
|
||||
is_archived: n.is_archived,
|
||||
created_at: n.created_at,
|
||||
updated_at: n.updated_at,
|
||||
tags: n.tags.into_iter().map(tag_response).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn note_version_response(v: NoteVersion) -> NoteVersionResponse {
|
||||
NoteVersionResponse {
|
||||
id: v.id,
|
||||
note_id: v.note_id.as_uuid(),
|
||||
title: v.title,
|
||||
content: v.content,
|
||||
created_at: v.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn note_link_response(l: NoteLink) -> NoteLinkResponse {
|
||||
NoteLinkResponse {
|
||||
source_id: l.source_id.as_uuid(),
|
||||
target_id: l.target_id.as_uuid(),
|
||||
score: l.score,
|
||||
created_at: l.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn user_response(u: User) -> UserResponse {
|
||||
UserResponse {
|
||||
id: u.id.as_uuid(),
|
||||
email: u.email.into_inner(),
|
||||
created_at: u.created_at,
|
||||
}
|
||||
}
|
||||
13
crates/presentation/src/openapi/auth.rs
Normal file
13
crates/presentation/src/openapi/auth.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use api_types::auth::{AuthResponse, LoginRequest, RegisterRequest, UserResponse};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::routes::auth::login_handler,
|
||||
crate::routes::auth::register_handler,
|
||||
crate::routes::auth::me_handler,
|
||||
),
|
||||
components(schemas(LoginRequest, RegisterRequest, AuthResponse, UserResponse))
|
||||
)]
|
||||
pub struct AuthDoc;
|
||||
16
crates/presentation/src/openapi/data.rs
Normal file
16
crates/presentation/src/openapi/data.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use api_types::{
|
||||
backup::{BackupData, BackupNote},
|
||||
config::ConfigResponse,
|
||||
};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::routes::data::get_config,
|
||||
crate::routes::data::export_data,
|
||||
crate::routes::data::import_data,
|
||||
),
|
||||
components(schemas(ConfigResponse, BackupData, BackupNote))
|
||||
)]
|
||||
pub struct DataDoc;
|
||||
50
crates/presentation/src/openapi/mod.rs
Normal file
50
crates/presentation/src/openapi/mod.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
mod auth;
|
||||
mod data;
|
||||
mod notes;
|
||||
mod tags;
|
||||
|
||||
use axum::Router;
|
||||
use utoipa::{
|
||||
Modify, OpenApi,
|
||||
openapi::security::{Http, HttpAuthScheme, SecurityScheme},
|
||||
};
|
||||
use utoipa_scalar::{Scalar, Servable as _};
|
||||
use utoipa_swagger_ui::SwaggerUi;
|
||||
|
||||
struct SecurityAddon;
|
||||
|
||||
impl Modify for SecurityAddon {
|
||||
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||
let components = openapi.components.get_or_insert_with(Default::default);
|
||||
components.add_security_scheme(
|
||||
"bearer_auth",
|
||||
SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn build() -> utoipa::openapi::OpenApi {
|
||||
let mut api = auth::AuthDoc::openapi();
|
||||
api.info = utoipa::openapi::InfoBuilder::new()
|
||||
.title("k-notes API")
|
||||
.version("1.0.0")
|
||||
.description(Some(
|
||||
"Self-hosted note-taking API. \
|
||||
Authenticate with `POST /api/v1/auth/login` to receive a Bearer token.",
|
||||
))
|
||||
.build();
|
||||
|
||||
api.merge(notes::NotesDoc::openapi());
|
||||
api.merge(tags::TagsDoc::openapi());
|
||||
api.merge(data::DataDoc::openapi());
|
||||
SecurityAddon.modify(&mut api);
|
||||
api
|
||||
}
|
||||
|
||||
pub fn serve(router: Router) -> Router {
|
||||
tracing::info!("API docs available at /docs (Swagger) and /scalar");
|
||||
let spec = build();
|
||||
router
|
||||
.merge(SwaggerUi::new("/docs").url("/openapi.json", spec.clone()))
|
||||
.merge(Scalar::with_url("/scalar", spec))
|
||||
}
|
||||
38
crates/presentation/src/openapi/notes.rs
Normal file
38
crates/presentation/src/openapi/notes.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use api_types::{
|
||||
notes::{
|
||||
AddTagRequest, ArchiveRequest, CreateNoteRequest, NoteLinkResponse, NoteResponse,
|
||||
NoteVersionResponse, PinRequest, UpdateNoteRequest,
|
||||
},
|
||||
tags::TagResponse,
|
||||
};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::routes::notes::list_notes,
|
||||
crate::routes::notes::create_note,
|
||||
crate::routes::notes::get_note,
|
||||
crate::routes::notes::update_note,
|
||||
crate::routes::notes::delete_note,
|
||||
crate::routes::notes::pin_note,
|
||||
crate::routes::notes::archive_note,
|
||||
crate::routes::notes::search_notes,
|
||||
crate::routes::notes::get_versions,
|
||||
crate::routes::notes::get_related,
|
||||
crate::routes::notes::add_tag,
|
||||
crate::routes::notes::remove_tag,
|
||||
),
|
||||
components(schemas(
|
||||
NoteResponse,
|
||||
NoteVersionResponse,
|
||||
NoteLinkResponse,
|
||||
CreateNoteRequest,
|
||||
UpdateNoteRequest,
|
||||
PinRequest,
|
||||
ArchiveRequest,
|
||||
AddTagRequest,
|
||||
TagResponse,
|
||||
))
|
||||
)]
|
||||
pub struct NotesDoc;
|
||||
14
crates/presentation/src/openapi/tags.rs
Normal file
14
crates/presentation/src/openapi/tags.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use api_types::tags::{CreateTagRequest, RenameTagRequest, TagResponse};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::routes::tags::list_tags,
|
||||
crate::routes::tags::create_tag,
|
||||
crate::routes::tags::delete_tag,
|
||||
crate::routes::tags::rename_tag,
|
||||
),
|
||||
components(schemas(TagResponse, CreateTagRequest, RenameTagRequest))
|
||||
)]
|
||||
pub struct TagsDoc;
|
||||
112
crates/presentation/src/routes/auth.rs
Normal file
112
crates/presentation/src/routes/auth.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use api_types::auth::{AuthResponse, LoginRequest, RegisterRequest, UserResponse};
|
||||
use application::auth::{
|
||||
commands::{LoginCommand, RegisterCommand},
|
||||
login, register,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
error::{ApiError, ApiResult},
|
||||
extractors::CurrentUser,
|
||||
mapping::user_response,
|
||||
state::PresentationState,
|
||||
};
|
||||
|
||||
pub fn router() -> Router<PresentationState> {
|
||||
Router::new()
|
||||
.route("/login", post(login_handler))
|
||||
.route("/register", post(register_handler))
|
||||
.route("/me", get(me_handler))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/auth/login",
|
||||
request_body = LoginRequest,
|
||||
responses(
|
||||
(status = 200, body = AuthResponse),
|
||||
(status = 403, body = api_types::errors::ErrorResponse, description = "Invalid credentials"),
|
||||
)
|
||||
)]
|
||||
pub async fn login_handler(
|
||||
State(state): State<PresentationState>,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> ApiResult<Json<AuthResponse>> {
|
||||
let user = login::execute(
|
||||
&state.ctx,
|
||||
LoginCommand {
|
||||
email: payload.email,
|
||||
password: payload.password,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let token = state
|
||||
.jwt_validator
|
||||
.create_token(&user)
|
||||
.map_err(|e| ApiError::internal(format!("jwt error: {e}")))?;
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
user: user_response(user),
|
||||
access_token: token,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/auth/register",
|
||||
request_body = RegisterRequest,
|
||||
responses(
|
||||
(status = 201, body = AuthResponse),
|
||||
(status = 403, body = api_types::errors::ErrorResponse, description = "Registration disabled"),
|
||||
(status = 409, body = api_types::errors::ErrorResponse, description = "Email already exists"),
|
||||
)
|
||||
)]
|
||||
pub async fn register_handler(
|
||||
State(state): State<PresentationState>,
|
||||
Json(payload): Json<RegisterRequest>,
|
||||
) -> ApiResult<(StatusCode, Json<AuthResponse>)> {
|
||||
if !state.ctx.config.allow_registration {
|
||||
return Err(ApiError::Forbidden("registration is disabled".into()));
|
||||
}
|
||||
|
||||
let user = register::execute(
|
||||
&state.ctx,
|
||||
RegisterCommand {
|
||||
email: payload.email,
|
||||
password: payload.password,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
let token = state
|
||||
.jwt_validator
|
||||
.create_token(&user)
|
||||
.map_err(|e| ApiError::internal(format!("jwt error: {e}")))?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(AuthResponse {
|
||||
user: user_response(user),
|
||||
access_token: token,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/auth/me",
|
||||
responses(
|
||||
(status = 200, body = UserResponse),
|
||||
(status = 401, body = api_types::errors::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn me_handler(CurrentUser(user): CurrentUser) -> Json<UserResponse> {
|
||||
Json(user_response(user))
|
||||
}
|
||||
88
crates/presentation/src/routes/data.rs
Normal file
88
crates/presentation/src/routes/data.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use axum::{Json, extract::State, http::StatusCode};
|
||||
|
||||
use api_types::{
|
||||
backup::{BackupData, BackupNote},
|
||||
config::ConfigResponse,
|
||||
};
|
||||
use application::notes::{export_notes, import_notes};
|
||||
|
||||
use crate::{
|
||||
error::{ApiError, ApiResult},
|
||||
extractors::CurrentUser,
|
||||
state::PresentationState,
|
||||
};
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/config",
|
||||
responses((status = 200, body = ConfigResponse))
|
||||
)]
|
||||
pub async fn get_config(State(state): State<PresentationState>) -> Json<ConfigResponse> {
|
||||
Json(ConfigResponse {
|
||||
allow_registration: state.ctx.config.allow_registration,
|
||||
})
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/export",
|
||||
responses(
|
||||
(status = 200, body = BackupData),
|
||||
(status = 401, body = api_types::errors::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn export_data(
|
||||
State(state): State<PresentationState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
) -> ApiResult<Json<BackupData>> {
|
||||
let notes = export_notes::execute(&state.ctx, user.id.as_uuid())
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
Ok(Json(BackupData {
|
||||
notes: notes
|
||||
.into_iter()
|
||||
.map(|n| BackupNote {
|
||||
title: n.title,
|
||||
content: n.content,
|
||||
color: n.color,
|
||||
is_pinned: n.is_pinned,
|
||||
is_archived: n.is_archived,
|
||||
tags: n.tags,
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/import",
|
||||
request_body = BackupData,
|
||||
responses(
|
||||
(status = 200, description = "Imported successfully"),
|
||||
(status = 401, body = api_types::errors::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn import_data(
|
||||
State(state): State<PresentationState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Json(payload): Json<BackupData>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
let notes = payload
|
||||
.notes
|
||||
.into_iter()
|
||||
.map(|n| import_notes::ImportNote {
|
||||
title: n.title,
|
||||
content: n.content,
|
||||
color: Some(n.color),
|
||||
is_pinned: n.is_pinned,
|
||||
is_archived: n.is_archived,
|
||||
tags: n.tags,
|
||||
})
|
||||
.collect();
|
||||
|
||||
import_notes::execute(&state.ctx, user.id.as_uuid(), notes)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
42
crates/presentation/src/routes/mod.rs
Normal file
42
crates/presentation/src/routes/mod.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
pub mod auth;
|
||||
pub mod data;
|
||||
pub mod notes;
|
||||
pub mod tags;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{delete, get, patch, post},
|
||||
};
|
||||
|
||||
use crate::state::PresentationState;
|
||||
|
||||
pub fn api_router() -> Router<PresentationState> {
|
||||
Router::new()
|
||||
.nest("/auth", auth::router())
|
||||
// Config
|
||||
.route("/config", get(data::get_config))
|
||||
// Export / Import
|
||||
.route("/export", get(data::export_data))
|
||||
.route("/import", post(data::import_data))
|
||||
// Notes
|
||||
.route("/notes", get(notes::list_notes).post(notes::create_note))
|
||||
.route(
|
||||
"/notes/{id}",
|
||||
get(notes::get_note)
|
||||
.patch(notes::update_note)
|
||||
.delete(notes::delete_note),
|
||||
)
|
||||
.route("/notes/{id}/versions", get(notes::get_versions))
|
||||
.route("/notes/{id}/related", get(notes::get_related))
|
||||
.route("/notes/{id}/pin", patch(notes::pin_note))
|
||||
.route("/notes/{id}/archive", patch(notes::archive_note))
|
||||
.route("/notes/{id}/tags", post(notes::add_tag))
|
||||
.route("/notes/{id}/tags/{tag_id}", delete(notes::remove_tag))
|
||||
.route("/search", get(notes::search_notes))
|
||||
// Tags
|
||||
.route("/tags", get(tags::list_tags).post(tags::create_tag))
|
||||
.route(
|
||||
"/tags/{id}",
|
||||
delete(tags::delete_tag).patch(tags::rename_tag),
|
||||
)
|
||||
}
|
||||
418
crates/presentation/src/routes/notes.rs
Normal file
418
crates/presentation/src/routes/notes.rs
Normal file
@@ -0,0 +1,418 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use api_types::notes::{
|
||||
AddTagRequest, ArchiveRequest, CreateNoteRequest, ListNotesParams, NoteLinkResponse,
|
||||
NoteResponse, NoteVersionResponse, PinRequest, SearchParams, UpdateNoteRequest,
|
||||
};
|
||||
use application::notes::{
|
||||
add_tag as uc_add_tag, archive_note as uc_archive_note,
|
||||
commands::{
|
||||
AddTagCommand, ArchiveNoteCommand, CreateNoteCommand, DeleteNoteCommand, PinNoteCommand,
|
||||
RemoveTagCommand, UpdateNoteCommand,
|
||||
},
|
||||
create_note as uc_create_note, delete_note as uc_delete_note, get_note as uc_get_note,
|
||||
get_related as uc_get_related, get_versions as uc_get_versions, list_notes as uc_list_notes,
|
||||
pin_note as uc_pin_note,
|
||||
queries::{GetNoteQuery, GetRelatedQuery, GetVersionsQuery, ListNotesQuery, SearchNotesQuery},
|
||||
remove_tag as uc_remove_tag, search_notes as uc_search_notes, update_note as uc_update_note,
|
||||
};
|
||||
use domain::note::entity::NoteFilter;
|
||||
|
||||
use crate::{
|
||||
error::{ApiError, ApiResult},
|
||||
extractors::CurrentUser,
|
||||
mapping::{note_link_response, note_response, note_version_response},
|
||||
state::PresentationState,
|
||||
};
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/notes",
|
||||
params(ListNotesParams),
|
||||
responses(
|
||||
(status = 200, body = Vec<NoteResponse>),
|
||||
(status = 401, body = api_types::errors::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn list_notes(
|
||||
State(state): State<PresentationState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Query(params): Query<ListNotesParams>,
|
||||
) -> ApiResult<Json<Vec<NoteResponse>>> {
|
||||
let user_id = user.id.as_uuid();
|
||||
|
||||
let filter = NoteFilter {
|
||||
is_pinned: params.pinned,
|
||||
is_archived: params.archived,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let notes = uc_list_notes::execute(
|
||||
&state.ctx,
|
||||
ListNotesQuery {
|
||||
user_id,
|
||||
filter,
|
||||
tag_name: params.tag,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
Ok(Json(notes.into_iter().map(note_response).collect()))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/notes",
|
||||
request_body = CreateNoteRequest,
|
||||
responses(
|
||||
(status = 201, body = NoteResponse),
|
||||
(status = 401, body = api_types::errors::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn create_note(
|
||||
State(state): State<PresentationState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Json(payload): Json<CreateNoteRequest>,
|
||||
) -> ApiResult<(StatusCode, Json<NoteResponse>)> {
|
||||
let note = uc_create_note::execute(
|
||||
&state.ctx,
|
||||
CreateNoteCommand {
|
||||
user_id: user.id.as_uuid(),
|
||||
title: payload.title,
|
||||
content: payload.content,
|
||||
color: payload.color,
|
||||
is_pinned: payload.is_pinned,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(note_response(note))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/notes/{id}",
|
||||
params(("id" = Uuid, Path, description = "Note ID")),
|
||||
responses(
|
||||
(status = 200, body = NoteResponse),
|
||||
(status = 401, body = api_types::errors::ErrorResponse),
|
||||
(status = 403, body = api_types::errors::ErrorResponse),
|
||||
(status = 404, body = api_types::errors::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_note(
|
||||
State(state): State<PresentationState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> ApiResult<Json<NoteResponse>> {
|
||||
let note = uc_get_note::execute(
|
||||
&state.ctx,
|
||||
GetNoteQuery {
|
||||
note_id: id,
|
||||
user_id: user.id.as_uuid(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
Ok(Json(note_response(note)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch, path = "/api/v1/notes/{id}",
|
||||
params(("id" = Uuid, Path, description = "Note ID")),
|
||||
request_body = UpdateNoteRequest,
|
||||
responses(
|
||||
(status = 200, body = NoteResponse),
|
||||
(status = 401, body = api_types::errors::ErrorResponse),
|
||||
(status = 403, body = api_types::errors::ErrorResponse),
|
||||
(status = 404, body = api_types::errors::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn update_note(
|
||||
State(state): State<PresentationState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateNoteRequest>,
|
||||
) -> ApiResult<Json<NoteResponse>> {
|
||||
let note = uc_update_note::execute(
|
||||
&state.ctx,
|
||||
UpdateNoteCommand {
|
||||
note_id: id,
|
||||
user_id: user.id.as_uuid(),
|
||||
title: payload.title,
|
||||
content: payload.content,
|
||||
color: payload.color,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
Ok(Json(note_response(note)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/v1/notes/{id}",
|
||||
params(("id" = Uuid, Path, description = "Note ID")),
|
||||
responses(
|
||||
(status = 204, description = "Deleted"),
|
||||
(status = 401, body = api_types::errors::ErrorResponse),
|
||||
(status = 403, body = api_types::errors::ErrorResponse),
|
||||
(status = 404, body = api_types::errors::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_note(
|
||||
State(state): State<PresentationState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
uc_delete_note::execute(
|
||||
&state.ctx,
|
||||
DeleteNoteCommand {
|
||||
note_id: id,
|
||||
user_id: user.id.as_uuid(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch, path = "/api/v1/notes/{id}/pin",
|
||||
params(("id" = Uuid, Path, description = "Note ID")),
|
||||
request_body = PinRequest,
|
||||
responses(
|
||||
(status = 200, body = NoteResponse),
|
||||
(status = 401, body = api_types::errors::ErrorResponse),
|
||||
(status = 403, body = api_types::errors::ErrorResponse),
|
||||
(status = 404, body = api_types::errors::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn pin_note(
|
||||
State(state): State<PresentationState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(payload): Json<PinRequest>,
|
||||
) -> ApiResult<Json<NoteResponse>> {
|
||||
let note = uc_pin_note::execute(
|
||||
&state.ctx,
|
||||
PinNoteCommand {
|
||||
note_id: id,
|
||||
user_id: user.id.as_uuid(),
|
||||
pinned: payload.pinned,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
Ok(Json(note_response(note)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch, path = "/api/v1/notes/{id}/archive",
|
||||
params(("id" = Uuid, Path, description = "Note ID")),
|
||||
request_body = ArchiveRequest,
|
||||
responses(
|
||||
(status = 200, body = NoteResponse),
|
||||
(status = 401, body = api_types::errors::ErrorResponse),
|
||||
(status = 403, body = api_types::errors::ErrorResponse),
|
||||
(status = 404, body = api_types::errors::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn archive_note(
|
||||
State(state): State<PresentationState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(payload): Json<ArchiveRequest>,
|
||||
) -> ApiResult<Json<NoteResponse>> {
|
||||
let note = uc_archive_note::execute(
|
||||
&state.ctx,
|
||||
ArchiveNoteCommand {
|
||||
note_id: id,
|
||||
user_id: user.id.as_uuid(),
|
||||
archived: payload.archived,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
Ok(Json(note_response(note)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/search",
|
||||
params(SearchParams),
|
||||
responses(
|
||||
(status = 200, body = Vec<NoteResponse>),
|
||||
(status = 401, body = api_types::errors::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn search_notes(
|
||||
State(state): State<PresentationState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Query(params): Query<SearchParams>,
|
||||
) -> ApiResult<Json<Vec<NoteResponse>>> {
|
||||
let notes = uc_search_notes::execute(
|
||||
&state.ctx,
|
||||
SearchNotesQuery {
|
||||
user_id: user.id.as_uuid(),
|
||||
query: params.q,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
Ok(Json(notes.into_iter().map(note_response).collect()))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/notes/{id}/versions",
|
||||
params(("id" = Uuid, Path, description = "Note ID")),
|
||||
responses(
|
||||
(status = 200, body = Vec<NoteVersionResponse>),
|
||||
(status = 401, body = api_types::errors::ErrorResponse),
|
||||
(status = 403, body = api_types::errors::ErrorResponse),
|
||||
(status = 404, body = api_types::errors::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_versions(
|
||||
State(state): State<PresentationState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> ApiResult<Json<Vec<NoteVersionResponse>>> {
|
||||
let versions = uc_get_versions::execute(
|
||||
&state.ctx,
|
||||
GetVersionsQuery {
|
||||
note_id: id,
|
||||
user_id: user.id.as_uuid(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
Ok(Json(
|
||||
versions.into_iter().map(note_version_response).collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/notes/{id}/related",
|
||||
params(("id" = Uuid, Path, description = "Note ID")),
|
||||
responses(
|
||||
(status = 200, body = Vec<NoteLinkResponse>),
|
||||
(status = 401, body = api_types::errors::ErrorResponse),
|
||||
(status = 403, body = api_types::errors::ErrorResponse),
|
||||
(status = 404, body = api_types::errors::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_related(
|
||||
State(state): State<PresentationState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> ApiResult<Json<Vec<NoteLinkResponse>>> {
|
||||
let links = uc_get_related::execute(
|
||||
&state.ctx,
|
||||
GetRelatedQuery {
|
||||
note_id: id,
|
||||
user_id: user.id.as_uuid(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
Ok(Json(links.into_iter().map(note_link_response).collect()))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/notes/{id}/tags",
|
||||
params(("id" = Uuid, Path, description = "Note ID")),
|
||||
request_body = AddTagRequest,
|
||||
responses(
|
||||
(status = 204, description = "Tag added"),
|
||||
(status = 401, body = api_types::errors::ErrorResponse),
|
||||
(status = 403, body = api_types::errors::ErrorResponse),
|
||||
(status = 404, body = api_types::errors::ErrorResponse),
|
||||
(status = 409, body = api_types::errors::ErrorResponse, description = "Tag limit reached"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn add_tag(
|
||||
State(state): State<PresentationState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(note_id): Path<Uuid>,
|
||||
Json(payload): Json<AddTagRequest>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
use application::tags::{commands::CreateTagCommand, create_tag};
|
||||
|
||||
let tag = create_tag::execute(
|
||||
&state.ctx,
|
||||
CreateTagCommand {
|
||||
user_id: user.id.as_uuid(),
|
||||
name: payload.tag_name,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
uc_add_tag::execute(
|
||||
&state.ctx,
|
||||
AddTagCommand {
|
||||
note_id,
|
||||
tag_id: tag.id.as_uuid(),
|
||||
user_id: user.id.as_uuid(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/v1/notes/{id}/tags/{tag_id}",
|
||||
params(
|
||||
("id" = Uuid, Path, description = "Note ID"),
|
||||
("tag_id" = Uuid, Path, description = "Tag ID"),
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "Tag removed"),
|
||||
(status = 401, body = api_types::errors::ErrorResponse),
|
||||
(status = 403, body = api_types::errors::ErrorResponse),
|
||||
(status = 404, body = api_types::errors::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn remove_tag(
|
||||
State(state): State<PresentationState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path((note_id, tag_id)): Path<(Uuid, Uuid)>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
uc_remove_tag::execute(
|
||||
&state.ctx,
|
||||
RemoveTagCommand {
|
||||
note_id,
|
||||
tag_id,
|
||||
user_id: user.id.as_uuid(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
133
crates/presentation/src/routes/tags.rs
Normal file
133
crates/presentation/src/routes/tags.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use api_types::tags::{CreateTagRequest, RenameTagRequest, TagResponse};
|
||||
use application::tags::{
|
||||
commands::{CreateTagCommand, DeleteTagCommand, RenameTagCommand},
|
||||
create_tag, delete_tag, list_tags,
|
||||
queries::ListTagsQuery,
|
||||
rename_tag,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
error::{ApiError, ApiResult},
|
||||
extractors::CurrentUser,
|
||||
mapping::tag_response,
|
||||
state::PresentationState,
|
||||
};
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/tags",
|
||||
responses(
|
||||
(status = 200, body = Vec<TagResponse>),
|
||||
(status = 401, body = api_types::errors::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn list_tags(
|
||||
State(state): State<PresentationState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
) -> ApiResult<Json<Vec<TagResponse>>> {
|
||||
let tags = list_tags::execute(
|
||||
&state.ctx,
|
||||
ListTagsQuery {
|
||||
user_id: user.id.as_uuid(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
Ok(Json(tags.into_iter().map(tag_response).collect()))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/tags",
|
||||
request_body = CreateTagRequest,
|
||||
responses(
|
||||
(status = 201, body = TagResponse),
|
||||
(status = 401, body = api_types::errors::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn create_tag(
|
||||
State(state): State<PresentationState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Json(payload): Json<CreateTagRequest>,
|
||||
) -> ApiResult<(StatusCode, Json<TagResponse>)> {
|
||||
let tag = create_tag::execute(
|
||||
&state.ctx,
|
||||
CreateTagCommand {
|
||||
user_id: user.id.as_uuid(),
|
||||
name: payload.name,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(tag_response(tag))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/v1/tags/{id}",
|
||||
params(("id" = Uuid, Path, description = "Tag ID")),
|
||||
responses(
|
||||
(status = 204, description = "Deleted"),
|
||||
(status = 401, body = api_types::errors::ErrorResponse),
|
||||
(status = 403, body = api_types::errors::ErrorResponse),
|
||||
(status = 404, body = api_types::errors::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_tag(
|
||||
State(state): State<PresentationState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
delete_tag::execute(
|
||||
&state.ctx,
|
||||
DeleteTagCommand {
|
||||
tag_id: id,
|
||||
user_id: user.id.as_uuid(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch, path = "/api/v1/tags/{id}",
|
||||
params(("id" = Uuid, Path, description = "Tag ID")),
|
||||
request_body = RenameTagRequest,
|
||||
responses(
|
||||
(status = 200, body = TagResponse),
|
||||
(status = 401, body = api_types::errors::ErrorResponse),
|
||||
(status = 403, body = api_types::errors::ErrorResponse),
|
||||
(status = 404, body = api_types::errors::ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn rename_tag(
|
||||
State(state): State<PresentationState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(payload): Json<RenameTagRequest>,
|
||||
) -> ApiResult<Json<TagResponse>> {
|
||||
let tag = rename_tag::execute(
|
||||
&state.ctx,
|
||||
RenameTagCommand {
|
||||
tag_id: id,
|
||||
user_id: user.id.as_uuid(),
|
||||
new_name: payload.name,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
Ok(Json(tag_response(tag)))
|
||||
}
|
||||
19
crates/presentation/src/state.rs
Normal file
19
crates/presentation/src/state.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::context::AppContext;
|
||||
use auth::jwt::JwtValidator;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PresentationState {
|
||||
pub ctx: AppContext,
|
||||
pub jwt_validator: Arc<JwtValidator>,
|
||||
}
|
||||
|
||||
impl PresentationState {
|
||||
pub fn new(ctx: AppContext, jwt_validator: JwtValidator) -> Self {
|
||||
Self {
|
||||
ctx,
|
||||
jwt_validator: Arc::new(jwt_validator),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user