Merge pull request #664 from EdouardVanbelle/feat/openapi
This commit is contained in:
@@ -22,3 +22,11 @@ Non-obvious rules that trip up new code. Terse on purpose.
|
||||
- **Never hand-craft blob paths.** No `blob_root: PathBuf` fields, no `<storage>/.blobs/<xx>/<hash>.blob` constructions. `BlobStorageBackend::local_blob_path` returns `None` under `EncryptedBlobBackend`; do not rely on it. The three services that did this pre-2026-08 (audio/media/face) are the anti-pattern — see memory `project_services_bypassing_blob_backend`.
|
||||
- **Persistent state = backend**, not `<storage_path>/*` sidecars. Local sidecars (`.thumbnails/`, `.transcoded/`, `.blob-cache/`, `.search-index/`, `.plugin-logs/`, `.uploads/`) are only for caches (regenerable) or truly-temp scratch (deleted on drop). Anything a user would notice losing → blob backend. Tier-2 migration plan: `docs/plan/derived-blobs.md`.
|
||||
- **Temp files use `OXICLOUD_TEMP_DIR`** via the shared config path (`AppConfig::temp_dir`) — not raw `std::env::temp_dir()`. Ops point it at real disk on RAM-constrained Linux deployments (default `/tmp` = tmpfs = RAM).
|
||||
|
||||
## OpenAPI
|
||||
|
||||
- **Every new `#[utoipa::path(...)]` handler MUST also be added to the `paths(...)` list in `src/interfaces/api/mod.rs`.** utoipa emits ONLY registered paths; the annotation alone is invisible to `resources/gen/openapi.json`. Historical drift found 21 annotated handlers that never reached the spec (admin drives, admin jobs pause/findings/purge, admin SMTP, admin storage rotate, admin promote-to-internal, admin sessions list/revoke, all OPAQUE endpoints, DPoP bind, magic-link send, profile PATCH, upgrade-to-internal, drive delete/members/policies/quota, grant notify, trash per-drive, user profile, dedup check-batch) — they all had valid `#[utoipa::path]` blocks but nobody registered them.
|
||||
- **Every DTO the new handler touches** — request body, response body, path/query params, error shapes — MUST also be added to `components(schemas(...))` in the same file, OR be reachable from an already-registered schema. Utoipa only pulls in schemas transitively from registered paths + registered top-level schemas.
|
||||
- **After adding: `cargo run --bin generate-openapi`** to regenerate `resources/gen/openapi.json`, then `git diff resources/gen/openapi.json` — the new path + its request/response schemas must be present. Zero-diff means you missed the registration.
|
||||
- Sanity check for the whole surface: `diff <(grep -oE 'path = "/api[^"]+"' src/interfaces/api/handlers/*.rs | grep -oE '/api[^"]+' | sort -u) <(jq -r '.paths | keys | .[]' resources/gen/openapi.json | sort -u)` — should always be empty. Non-empty diff = drift.
|
||||
- Handlers referenced by the `paths(...)` list MUST be `pub` (module-visible from the paths list). Private `async fn` compiles at the router mount but breaks the paths list with a visibility error — see `get_smtp_info`, `send_smtp_test`, `get_user_profile` for the retrofit.
|
||||
|
||||
@@ -1718,7 +1718,9 @@ async fn reextract_image_metadata(
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "admin"
|
||||
)]
|
||||
async fn get_smtp_info(State(state): State<Arc<AppState>>) -> Result<impl IntoResponse, AppError> {
|
||||
pub async fn get_smtp_info(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let smtp = &state.core.config.smtp;
|
||||
let info = SmtpInfoDto {
|
||||
enabled: smtp.is_enabled() && state.email_sender.is_some(),
|
||||
@@ -1802,7 +1804,7 @@ struct CapturedEmailQuery {
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "admin"
|
||||
)]
|
||||
async fn send_smtp_test(
|
||||
pub async fn send_smtp_test(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Json(dto): Json<SendSmtpTestDto>,
|
||||
|
||||
@@ -15,9 +15,11 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::dtos::people_dto::{FaceBoxDto, PersonDto};
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
fn disabled() -> Response {
|
||||
(
|
||||
@@ -36,6 +38,15 @@ fn bad_id() -> Response {
|
||||
}
|
||||
|
||||
/// GET /api/people — identity clusters for the caller.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/people",
|
||||
responses(
|
||||
(status = 200, description = "Identity clusters", body = [PersonDto]),
|
||||
(status = 404, description = "People feature disabled"),
|
||||
),
|
||||
tag = "people"
|
||||
)]
|
||||
pub async fn list_people(State(state): State<Arc<AppState>>, auth_user: AuthUser) -> Response {
|
||||
let Some(svc) = state.people_service.as_ref() else {
|
||||
return disabled();
|
||||
@@ -47,6 +58,17 @@ pub async fn list_people(State(state): State<Arc<AppState>>, auth_user: AuthUser
|
||||
}
|
||||
|
||||
/// GET /api/people/{id}/photos — file ids of a person's photos.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/people/{id}/photos",
|
||||
params(("id" = String, Path, description = "Person cluster id")),
|
||||
responses(
|
||||
(status = 200, description = "File ids where this person appears", body = [String]),
|
||||
(status = 400, description = "Malformed person id"),
|
||||
(status = 404, description = "People feature disabled"),
|
||||
),
|
||||
tag = "people"
|
||||
)]
|
||||
pub async fn person_photos(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
@@ -64,12 +86,26 @@ pub async fn person_photos(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct RenameBody {
|
||||
/// New display name for the cluster. `null` or omitted clears
|
||||
/// the name, reverting the cluster to its "Unnamed" state.
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// PATCH /api/people/{id} — name (or clear the name of) a person.
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/people/{id}",
|
||||
params(("id" = String, Path, description = "Person cluster id")),
|
||||
request_body = RenameBody,
|
||||
responses(
|
||||
(status = 204, description = "Renamed"),
|
||||
(status = 400, description = "Malformed person id"),
|
||||
(status = 404, description = "People feature disabled"),
|
||||
),
|
||||
tag = "people"
|
||||
)]
|
||||
pub async fn rename_person(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
@@ -88,13 +124,28 @@ pub async fn rename_person(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct MergeBody {
|
||||
/// Cluster id that will absorb the other one (`from`'s photos are
|
||||
/// reattributed to `into`; `from` is deleted). Typically the
|
||||
/// larger / named cluster wins.
|
||||
pub into: String,
|
||||
/// Cluster id being merged into `into`. Deleted after the merge.
|
||||
pub from: String,
|
||||
}
|
||||
|
||||
/// POST /api/people/merge — merge `from` into `into`.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/people/merge",
|
||||
request_body = MergeBody,
|
||||
responses(
|
||||
(status = 204, description = "Merged"),
|
||||
(status = 400, description = "Malformed cluster id(s)"),
|
||||
(status = 404, description = "People feature disabled"),
|
||||
),
|
||||
tag = "people"
|
||||
)]
|
||||
pub async fn merge_people(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
@@ -113,6 +164,15 @@ pub async fn merge_people(
|
||||
}
|
||||
|
||||
/// POST /api/people/recluster — re-run identity clustering for the caller.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/people/recluster",
|
||||
responses(
|
||||
(status = 200, description = "Recluster complete", body = serde_json::Value),
|
||||
(status = 404, description = "People feature disabled"),
|
||||
),
|
||||
tag = "people"
|
||||
)]
|
||||
pub async fn recluster(State(state): State<Arc<AppState>>, auth_user: AuthUser) -> Response {
|
||||
let Some(svc) = state.people_service.as_ref() else {
|
||||
return disabled();
|
||||
@@ -124,6 +184,15 @@ pub async fn recluster(State(state): State<Arc<AppState>>, auth_user: AuthUser)
|
||||
}
|
||||
|
||||
/// DELETE /api/people/data — erase all of the caller's face data.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/people/data",
|
||||
responses(
|
||||
(status = 204, description = "All face data erased"),
|
||||
(status = 404, description = "People feature disabled"),
|
||||
),
|
||||
tag = "people"
|
||||
)]
|
||||
pub async fn delete_all(State(state): State<Arc<AppState>>, auth_user: AuthUser) -> Response {
|
||||
let Some(svc) = state.people_service.as_ref() else {
|
||||
return disabled();
|
||||
@@ -135,6 +204,17 @@ pub async fn delete_all(State(state): State<Arc<AppState>>, auth_user: AuthUser)
|
||||
}
|
||||
|
||||
/// GET /api/people/faces/{file_id} — face boxes within a photo (lightbox tags).
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/people/faces/{file_id}",
|
||||
params(("file_id" = String, Path, description = "Photo file id")),
|
||||
responses(
|
||||
(status = 200, description = "Face bounding boxes in the photo", body = [FaceBoxDto]),
|
||||
(status = 400, description = "Malformed file id"),
|
||||
(status = 404, description = "People feature disabled"),
|
||||
),
|
||||
tag = "people"
|
||||
)]
|
||||
pub async fn faces_for_file(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
|
||||
@@ -46,7 +46,7 @@ pub fn user_routes() -> Router<Arc<AppState>> {
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "users",
|
||||
)]
|
||||
async fn get_user_profile(
|
||||
pub async fn get_user_profile(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(target_id): Path<Uuid>,
|
||||
|
||||
@@ -82,6 +82,15 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
handlers::auth_handler::oidc_backchannel_logout,
|
||||
handlers::auth_handler::oidc_link_start,
|
||||
handlers::auth_handler::oidc_unlink,
|
||||
// DPoP post-redirect bind (Gate 3), magic-link SEND (the
|
||||
// outbound half of the passwordless flow — /magic/v1/{token}
|
||||
// redemption is a browser redirect, not an API endpoint),
|
||||
// profile edit (PATCH — the read is via /me), and the
|
||||
// external-user upgrade path.
|
||||
handlers::auth_handler::dpop_bind,
|
||||
handlers::auth_handler::send_magic_link,
|
||||
handlers::auth_handler::update_profile,
|
||||
handlers::auth_handler::upgrade_to_internal,
|
||||
// File handlers (free functions — see file_handler.rs for why)
|
||||
handlers::file_handler::list_files_query,
|
||||
handlers::file_handler::upload_file_with_thumbnails,
|
||||
@@ -125,6 +134,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
handlers::chunked_upload_handler::cancel_upload,
|
||||
// Dedup handlers — all free functions for the same utoipa reason as chunked uploads.
|
||||
handlers::dedup_handler::check_hash,
|
||||
handlers::dedup_handler::check_hashes_batch,
|
||||
handlers::dedup_handler::get_stats,
|
||||
handlers::dedup_handler::get_blob,
|
||||
handlers::dedup_handler::recalculate_stats,
|
||||
@@ -135,6 +145,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
handlers::trash_handler::restore_from_trash,
|
||||
handlers::trash_handler::delete_permanently,
|
||||
handlers::trash_handler::empty_trash,
|
||||
handlers::trash_handler::empty_trash_for_drive,
|
||||
// Share handlers (free functions)
|
||||
handlers::share_handler::create_shared_link,
|
||||
handlers::share_handler::get_shared_link,
|
||||
@@ -162,8 +173,29 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
// Photos handler (free function)
|
||||
handlers::photos_handler::list_photos,
|
||||
handlers::photos_handler::list_photos_geo,
|
||||
// People / face-clustering handlers — mounted only when
|
||||
// `OXICLOUD_ENABLE_FACES` is on; each handler is defensive
|
||||
// (`disabled()` returns 404 otherwise). All work is strictly
|
||||
// caller-scoped by `PeopleService`.
|
||||
handlers::people_handler::list_people,
|
||||
handlers::people_handler::person_photos,
|
||||
handlers::people_handler::rename_person,
|
||||
handlers::people_handler::merge_people,
|
||||
handlers::people_handler::recluster,
|
||||
handlers::people_handler::delete_all,
|
||||
handlers::people_handler::faces_for_file,
|
||||
// Drive handler (free function)
|
||||
handlers::drive_handler::list_drives,
|
||||
handlers::drive_handler::delete_drive,
|
||||
handlers::drive_handler::list_drive_members,
|
||||
handlers::drive_handler::add_drive_member,
|
||||
handlers::drive_handler::update_drive_member,
|
||||
handlers::drive_handler::remove_drive_member,
|
||||
handlers::drive_handler::update_drive_policies,
|
||||
handlers::drive_handler::update_drive_quota,
|
||||
// Users handler — public profile lookup (auth-required, but
|
||||
// returns the callee's public view, not `/me`'s self-view).
|
||||
handlers::users_handler::get_user_profile,
|
||||
// Batch handlers (free functions)
|
||||
handlers::batch_handler::move_files_batch,
|
||||
handlers::batch_handler::copy_files_batch,
|
||||
@@ -240,6 +272,27 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
handlers::admin_handler::cancel_job,
|
||||
handlers::admin_handler::list_job_runs,
|
||||
handlers::admin_handler::get_job_run,
|
||||
handlers::admin_handler::pause_job,
|
||||
handlers::admin_handler::list_job_run_findings,
|
||||
handlers::admin_handler::purge_job_runs,
|
||||
// Admin drive management — full CRUD on drives + membership,
|
||||
// distinct from the user-facing /api/drives surface (admin can
|
||||
// touch any drive; user can touch only those they're an owner
|
||||
// of).
|
||||
handlers::admin_handler::list_all_drives,
|
||||
handlers::admin_handler::delete_drive_admin,
|
||||
handlers::admin_handler::list_drive_members_admin,
|
||||
handlers::admin_handler::add_drive_member_admin,
|
||||
handlers::admin_handler::update_drive_member_admin,
|
||||
handlers::admin_handler::remove_drive_member_admin,
|
||||
// Admin SMTP diagnostics + backend rotation + user promotion.
|
||||
// The two SMTP fns were pub-only-for-utoipa (private in-router
|
||||
// helpers before this branch); see the handler for the
|
||||
// read-only vs test-send semantics.
|
||||
handlers::admin_handler::get_smtp_info,
|
||||
handlers::admin_handler::send_smtp_test,
|
||||
handlers::admin_handler::trigger_backend_rotate,
|
||||
handlers::admin_handler::admin_promote_external_to_internal,
|
||||
// Admin sessions panel — list + revoke. Function names lack
|
||||
// the `_admin_` suffix; the `/api/admin/` prefix comes from
|
||||
// the router mount, not the handler name.
|
||||
@@ -266,6 +319,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
handlers::grant_handler::list_outgoing,
|
||||
handlers::grant_handler::list_my_shares,
|
||||
handlers::grant_handler::list_on_resource,
|
||||
handlers::grant_handler::notify_grant_recipient,
|
||||
// Subject-group handlers (ReBAC named groups) — free functions
|
||||
handlers::subject_group_handler::create_group,
|
||||
handlers::subject_group_handler::list_groups,
|
||||
@@ -342,6 +396,14 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
handlers::opaque_auth_handler::OpaqueLoginKe1Response,
|
||||
handlers::opaque_auth_handler::OpaqueLoginKe3Dto,
|
||||
handlers::opaque_auth_handler::OpaqueParamsResponse,
|
||||
// People / face-clustering — response DTOs published by the
|
||||
// /api/people/* endpoints (list_people, faces_for_file), plus
|
||||
// the two request bodies (rename, merge). Face indexing pipeline
|
||||
// is documented in `face_indexing_service` + `onnx_face_analyzer`.
|
||||
crate::application::dtos::people_dto::PersonDto,
|
||||
crate::application::dtos::people_dto::FaceBoxDto,
|
||||
handlers::people_handler::RenameBody,
|
||||
handlers::people_handler::MergeBody,
|
||||
// Share schemas
|
||||
ShareDto,
|
||||
CreateShareDto,
|
||||
|
||||
+15
-4
@@ -69,10 +69,21 @@ OXICLOUD_CHUNK_MAX_BYTES=4194304
|
||||
# _nextcloud_put_blake3 = 32 B) stay safely under this cap.
|
||||
OXICLOUD_DIRECT_PUT_MAX_BYTES=4194304
|
||||
|
||||
# grow up limits for tests
|
||||
OXICLOUD_RATE_LIMIT_REFRESH_MAX=3600
|
||||
OXICLOUD_RATE_LIMIT_LOGIN_MAX=3600
|
||||
OXICLOUD_RATE_LIMIT_REGISTER_MAX=3600
|
||||
# Rate-limit ceiling for tests. `_MAX` alone isn't enough because
|
||||
# the default `_WINDOW_SECS = 60` gives an effective rate of
|
||||
# `MAX / 60` req/sec — a bursty Playwright suite (many workers,
|
||||
# parallel logins + auto-refresh churn under DPoP required mode)
|
||||
# overflows even at MAX=3600 (60 req/sec shared across the whole
|
||||
# runner IP). Widening the window to 1 h means the same MAX is a
|
||||
# 1-hour budget, well above what any single CI run consumes
|
||||
# (< 5 min end-to-end). Applied to all three buckets so login /
|
||||
# refresh / register all share the same generous test posture.
|
||||
OXICLOUD_RATE_LIMIT_REFRESH_MAX=36000
|
||||
OXICLOUD_RATE_LIMIT_REFRESH_WINDOW_SECS=3600
|
||||
OXICLOUD_RATE_LIMIT_LOGIN_MAX=36000
|
||||
OXICLOUD_RATE_LIMIT_LOGIN_WINDOW_SECS=3600
|
||||
OXICLOUD_RATE_LIMIT_REGISTER_MAX=36000
|
||||
OXICLOUD_RATE_LIMIT_REGISTER_WINDOW_SECS=3600
|
||||
|
||||
# Magic-link / external-users flow (PR 9). The mock SMTP captures every
|
||||
# outbound message in-process so external_users.hurl can retrieve the
|
||||
|
||||
Reference in New Issue
Block a user