1use axum::extract::rejection::{JsonRejection, PathRejection};
8use axum::extract::{FromRequest, FromRequestParts, Json, MatchedPath, Path, Request};
9use axum::http::StatusCode;
10use axum::http::request::Parts;
11use axum::middleware::Next;
12use axum::response::{IntoResponse, Response};
13use serde::de::DeserializeOwned;
14
15use tollgate_core::{Generation, SnapshotValidationError};
16use tollgate_store::IngestError;
17use tollgate_store::wire::Problem;
18use tollgate_store::{
19 AllocateError, CreateAccountError, PublishSnapshotError, SetStatusError, StoreError,
20};
21
22#[derive(Debug)]
30pub struct ApiError {
31 pub status: StatusCode,
33 pub code: &'static str,
36 pub title: String,
39 pub generation: Option<Generation>,
41 pub balance_exhaustion: Option<tollgate_core::BalanceExhaustion>,
43 pub balance_shortfall: Option<tollgate_core::BalanceShortfall>,
46}
47
48pub(crate) struct ApiJson<T>(pub T);
54
55impl<T, S> FromRequest<S> for ApiJson<T>
56where
57 T: DeserializeOwned,
58 S: Send + Sync,
59{
60 type Rejection = ApiError;
61
62 async fn from_request(request: Request, state: &S) -> Result<Self, Self::Rejection> {
63 Json::<T>::from_request(request, state)
64 .await
65 .map(|Json(value)| Self(value))
66 .map_err(ApiError::from)
67 }
68}
69
70pub(crate) struct ApiPath<T>(pub T);
72
73pub(crate) struct ApiQuery<T>(pub T);
75impl<T, S> FromRequestParts<S> for ApiQuery<T>
76where
77 T: DeserializeOwned + Send,
78 S: Send + Sync,
79{
80 type Rejection = ApiError;
81 async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
82 axum::extract::Query::<T>::from_request_parts(parts, state)
83 .await
84 .map(|axum::extract::Query(value)| Self(value))
85 .map_err(|_| {
86 ApiError::bad_request(
87 "invalid-query",
88 "query parameters are malformed or unsupported",
89 )
90 })
91 }
92}
93
94impl<T, S> FromRequestParts<S> for ApiPath<T>
95where
96 T: DeserializeOwned + Send,
97 S: Send + Sync,
98{
99 type Rejection = ApiError;
100
101 async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
102 Path::<T>::from_request_parts(parts, state)
103 .await
104 .map(|Path(value)| Self(value))
105 .map_err(ApiError::from)
106 }
107}
108
109impl ApiError {
110 pub fn unauthorized() -> Self {
113 Self {
114 status: StatusCode::UNAUTHORIZED,
115 code: "authentication-required",
116 title: "valid control-plane credentials required".into(),
117 generation: None,
118 balance_exhaustion: None,
119 balance_shortfall: None,
120 }
121 }
122
123 pub(crate) fn refused_scope(code: &'static str, title: impl Into<String>) -> Self {
128 Self {
129 status: StatusCode::FORBIDDEN,
130 code,
131 title: title.into(),
132 generation: None,
133 balance_exhaustion: None,
134 balance_shortfall: None,
135 }
136 }
137
138 pub fn forbidden() -> Self {
141 Self {
142 status: StatusCode::FORBIDDEN,
143 code: "scope-forbidden",
144 title: "credential does not authorize this control-plane operation".into(),
145 generation: None,
146 balance_exhaustion: None,
147 balance_shortfall: None,
148 }
149 }
150 pub fn not_found(code: &'static str, title: impl Into<String>) -> Self {
152 ApiError {
153 status: StatusCode::NOT_FOUND,
154 code,
155 title: title.into(),
156 generation: None,
157 balance_exhaustion: None,
158 balance_shortfall: None,
159 }
160 }
161
162 pub fn revoked(generation: Generation) -> Self {
166 ApiError {
167 status: StatusCode::GONE,
168 code: "revoked-principal",
169 title: "snapshot revoked".to_string(),
170 generation: Some(generation),
171 balance_exhaustion: None,
172 balance_shortfall: None,
173 }
174 }
175
176 pub fn bad_request(code: &'static str, title: impl Into<String>) -> Self {
178 ApiError {
179 status: StatusCode::BAD_REQUEST,
180 code,
181 title: title.into(),
182 generation: None,
183 balance_exhaustion: None,
184 balance_shortfall: None,
185 }
186 }
187
188 pub fn not_implemented(code: &'static str, title: impl Into<String>) -> Self {
192 ApiError {
193 status: StatusCode::NOT_IMPLEMENTED,
194 code,
195 title: title.into(),
196 generation: None,
197 balance_exhaustion: None,
198 balance_shortfall: None,
199 }
200 }
201}
202
203impl From<JsonRejection> for ApiError {
204 fn from(error: JsonRejection) -> Self {
205 let status = error.into_response().status();
206 if status == StatusCode::PAYLOAD_TOO_LARGE {
217 return ApiError {
218 status,
219 code: "batch-too-large",
220 title: "request body exceeds this endpoint's limit".into(),
221 generation: None,
222 balance_exhaustion: None,
223 balance_shortfall: None,
224 };
225 }
226 ApiError {
227 status,
228 code: "invalid-json",
229 title: "request body is not valid JSON for this endpoint".to_string(),
230 generation: None,
231 balance_exhaustion: None,
232 balance_shortfall: None,
233 }
234 }
235}
236
237impl From<PathRejection> for ApiError {
238 fn from(error: PathRejection) -> Self {
239 let status = error.into_response().status();
240 ApiError {
241 status,
242 code: "invalid-id",
243 title: "path identifier must be exactly 32 lowercase hexadecimal digits".to_string(),
244 generation: None,
245 balance_exhaustion: None,
246 balance_shortfall: None,
247 }
248 }
249}
250
251impl From<AllocateError> for ApiError {
252 fn from(e: AllocateError) -> Self {
253 let balance_exhaustion = match &e {
254 AllocateError::BalanceExhausted(evidence) => Some(*evidence),
255 _ => None,
256 };
257 let balance_shortfall = match &e {
258 AllocateError::BalanceInsufficient(evidence) => Some(*evidence),
259 _ => None,
260 };
261 let (status, code) = match e {
262 AllocateError::UnknownAccount => (StatusCode::NOT_FOUND, "unknown-account"),
263 AllocateError::AccountInactive => (StatusCode::CONFLICT, "account-inactive"),
264 AllocateError::InsufficientBalance | AllocateError::BalanceInsufficient(_) => {
267 (StatusCode::CONFLICT, "insufficient-balance")
268 }
269 AllocateError::BalanceExhausted(_) => (StatusCode::CONFLICT, "balance-exhausted"),
270 AllocateError::BalanceOverflow => {
271 (StatusCode::UNPROCESSABLE_ENTITY, "balance-overflow")
272 }
273 AllocateError::InvalidTtl => (StatusCode::UNPROCESSABLE_ENTITY, "invalid-ttl"),
274 AllocateError::UnknownLease => (StatusCode::NOT_FOUND, "unknown-lease"),
275 AllocateError::Fenced => (StatusCode::CONFLICT, "fenced"),
276 AllocateError::LeaseNotActive => (StatusCode::CONFLICT, "lease-not-active"),
277 AllocateError::InvalidRelease => (StatusCode::UNPROCESSABLE_ENTITY, "invalid-release"),
278 AllocateError::Storage(inner) => return ApiError::from(inner),
279 };
280 ApiError {
281 status,
282 code,
283 title: e.to_string(),
284 generation: None,
285 balance_exhaustion,
286 balance_shortfall,
287 }
288 }
289}
290
291impl From<CreateAccountError> for ApiError {
292 fn from(e: CreateAccountError) -> Self {
293 match e {
294 CreateAccountError::AlreadyExists => ApiError {
295 status: StatusCode::CONFLICT,
296 code: "account-exists",
297 title: "account already exists".to_string(),
298 generation: None,
299 balance_exhaustion: None,
300 balance_shortfall: None,
301 },
302 CreateAccountError::Storage(inner) => ApiError::from(inner),
303 }
304 }
305}
306
307impl From<tollgate_store::KeyError> for ApiError {
308 fn from(e: tollgate_store::KeyError) -> Self {
309 use tollgate_store::KeyError;
310 let (status, code) = match &e {
311 KeyError::UnknownAccount => (StatusCode::NOT_FOUND, "unknown-account"),
312 KeyError::UnknownKey => (StatusCode::NOT_FOUND, "unknown-credential"),
313 KeyError::AlreadyExists => (StatusCode::CONFLICT, "credential-exists"),
318 KeyError::ActiveKeyLimit { .. } => (StatusCode::CONFLICT, "active-key-limit"),
322 KeyError::Storage(inner) => return inner.clone().into(),
323 };
324 ApiError {
325 status,
326 code,
327 title: e.to_string(),
328 generation: None,
329 balance_exhaustion: None,
330 balance_shortfall: None,
331 }
332 }
333}
334
335impl From<tollgate_store::KeySnapshotError> for ApiError {
336 fn from(e: tollgate_store::KeySnapshotError) -> Self {
337 use tollgate_store::KeySnapshotError;
338 let (status, code) = match &e {
339 KeySnapshotError::UnknownCredential => (StatusCode::NOT_FOUND, "unknown-credential"),
341 KeySnapshotError::Retired { .. } => (StatusCode::CONFLICT, "credential-retired"),
344 KeySnapshotError::Publish(inner) => return inner.clone().into(),
345 KeySnapshotError::Storage(inner) => return inner.clone().into(),
346 };
347 ApiError {
348 status,
349 code,
350 title: e.to_string(),
351 generation: None,
352 balance_exhaustion: None,
353 balance_shortfall: None,
354 }
355 }
356}
357
358impl From<tollgate_store::BudgetError> for ApiError {
359 fn from(e: tollgate_store::BudgetError) -> Self {
360 match e {
361 tollgate_store::BudgetError::UnknownAccount => ApiError {
362 status: StatusCode::NOT_FOUND,
363 code: "unknown-account",
364 title: e.to_string(),
365 generation: None,
366 balance_exhaustion: None,
367 balance_shortfall: None,
368 },
369 tollgate_store::BudgetError::Storage(inner) => inner.into(),
370 }
371 }
372}
373
374impl From<SetStatusError> for ApiError {
375 fn from(e: SetStatusError) -> Self {
376 match e {
377 SetStatusError::UnknownAccount => ApiError {
378 status: StatusCode::NOT_FOUND,
379 code: "unknown-account",
380 title: e.to_string(),
381 generation: None,
382 balance_exhaustion: None,
383 balance_shortfall: None,
384 },
385 SetStatusError::AccountClosed => ApiError {
389 status: StatusCode::CONFLICT,
390 code: "account-closed",
391 title: e.to_string(),
392 generation: None,
393 balance_exhaustion: None,
394 balance_shortfall: None,
395 },
396 SetStatusError::NotProvisioned => {
399 ApiError::refused_scope("account-not-provisioned", e.to_string())
400 }
401 SetStatusError::OperatorHold => ApiError::refused_scope("operator-hold", e.to_string()),
402 SetStatusError::Storage(inner) => ApiError::from(inner),
403 }
404 }
405}
406
407impl From<PublishSnapshotError> for ApiError {
408 fn from(e: PublishSnapshotError) -> Self {
409 match e {
410 PublishSnapshotError::CredentialMismatch { .. } => ApiError {
411 status: StatusCode::UNPROCESSABLE_ENTITY,
412 code: "invalid-credential-binding",
413 title: e.to_string(),
414 generation: None,
415 balance_exhaustion: None,
416 balance_shortfall: None,
417 },
418 PublishSnapshotError::StatusMismatch { .. } => ApiError {
419 status: StatusCode::CONFLICT,
420 code: "snapshot-status-mismatch",
421 title: e.to_string(),
422 generation: None,
423 balance_exhaustion: None,
424 balance_shortfall: None,
425 },
426 PublishSnapshotError::CapacityClassMismatch { .. } => ApiError {
431 status: StatusCode::CONFLICT,
432 code: "snapshot-capacity-class-mismatch",
433 title: e.to_string(),
434 generation: None,
435 balance_exhaustion: None,
436 balance_shortfall: None,
437 },
438 PublishSnapshotError::Storage(inner) => ApiError::from(inner),
439 }
440 }
441}
442
443impl From<StoreError> for ApiError {
444 fn from(_: StoreError) -> Self {
445 ApiError {
446 status: StatusCode::SERVICE_UNAVAILABLE,
447 code: "storage",
448 title: "backend unavailable".into(),
451 generation: None,
452 balance_exhaustion: None,
453 balance_shortfall: None,
454 }
455 }
456}
457
458impl From<IngestError> for ApiError {
459 fn from(error: IngestError) -> Self {
460 match error {
461 IngestError::Unavailable(e) => ApiError::from(e),
464 IngestError::Refused(_) => ApiError {
471 status: StatusCode::UNPROCESSABLE_ENTITY,
472 code: "usage-refused",
473 title: "usage batch refused".into(),
474 generation: None,
475 balance_exhaustion: None,
476 balance_shortfall: None,
477 },
478 }
479 }
480}
481
482impl From<SnapshotValidationError> for ApiError {
483 fn from(error: SnapshotValidationError) -> Self {
484 ApiError {
485 status: StatusCode::UNPROCESSABLE_ENTITY,
486 code: "invalid-snapshot-limits",
487 title: error.to_string(),
488 generation: None,
489 balance_exhaustion: None,
490 balance_shortfall: None,
491 }
492 }
493}
494
495impl IntoResponse for ApiError {
496 fn into_response(self) -> Response {
497 self.render(|| diagnostic_id(getrandom::fill))
498 }
499}
500
501#[derive(Clone)]
504struct HttpFailure {
505 code: &'static str,
506 error_id: Option<String>,
507}
508
509fn diagnostic_id(fill: impl FnOnce(&mut [u8]) -> Result<(), getrandom::Error>) -> Option<String> {
510 let mut bytes = [0u8; 16];
511 fill(&mut bytes).ok()?;
512 Some(tollgate_core::RequestId(u128::from_be_bytes(bytes)).to_string())
513}
514
515impl ApiError {
516 fn render(self, new_id: impl FnOnce() -> Option<String>) -> Response {
517 let unauthorized = self.status == StatusCode::UNAUTHORIZED;
518 let failure =
519 (self.status.is_server_error() || self.code == "usage-refused").then(|| HttpFailure {
520 code: self.code,
521 error_id: new_id(),
522 });
523 let problem = Problem {
524 status: self.status.as_u16(),
525 code: self.code.to_string(),
526 title: self.title,
527 generation: self.generation,
528 balance_exhaustion: self.balance_exhaustion,
529 balance_shortfall: self.balance_shortfall,
530 };
531 #[derive(serde::Serialize)]
534 struct DiagnosticProblem {
535 #[serde(flatten)]
536 problem: Problem,
537 #[serde(skip_serializing_if = "Option::is_none")]
538 error_id: Option<String>,
539 }
540 let body = DiagnosticProblem {
541 problem,
542 error_id: failure
543 .as_ref()
544 .and_then(|failure| failure.error_id.clone()),
545 };
546 let mut response = (self.status, Json(body)).into_response();
547 if let Some(failure) = failure {
548 response.extensions_mut().insert(failure);
549 }
550 response.headers_mut().insert(
551 axum::http::header::CONTENT_TYPE,
552 axum::http::HeaderValue::from_static("application/problem+json"),
553 );
554 if unauthorized {
555 response.headers_mut().insert(
556 axum::http::header::WWW_AUTHENTICATE,
557 axum::http::HeaderValue::from_static("Bearer realm=\"tollgate-control\""),
558 );
559 }
560 response
561 }
562}
563
564pub(crate) async fn report_http_failure(request: Request, next: Next) -> Response {
567 let route = request.extensions().get::<MatchedPath>().cloned();
568 let response = next.run(request).await;
569 if let Some(failure) = response.extensions().get::<HttpFailure>() {
570 tracing::warn!(
571 target: "tollgate::diagnostics",
572 route = route.as_ref().map(MatchedPath::as_str).unwrap_or("unmatched"),
573 code = failure.code,
574 status = response.status().as_u16(),
575 error_id = failure.error_id.as_deref(),
576 error_id_unavailable = failure.error_id.is_none(),
577 "control-plane operation failed; consult backend health and retained operational records"
578 );
579 }
580 response
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586
587 #[test]
588 fn diagnostic_identifiers_use_all_entropy_and_surface_entropy_failure() {
589 let id = diagnostic_id(|bytes| {
590 bytes.copy_from_slice(&0x00112233445566778899aabbccddeeff_u128.to_be_bytes());
591 Ok(())
592 });
593 assert_eq!(id.as_deref(), Some("00112233445566778899aabbccddeeff"));
594 assert!(diagnostic_id(|_| Err(getrandom::Error::UNSUPPORTED)).is_none());
595 }
596
597 #[tokio::test]
598 async fn entropy_failure_preserves_the_error_without_inventing_an_identifier() {
599 use http_body_util::BodyExt;
600 let response = ApiError::from(StoreError("fixture-secret-70".into())).render(|| None);
601 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
602 let failure = response.extensions().get::<HttpFailure>().unwrap();
603 assert_eq!(failure.code, "storage");
604 assert!(failure.error_id.is_none());
605 let body = response.into_body().collect().await.unwrap().to_bytes();
606 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
607 assert_eq!(json["title"], "backend unavailable");
608 assert!(json.get("error_id").is_none());
609 }
610}