This commit is contained in:
2025-10-31 01:19:01 +01:00
parent 5b5916ff92
commit fa43bc749e
16 changed files with 878 additions and 872 deletions
@@ -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,
+2 -1
View File
@@ -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,
+67 -727
View File
@@ -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<String>, 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");
}
}
+135 -1
View File
@@ -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<AppState>, _payload: Json<LoginRequest>) -> AppResult<Response> {
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<AppState>,
Json(payload): Json<SignupStartRequest>,
@@ -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<AppState>,
Json(payload): Json<SignupFinishRequest>,
@@ -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<AppState>,
jar: Option<TypedHeader<Cookie>>,
@@ -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<AppState>,
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
@@ -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<AppState>,
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<AuthenticatedUser> {
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<AppState>,
auth: Option<TypedHeader<Authorization<Bearer>>>,
@@ -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<AppState>,
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<AppState>,
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<AppState>,
Json(payload): Json<PasskeyLoginStartPayload>,
@@ -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<AppState>,
Json(payload): Json<PasskeyLoginFinishPayload>,
+45
View File
@@ -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<Uuid>,
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<Uuid>,
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;
+44
View File
@@ -26,6 +26,12 @@ pub struct UpdateDocumentTypeRequest {
pub name: Option<String>,
}
#[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<Uuid>,
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<Uuid>,
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;
+288 -38
View File
@@ -378,6 +378,13 @@ pub struct AssetObjectsQuery {
pub limit: Option<i32>,
}
#[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<AppState>,
Query(params): Query<DocumentListQuery>,
@@ -633,48 +640,18 @@ pub async fn list_documents(
.load(&mut conn)?
};
let type_ids: HashSet<Uuid> = docs.iter().filter_map(|doc| doc.document_type_id).collect();
let doc_type_map: HashMap<Uuid, DocumentType> = if type_ids.is_empty() {
HashMap::new()
} else {
let ids: Vec<Uuid> = type_ids.iter().copied().collect();
document_types::table
.filter(document_types::tenant_id.eq(tenant_id))
.filter(document_types::id.eq_any(&ids))
.load::<DocumentType>(&mut conn)?
.into_iter()
.map(|typ| (typ.id, typ))
.collect()
};
let doc_ids: Vec<Uuid> = 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<DocumentCheckQuery>,
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<AppState>,
Path(document_id): Path<Uuid>,
@@ -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<AppState>,
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<Uuid>,
Query(query): Query<AssetRequestQuery>,
@@ -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<AppState>,
Path(document_id): Path<Uuid>,
@@ -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<AppState>,
Path(asset_id): Path<Uuid>,
@@ -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<Uuid>,
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<AppState>,
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<AppState>,
Path(token): Path<String>,
@@ -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<Uuid>,
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<AppState>,
Path(document_id): Path<Uuid>,
@@ -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<Uuid>,
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<Uuid>,
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<Uuid>,
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<Uuid>,
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<Document>,
) -> AppResult<Vec<DocumentResponse>> {
if docs.is_empty() {
return Ok(Vec::new());
}
let type_ids: HashSet<Uuid> = docs.iter().filter_map(|doc| doc.document_type_id).collect();
let doc_type_map: HashMap<Uuid, DocumentType> = if type_ids.is_empty() {
HashMap::new()
} else {
let ids: Vec<Uuid> = type_ids.iter().copied().collect();
document_types::table
.filter(document_types::tenant_id.eq(tenant_id))
.filter(document_types::id.eq_any(&ids))
.load::<DocumentType>(conn)?
.into_iter()
.map(|typ| (typ.id, typ))
.collect()
};
let doc_ids: Vec<Uuid> = 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;
+100 -54
View File
@@ -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<Uuid>)]
pub parent_id: Option<Option<Uuid>>,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_patch_field")]
#[schema(nullable)]
pub name: Option<Option<String>>,
}
#[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<Uuid>,
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<AppState>,
Path(folder_identifier): Path<String>,
@@ -336,47 +385,7 @@ pub async fn list_folder_contents(
.load(&mut conn)?
};
let type_ids: HashSet<Uuid> = docs.iter().filter_map(|doc| doc.document_type_id).collect();
let doc_type_map: HashMap<Uuid, DocumentType> = if type_ids.is_empty() {
HashMap::new()
} else {
let ids: Vec<Uuid> = type_ids.iter().copied().collect();
document_types::table
.filter(document_types::tenant_id.eq(tenant_id))
.filter(document_types::id.eq_any(&ids))
.load::<DocumentType>(&mut conn)?
.into_iter()
.map(|typ| (typ.id, typ))
.collect()
};
let doc_ids: Vec<Uuid> = 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<Uuid>,
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<Uuid>,
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;
+10
View File
@@ -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<serde_json::Value>) {
(StatusCode::OK, Json(json!({ "status": "ok" })))
}
+55
View File
@@ -57,6 +57,12 @@ pub struct RevokePasskeyQuery {
pub reason: Option<String>,
}
#[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<AppState>,
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<String>, Query, description = "Optional reason for revoking the passkey")
),
responses((status = 204, description = "Passkey revoked")),
tag = "Profile"
)]
pub async fn delete_passkey(
State(state): State<AppState>,
TenantScopedConn {
@@ -166,3 +202,22 @@ fn parse_timestamp(value: &str) -> AppResult<NaiveDateTime> {
.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;
+74 -4
View File
@@ -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<Option<&'a str>>,
}
#[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<String>)]
pub label: Option<Option<String>>,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_patch_field")]
#[schema(nullable, value_type = Option<String>)]
pub color: Option<Option<String>>,
}
#[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<Uuid>,
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<Uuid>,
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;
+9
View File
@@ -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<NullableValue
Some(other) => Err(format!("expected string or null, got {other}")),
}
}
pub fn deserialize_patch_field<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de>,
{
Option::<T>::deserialize(deserializer).map(Some)
}
+14 -14
View File
@@ -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();
+27 -25
View File
@@ -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<Mutex<()>> = 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<Vec<Job>> {
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::<Job>(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)?;
+4 -4
View File
@@ -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;
+3 -3
View File
@@ -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;