• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

divviup / divviup-api / 25222483819

01 May 2026 04:24PM UTC coverage: 58.828% (+1.0%) from 57.824%
25222483819

push

github

web-flow
Migrate from Trillium [part 7A]: API sub-router and CRUD routes (#2235)

Wire the Axum `/api` sub-router and migrate 17 simple CRUD handlers
for users, accounts, memberships, API tokens, and collector
credentials. Trillium remains primary and handles MIME type
validation, auth gating, and session management during the proxy
transition period.

Key changes:
- Add `extract_entity` helper for permission-checked entity lookups
- Add `Json<T>` extractor using serde_path_to_error for error parity
- Add test-user injection shim so integration tests work through proxy
- Ensure `User::from_parts` always populates admin flag
- Return 404 (not 403) on permission failures to hide resource existence

157 of 177 new or added lines in 11 files covered. (88.7%)

22 existing lines in 4 files now uncovered.

4518 of 7680 relevant lines covered (58.83%)

62.59 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

80.0
/src/handler/extract.rs
1
//! Shared helpers for Axum extractors and responses.
2
use std::collections::HashMap;
3
use std::sync::Arc;
4

5
use axum::body::Bytes;
6
use axum::extract::{FromRef, FromRequest, FromRequestParts, Path, Request};
7
use axum::http::{request::Parts, StatusCode};
8
use axum::response::{IntoResponse, Response};
9
use sea_orm::EntityTrait;
10
use serde::{de::DeserializeOwned, Serialize};
11
use trillium_api::Error as ApiError;
12
use uuid::Uuid;
13

14
use crate::{handler::Error, Db, Permissions, PermissionsActor};
15

16
/// A JSON extractor/response that mirrors the Trillium `api()` + `Json<T>`
17
/// behaviour: request bodies are deserialized via [`serde_path_to_error`] so
18
/// that parse failures produce the same `{"path":…,"message":…}` error shape.
19
pub struct Json<T>(pub T);
20

21
impl<T, S> FromRequest<S> for Json<T>
22
where
23
    T: DeserializeOwned,
24
    S: Send + Sync,
25
{
26
    type Rejection = Error;
27

28
    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
32✔
29
        let bytes = Bytes::from_request(req, state).await.map_err(|e| {
32✔
NEW
30
            if e.status() == StatusCode::PAYLOAD_TOO_LARGE {
×
NEW
31
                Error::PayloadTooLarge
×
32
            } else {
NEW
33
                Error::Other(Arc::new(e))
×
34
            }
NEW
35
        })?;
×
36
        let deserializer = &mut serde_json::Deserializer::from_slice(&bytes);
32✔
37
        serde_path_to_error::deserialize(deserializer)
32✔
38
            .map(Json)
32✔
39
            .map_err(|err| {
32✔
NEW
40
                Error::Json(ApiError::ParseError {
×
NEW
41
                    path: err.path().to_string(),
×
NEW
42
                    message: err.inner().to_string(),
×
NEW
43
                })
×
NEW
44
            })
×
45
    }
32✔
46
}
47

48
impl<T: Serialize> IntoResponse for Json<T> {
49
    fn into_response(self) -> Response {
62✔
50
        axum::Json(self.0).into_response()
62✔
51
    }
62✔
52
}
53

54
/// Look up an entity by a named path parameter, check permissions, and
55
/// return it — or an appropriate [`Error`].
56
///
57
/// `E` is the Sea-ORM **Entity** type (e.g. `Accounts`). Its `Model` must
58
/// implement [`Permissions`] and [`Clone`].
59
///
60
/// **Note**: This always requires a valid [`PermissionsActor`], so it cannot
61
/// be used for truly public (unauthenticated) entity endpoints. If such a
62
/// route is needed in the future, extract the entity and check permissions
63
/// separately.
64
///
65
/// # Errors
66
///
67
/// * [`Error::NotFound`] — path param missing / unparseable, no DB row,
68
///   or the actor lacks permission (intentionally hides resource existence)
69
/// * Propagates DB errors and [`PermissionsActor`] extraction failures
70
pub async fn extract_entity<E, S>(
95✔
71
    parts: &mut Parts,
95✔
72
    state: &S,
95✔
73
    param_name: &str,
95✔
74
) -> Result<E::Model, Error>
95✔
75
where
95✔
76
    E: EntityTrait,
95✔
77
    <E::PrimaryKey as sea_orm::PrimaryKeyTrait>::ValueType: From<Uuid>,
95✔
78
    E::Model: Permissions + Clone,
95✔
79
    Db: FromRef<S>,
95✔
80
    S: Send + Sync,
95✔
81
{
95✔
82
    let Path(params) = Path::<HashMap<String, String>>::from_request_parts(parts, state)
95✔
83
        .await
95✔
84
        .map_err(|_| Error::NotFound)?;
95✔
85

86
    let id = params
95✔
87
        .get(param_name)
95✔
88
        .and_then(|s| s.parse::<Uuid>().ok())
95✔
89
        .ok_or(Error::NotFound)?;
95✔
90

91
    let actor = PermissionsActor::from_request_parts(parts, state).await?;
89✔
92
    let db = Db::from_ref(state);
89✔
93

94
    let entity = E::find_by_id(id).one(&db).await?.ok_or(Error::NotFound)?;
89✔
95

96
    actor
85✔
97
        .if_allowed_http(&parts.method, entity)
85✔
98
        .ok_or(Error::NotFound)
85✔
99
}
95✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc