backbone_bucket/bucket_module.rs
1//! Custom BucketModule extensions — Phase 6: Child Entity Collapse
2//!
3//! This file is NEVER touched by backbone-schema generators.
4//! It adds http_routes() to BucketModule, which returns only the 10 active
5//! CRUD stacks (Thumbnail, FileVersion, and AccessLog are intentionally excluded
6//! as they are child entities with no independent lifecycle).
7//!
8//! The generated routes() in lib.rs is always regenerated with all 13 entities.
9//! The app MUST call http_routes() instead.
10
11use std::sync::Arc;
12use axum::{response::IntoResponse, Router};
13
14use crate::auth::{ArcAuthzPolicy, AuthExtractor, HasOwnerId};
15use crate::error::{BucketError, BucketResult};
16use crate::presentation::http::{
17 create_bucket_routes,
18 create_content_hash_routes,
19 create_conversion_job_routes,
20 create_file_comment_routes,
21 create_file_lock_routes,
22 create_file_share_routes,
23 create_processing_job_routes,
24 create_stored_file_routes,
25 create_upload_session_routes,
26 create_user_quota_routes,
27 serving_router as build_serving_router,
28 upload_router as build_upload_router,
29 ServingContext,
30 UploadConfig,
31 UploadContext,
32};
33use crate::BucketModule;
34
35impl BucketModule {
36 /// HTTP routes for the 10 active CRUD stacks.
37 ///
38 /// Excludes Thumbnail, FileVersion, AccessLog — child entities managed through
39 /// their parent (StoredFile/Bucket) and not exposed as independent endpoints.
40 pub fn http_routes(&self) -> Router {
41 Router::new()
42 .merge(create_bucket_routes(self.bucket_service.clone()))
43 .merge(create_content_hash_routes(self.content_hash_service.clone()))
44 .merge(create_conversion_job_routes(self.conversion_job_service.clone()))
45 .merge(create_file_comment_routes(self.file_comment_service.clone()))
46 .merge(create_file_lock_routes(self.file_lock_service.clone()))
47 .merge(create_file_share_routes(self.file_share_service.clone()))
48 .merge(create_processing_job_routes(self.processing_job_service.clone()))
49 .merge(create_stored_file_routes(self.stored_file_service.clone()))
50 .merge(create_upload_session_routes(self.upload_session_service.clone()))
51 .merge(create_user_quota_routes(self.user_quota_service.clone()))
52 }
53
54 /// The 10 generated CRUD routers, composed.
55 ///
56 /// Alias for [`Self::http_routes`]. Returns the existing
57 /// `/api/v1/bucket/*` router — unchanged from before the serving
58 /// handler was added. Named to pair symmetrically with
59 /// [`Self::serving_router`].
60 pub fn crud_router(&self) -> Router {
61 self.http_routes()
62 }
63
64 /// Build the mode-B serving router.
65 ///
66 /// `A` is the consumer's [`AuthExtractor`] — and, since Axum
67 /// extractors yield themselves, also the identity type the
68 /// [`crate::auth::AuthzPolicy`] decides on.
69 ///
70 /// Returns `Err` when `.with_storage()` / `.with_config()` were not
71 /// called on the builder — the serving handler can't operate without
72 /// both.
73 pub fn serving_router<A>(&self, authz: ArcAuthzPolicy<A>) -> BucketResult<Router>
74 where
75 A: AuthExtractor<()> + HasOwnerId + Clone + 'static,
76 A::Rejection: IntoResponse,
77 {
78 let storage = self
79 .storage
80 .clone()
81 .ok_or_else(|| BucketError::Config("storage backend not configured".into()))?;
82 let config = self
83 .bucket_config
84 .clone()
85 .ok_or_else(|| BucketError::Config("bucket config not provided".into()))?;
86 let ctx = ServingContext::<A> {
87 storage,
88 file_repo: self.stored_file_repository.clone(),
89 authz,
90 config,
91 };
92 Ok(build_serving_router::<A>(ctx))
93 }
94
95 /// Build the multipart upload router.
96 ///
97 /// Mounts five routes (see [`crate::presentation::http::upload`]):
98 /// single-shot `POST /uploads`, and the resumable session lifecycle
99 /// under `/uploads/sessions`. The consumer's [`AuthExtractor`] `A`
100 /// must also implement [`HasOwnerId`] so `owner_id` is derived from
101 /// the authenticated identity instead of being trusted from input.
102 ///
103 /// Returns `Err` when `.with_storage()` / `.with_config()` were not
104 /// configured — uploads cannot run without an [`ObjectStorage`]
105 /// backend and the wired-up [`crate::FileService`].
106 pub fn upload_router<A>(&self, config: UploadConfig) -> BucketResult<Router>
107 where
108 A: AuthExtractor<()> + HasOwnerId + Clone + 'static,
109 A::Rejection: IntoResponse,
110 {
111 let storage = self
112 .storage
113 .clone()
114 .ok_or_else(|| BucketError::Config("storage backend not configured".into()))?;
115 let file_service = self
116 .file_service
117 .clone()
118 .ok_or_else(|| BucketError::Config("file service not configured (call .with_storage() and .with_config())".into()))?;
119 let ctx = UploadContext {
120 file_service,
121 multipart_service: self.multipart_upload_service.clone(),
122 bucket_service: self.bucket_service.clone(),
123 storage,
124 };
125 Ok(build_upload_router::<A>(ctx, config))
126 }
127
128 /// One-call composition: CRUD + upload + serving routers merged.
129 ///
130 /// Pick this when the consumer just wants "everything the module
131 /// exposes" under a single nest point. Each sub-router is still
132 /// reachable individually via [`Self::crud_router`] /
133 /// [`Self::upload_router`] / [`Self::serving_router`] for advanced
134 /// composition.
135 ///
136 /// The merged router pairs CRUD endpoints with the upload surface
137 /// at the same prefix the consumer chooses (e.g. `/api/v1/bucket`),
138 /// and nests the serving handler under `serving_prefix` (default
139 /// `/cdn`). Passing `RouterOptions::default()` plus a configured
140 /// `BucketModule` is the smallest possible wiring:
141 ///
142 /// ```ignore
143 /// let app = Router::new()
144 /// .nest("/api/v1/bucket", bucket.router::<MyUser>(opts)?);
145 /// ```
146 ///
147 /// Returns `Err` for the same reasons [`Self::serving_router`] and
148 /// [`Self::upload_router`] do — `.with_storage()` and
149 /// `.with_config()` are required on the builder.
150 pub fn router<A>(&self, opts: RouterOptions<A>) -> BucketResult<Router>
151 where
152 A: AuthExtractor<()> + HasOwnerId + Clone + 'static,
153 A::Rejection: IntoResponse,
154 {
155 let RouterOptions {
156 upload_config,
157 authz,
158 serving_prefix,
159 } = opts;
160 let serving = self.serving_router::<A>(authz)?;
161 let uploads = self.upload_router::<A>(upload_config)?;
162 Ok(self
163 .crud_router()
164 .merge(uploads)
165 .nest(&serving_prefix, serving))
166 }
167}
168
169/// Options for [`BucketModule::router`].
170///
171/// `authz` is the only required field — the policy decides whether an
172/// authenticated caller may read a given file. Defaults: stock
173/// [`UploadConfig`] (256 MiB single-shot / 16 MiB per chunk) and
174/// `/cdn` as the serving prefix.
175pub struct RouterOptions<A> {
176 pub upload_config: UploadConfig,
177 pub authz: ArcAuthzPolicy<A>,
178 pub serving_prefix: String,
179}
180
181impl<A> RouterOptions<A> {
182 /// Construct with the policy and module-default values.
183 pub fn new(authz: ArcAuthzPolicy<A>) -> Self {
184 Self {
185 upload_config: UploadConfig::default(),
186 authz,
187 serving_prefix: "/cdn".to_string(),
188 }
189 }
190
191 /// Override the single-shot / chunk body limits.
192 pub fn with_upload_config(mut self, cfg: UploadConfig) -> Self {
193 self.upload_config = cfg;
194 self
195 }
196
197 /// Override the serving-router mount path (default `/cdn`).
198 pub fn with_serving_prefix(mut self, prefix: impl Into<String>) -> Self {
199 self.serving_prefix = prefix.into();
200 self
201 }
202}