diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 804cc16..ab266a7 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -67,7 +67,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -635,6 +635,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "utoipa", "uuid", ] @@ -704,7 +705,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn", + "syn 2.0.106", ] [[package]] @@ -989,7 +990,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.106", ] [[package]] @@ -1000,7 +1001,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -1050,7 +1051,7 @@ dependencies = [ "dsl_auto_type", "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -1070,7 +1071,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" dependencies = [ - "syn", + "syn 2.0.106", ] [[package]] @@ -1092,7 +1093,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -1118,7 +1119,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -1287,7 +1288,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -1812,6 +1813,8 @@ checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", "hashbrown 0.16.0", + "serde", + "serde_core", ] [[package]] @@ -2267,7 +2270,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -2359,7 +2362,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.106", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", ] [[package]] @@ -2894,7 +2921,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -3082,6 +3109,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.106" @@ -3110,7 +3147,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -3152,7 +3189,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -3163,7 +3200,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -3259,7 +3296,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -3409,7 +3446,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -3514,6 +3551,31 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utoipa" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c24e8ab68ff9ee746aad22d39b5535601e6416d1b0feeabf78be986a5c4392" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.106", + "uuid", +] + [[package]] name = "uuid" version = "1.18.1" @@ -3615,7 +3677,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn", + "syn 2.0.106", "wasm-bindgen-shared", ] @@ -3650,7 +3712,7 @@ checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -3727,7 +3789,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -3738,7 +3800,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -3965,7 +4027,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", "synstructure", ] @@ -3986,7 +4048,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] @@ -4006,7 +4068,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", "synstructure", ] @@ -4046,7 +4108,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", ] [[package]] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index dffbd78..aab422b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -45,6 +45,7 @@ quick-xml = "0.32" futures-util = "0.3" url = "2.5" once_cell = "1.19" +utoipa = { version = "4.2", default-features = false, features = ["chrono", "uuid", "preserve_order"] } # Error handling thiserror = "1.0" diff --git a/backend/src/bin/openapi_dump.rs b/backend/src/bin/openapi_dump.rs new file mode 100644 index 0000000..dfc49da --- /dev/null +++ b/backend/src/bin/openapi_dump.rs @@ -0,0 +1,8 @@ +use backend::openapi::ApiDoc; +use utoipa::OpenApi; + +fn main() { + let spec = ApiDoc::openapi(); + let json = serde_json::to_string_pretty(&spec).expect("serialize openapi"); + println!("{}", json); +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 581469a..50b333f 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -4,6 +4,7 @@ pub mod db; pub mod error; pub mod jobs; pub mod models; +pub mod openapi; pub mod routes; pub mod s3; pub mod schema; diff --git a/backend/src/openapi.rs b/backend/src/openapi.rs new file mode 100644 index 0000000..d3fac6e --- /dev/null +++ b/backend/src/openapi.rs @@ -0,0 +1,902 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use utoipa::{IntoParams, OpenApi, ToSchema}; +use uuid::Uuid; + +#[derive(OpenApi)] +#[openapi( + paths( + doc::health_check, + doc::login, + doc::refresh, + doc::logout, + doc::me, + doc::select_tenant, + doc::list_documents, + doc::upload_document, + doc::get_document, + doc::update_document, + doc::delete_document, + doc::download_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_correspondents, + doc::create_correspondent, + doc::update_correspondent, + doc::delete_correspondent, + ), + components( + schemas( + schemas::LoginRequest, + schemas::AccessTokenResponse, + schemas::TenantSummary, + schemas::TenantSelectionResponse, + schemas::TenantSelectionRequest, + schemas::LoginResponseVariants, + schemas::DocumentResponse, + schemas::DocumentDetailResponse, + schemas::DocumentVersion, + schemas::DocumentAssetSummary, + schemas::DocumentAssetDetail, + schemas::DocumentAssetObject, + schemas::DocumentCorrespondent, + schemas::DocumentTag, + schemas::DocumentDownloadResponse, + schemas::UpdateDocumentRequest, + schemas::BulkMoveDocumentsRequest, + schemas::BulkMoveDocumentsResponse, + schemas::AssignTagsRequest, + schemas::MoveDocumentRequest, + schemas::BulkTagRequest, + schemas::BulkTagResponse, + schemas::CorrespondentAssignment, + schemas::BulkTagAction, + schemas::BulkCorrespondentsRequest, + schemas::BulkCorrespondentsResponse, + schemas::BulkCorrespondentAction, + schemas::AssignCorrespondentsRequest, + schemas::RemoveCorrespondentParams, + schemas::ReanalyzeRequest, + schemas::ReanalyzeResponse, + schemas::DocumentAssetRequestParams, + schemas::AssetObjectsQuery, + schemas::UploadDocumentForm, + schemas::CreateFolderRequest, + schemas::EnsureFolderPathRequest, + schemas::FolderResponse, + schemas::FolderInfo, + schemas::FolderContentsResponse, + schemas::FolderDocumentSummary, + schemas::UpdateFolderRequest, + schemas::FolderContentsParams, + schemas::TagCatalogEntry, + schemas::CreateTagRequest, + schemas::UpdateTagRequest, + schemas::CorrespondentCatalogEntry, + schemas::CreateCorrespondentRequest, + schemas::UpdateCorrespondentRequest, + ) + ), + 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 = "Correspondents", description = "Correspondent catalog") + ) +)] +pub struct ApiDoc; + +#[allow(dead_code)] +mod doc { + use super::schemas::*; + use uuid::Uuid; + + #[allow(dead_code)] + fn __keep_uuid_import() { + let _ = Uuid::nil(); + } + + #[utoipa::path( + get, + path = "/api/health", + responses((status = 200, description = "Service is healthy")), + tag = "Health" + )] + pub(super) fn health_check() {} + + #[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/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( + 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( + 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 uploaded", body = DocumentDetailResponse)), + tag = "Documents" + )] + pub(super) fn upload_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}/download", + params(("id" = Uuid, Path, description = "Document ID")), + responses((status = 200, description = "Download metadata", body = DocumentDownloadResponse)), + tag = "Documents" + )] + pub(super) fn download_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 = BulkMoveDocumentsRequest, + responses((status = 200, description = "Bulk move outcome", body = BulkMoveDocumentsResponse)), + 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 = BulkCorrespondentsResponse)), + 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"), + RemoveCorrespondentParams + ), + responses((status = 204, description = "Correspondent removed")), + tag = "Documents" + )] + pub(super) fn remove_correspondent() {} + + #[utoipa::path( + post, + path = "/api/documents/bulk/reanalyze", + request_body = ReanalyzeRequest, + responses((status = 200, description = "Reanalyze queued", body = ReanalyzeResponse)), + 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 = [DocumentAssetSummary])), + tag = "Assets" + )] + pub(super) fn list_document_assets() {} + + #[utoipa::path( + post, + path = "/api/documents/{id}/assets", + params( + ("id" = Uuid, Path, description = "Document ID"), + DocumentAssetRequestParams + ), + 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 = DocumentAssetDetail)), + tag = "Assets" + )] + pub(super) fn get_document_asset() {} + + #[utoipa::path( + post, + path = "/api/folders", + request_body = CreateFolderRequest, + responses((status = 200, description = "Folder created", 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"), + FolderContentsParams + ), + 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( + 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() {} +} + +#[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"); + } +} + +pub mod schemas { + use super::*; + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct LoginRequest { + pub username: String, + pub password: String, + #[schema(nullable)] + pub preferred_tenant_slug: Option, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct AccessTokenResponse { + pub access_token: String, + pub token_type: String, + pub expires_in: i64, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct TenantSummary { + pub tenant_id: Uuid, + pub slug: String, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct TenantSelectionResponse { + pub selection_token: String, + pub tenants: Vec, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct TenantSelectionRequest { + pub tenant_id: Uuid, + } + + #[derive(Serialize, Deserialize, ToSchema)] + #[serde(untagged)] + pub enum LoginResponseVariants { + Token(AccessTokenResponse), + Selection(TenantSelectionResponse), + } + + #[derive(Serialize, Deserialize, IntoParams, ToSchema)] + #[into_params(parameter_in = Query)] + pub struct DocumentListQuery { + pub folder_id: Option, + pub include_deleted: Option, + pub include_descendants: Option, + pub query: Option, + pub tags: Option, + pub correspondents: Option, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct DocumentTag { + pub id: Uuid, + pub label: String, + #[schema(nullable)] + pub color: Option, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct DocumentAssetObject { + pub id: Uuid, + pub ordinal: i32, + pub metadata: Value, + #[schema(nullable)] + pub url: Option, + #[schema(nullable)] + pub expires_at: Option, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct DocumentAssetSummary { + pub id: Uuid, + pub asset_type: String, + pub mime_type: String, + pub metadata: Value, + #[schema(nullable)] + pub cardinality: Option, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct DocumentAssetDetail { + pub id: Uuid, + pub asset_type: String, + pub mime_type: String, + pub metadata: Value, + pub created_at: String, + #[schema(nullable)] + pub cardinality: Option, + pub objects: Vec, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct DocumentVersion { + pub id: Uuid, + pub version_number: i32, + pub checksum: String, + pub size_bytes: i64, + pub created_at: String, + pub metadata: Value, + #[schema(nullable)] + pub operations_summary: Option, + #[schema(nullable)] + pub assets: Option>, + pub download_path: String, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct DocumentCorrespondent { + pub id: Uuid, + pub name: String, + pub role: String, + pub metadata: Value, + pub assigned_at: String, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct DocumentResponse { + pub id: Uuid, + pub filename: String, + pub title: String, + pub original_name: String, + #[schema(nullable)] + pub content_type: Option, + #[schema(nullable)] + pub folder_id: Option, + pub uploaded_at: String, + pub updated_at: String, + #[schema(nullable)] + pub deleted_at: Option, + #[schema(nullable)] + pub issued_at: Option, + pub metadata: Value, + pub tags: Vec, + #[schema(nullable)] + pub correspondents: Option>, + #[schema(nullable)] + pub current_version: Option, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct DocumentDetailResponse { + pub document: DocumentResponse, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct DocumentDownloadResponse { + pub url: String, + pub expires_in: u64, + pub filename: String, + #[schema(nullable)] + pub content_type: Option, + pub size_bytes: i64, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct UpdateDocumentRequest { + #[schema(nullable)] + pub title: Option, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct BulkMoveDocumentsRequest { + pub document_ids: Vec, + #[schema(nullable)] + pub folder_id: Option, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct BulkMoveDocumentsResponse { + pub updated: usize, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct AssignTagsRequest { + pub tag_ids: Vec, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct MoveDocumentRequest { + #[schema(nullable)] + pub folder_id: Option, + } + + #[derive(Serialize, Deserialize, ToSchema)] + #[serde(rename_all = "snake_case")] + pub enum BulkTagAction { + Add, + Remove, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct BulkTagRequest { + pub document_ids: Vec, + pub tag_ids: Vec, + pub action: BulkTagAction, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct BulkTagResponse { + pub added: usize, + pub removed: usize, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct CorrespondentAssignment { + pub correspondent_id: Uuid, + pub role: String, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct AssignCorrespondentsRequest { + pub assignments: Vec, + #[serde(default)] + pub replace: bool, + } + + #[derive(Serialize, Deserialize, ToSchema)] + #[serde(rename_all = "lowercase")] + pub enum BulkCorrespondentAction { + Add, + Remove, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct BulkCorrespondentsRequest { + pub document_ids: Vec, + pub assignments: Vec, + #[serde(default = "default_bulk_correspondent_action")] + pub action: BulkCorrespondentAction, + } + + fn default_bulk_correspondent_action() -> BulkCorrespondentAction { + BulkCorrespondentAction::Add + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct BulkCorrespondentsResponse { + pub assigned: usize, + pub removed: usize, + } + + #[derive(Serialize, Deserialize, IntoParams, ToSchema)] + #[into_params(parameter_in = Query)] + pub struct RemoveCorrespondentParams { + pub role: String, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct ReanalyzeRequest { + pub document_ids: Vec, + #[serde(default)] + pub force: bool, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct ReanalyzeResponse { + pub queued: usize, + } + + #[derive(Serialize, Deserialize, IntoParams, ToSchema)] + #[into_params(parameter_in = Query)] + pub struct DocumentAssetRequestParams { + #[serde(default)] + pub force: Option, + } + + #[derive(Serialize, Deserialize, IntoParams, ToSchema)] + #[into_params(parameter_in = Query)] + pub struct AssetObjectsQuery { + #[serde(default)] + pub start: Option, + #[serde(default)] + pub limit: Option, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct UploadDocumentForm { + #[schema(value_type = String, format = Binary)] + pub file: String, + #[schema(nullable)] + pub folder_id: Option, + #[schema(nullable)] + pub metadata: Option, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct CreateFolderRequest { + pub name: String, + #[schema(nullable)] + pub parent_id: Option, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct EnsureFolderPathRequest { + #[schema(nullable)] + pub parent_id: Option, + pub segments: Vec, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct FolderInfo { + pub id: Uuid, + pub name: String, + #[schema(nullable)] + pub parent_id: Option, + pub created_at: String, + pub updated_at: String, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct FolderResponse { + pub folder: FolderInfo, + } + + #[derive(Serialize, Deserialize, IntoParams, ToSchema)] + #[into_params(parameter_in = Query)] + pub struct FolderContentsParams { + #[serde(default)] + pub include_documents: Option, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct FolderDocumentSummary { + pub id: Uuid, + pub title: String, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct FolderContentsResponse { + #[schema(nullable)] + pub folder: Option, + pub subfolders: Vec, + pub documents: Vec, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct UpdateFolderRequest { + #[schema(nullable)] + pub parent_id: Option>, + #[schema(nullable)] + pub name: Option, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct TagCatalogEntry { + pub id: Uuid, + pub label: String, + #[schema(nullable)] + pub color: Option, + pub usage_count: i64, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct CreateTagRequest { + pub label: String, + #[schema(nullable)] + pub color: Option, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct UpdateTagRequest { + #[schema(nullable)] + pub label: Option>, + #[schema(nullable)] + pub color: Option>, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct CorrespondentCatalogEntry { + pub id: Uuid, + pub name: String, + pub metadata: Value, + pub role_counts: Value, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct CreateCorrespondentRequest { + pub name: String, + #[schema(default, value_type = Object)] + pub metadata: Value, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct UpdateCorrespondentRequest { + #[schema(nullable)] + pub name: Option, + #[schema(nullable)] + pub metadata: Option, + } +} diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index 356854c..98374c8 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -14,6 +14,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use tracing::{debug, error, info, warn}; +use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; use super::folders::gather_descendant_folder_ids; @@ -55,7 +56,8 @@ use search_utils::{build_quickwit_query, extract_document_id}; const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300; const QUICKWIT_MAX_HITS: usize = 200; -#[derive(Deserialize)] +#[derive(Deserialize, IntoParams, ToSchema)] +#[into_params(parameter_in = Query)] pub struct DocumentListQuery { pub folder_id: Option, #[serde(default)] @@ -67,13 +69,14 @@ pub struct DocumentListQuery { pub correspondents: Option, } -#[derive(Deserialize)] +#[derive(Deserialize, IntoParams, ToSchema)] +#[into_params(parameter_in = Query)] pub struct AssetRequestQuery { #[serde(default)] pub force: bool, } -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub struct TagResponse { pub id: Uuid, pub label: String, @@ -90,7 +93,7 @@ impl From for TagResponse { } } -#[derive(Serialize, Clone)] +#[derive(Serialize, Clone, ToSchema)] pub struct DocumentVersionResponse { pub id: Uuid, pub version_number: i32, @@ -103,7 +106,7 @@ pub struct DocumentVersionResponse { pub operations_summary: Option, } -#[derive(Serialize, Clone)] +#[derive(Serialize, Clone, ToSchema)] pub struct DocumentAssetResponse { pub id: Uuid, pub asset_type: String, @@ -113,7 +116,7 @@ pub struct DocumentAssetResponse { pub cardinality: Option, } -#[derive(Serialize, Clone)] +#[derive(Serialize, Clone, ToSchema)] pub struct DocumentAssetObjectResponse { pub id: Uuid, pub ordinal: i32, @@ -124,7 +127,7 @@ pub struct DocumentAssetObjectResponse { pub expires_at: Option, } -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub struct DocumentAssetDetailResponse { pub id: Uuid, pub asset_type: String, @@ -137,7 +140,7 @@ pub struct DocumentAssetDetailResponse { pub objects: Vec, } -#[derive(Serialize, Clone)] +#[derive(Serialize, Clone, ToSchema)] pub struct DocumentCurrentVersionResponse { #[serde(flatten)] pub version: DocumentVersionResponse, @@ -146,7 +149,7 @@ pub struct DocumentCurrentVersionResponse { pub download_path: String, } -#[derive(Serialize, Clone)] +#[derive(Serialize, Clone, ToSchema)] pub struct DocumentCorrespondentResponse { pub id: Uuid, pub name: String, @@ -155,7 +158,7 @@ pub struct DocumentCorrespondentResponse { pub assigned_at: String, } -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub struct DocumentResponse { pub id: Uuid, pub filename: String, @@ -174,12 +177,12 @@ pub struct DocumentResponse { #[serde(skip_serializing_if = "Option::is_none")] pub current_version: Option, } -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub struct DocumentDetailResponse { pub document: DocumentResponse, } -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub struct DocumentDownloadResponse { pub url: String, pub expires_in: u64, @@ -188,67 +191,67 @@ pub struct DocumentDownloadResponse { pub size_bytes: i64, } -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub struct BulkReanalyzeResponse { pub queued: usize, } -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub struct BulkMoveRequest { pub document_ids: Vec, pub folder_id: Option, } -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub struct UpdateDocumentRequest { pub title: Option, } -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub struct BulkMoveResponse { pub updated: usize, } -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] #[serde(rename_all = "snake_case")] pub enum BulkTagAction { Add, Remove, } -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub struct BulkTagRequest { pub document_ids: Vec, pub tag_ids: Vec, pub action: BulkTagAction, } -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub struct BulkTagResponse { pub added: usize, pub removed: usize, } -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub struct BulkCorrespondentResponse { pub assigned: usize, pub removed: usize, } -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub struct CorrespondentAssignmentInput { pub correspondent_id: Uuid, pub role: String, } -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub struct AssignCorrespondentsRequest { pub assignments: Vec, #[serde(default)] pub replace: bool, } -#[derive(Deserialize, Copy, Clone, PartialEq, Eq)] +#[derive(Deserialize, Copy, Clone, PartialEq, Eq, ToSchema)] #[serde(rename_all = "lowercase")] pub enum BulkCorrespondentAction { Add, @@ -259,7 +262,7 @@ fn default_bulk_correspondent_action() -> BulkCorrespondentAction { BulkCorrespondentAction::Add } -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub struct BulkCorrespondentsRequest { pub document_ids: Vec, pub assignments: Vec, @@ -267,12 +270,13 @@ pub struct BulkCorrespondentsRequest { pub action: BulkCorrespondentAction, } -#[derive(Deserialize)] +#[derive(Deserialize, IntoParams, ToSchema)] +#[into_params(parameter_in = Query)] pub struct CorrespondentRoleQuery { pub role: String, } -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub struct BulkReanalyzeSelectionRequest { pub document_ids: Vec, #[serde(default = "default_true")] @@ -296,17 +300,28 @@ struct UploadOutcome { created: bool, } -#[derive(Deserialize)] +#[derive(ToSchema)] +pub struct UploadDocumentForm { + #[schema(value_type = String, format = Binary)] + pub file: String, + #[schema(nullable)] + pub folder_id: Option, + #[schema(nullable)] + pub metadata: Option, +} + +#[derive(Deserialize, ToSchema)] pub struct MoveDocumentRequest { pub folder_id: Option, } -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub struct AssignTagsRequest { pub tag_ids: Vec, } -#[derive(Deserialize, Default)] +#[derive(Deserialize, Default, IntoParams, ToSchema)] +#[into_params(parameter_in = Query)] pub struct AssetObjectsQuery { #[serde(default)] pub start: Option, diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index ec2f419..92646bb 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -2,12 +2,15 @@ use axum::http::HeaderValue; use axum::{ extract::DefaultBodyLimit, middleware, + response::Json, routing::{delete, get, patch, post}, Router, }; +use std::sync::Arc; use tower_http::cors::{AllowOrigin, CorsLayer}; +use utoipa::OpenApi; -use crate::{auth::AuthenticatedUser, state::AppState}; +use crate::{auth::AuthenticatedUser, openapi::ApiDoc, state::AppState}; pub mod auth; pub mod correspondents; @@ -128,9 +131,22 @@ pub fn create_router(state: AppState) -> Router<()> { .nest("/api/assets", assets_routes) .layer(middleware::from_extractor_with_state::(protected_state)); + let openapi_arc = Arc::new(ApiDoc::openapi()); + let docs_route = Router::new().route( + "/api/docs/openapi.json", + get({ + let spec = openapi_arc.clone(); + move || { + let spec = spec.clone(); + async move { Json((*spec).clone()) } + } + }), + ); + Router::new() .merge(download_routes) .merge(protected_routes) + .merge(docs_route) .nest("/api/auth", auth_routes) .route("/api/health", get(health::health_check)) .with_state(state) diff --git a/docs/api.txt b/docs/api.txt index d48c0c0..9dea7ae 100644 --- a/docs/api.txt +++ b/docs/api.txt @@ -50,6 +50,7 @@ Folders - GET /api/folders/:id/contents - List subfolders and documents inside a folder; use `root` for the workspace root. - DELETE /api/folders/:id - Soft-delete a folder. - PATCH /api/folders/:id - Update a folder's parent (`parent_id`) and/or rename it (`name`). +- GET /api/docs/openapi.json - Generated OpenAPI specification (JSON). Tags ----