refactor (v2): better arch

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-06-07 21:19:54 +02:00
parent 0753f3d256
commit 839308ec19
166 changed files with 8553 additions and 884 deletions

View 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))
}

View 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)
}

View 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),
)
}

View 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)
}

View 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)))
}