From fa43bc749ec785a799c507cec539d10cf10e6219 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Fri, 31 Oct 2025 01:19:01 +0100 Subject: [PATCH] cleanup --- .../202510300000_initial_schema/up.sql | 2 +- backend/src/auth/mod.rs | 3 +- backend/src/openapi.rs | 794 ++---------------- backend/src/routes/auth.rs | 136 ++- backend/src/routes/correspondents.rs | 45 + backend/src/routes/document_types.rs | 44 + backend/src/routes/documents.rs | 326 ++++++- backend/src/routes/folders.rs | 154 ++-- backend/src/routes/health.rs | 10 + backend/src/routes/profile.rs | 55 ++ backend/src/routes/tags.rs | 78 +- backend/src/utils/json.rs | 9 + backend/tests/auth_flow.rs | 28 +- backend/tests/common/mod.rs | 52 +- backend/tests/tags_flow.rs | 8 +- backend/tests/webdav_flow.rs | 6 +- 16 files changed, 878 insertions(+), 872 deletions(-) diff --git a/backend/migrations/202510300000_initial_schema/up.sql b/backend/migrations/202510300000_initial_schema/up.sql index b39123c..c6d4296 100644 --- a/backend/migrations/202510300000_initial_schema/up.sql +++ b/backend/migrations/202510300000_initial_schema/up.sql @@ -65,7 +65,7 @@ CREATE TABLE documents ( original_name VARCHAR(255) NOT NULL, content_type VARCHAR(100), folder_id UUID REFERENCES folders(id) ON DELETE SET NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ, metadata JSONB NOT NULL DEFAULT '{}'::jsonb, diff --git a/backend/src/auth/mod.rs b/backend/src/auth/mod.rs index 211e91b..c57dec8 100644 --- a/backend/src/auth/mod.rs +++ b/backend/src/auth/mod.rs @@ -7,6 +7,7 @@ use axum::{async_trait, extract::FromRequestParts, http::request::Parts}; use axum_extra::headers::{authorization::Bearer, Authorization}; use axum_extra::TypedHeader; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; use crate::{ error::AppError, @@ -14,7 +15,7 @@ use crate::{ }; use uuid::Uuid; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct AuthenticatedUser { pub user_id: uuid::Uuid, pub username: String, diff --git a/backend/src/openapi.rs b/backend/src/openapi.rs index f9ddc14..81ce2ba 100644 --- a/backend/src/openapi.rs +++ b/backend/src/openapi.rs @@ -1,737 +1,64 @@ +use utoipa::openapi::{self, tag::TagBuilder, InfoBuilder}; use utoipa::OpenApi; -#[derive(OpenApi)] -#[openapi( - paths( - doc::health_check, - doc::login, - doc::signup_start, - doc::signup_finish, - doc::refresh, - doc::logout, - doc::me, - doc::list_tenants, - doc::select_tenant, - doc::passkey_register_start, - doc::passkey_register_finish, - doc::passkey_login_start, - doc::passkey_login_finish, - doc::list_documents, - doc::check_document, - doc::upload_document, - doc::get_document, - doc::update_document, - doc::delete_document, - doc::download_with_token, - doc::move_document, - doc::assign_tags, - doc::remove_tag, - doc::bulk_move_documents, - doc::bulk_update_tags, - doc::bulk_assign_correspondents, - doc::assign_correspondents, - doc::remove_correspondent, - doc::reanalyze_selected_documents, - doc::list_document_assets, - doc::request_document_assets, - doc::get_document_asset, - doc::create_folder, - doc::ensure_folder_path, - doc::get_folder, - doc::list_folder_contents, - doc::delete_folder, - doc::update_folder, - doc::list_tags, - doc::create_tag, - doc::update_tag, - doc::delete_tag, - doc::list_document_types, - doc::create_document_type, - doc::update_document_type, - doc::delete_document_type, - doc::list_correspondents, - doc::create_correspondent, - doc::update_correspondent, - doc::delete_correspondent, - doc::list_webdav_tokens, - doc::create_webdav_token, - doc::delete_webdav_token, - doc::list_passkeys, - doc::delete_passkey, - ), - components( - schemas( - schemas::LoginRequest, - schemas::SignupStartRequest, - schemas::SignupStartResponse, - schemas::SignupFinishRequest, - schemas::LoginResponse, - schemas::LoginResponseVariants, - schemas::TenantSnippet, - schemas::TenantSelectionResponse, - schemas::TenantSelectionRequest, - schemas::TenantListResponse, - schemas::RegistrationChallengeResponse, - schemas::AuthenticationChallengeResponse, - schemas::PasskeySummary, - schemas::PasskeyRegistrationFinishPayload, - schemas::PasskeyLoginStartPayload, - schemas::PasskeyLoginFinishPayload, - schemas::DocumentListQuery, - schemas::DocumentStatusFilter, - schemas::DocumentResponse, - schemas::DocumentDetailResponse, - schemas::DocumentMetadataUpdate, - schemas::DocumentVersionResponse, - schemas::DocumentVersionDetailResponse, - schemas::DocumentAssetResponse, - schemas::DocumentAssetDetailResponse, - schemas::DocumentAssetObjectResponse, - schemas::DocumentTypeResponse, - schemas::CreateDocumentTypeRequest, - schemas::UpdateDocumentTypeRequest, - schemas::DocumentCorrespondentResponse, - schemas::TagResponse, - schemas::UpdateDocumentRequest, - schemas::RestoreDocumentRequest, - schemas::BulkMoveRequest, - schemas::BulkMoveResponse, - schemas::AssignTagsRequest, - schemas::MoveDocumentRequest, - schemas::BulkTagAction, - schemas::BulkTagRequest, - schemas::BulkTagResponse, - schemas::CorrespondentAssignmentInput, - schemas::AssignCorrespondentsRequest, - schemas::BulkCorrespondentAction, - schemas::BulkCorrespondentsRequest, - schemas::BulkCorrespondentResponse, - schemas::BulkReanalyzeSelectionRequest, - schemas::BulkReanalyzeResponse, - schemas::AssetRequestQuery, - schemas::AssetObjectsQuery, - schemas::DocumentCheckQuery, - schemas::DocumentCheckResponse, - schemas::UploadDocumentForm, - schemas::CreateFolderRequest, - schemas::EnsureFolderPathRequest, - schemas::FolderInfo, - schemas::FolderResponse, - schemas::FolderContentsQuery, - schemas::FolderContentsResponse, - schemas::UpdateFolderRequest, - schemas::TagCatalogEntry, - schemas::CreateTagRequest, - schemas::UpdateTagRequest, - schemas::CorrespondentUsage, - schemas::CorrespondentSummary, - schemas::CreateCorrespondentRequest, - schemas::UpdateCorrespondentRequest, - schemas::WebdavTokenResponse, - schemas::WebdavTokenCreatedResponse, - schemas::CreateWebdavTokenRequest, - schemas::RevokePasskeyQuery, - ) - ), - tags( - (name = "Health", description = "Service health"), - (name = "Auth", description = "Authentication"), - (name = "Documents", description = "Document management"), - (name = "Assets", description = "Document assets"), - (name = "Folders", description = "Folder management"), - (name = "Tags", description = "Tag catalog"), - (name = "DocumentTypes", description = "Document type catalog"), - (name = "Correspondents", description = "Correspondent catalog"), - (name = "Profile", description = "User profile and WebDAV tokens") - ) -)] pub struct ApiDoc; -#[allow(dead_code)] -mod doc { - use super::schemas::*; - use uuid::Uuid; +impl OpenApi for ApiDoc { + fn openapi() -> openapi::OpenApi { + let mut doc = crate::routes::health::HealthApiDoc::openapi(); + doc.merge(crate::routes::auth::AuthApiDoc::openapi()); + doc.merge(crate::routes::documents::DocumentsApiDoc::openapi()); + doc.merge(crate::routes::folders::FoldersApiDoc::openapi()); + doc.merge(crate::routes::tags::TagsApiDoc::openapi()); + doc.merge(crate::routes::document_types::DocumentTypesApiDoc::openapi()); + doc.merge(crate::routes::correspondents::CorrespondentsApiDoc::openapi()); + doc.merge(crate::routes::profile::ProfileApiDoc::openapi()); - #[allow(dead_code)] - fn __keep_uuid_import() { - let _ = Uuid::nil(); - } + doc.info = InfoBuilder::new() + .title("Papercrate API") + .version(env!("CARGO_PKG_VERSION")) + .build(); - #[utoipa::path( - get, - path = "/api/health", - responses((status = 200, description = "Service is healthy")), - tag = "Health" - )] - pub(super) fn health_check() {} + doc.tags = Some(vec![ + TagBuilder::new() + .name("Health") + .description(Some("Service health")) + .build(), + TagBuilder::new() + .name("Auth") + .description(Some("Authentication")) + .build(), + TagBuilder::new() + .name("Documents") + .description(Some("Document management")) + .build(), + TagBuilder::new() + .name("Assets") + .description(Some("Document assets")) + .build(), + TagBuilder::new() + .name("Folders") + .description(Some("Folder management")) + .build(), + TagBuilder::new() + .name("Tags") + .description(Some("Tag catalog")) + .build(), + TagBuilder::new() + .name("DocumentTypes") + .description(Some("Document type catalog")) + .build(), + TagBuilder::new() + .name("Correspondents") + .description(Some("Correspondent catalog")) + .build(), + TagBuilder::new() + .name("Profile") + .description(Some("User profile and WebDAV tokens")) + .build(), + ]); - #[utoipa::path( - post, - path = "/api/auth/login", - request_body = LoginRequest, - responses( - (status = 200, description = "Login succeeded", body = LoginResponseVariants), - (status = 401, description = "Invalid credentials") - ), - tag = "Auth" - )] - pub(super) fn login() {} - - #[utoipa::path( - post, - path = "/api/auth/signup/start", - request_body = SignupStartRequest, - responses( - (status = 200, description = "Signup challenge created", body = SignupStartResponse), - (status = 400, description = "Invalid signup request"), - (status = 409, description = "Username already exists") - ), - tag = "Auth" - )] - pub(super) fn signup_start() {} - - #[utoipa::path( - post, - path = "/api/auth/signup/finish", - request_body = SignupFinishRequest, - responses( - (status = 200, description = "Signup completed", body = LoginResponseVariants), - (status = 400, description = "Invalid signup completion"), - (status = 409, description = "Username already exists") - ), - tag = "Auth" - )] - pub(super) fn signup_finish() {} - - #[utoipa::path( - post, - path = "/api/auth/refresh", - responses( - (status = 200, description = "Refreshed access token", body = AccessTokenResponse), - (status = 401, description = "Missing or invalid refresh token") - ), - tag = "Auth" - )] - pub(super) fn refresh() {} - - #[utoipa::path( - post, - path = "/api/auth/logout", - responses((status = 204, description = "Session revoked")), - tag = "Auth" - )] - pub(super) fn logout() {} - - #[utoipa::path( - get, - path = "/api/auth/me", - responses((status = 200, description = "Authenticated principal", body = AccessTokenResponse)), - tag = "Auth" - )] - pub(super) fn me() {} - - #[utoipa::path( - get, - path = "/api/auth/tenants", - responses((status = 200, description = "Available tenants", body = TenantListResponse)), - tag = "Auth" - )] - pub(super) fn list_tenants() {} - - #[utoipa::path( - post, - path = "/api/auth/select-tenant", - request_body = TenantSelectionRequest, - responses((status = 200, description = "Tenant selected", body = AccessTokenResponse)), - tag = "Auth" - )] - pub(super) fn select_tenant() {} - - #[utoipa::path( - post, - path = "/api/auth/passkeys/register/start", - responses((status = 200, description = "Passkey registration challenge", body = RegistrationChallengeResponse)), - tag = "Auth" - )] - pub(super) fn passkey_register_start() {} - - #[utoipa::path( - post, - path = "/api/auth/passkeys/register/finish", - request_body = PasskeyRegistrationFinishPayload, - responses((status = 200, description = "Passkey registered", body = PasskeySummary)), - tag = "Auth" - )] - pub(super) fn passkey_register_finish() {} - - #[utoipa::path( - post, - path = "/api/auth/passkeys/login/start", - request_body = PasskeyLoginStartPayload, - responses((status = 200, description = "Passkey authentication challenge", body = AuthenticationChallengeResponse)), - tag = "Auth" - )] - pub(super) fn passkey_login_start() {} - - #[utoipa::path( - post, - path = "/api/auth/passkeys/login/finish", - request_body = PasskeyLoginFinishPayload, - responses( - (status = 200, description = "Passkey login successful", body = LoginResponseVariants), - (status = 401, description = "Authentication failed") - ), - tag = "Auth" - )] - pub(super) fn passkey_login_finish() {} - - #[utoipa::path( - get, - path = "/api/documents", - params(DocumentListQuery), - responses((status = 200, description = "List documents", body = [DocumentResponse])), - tag = "Documents" - )] - pub(super) fn list_documents() {} - - #[utoipa::path( - post, - path = "/api/documents", - request_body = UploadDocumentForm, - responses( - (status = 201, description = "Document created", body = DocumentDetailResponse), - (status = 200, description = "Existing document reused", body = DocumentDetailResponse), - (status = 204, description = "Upload skipped because the document already exists") - ), - tag = "Documents" - )] - pub(super) fn upload_document() {} - - #[utoipa::path( - get, - path = "/api/documents/check", - params(DocumentCheckQuery), - responses((status = 200, description = "Checksum lookup", body = DocumentCheckResponse)), - tag = "Documents" - )] - pub(super) fn check_document() {} - - #[utoipa::path( - get, - path = "/api/documents/{id}", - params(("id" = Uuid, Path, description = "Document ID")), - responses((status = 200, description = "Document detail", body = DocumentDetailResponse)), - tag = "Documents" - )] - pub(super) fn get_document() {} - - #[utoipa::path( - patch, - path = "/api/documents/{id}", - params(("id" = Uuid, Path, description = "Document ID")), - request_body = UpdateDocumentRequest, - responses((status = 200, description = "Updated document", body = DocumentDetailResponse)), - tag = "Documents" - )] - pub(super) fn update_document() {} - - #[utoipa::path( - delete, - path = "/api/documents/{id}", - params(("id" = Uuid, Path, description = "Document ID")), - responses((status = 204, description = "Document deleted")), - tag = "Documents" - )] - pub(super) fn delete_document() {} - - #[utoipa::path( - get, - path = "/api/documents/{id}/versions", - params(("id" = Uuid, Path, description = "Document ID")), - responses((status = 200, description = "Document versions", body = [DocumentVersionResponse])), - tag = "Documents" - )] - pub(super) fn list_document_versions() {} - - #[utoipa::path( - get, - path = "/api/documents/{id}/versions/{version_id}", - params( - ("id" = Uuid, Path, description = "Document ID"), - ("version_id" = Uuid, Path, description = "Version ID"), - ), - responses((status = 200, description = "Document version detail", body = DocumentVersionDetailResponse)), - tag = "Documents" - )] - pub(super) fn get_document_version() {} - - #[utoipa::path( - post, - path = "/api/documents/{id}/restore", - params(("id" = Uuid, Path, description = "Document ID")), - request_body = RestoreDocumentRequest, - responses((status = 204, description = "Document restored")), - tag = "Documents" - )] - pub(super) fn restore_document() {} - - #[utoipa::path( - get, - path = "/download/{token}", - params(("token" = String, Path, description = "Download token")), - responses((status = 302, description = "Redirect to pre-signed URL")), - tag = "Documents" - )] - pub(super) fn download_with_token() {} - - #[utoipa::path( - patch, - path = "/api/documents/{id}/folder", - params(("id" = Uuid, Path, description = "Document ID")), - request_body = MoveDocumentRequest, - responses((status = 204, description = "Document moved")), - tag = "Documents" - )] - pub(super) fn move_document() {} - - #[utoipa::path( - post, - path = "/api/documents/{id}/tags", - params(("id" = Uuid, Path, description = "Document ID")), - request_body = AssignTagsRequest, - responses((status = 204, description = "Tags assigned")), - tag = "Documents" - )] - pub(super) fn assign_tags() {} - - #[utoipa::path( - delete, - path = "/api/documents/{id}/tags/{tag_id}", - params( - ("id" = Uuid, Path, description = "Document ID"), - ("tag_id" = Uuid, Path, description = "Tag ID") - ), - responses((status = 204, description = "Tag removed")), - tag = "Documents" - )] - pub(super) fn remove_tag() {} - - #[utoipa::path( - post, - path = "/api/documents/bulk/move", - request_body = BulkMoveRequest, - responses((status = 200, description = "Bulk move outcome", body = BulkMoveResponse)), - tag = "Documents" - )] - pub(super) fn bulk_move_documents() {} - - #[utoipa::path( - post, - path = "/api/documents/bulk/tags", - request_body = BulkTagRequest, - responses((status = 200, description = "Bulk tag outcome", body = BulkTagResponse)), - tag = "Documents" - )] - pub(super) fn bulk_update_tags() {} - - #[utoipa::path( - post, - path = "/api/documents/bulk/correspondents", - request_body = BulkCorrespondentsRequest, - responses((status = 200, description = "Bulk correspondents outcome", body = BulkCorrespondentResponse)), - tag = "Documents" - )] - pub(super) fn bulk_assign_correspondents() {} - - #[utoipa::path( - post, - path = "/api/documents/{id}/correspondents", - params(("id" = Uuid, Path, description = "Document ID")), - request_body = AssignCorrespondentsRequest, - responses((status = 204, description = "Correspondents assigned")), - tag = "Documents" - )] - pub(super) fn assign_correspondents() {} - - #[utoipa::path( - delete, - path = "/api/documents/{id}/correspondents/{correspondent_id}", - params( - ("id" = Uuid, Path, description = "Document ID"), - ("correspondent_id" = Uuid, Path, description = "Correspondent ID") - ), - responses((status = 204, description = "Correspondent removed")), - tag = "Documents" - )] - pub(super) fn remove_correspondent() {} - - #[utoipa::path( - post, - path = "/api/documents/bulk/reanalyze", - request_body = BulkReanalyzeSelectionRequest, - responses((status = 200, description = "Reanalyze queued", body = BulkReanalyzeResponse)), - tag = "Documents" - )] - pub(super) fn reanalyze_selected_documents() {} - - #[utoipa::path( - get, - path = "/api/documents/{id}/assets", - params(("id" = Uuid, Path, description = "Document ID")), - responses((status = 200, description = "Document assets", body = [DocumentAssetResponse])), - tag = "Assets" - )] - pub(super) fn list_document_assets() {} - - #[utoipa::path( - post, - path = "/api/documents/{id}/assets", - params( - ("id" = Uuid, Path, description = "Document ID"), - AssetRequestQuery - ), - responses((status = 202, description = "Asset generation requested")), - tag = "Assets" - )] - pub(super) fn request_document_assets() {} - - #[utoipa::path( - get, - path = "/api/assets/{asset_id}", - params( - ("asset_id" = Uuid, Path, description = "Asset ID"), - AssetObjectsQuery - ), - responses((status = 200, description = "Asset detail", body = DocumentAssetDetailResponse)), - tag = "Assets" - )] - pub(super) fn get_document_asset() {} - - #[utoipa::path( - post, - path = "/api/folders", - request_body = CreateFolderRequest, - responses( - (status = 201, description = "Folder created", body = FolderResponse), - (status = 200, description = "Folder already existed", body = FolderResponse) - ), - tag = "Folders" - )] - pub(super) fn create_folder() {} - - #[utoipa::path( - post, - path = "/api/folders/path", - request_body = EnsureFolderPathRequest, - responses((status = 200, description = "Folder path ensured", body = FolderResponse)), - tag = "Folders" - )] - pub(super) fn ensure_folder_path() {} - - #[utoipa::path( - get, - path = "/api/folders/{id}", - params(("id" = Uuid, Path, description = "Folder ID")), - responses((status = 200, description = "Folder detail", body = FolderResponse)), - tag = "Folders" - )] - pub(super) fn get_folder() {} - - #[utoipa::path( - get, - path = "/api/folders/{id}/contents", - params( - ("id" = Uuid, Path, description = "Folder ID"), - FolderContentsQuery - ), - responses((status = 200, description = "Folder contents", body = FolderContentsResponse)), - tag = "Folders" - )] - pub(super) fn list_folder_contents() {} - - #[utoipa::path( - delete, - path = "/api/folders/{id}", - params(("id" = Uuid, Path, description = "Folder ID")), - responses((status = 204, description = "Folder deleted")), - tag = "Folders" - )] - pub(super) fn delete_folder() {} - - #[utoipa::path( - patch, - path = "/api/folders/{id}", - params(("id" = Uuid, Path, description = "Folder ID")), - request_body = UpdateFolderRequest, - responses((status = 204, description = "Folder updated")), - tag = "Folders" - )] - pub(super) fn update_folder() {} - - #[utoipa::path( - get, - path = "/api/tags", - responses((status = 200, description = "Tags", body = [TagCatalogEntry])), - tag = "Tags" - )] - pub(super) fn list_tags() {} - - #[utoipa::path( - post, - path = "/api/tags", - request_body = CreateTagRequest, - responses((status = 200, description = "Tag created", body = TagCatalogEntry)), - tag = "Tags" - )] - pub(super) fn create_tag() {} - - #[utoipa::path( - get, - path = "/api/document-types", - responses((status = 200, description = "Document types", body = [DocumentTypeResponse])), - tag = "DocumentTypes" - )] - pub(super) fn list_document_types() {} - - #[utoipa::path( - post, - path = "/api/document-types", - request_body = CreateDocumentTypeRequest, - responses((status = 201, description = "Document type created", body = DocumentTypeResponse)), - tag = "DocumentTypes" - )] - pub(super) fn create_document_type() {} - - #[utoipa::path( - patch, - path = "/api/document-types/{id}", - params(("id" = Uuid, Path, description = "Document type ID")), - request_body = UpdateDocumentTypeRequest, - responses((status = 200, description = "Document type updated", body = DocumentTypeResponse)), - tag = "DocumentTypes" - )] - pub(super) fn update_document_type() {} - - #[utoipa::path( - delete, - path = "/api/document-types/{id}", - params(("id" = Uuid, Path, description = "Document type ID")), - responses((status = 204, description = "Document type deleted")), - tag = "DocumentTypes" - )] - pub(super) fn delete_document_type() {} - - #[utoipa::path( - patch, - path = "/api/tags/{id}", - params(("id" = Uuid, Path, description = "Tag ID")), - request_body = UpdateTagRequest, - responses((status = 200, description = "Tag updated", body = TagCatalogEntry)), - tag = "Tags" - )] - pub(super) fn update_tag() {} - - #[utoipa::path( - delete, - path = "/api/tags/{id}", - params(("id" = Uuid, Path, description = "Tag ID")), - responses((status = 204, description = "Tag deleted")), - tag = "Tags" - )] - pub(super) fn delete_tag() {} - - #[utoipa::path( - get, - path = "/api/correspondents", - responses((status = 200, description = "Correspondents", body = [CorrespondentCatalogEntry])), - tag = "Correspondents" - )] - pub(super) fn list_correspondents() {} - - #[utoipa::path( - post, - path = "/api/correspondents", - request_body = CreateCorrespondentRequest, - responses((status = 200, description = "Correspondent created", body = CorrespondentCatalogEntry)), - tag = "Correspondents" - )] - pub(super) fn create_correspondent() {} - - #[utoipa::path( - patch, - path = "/api/correspondents/{id}", - params(("id" = Uuid, Path, description = "Correspondent ID")), - request_body = UpdateCorrespondentRequest, - responses((status = 200, description = "Correspondent updated", body = CorrespondentCatalogEntry)), - tag = "Correspondents" - )] - pub(super) fn update_correspondent() {} - - #[utoipa::path( - delete, - path = "/api/correspondents/{id}", - params(("id" = Uuid, Path, description = "Correspondent ID")), - responses((status = 204, description = "Correspondent deleted")), - tag = "Correspondents" - )] - pub(super) fn delete_correspondent() {} - - #[utoipa::path( - get, - path = "/api/profile/webdav-tokens", - responses((status = 200, description = "List WebDAV tokens", body = [WebdavTokenResponse])), - tag = "Profile" - )] - pub(super) fn list_webdav_tokens() {} - - #[utoipa::path( - post, - path = "/api/profile/webdav-tokens", - request_body = CreateWebdavTokenRequest, - responses((status = 201, description = "WebDAV token created", body = WebdavTokenCreatedResponse)), - tag = "Profile" - )] - pub(super) fn create_webdav_token() {} - - #[utoipa::path( - delete, - path = "/api/profile/webdav-tokens/{id}", - params(("id" = Uuid, Path, description = "WebDAV token ID")), - responses((status = 204, description = "WebDAV token revoked")), - tag = "Profile" - )] - pub(super) fn delete_webdav_token() {} - - #[utoipa::path( - get, - path = "/api/profile/passkeys", - responses((status = 200, description = "List registered passkeys", body = [PasskeySummary])), - tag = "Profile" - )] - pub(super) fn list_passkeys() {} - - #[utoipa::path( - delete, - path = "/api/profile/passkeys/{id}", - params( - ("id" = Uuid, Path, description = "Passkey ID"), - ("reason" = Option, Query, description = "Optional reason for revoking the passkey") - ), - responses((status = 204, description = "Passkey revoked")), - tag = "Profile" - )] - pub(super) fn delete_passkey() {} -} - -#[cfg(test)] -mod tests { - use super::ApiDoc; - use utoipa::OpenApi; - - #[test] - fn openapi_serializes() { - let spec = ApiDoc::openapi(); - let _ = serde_json::to_string(&spec).expect("serialize openapi"); + doc } } @@ -740,6 +67,7 @@ pub mod schemas { AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse, }; + pub use crate::auth::AuthenticatedUser; pub use crate::documents::asset::{ DocumentAssetDetailResponse, DocumentAssetObjectResponse, DocumentAssetResponse, DocumentVersionDetailResponse, DocumentVersionResponse, @@ -775,3 +103,15 @@ pub mod schemas { }; pub use crate::routes::tags::{CreateTagRequest, TagCatalogEntry, UpdateTagRequest}; } + +#[cfg(test)] +mod tests { + use super::ApiDoc; + use utoipa::OpenApi; + + #[test] + fn openapi_serializes() { + let spec = ApiDoc::openapi(); + let _ = serde_json::to_string(&spec).expect("serialize openapi"); + } +} diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index 45a4cc7..6f467b8 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -13,7 +13,7 @@ use diesel::{pg::PgConnection, prelude::*}; use rand::{rngs::OsRng, RngCore}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use utoipa::ToSchema; +use utoipa::{OpenApi, ToSchema}; use uuid::Uuid; use crate::{ @@ -104,12 +104,71 @@ pub enum LoginResponseVariants { Selection(TenantSelectionResponse), } +#[derive(OpenApi)] +#[openapi( + paths( + login, + signup_start, + signup_finish, + refresh, + logout, + me, + list_tenants, + select_tenant, + passkey_register_start, + passkey_register_finish, + passkey_login_start, + passkey_login_finish, + ), + components(schemas( + LoginRequest, + SignupStartRequest, + SignupStartResponse, + SignupFinishRequest, + LoginResponse, + LoginResponseVariants, + TenantSnippet, + TenantSelectionResponse, + TenantSelectionRequest, + TenantListResponse, + crate::auth::AuthenticatedUser, + crate::auth::passkeys::RegistrationChallengeResponse, + crate::auth::passkeys::AuthenticationChallengeResponse, + crate::auth::passkeys::PasskeySummary, + crate::auth::passkeys::PasskeyRegistrationFinishPayload, + crate::auth::passkeys::PasskeyLoginStartPayload, + crate::auth::passkeys::PasskeyLoginFinishPayload, + )) +)] +pub struct AuthApiDoc; + +#[utoipa::path( + post, + path = "/api/auth/login", + request_body = LoginRequest, + responses( + (status = 200, description = "Login succeeded", body = LoginResponseVariants), + (status = 401, description = "Invalid credentials") + ), + tag = "Auth" +)] pub async fn login(_state: State, _payload: Json) -> AppResult { Err(AppError::bad_request( "password authentication is no longer supported", )) } +#[utoipa::path( + post, + path = "/api/auth/signup/start", + request_body = SignupStartRequest, + responses( + (status = 200, description = "Signup challenge created", body = SignupStartResponse), + (status = 400, description = "Invalid signup request"), + (status = 409, description = "Username already exists") + ), + tag = "Auth" +)] pub async fn signup_start( State(state): State, Json(payload): Json, @@ -147,6 +206,17 @@ pub async fn signup_start( })) } +#[utoipa::path( + post, + path = "/api/auth/signup/finish", + request_body = SignupFinishRequest, + responses( + (status = 200, description = "Signup completed", body = LoginResponseVariants), + (status = 400, description = "Invalid signup completion"), + (status = 409, description = "Username already exists") + ), + tag = "Auth" +)] pub async fn signup_finish( State(state): State, Json(payload): Json, @@ -204,6 +274,15 @@ pub async fn signup_finish( Ok(response) } +#[utoipa::path( + post, + path = "/api/auth/refresh", + responses( + (status = 200, description = "Refreshed access token", body = LoginResponse), + (status = 401, description = "Missing or invalid refresh token") + ), + tag = "Auth" +)] pub async fn refresh( State(state): State, jar: Option>, @@ -257,6 +336,13 @@ fn insert_user(conn: &mut PgConnection, id: Uuid, username: &str) -> AppResult<( .map_err(AppError::from) } +#[utoipa::path( + post, + path = "/api/auth/select-tenant", + request_body = TenantSelectionRequest, + responses((status = 200, description = "Tenant selected", body = LoginResponse)), + tag = "Auth" +)] pub async fn select_tenant( State(state): State, TypedHeader(Authorization(bearer)): TypedHeader>, @@ -293,6 +379,12 @@ pub async fn select_tenant( issue_session(&state, &mut conn, &user, payload.tenant_id) } +#[utoipa::path( + post, + path = "/api/auth/logout", + responses((status = 204, description = "Session revoked")), + tag = "Auth" +)] pub async fn logout( State(state): State, user: AuthenticatedUser, @@ -338,10 +430,22 @@ pub async fn logout( Ok((headers, StatusCode::NO_CONTENT)) } +#[utoipa::path( + get, + path = "/api/auth/me", + responses((status = 200, description = "Authenticated principal", body = AuthenticatedUser)), + tag = "Auth" +)] pub async fn me(user: AuthenticatedUser) -> Json { Json(user) } +#[utoipa::path( + get, + path = "/api/auth/tenants", + responses((status = 200, description = "Available tenants", body = TenantListResponse)), + tag = "Auth" +)] pub async fn list_tenants( State(state): State, auth: Option>>, @@ -374,6 +478,12 @@ pub async fn list_tenants( Ok(Json(TenantListResponse { tenants })) } +#[utoipa::path( + post, + path = "/api/auth/passkeys/register/start", + responses((status = 200, description = "Passkey registration challenge", body = RegistrationChallengeResponse)), + tag = "Auth" +)] pub async fn passkey_register_start( State(state): State, user: AuthenticatedUser, @@ -389,6 +499,13 @@ pub async fn passkey_register_start( Ok(Json(challenge)) } +#[utoipa::path( + post, + path = "/api/auth/passkeys/register/finish", + request_body = PasskeyRegistrationFinishPayload, + responses((status = 200, description = "Passkey registered", body = PasskeySummary)), + tag = "Auth" +)] pub async fn passkey_register_finish( State(state): State, user: AuthenticatedUser, @@ -419,6 +536,13 @@ pub async fn passkey_register_finish( Ok(Json(PasskeySummary::from(passkey))) } +#[utoipa::path( + post, + path = "/api/auth/passkeys/login/start", + request_body = PasskeyLoginStartPayload, + responses((status = 200, description = "Passkey authentication challenge", body = AuthenticationChallengeResponse)), + tag = "Auth" +)] pub async fn passkey_login_start( State(state): State, Json(payload): Json, @@ -442,6 +566,16 @@ pub async fn passkey_login_start( Ok(Json(challenge)) } +#[utoipa::path( + post, + path = "/api/auth/passkeys/login/finish", + request_body = PasskeyLoginFinishPayload, + responses( + (status = 200, description = "Passkey login successful", body = LoginResponseVariants), + (status = 401, description = "Authentication failed") + ), + tag = "Auth" +)] pub async fn passkey_login_finish( State(state): State, Json(payload): Json, diff --git a/backend/src/routes/correspondents.rs b/backend/src/routes/correspondents.rs index c8da22a..306b029 100644 --- a/backend/src/routes/correspondents.rs +++ b/backend/src/routes/correspondents.rs @@ -58,6 +58,12 @@ struct CorrespondentChangeset<'a> { metadata: Option<&'a Value>, } +#[utoipa::path( + get, + path = "/api/correspondents", + responses((status = 200, description = "Correspondents", body = [CorrespondentSummary])), + tag = "Correspondents" +)] pub async fn list_correspondents( TenantScopedConn { mut conn, @@ -90,6 +96,13 @@ pub async fn list_correspondents( response.into_json() } +#[utoipa::path( + post, + path = "/api/correspondents", + request_body = CreateCorrespondentRequest, + responses((status = 200, description = "Correspondent created", body = CorrespondentSummary)), + tag = "Correspondents" +)] pub async fn create_correspondent( TenantScopedConn { mut conn, @@ -132,6 +145,14 @@ pub async fn create_correspondent( build_summary(correspondent, 0).into_json() } +#[utoipa::path( + patch, + path = "/api/correspondents/{id}", + params(("id" = Uuid, Path, description = "Correspondent ID")), + request_body = UpdateCorrespondentRequest, + responses((status = 200, description = "Correspondent updated", body = CorrespondentSummary)), + tag = "Correspondents" +)] pub async fn update_correspondent( Path(correspondent_id): Path, TenantScopedConn { @@ -206,6 +227,13 @@ pub async fn update_correspondent( build_summary(updated, usage).into_json() } +#[utoipa::path( + delete, + path = "/api/correspondents/{id}", + params(("id" = Uuid, Path, description = "Correspondent ID")), + responses((status = 204, description = "Correspondent deleted")), + tag = "Correspondents" +)] pub async fn delete_correspondent( Path(correspondent_id): Path, TenantScopedConn { @@ -269,3 +297,20 @@ fn load_usage_for_correspondent( Ok(total) } + +#[derive(utoipa::OpenApi)] +#[openapi( + paths( + crate::routes::correspondents::list_correspondents, + crate::routes::correspondents::create_correspondent, + crate::routes::correspondents::update_correspondent, + crate::routes::correspondents::delete_correspondent + ), + components(schemas( + crate::routes::correspondents::CorrespondentSummary, + crate::routes::correspondents::CorrespondentUsage, + crate::routes::correspondents::CreateCorrespondentRequest, + crate::routes::correspondents::UpdateCorrespondentRequest + )) +)] +pub struct CorrespondentsApiDoc; diff --git a/backend/src/routes/document_types.rs b/backend/src/routes/document_types.rs index be808b7..7f614c6 100644 --- a/backend/src/routes/document_types.rs +++ b/backend/src/routes/document_types.rs @@ -26,6 +26,12 @@ pub struct UpdateDocumentTypeRequest { pub name: Option, } +#[utoipa::path( + get, + path = "/api/document-types", + responses((status = 200, description = "Document types", body = [DocumentTypeResponse])), + tag = "DocumentTypes" +)] pub async fn list_document_types( TenantScopedConn { mut conn, @@ -43,6 +49,13 @@ pub async fn list_document_types( )) } +#[utoipa::path( + post, + path = "/api/document-types", + request_body = CreateDocumentTypeRequest, + responses((status = 201, description = "Document type created", body = DocumentTypeResponse)), + tag = "DocumentTypes" +)] pub async fn create_document_type( TenantScopedConn { mut conn, @@ -81,6 +94,14 @@ pub async fn create_document_type( } } +#[utoipa::path( + patch, + path = "/api/document-types/{id}", + params(("id" = Uuid, Path, description = "Document type ID")), + request_body = UpdateDocumentTypeRequest, + responses((status = 200, description = "Document type updated", body = DocumentTypeResponse)), + tag = "DocumentTypes" +)] pub async fn update_document_type( Path(document_type_id): Path, TenantScopedConn { @@ -119,6 +140,13 @@ pub async fn update_document_type( } } +#[utoipa::path( + delete, + path = "/api/document-types/{id}", + params(("id" = Uuid, Path, description = "Document type ID")), + responses((status = 204, description = "Document type deleted")), + tag = "DocumentTypes" +)] pub async fn delete_document_type( Path(document_type_id): Path, TenantScopedConn { @@ -140,3 +168,19 @@ pub async fn delete_document_type( Ok(StatusCode::NO_CONTENT) } + +#[derive(utoipa::OpenApi)] +#[openapi( + paths( + crate::routes::document_types::list_document_types, + crate::routes::document_types::create_document_type, + crate::routes::document_types::update_document_type, + crate::routes::document_types::delete_document_type + ), + components(schemas( + crate::routes::document_types::CreateDocumentTypeRequest, + crate::routes::document_types::UpdateDocumentTypeRequest, + crate::routes::documents::DocumentTypeResponse + )) +)] +pub struct DocumentTypesApiDoc; diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index a0af0a5..6bfaf2f 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -378,6 +378,13 @@ pub struct AssetObjectsQuery { pub limit: Option, } +#[utoipa::path( + get, + path = "/api/documents", + params(DocumentListQuery), + responses((status = 200, description = "List documents", body = [DocumentResponse])), + tag = "Documents" +)] pub async fn list_documents( State(state): State, Query(params): Query, @@ -633,48 +640,18 @@ pub async fn list_documents( .load(&mut conn)? }; - let type_ids: HashSet = docs.iter().filter_map(|doc| doc.document_type_id).collect(); - let doc_type_map: HashMap = if type_ids.is_empty() { - HashMap::new() - } else { - let ids: Vec = type_ids.iter().copied().collect(); - document_types::table - .filter(document_types::tenant_id.eq(tenant_id)) - .filter(document_types::id.eq_any(&ids)) - .load::(&mut conn)? - .into_iter() - .map(|typ| (typ.id, typ)) - .collect() - }; - - let doc_ids: Vec = docs.iter().map(|doc| doc.id).collect(); - let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?; - let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?; - drop(conn); - - let primary_versions = load_primary_assets(&state, tenant_id, &docs)?; - let mut response = Vec::with_capacity(doc_ids.len()); - for doc in docs { - let tags = tags_map.get(&doc.id).cloned(); - let correspondents = correspondents_map.remove(&doc.id).unwrap_or_default(); - let current_version = primary_versions.get(&doc.id).cloned(); - let doc_type = doc - .document_type_id - .and_then(|id| doc_type_map.get(&id).cloned()); - response.push(to_document_response( - &state, - user_id, - doc, - tags, - correspondents, - current_version, - doc_type, - )?); - } + let response = hydrate_documents(&state, &mut conn, tenant_id, user_id, docs)?; Ok(Json(response)) } +#[utoipa::path( + get, + path = "/api/documents/check", + params(DocumentCheckQuery), + responses((status = 200, description = "Checksum lookup", body = DocumentCheckResponse)), + tag = "Documents" +)] pub async fn check_document( Query(query): Query, TenantScopedConn { @@ -728,6 +705,13 @@ pub async fn check_document( } } +#[utoipa::path( + get, + path = "/api/documents/{id}", + params(("id" = Uuid, Path, description = "Document ID")), + responses((status = 200, description = "Document detail", body = DocumentDetailResponse)), + tag = "Documents" +)] pub async fn get_document( State(state): State, Path(document_id): Path, @@ -773,6 +757,17 @@ pub async fn get_document( })) } +#[utoipa::path( + post, + path = "/api/documents", + request_body = UploadDocumentForm, + responses( + (status = 201, description = "Document created", body = DocumentDetailResponse), + (status = 200, description = "Existing document reused", body = DocumentDetailResponse), + (status = 204, description = "Upload skipped because the document already exists") + ), + tag = "Documents" +)] pub async fn upload_document( State(state): State, TenantScopedConn { @@ -978,6 +973,13 @@ pub async fn upload_document( Ok(response) } +#[utoipa::path( + post, + path = "/api/documents/{id}/assets", + params(("id" = Uuid, Path, description = "Document ID"), AssetRequestQuery), + responses((status = 202, description = "Asset generation requested")), + tag = "Assets" +)] pub async fn request_document_assets( Path(document_id): Path, Query(query): Query, @@ -1014,6 +1016,13 @@ pub async fn request_document_assets( Ok(StatusCode::ACCEPTED) } +#[utoipa::path( + post, + path = "/api/documents/bulk/reanalyze", + request_body = BulkReanalyzeSelectionRequest, + responses((status = 200, description = "Reanalyze queued", body = BulkReanalyzeResponse)), + tag = "Documents" +)] pub async fn reanalyze_selected_documents( TenantScopedConn { mut conn, @@ -1065,6 +1074,13 @@ pub async fn reanalyze_selected_documents( Ok((StatusCode::ACCEPTED, Json(BulkReanalyzeResponse { queued }))) } +#[utoipa::path( + get, + path = "/api/documents/{id}/assets", + params(("id" = Uuid, Path, description = "Document ID")), + responses((status = 200, description = "Document assets", body = [DocumentAssetResponse])), + tag = "Assets" +)] pub async fn list_document_assets( State(state): State, Path(document_id): Path, @@ -1089,6 +1105,13 @@ pub async fn list_document_assets( Ok(Json(assets)) } +#[utoipa::path( + get, + path = "/api/assets/{asset_id}", + params(("asset_id" = Uuid, Path, description = "Asset ID"), AssetObjectsQuery), + responses((status = 200, description = "Asset detail", body = DocumentAssetDetailResponse)), + tag = "Assets" +)] pub async fn get_document_asset( State(state): State, Path(asset_id): Path, @@ -1163,6 +1186,13 @@ pub async fn get_document_asset( Ok(Json(to_asset_detail_response(asset, object_responses))) } +#[utoipa::path( + get, + path = "/api/documents/{id}/versions", + params(("id" = Uuid, Path, description = "Document ID")), + responses((status = 200, description = "Document versions", body = [DocumentVersionResponse])), + tag = "Documents" +)] pub async fn list_document_versions( Path(document_id): Path, TenantScopedConn { @@ -1192,6 +1222,16 @@ pub async fn list_document_versions( Ok(Json(versions)) } +#[utoipa::path( + get, + path = "/api/documents/{id}/versions/{version_id}", + params( + ("id" = Uuid, Path, description = "Document ID"), + ("version_id" = Uuid, Path, description = "Version ID"), + ), + responses((status = 200, description = "Document version detail", body = DocumentVersionDetailResponse)), + tag = "Documents" +)] pub async fn get_document_version( State(state): State, Path((document_id, version_id)): Path<(Uuid, Uuid)>, @@ -1230,6 +1270,13 @@ pub async fn get_document_version( })) } +#[utoipa::path( + get, + path = "/download/{token}", + params(("token" = String, Path, description = "Download token")), + responses((status = 302, description = "Redirect to pre-signed URL")), + tag = "Documents" +)] pub async fn download_with_token( State(state): State, Path(token): Path, @@ -1282,6 +1329,13 @@ pub async fn download_with_token( Ok(axum::response::Redirect::temporary(&presigned_url)) } +#[utoipa::path( + delete, + path = "/api/documents/{id}", + params(("id" = Uuid, Path, description = "Document ID")), + responses((status = 204, description = "Document deleted")), + tag = "Documents" +)] pub async fn delete_document( Path(document_id): Path, TenantScopedConn { @@ -1304,6 +1358,14 @@ pub async fn delete_document( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + patch, + path = "/api/documents/{id}", + params(("id" = Uuid, Path, description = "Document ID")), + request_body = UpdateDocumentRequest, + responses((status = 200, description = "Updated document", body = DocumentDetailResponse)), + tag = "Documents" +)] pub async fn update_document( State(state): State, Path(document_id): Path, @@ -1519,6 +1581,14 @@ pub async fn update_document( })) } +#[utoipa::path( + post, + path = "/api/documents/{id}/restore", + params(("id" = Uuid, Path, description = "Document ID")), + request_body = RestoreDocumentRequest, + responses((status = 204, description = "Document restored")), + tag = "Documents" +)] pub async fn restore_document( Path(document_id): Path, TenantScopedConn { @@ -1569,6 +1639,14 @@ pub async fn restore_document( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + patch, + path = "/api/documents/{id}/folder", + params(("id" = Uuid, Path, description = "Document ID")), + request_body = MoveDocumentRequest, + responses((status = 204, description = "Document moved")), + tag = "Documents" +)] pub async fn move_document( Path(document_id): Path, TenantScopedConn { @@ -1597,6 +1675,13 @@ pub async fn move_document( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + post, + path = "/api/documents/bulk/move", + request_body = BulkMoveRequest, + responses((status = 200, description = "Bulk move outcome", body = BulkMoveResponse)), + tag = "Documents" +)] pub async fn bulk_move_documents( TenantScopedConn { mut conn, @@ -1680,6 +1765,14 @@ pub async fn bulk_move_documents( Ok((StatusCode::OK, body.into_json()?)) } +#[utoipa::path( + post, + path = "/api/documents/{id}/correspondents", + params(("id" = Uuid, Path, description = "Document ID")), + request_body = AssignCorrespondentsRequest, + responses((status = 204, description = "Correspondents assigned")), + tag = "Documents" +)] pub async fn assign_correspondents( Path(document_id): Path, TenantScopedConn { @@ -1761,6 +1854,13 @@ pub async fn assign_correspondents( no_content() } +#[utoipa::path( + post, + path = "/api/documents/bulk/correspondents", + request_body = BulkCorrespondentsRequest, + responses((status = 200, description = "Bulk correspondents outcome", body = BulkCorrespondentResponse)), + tag = "Documents" +)] pub async fn bulk_assign_correspondents( TenantScopedConn { mut conn, @@ -1853,6 +1953,16 @@ pub async fn bulk_assign_correspondents( Ok((StatusCode::OK, body.into_json()?)) } +#[utoipa::path( + delete, + path = "/api/documents/{id}/correspondents/{correspondent_id}", + params( + ("id" = Uuid, Path, description = "Document ID"), + ("correspondent_id" = Uuid, Path, description = "Correspondent ID") + ), + responses((status = 204, description = "Correspondent removed")), + tag = "Documents" +)] pub async fn remove_correspondent( Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>, TenantScopedConn { @@ -1892,6 +2002,14 @@ pub async fn remove_correspondent( no_content() } +#[utoipa::path( + post, + path = "/api/documents/{id}/tags", + params(("id" = Uuid, Path, description = "Document ID")), + request_body = AssignTagsRequest, + responses((status = 204, description = "Tags assigned")), + tag = "Documents" +)] pub async fn assign_tags( Path(document_id): Path, TenantScopedConn { @@ -1922,6 +2040,13 @@ pub async fn assign_tags( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + post, + path = "/api/documents/bulk/tags", + request_body = BulkTagRequest, + responses((status = 200, description = "Bulk tag outcome", body = BulkTagResponse)), + tag = "Documents" +)] pub async fn bulk_update_tags( TenantScopedConn { mut conn, @@ -2007,6 +2132,16 @@ pub async fn bulk_update_tags( Ok((StatusCode::OK, response.into_json()?)) } +#[utoipa::path( + delete, + path = "/api/documents/{id}/tags/{tag_id}", + params( + ("id" = Uuid, Path, description = "Document ID"), + ("tag_id" = Uuid, Path, description = "Tag ID") + ), + responses((status = 204, description = "Tag removed")), + tag = "Documents" +)] pub async fn remove_tag( Path((document_id, tag_id)): Path<(Uuid, Uuid)>, TenantScopedConn { @@ -2363,3 +2498,118 @@ fn load_document_type( Ok(None) } } + +pub(crate) fn hydrate_documents( + state: &AppState, + conn: &mut PgConnection, + tenant_id: Uuid, + user_id: Uuid, + docs: Vec, +) -> AppResult> { + if docs.is_empty() { + return Ok(Vec::new()); + } + + let type_ids: HashSet = docs.iter().filter_map(|doc| doc.document_type_id).collect(); + let doc_type_map: HashMap = if type_ids.is_empty() { + HashMap::new() + } else { + let ids: Vec = type_ids.iter().copied().collect(); + document_types::table + .filter(document_types::tenant_id.eq(tenant_id)) + .filter(document_types::id.eq_any(&ids)) + .load::(conn)? + .into_iter() + .map(|typ| (typ.id, typ)) + .collect() + }; + + let doc_ids: Vec = docs.iter().map(|doc| doc.id).collect(); + let tags_map = load_tags_for_documents(conn, &doc_ids)?; + let mut correspondents_map = load_correspondents_for_documents(conn, &doc_ids)?; + + let primary_versions = load_primary_assets(state, tenant_id, &docs)?; + let mut responses = Vec::with_capacity(doc_ids.len()); + for doc in docs { + let tags = tags_map.get(&doc.id).cloned(); + let correspondents = correspondents_map.remove(&doc.id).unwrap_or_default(); + let current_version = primary_versions.get(&doc.id).cloned(); + let doc_type = doc + .document_type_id + .and_then(|id| doc_type_map.get(&id).cloned()); + responses.push(to_document_response( + state, + user_id, + doc, + tags, + correspondents, + current_version, + doc_type, + )?); + } + + Ok(responses) +} + +#[derive(utoipa::OpenApi)] +#[openapi( + paths( + crate::routes::documents::list_documents, + crate::routes::documents::check_document, + crate::routes::documents::upload_document, + crate::routes::documents::get_document, + crate::routes::documents::update_document, + crate::routes::documents::delete_document, + crate::routes::documents::restore_document, + crate::routes::documents::download_with_token, + crate::routes::documents::move_document, + crate::routes::documents::assign_tags, + crate::routes::documents::remove_tag, + crate::routes::documents::bulk_move_documents, + crate::routes::documents::bulk_update_tags, + crate::routes::documents::bulk_assign_correspondents, + crate::routes::documents::assign_correspondents, + crate::routes::documents::remove_correspondent, + crate::routes::documents::reanalyze_selected_documents, + crate::routes::documents::list_document_assets, + crate::routes::documents::request_document_assets, + crate::routes::documents::get_document_asset, + crate::routes::documents::list_document_versions, + crate::routes::documents::get_document_version, + ), + components(schemas( + crate::routes::documents::DocumentListQuery, + crate::routes::documents::DocumentStatusFilter, + crate::routes::documents::AssetRequestQuery, + crate::routes::documents::DocumentCheckQuery, + crate::routes::documents::DocumentCheckResponse, + crate::routes::documents::DocumentResponse, + crate::routes::documents::DocumentDetailResponse, + crate::routes::documents::DocumentMetadataUpdate, + crate::routes::documents::DocumentTypeResponse, + crate::routes::documents::TagResponse, + crate::routes::documents::CorrespondentAssignmentInput, + crate::routes::documents::AssignCorrespondentsRequest, + crate::routes::documents::BulkCorrespondentAction, + crate::routes::documents::BulkCorrespondentsRequest, + crate::routes::documents::BulkCorrespondentResponse, + crate::routes::documents::BulkMoveRequest, + crate::routes::documents::BulkMoveResponse, + crate::routes::documents::BulkTagAction, + crate::routes::documents::BulkTagRequest, + crate::routes::documents::BulkTagResponse, + crate::routes::documents::AssignTagsRequest, + crate::routes::documents::MoveDocumentRequest, + crate::routes::documents::BulkReanalyzeSelectionRequest, + crate::routes::documents::BulkReanalyzeResponse, + crate::routes::documents::AssetObjectsQuery, + crate::routes::documents::UploadDocumentForm, + crate::documents::asset::DocumentVersionResponse, + crate::documents::asset::DocumentVersionDetailResponse, + crate::documents::asset::DocumentAssetResponse, + crate::documents::asset::DocumentAssetDetailResponse, + crate::documents::asset::DocumentAssetObjectResponse, + crate::documents::correspondents::DocumentCorrespondentResponse, + )) +)] +pub struct DocumentsApiDoc; diff --git a/backend/src/routes/folders.rs b/backend/src/routes/folders.rs index a407e68..fb34239 100644 --- a/backend/src/routes/folders.rs +++ b/backend/src/routes/folders.rs @@ -1,5 +1,3 @@ -use std::collections::{HashMap, HashSet}; - use axum::{ extract::{Json, Path, Query, State}, http::StatusCode, @@ -9,20 +7,16 @@ use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; -use crate::models::{Document, DocumentType, Folder, NewFolder}; -use crate::schema::{document_types, documents, folders}; +use crate::models::{Document, Folder, NewFolder}; +use crate::schema::{documents, folders}; use crate::state::AppState; use crate::{ auth::TenantScopedConn, error::{AppError, AppResult}, }; -use super::documents::{to_document_response, DocumentResponse}; -use crate::documents::{ - asset::load_primary_assets, correspondents::load_correspondents_for_documents, - tags::load_tags_for_documents, -}; -use crate::utils::time::to_iso; +use super::documents::{hydrate_documents, DocumentResponse}; +use crate::utils::{json::deserialize_patch_field, time::to_iso}; #[derive(Deserialize, ToSchema)] pub struct CreateFolderRequest { @@ -74,16 +68,47 @@ pub struct FolderInfo { } #[derive(Default, Deserialize, ToSchema)] -#[serde(default)] pub struct UpdateFolderRequest { - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_patch_field")] #[schema(nullable, value_type = Option)] pub parent_id: Option>, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_patch_field")] #[schema(nullable)] pub name: Option>, } +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn update_folder_request_deserializes_null_parent() { + let req: UpdateFolderRequest = + serde_json::from_value(json!({ "parent_id": null })).unwrap(); + assert!(matches!(req.parent_id, Some(None))); + } + + #[test] + fn update_folder_request_deserializes_absent_parent() { + let req: UpdateFolderRequest = serde_json::from_value(json!({})).unwrap(); + assert!(req.parent_id.is_none()); + } + + #[test] + fn update_folder_request_deserializes_null_name() { + let req: UpdateFolderRequest = serde_json::from_value(json!({ "name": null })).unwrap(); + assert!(matches!(req.name, Some(None))); + } +} + +#[utoipa::path( + get, + path = "/api/folders/{id}", + params(("id" = Uuid, Path, description = "Folder ID")), + responses((status = 200, description = "Folder detail", body = FolderResponse)), + tag = "Folders" +)] pub async fn get_folder( Path(folder_id): Path, TenantScopedConn { @@ -102,6 +127,13 @@ pub async fn get_folder( })) } +#[utoipa::path( + post, + path = "/api/folders/path", + request_body = EnsureFolderPathRequest, + responses((status = 200, description = "Folder path ensured", body = FolderResponse)), + tag = "Folders" +)] pub async fn ensure_folder_path( TenantScopedConn { mut conn, @@ -188,6 +220,16 @@ pub async fn ensure_folder_path( })) } +#[utoipa::path( + post, + path = "/api/folders", + request_body = CreateFolderRequest, + responses( + (status = 201, description = "Folder created", body = FolderResponse), + (status = 200, description = "Folder already existed", body = FolderResponse) + ), + tag = "Folders" +)] pub async fn create_folder( TenantScopedConn { mut conn, @@ -275,6 +317,13 @@ pub async fn create_folder( } } +#[utoipa::path( + get, + path = "/api/folders/{id}/contents", + params(("id" = Uuid, Path, description = "Folder ID"), FolderContentsQuery), + responses((status = 200, description = "Folder contents", body = FolderContentsResponse)), + tag = "Folders" +)] pub async fn list_folder_contents( State(state): State, Path(folder_identifier): Path, @@ -336,47 +385,7 @@ pub async fn list_folder_contents( .load(&mut conn)? }; - let type_ids: HashSet = docs.iter().filter_map(|doc| doc.document_type_id).collect(); - let doc_type_map: HashMap = if type_ids.is_empty() { - HashMap::new() - } else { - let ids: Vec = type_ids.iter().copied().collect(); - document_types::table - .filter(document_types::tenant_id.eq(tenant_id)) - .filter(document_types::id.eq_any(&ids)) - .load::(&mut conn)? - .into_iter() - .map(|typ| (typ.id, typ)) - .collect() - }; - - let doc_ids: Vec = docs.iter().map(|doc| doc.id).collect(); - let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?; - let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?; - drop(conn); - - let primary_versions = load_primary_assets(&state, tenant_id, &docs)?; - - let mut documents = Vec::with_capacity(doc_ids.len()); - for doc in docs { - let tags = tags_map.get(&doc.id).cloned(); - let correspondents = correspondents_map.remove(&doc.id).unwrap_or_default(); - let current_version = primary_versions.get(&doc.id).cloned(); - let doc_type = doc - .document_type_id - .and_then(|id| doc_type_map.get(&id).cloned()); - documents.push(to_document_response( - &state, - user_id, - doc, - tags, - correspondents, - current_version, - doc_type, - )?); - } - - documents + hydrate_documents(&state, &mut conn, tenant_id, user_id, docs)? } else { Vec::new() }; @@ -388,6 +397,13 @@ pub async fn list_folder_contents( })) } +#[utoipa::path( + delete, + path = "/api/folders/{id}", + params(("id" = Uuid, Path, description = "Folder ID")), + responses((status = 204, description = "Folder deleted")), + tag = "Folders" +)] pub async fn delete_folder( Path(folder_id): Path, TenantScopedConn { @@ -442,6 +458,14 @@ pub async fn delete_folder( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + patch, + path = "/api/folders/{id}", + params(("id" = Uuid, Path, description = "Folder ID")), + request_body = UpdateFolderRequest, + responses((status = 204, description = "Folder updated")), + tag = "Folders" +)] pub async fn update_folder( Path(folder_id): Path, TenantScopedConn { @@ -586,3 +610,25 @@ pub(super) fn gather_descendant_folder_ids( Ok(ids) } + +#[derive(utoipa::OpenApi)] +#[openapi( + paths( + crate::routes::folders::create_folder, + crate::routes::folders::ensure_folder_path, + crate::routes::folders::get_folder, + crate::routes::folders::list_folder_contents, + crate::routes::folders::delete_folder, + crate::routes::folders::update_folder + ), + components(schemas( + crate::routes::folders::CreateFolderRequest, + crate::routes::folders::EnsureFolderPathRequest, + crate::routes::folders::FolderResponse, + crate::routes::folders::FolderInfo, + crate::routes::folders::FolderContentsQuery, + crate::routes::folders::FolderContentsResponse, + crate::routes::folders::UpdateFolderRequest + )) +)] +pub struct FoldersApiDoc; diff --git a/backend/src/routes/health.rs b/backend/src/routes/health.rs index 22442f7..13f43cd 100644 --- a/backend/src/routes/health.rs +++ b/backend/src/routes/health.rs @@ -1,6 +1,16 @@ use axum::{http::StatusCode, response::Json}; use serde_json::json; +#[derive(utoipa::OpenApi)] +#[openapi(paths(crate::routes::health::health_check))] +pub struct HealthApiDoc; + +#[utoipa::path( + get, + path = "/api/health", + responses((status = 200, description = "Service is healthy")), + tag = "Health" +)] pub async fn health_check() -> (StatusCode, Json) { (StatusCode::OK, Json(json!({ "status": "ok" }))) } diff --git a/backend/src/routes/profile.rs b/backend/src/routes/profile.rs index 4eb7033..dbdcdaa 100644 --- a/backend/src/routes/profile.rs +++ b/backend/src/routes/profile.rs @@ -57,6 +57,12 @@ pub struct RevokePasskeyQuery { pub reason: Option, } +#[utoipa::path( + get, + path = "/api/profile/passkeys", + responses((status = 200, description = "List registered passkeys", body = [PasskeySummary])), + tag = "Profile" +)] pub async fn list_passkeys( State(state): State, TenantScopedConn { @@ -72,6 +78,12 @@ pub async fn list_passkeys( Ok(Json(passkeys)) } +#[utoipa::path( + get, + path = "/api/profile/webdav-tokens", + responses((status = 200, description = "List WebDAV tokens", body = [WebdavTokenResponse])), + tag = "Profile" +)] pub async fn list_webdav_tokens( TenantScopedConn { mut conn, @@ -85,6 +97,13 @@ pub async fn list_webdav_tokens( Ok(Json(responses)) } +#[utoipa::path( + post, + path = "/api/profile/webdav-tokens", + request_body = CreateWebdavTokenRequest, + responses((status = 201, description = "WebDAV token created", body = WebdavTokenCreatedResponse)), + tag = "Profile" +)] pub async fn create_webdav_token( TenantScopedConn { mut conn, @@ -115,6 +134,13 @@ pub async fn create_webdav_token( Ok((StatusCode::CREATED, Json(response))) } +#[utoipa::path( + delete, + path = "/api/profile/webdav-tokens/{id}", + params(("id" = Uuid, Path, description = "WebDAV token ID")), + responses((status = 204, description = "WebDAV token revoked")), + tag = "Profile" +)] pub async fn delete_webdav_token( TenantScopedConn { mut conn, user_id, .. @@ -125,6 +151,16 @@ pub async fn delete_webdav_token( no_content() } +#[utoipa::path( + delete, + path = "/api/profile/passkeys/{id}", + params( + ("id" = Uuid, Path, description = "Passkey ID"), + ("reason" = Option, Query, description = "Optional reason for revoking the passkey") + ), + responses((status = 204, description = "Passkey revoked")), + tag = "Profile" +)] pub async fn delete_passkey( State(state): State, TenantScopedConn { @@ -166,3 +202,22 @@ fn parse_timestamp(value: &str) -> AppResult { .map_err(|_| AppError::bad_request("invalid expires_at timestamp"))?; Ok(dt.naive_utc()) } + +#[derive(utoipa::OpenApi)] +#[openapi( + paths( + crate::routes::profile::list_webdav_tokens, + crate::routes::profile::create_webdav_token, + crate::routes::profile::delete_webdav_token, + crate::routes::profile::list_passkeys, + crate::routes::profile::delete_passkey + ), + components(schemas( + crate::routes::profile::WebdavTokenResponse, + crate::routes::profile::WebdavTokenCreatedResponse, + crate::routes::profile::CreateWebdavTokenRequest, + crate::routes::profile::RevokePasskeyQuery, + crate::auth::passkeys::PasskeySummary + )) +)] +pub struct ProfileApiDoc; diff --git a/backend/src/routes/tags.rs b/backend/src/routes/tags.rs index d60db03..f81af22 100644 --- a/backend/src/routes/tags.rs +++ b/backend/src/routes/tags.rs @@ -9,7 +9,10 @@ use crate::auth::TenantScopedConn; use crate::error::{AppError, AppResult}; use crate::models::{NewTag, Tag}; use crate::schema::{document_tags, tags}; -use crate::utils::db::{no_content, EnsureEntity, IntoJsonResponse}; +use crate::utils::{ + db::{no_content, EnsureEntity, IntoJsonResponse}, + json::deserialize_patch_field, +}; #[derive(Deserialize, ToSchema)] pub struct CreateTagRequest { @@ -25,6 +28,30 @@ struct UpdateTagChangeset<'a> { color: Option>, } +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn update_tag_request_deserializes_null_fields() { + let request: UpdateTagRequest = serde_json::from_value(json!({ + "label": null, + "color": null + })) + .unwrap(); + assert!(matches!(request.label, Some(None))); + assert!(matches!(request.color, Some(None))); + } + + #[test] + fn update_tag_request_omitted_fields_are_none() { + let request: UpdateTagRequest = serde_json::from_value(json!({})).unwrap(); + assert!(request.label.is_none()); + assert!(request.color.is_none()); + } +} + #[derive(Serialize, ToSchema)] pub struct TagCatalogEntry { pub id: Uuid, @@ -35,16 +62,21 @@ pub struct TagCatalogEntry { } #[derive(Debug, Default, Deserialize, ToSchema)] -#[serde(default)] pub struct UpdateTagRequest { - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_patch_field")] #[schema(nullable, value_type = Option)] pub label: Option>, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_patch_field")] #[schema(nullable, value_type = Option)] pub color: Option>, } +#[utoipa::path( + get, + path = "/api/tags", + responses((status = 200, description = "Tags", body = [TagCatalogEntry])), + tag = "Tags" +)] pub async fn list_tags( TenantScopedConn { mut conn, @@ -78,6 +110,13 @@ pub async fn list_tags( response.into_json() } +#[utoipa::path( + post, + path = "/api/tags", + request_body = CreateTagRequest, + responses((status = 200, description = "Tag created", body = TagCatalogEntry)), + tag = "Tags" +)] pub async fn create_tag( TenantScopedConn { mut conn, @@ -126,6 +165,14 @@ pub async fn create_tag( .into_json() } +#[utoipa::path( + patch, + path = "/api/tags/{id}", + params(("id" = Uuid, Path, description = "Tag ID")), + request_body = UpdateTagRequest, + responses((status = 200, description = "Tag updated", body = TagCatalogEntry)), + tag = "Tags" +)] pub async fn update_tag( Path(tag_id): Path, TenantScopedConn { @@ -253,6 +300,13 @@ pub async fn update_tag( .into_json() } +#[utoipa::path( + delete, + path = "/api/tags/{id}", + params(("id" = Uuid, Path, description = "Tag ID")), + responses((status = 204, description = "Tag deleted")), + tag = "Tags" +)] pub async fn delete_tag( Path(tag_id): Path, TenantScopedConn { @@ -285,3 +339,19 @@ pub async fn delete_tag( no_content() } + +#[derive(utoipa::OpenApi)] +#[openapi( + paths( + crate::routes::tags::list_tags, + crate::routes::tags::create_tag, + crate::routes::tags::update_tag, + crate::routes::tags::delete_tag + ), + components(schemas( + crate::routes::tags::CreateTagRequest, + crate::routes::tags::TagCatalogEntry, + crate::routes::tags::UpdateTagRequest + )) +)] +pub struct TagsApiDoc; diff --git a/backend/src/utils/json.rs b/backend/src/utils/json.rs index 7f9a366..544ab7b 100644 --- a/backend/src/utils/json.rs +++ b/backend/src/utils/json.rs @@ -1,3 +1,4 @@ +use serde::Deserialize; use serde_json::Value; pub enum NullableValue { @@ -14,3 +15,11 @@ pub fn classify_nullable(optional_value: Option<&Value>) -> Result Err(format!("expected string or null, got {other}")), } } + +pub fn deserialize_patch_field<'de, D, T>(deserializer: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer).map(Some) +} diff --git a/backend/tests/auth_flow.rs b/backend/tests/auth_flow.rs index fd02dfc..796d3cc 100644 --- a/backend/tests/auth_flow.rs +++ b/backend/tests/auth_flow.rs @@ -2,16 +2,16 @@ mod common; use anyhow::{anyhow, Context, Result}; use axum::http::{header::SET_COOKIE, StatusCode}; -use backend::auth::passkeys::{ - PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload, - RegistrationChallengeResponse, -}; -use backend::models::{NewRefreshToken, NewUserMembership, TenantStatus, UserPasskey}; -use backend::openapi::schemas::PasskeySummary; -use backend::schema::{refresh_tokens, tenants, user_memberships, users}; use chrono::{Duration as ChronoDuration, Utc}; use common::{acquire_db_lock, body_to_vec, TestApp}; use diesel::prelude::*; +use papercrate::auth::passkeys::{ + PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload, + RegistrationChallengeResponse, +}; +use papercrate::models::{NewRefreshToken, NewUserMembership, TenantStatus, UserPasskey}; +use papercrate::openapi::schemas::PasskeySummary; +use papercrate::schema::{refresh_tokens, tenants, user_memberships, users}; use rand::rngs::OsRng; use rand::RngCore; use serde::Deserialize; @@ -169,8 +169,8 @@ async fn passkey_register_start_creates_challenge() -> Result<()> { let challenge_id = challenge.challenge_id; app.with_conn(move |conn| { - use backend::schema::webauthn_challenges::dsl; use diesel::dsl::{exists, select}; + use papercrate::schema::webauthn_challenges::dsl; let exists: bool = select(exists( dsl::webauthn_challenges.filter(dsl::id.eq(challenge_id)), @@ -322,7 +322,7 @@ async fn delete_passkey_soft_revokes() -> Result<()> { assert_eq!(response.status(), StatusCode::NO_CONTENT); app.with_conn(move |conn| { - use backend::schema::user_passkeys::dsl as passkey_dsl; + use papercrate::schema::user_passkeys::dsl as passkey_dsl; let record = passkey_dsl::user_passkeys .find(passkey_id) @@ -574,18 +574,18 @@ async fn login_with_session( let username = username.to_string(); let state = app.state.clone(); app.with_conn(move |conn| { - use backend::schema::user_memberships::dsl as memberships_dsl; - use backend::schema::users::dsl as users_dsl; + use papercrate::schema::user_memberships::dsl as memberships_dsl; + use papercrate::schema::users::dsl as users_dsl; - let user: backend::models::User = users_dsl::users + let user: papercrate::models::User = users_dsl::users .filter(users_dsl::username.eq(&username)) .first(conn)?; - let membership: backend::models::UserMembership = memberships_dsl::user_memberships + let membership: papercrate::models::UserMembership = memberships_dsl::user_memberships .filter(memberships_dsl::user_id.eq(user.id)) .first(conn)?; - let tenant: backend::models::Tenant = + let tenant: papercrate::models::Tenant = tenants::table.find(membership.tenant_id).first(conn)?; let now = Utc::now(); diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 52967f6..61b6fee 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -8,17 +8,6 @@ use async_trait::async_trait; use axum::body::Body; use axum::http::{header, Method, Request}; use axum::Router; -use backend::auth::jwt::JwtService; -use backend::config::AppConfig; -use backend::db::{self, PgPool}; -use backend::models::{ - Job, NewRefreshToken, NewUser, NewUserMembership, NewUserPasskey, Tenant, TenantStatus, User, - UserMembership, -}; -use backend::routes; -use backend::schema::refresh_tokens::dsl as refresh_dsl; -use backend::state::AppState; -use backend::storage::ObjectStorage; use chrono::{Duration as ChronoDuration, Utc}; use diesel::connection::SimpleConnection; use diesel::prelude::*; @@ -27,9 +16,20 @@ use diesel::PgConnection; use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; use http_body_util::BodyExt; use once_cell::sync::Lazy; +use papercrate::auth::jwt::JwtService; +use papercrate::config::AppConfig; +use papercrate::db::{self, PgPool}; +use papercrate::models::{ + Job, NewRefreshToken, NewUser, NewUserMembership, NewUserPasskey, Tenant, TenantStatus, User, + UserMembership, +}; +use papercrate::routes; +use papercrate::schema::refresh_tokens::dsl as refresh_dsl; +use papercrate::state::AppState; +use papercrate::storage::ObjectStorage; use rand::rngs::OsRng; use rand::RngCore; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use serde_json::{self, json}; use sha2::{Digest, Sha256}; use tokio::sync::Mutex; @@ -37,8 +37,8 @@ use tower::util::ServiceExt; use uuid::Uuid; const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); -const RESET_SCHEMA_SQL: &str = - include_str!("../../migrations/202510300000_initial_schema/down.sql"); +const RESET_DATABASE_SQL: &str = + "DROP SCHEMA IF EXISTS public CASCADE;\nCREATE SCHEMA public;\nGRANT ALL ON SCHEMA public TO public;"; static DB_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); @@ -222,7 +222,7 @@ impl TestApp { id: Uuid::new_v4(), username, }; - diesel::insert_into(backend::schema::users::table) + diesel::insert_into(papercrate::schema::users::table) .values(&user) .execute(conn) .context("failed to insert user")?; @@ -233,7 +233,7 @@ impl TestApp { tenant_id, }; - diesel::insert_into(backend::schema::user_memberships::table) + diesel::insert_into(papercrate::schema::user_memberships::table) .values(&membership) .execute(conn) .context("failed to insert user membership")?; @@ -260,7 +260,7 @@ impl TestApp { nickname, }; - diesel::insert_into(backend::schema::user_passkeys::table) + diesel::insert_into(papercrate::schema::user_passkeys::table) .values(&passkey) .execute(conn) .context("failed to insert passkey")?; @@ -274,7 +274,7 @@ impl TestApp { let name_value = TEST_TENANT_NAME.to_string(); let quickwit_enabled = self.state.config.quickwit_endpoint.is_some(); self.with_conn(move |conn| { - use backend::schema::tenants::dsl as tenants_dsl; + use papercrate::schema::tenants::dsl as tenants_dsl; let existing = tenants_dsl::tenants .filter(tenants_dsl::name.eq(&name_value)) @@ -334,9 +334,9 @@ impl TestApp { let username = username.to_string(); let state = self.state.clone(); self.with_conn(move |conn| { - use backend::schema::tenants::dsl as tenants_dsl; - use backend::schema::user_memberships::dsl as memberships_dsl; - use backend::schema::users::dsl as users_dsl; + use papercrate::schema::tenants::dsl as tenants_dsl; + use papercrate::schema::user_memberships::dsl as memberships_dsl; + use papercrate::schema::users::dsl as users_dsl; let user: User = users_dsl::users .filter(users_dsl::username.eq(&username)) @@ -383,7 +383,7 @@ impl TestApp { #[allow(dead_code)] pub async fn clear_jobs(&self) -> Result<()> { self.with_conn(|conn| { - use backend::schema::jobs::dsl::jobs as jobs_table; + use papercrate::schema::jobs::dsl::jobs as jobs_table; diesel::delete(jobs_table) .execute(conn) .context("failed to clear jobs")?; @@ -396,7 +396,7 @@ impl TestApp { pub async fn jobs_by_type(&self, ty: &str) -> Result> { let ty = ty.to_string(); self.with_conn(move |conn| { - use backend::schema::jobs::dsl::{job_type as job_type_col, jobs as jobs_table}; + use papercrate::schema::jobs::dsl::{job_type as job_type_col, jobs as jobs_table}; let rows = jobs_table .filter(job_type_col.eq(&ty)) .load::(conn) @@ -699,8 +699,10 @@ async fn prepare_database(pool: &PgPool) -> Result<()> { let mut conn = pool .get() .map_err(|err| anyhow!("failed to acquire connection: {err}"))?; - let _ = conn.batch_execute(RESET_SCHEMA_SQL); - let _ = conn.batch_execute("DROP TABLE IF EXISTS __diesel_schema_migrations;"); + conn.batch_execute(RESET_DATABASE_SQL) + .map_err(|err| anyhow!("failed to reset schema: {err}"))?; + conn.batch_execute("DROP TABLE IF EXISTS __diesel_schema_migrations;") + .map_err(|err| anyhow!("failed to drop diesel schema table: {err}"))?; conn.run_pending_migrations(MIGRATIONS) .map_err(|err| anyhow!("failed to run migrations: {err}"))?; truncate_all(&mut conn)?; diff --git a/backend/tests/tags_flow.rs b/backend/tests/tags_flow.rs index 0f52350..f8ac054 100644 --- a/backend/tests/tags_flow.rs +++ b/backend/tests/tags_flow.rs @@ -2,13 +2,13 @@ mod common; use anyhow::Result; use axum::http::StatusCode; -use backend::models::{NewUser, NewUserMembership, Tag, TenantStatus}; -use backend::schema::{ +use common::{acquire_db_lock, body_to_vec, TestApp}; +use diesel::prelude::*; +use papercrate::models::{NewUser, NewUserMembership, Tag, TenantStatus}; +use papercrate::schema::{ tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl, users::dsl as users_dsl, }; -use common::{acquire_db_lock, body_to_vec, TestApp}; -use diesel::prelude::*; use serde::Deserialize; use serde::Serialize; use uuid::Uuid; diff --git a/backend/tests/webdav_flow.rs b/backend/tests/webdav_flow.rs index 0b4c0df..581b722 100644 --- a/backend/tests/webdav_flow.rs +++ b/backend/tests/webdav_flow.rs @@ -3,13 +3,13 @@ mod common; use anyhow::Result; use axum::body::Body; use axum::http::{header, Method, Request, StatusCode}; -use backend::models::WebdavToken; -use backend::routes::webdav; -use backend::schema::webdav_tokens; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; use common::{acquire_db_lock, body_to_vec, TestApp}; use diesel::prelude::*; +use papercrate::models::WebdavToken; +use papercrate::routes::webdav; +use papercrate::schema::webdav_tokens; use serde::Deserialize; use serde_json::json; use tower::ServiceExt;