Skip to main content

uptrakit_web_api_types/
roles.rs

1use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
3use uptrakit_shared_types::access::bounds::MAX_GRANT_DESCRIPTION_LEN;
4use uuid::Uuid;
5
6use crate::validation::{Validate, ValidationError};
7
8/// A role — global built-in or tenant-scoped custom.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
11pub struct RoleResponse {
12    pub id: Uuid,
13    pub name: String,
14    pub description: Option<String>,
15    pub is_built_in: bool,
16    /// `null` for the global built-ins; the owning tenant for custom roles.
17    pub tenant_id: Option<Uuid>,
18    #[serde(with = "time::serde::rfc3339")]
19    #[cfg_attr(
20        feature = "openapi",
21        schema(value_type = String, format = DateTime)
22    )]
23    pub created_at: OffsetDateTime,
24}
25
26/// Body for `POST /api/v1/roles`.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
29pub struct CreateRoleRequest {
30    /// 1-64 chars: lowercase alphanumeric plus `-`/`_`, starting with a letter
31    pub name: String,
32    pub description: Option<String>,
33}
34
35/// Body for `PUT /api/v1/roles/{id}`.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
38pub struct UpdateRoleRequest {
39    pub name: String,
40    pub description: Option<String>,
41}
42
43fn validate_role_name(name: &str) -> Result<(), ValidationError> {
44    let mut chars = name.chars();
45    let first_ok = chars.next().is_some_and(|c| c.is_ascii_lowercase());
46    let rest_ok =
47        chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_');
48    if !first_ok || !rest_ok || name.len() > 64 {
49        return Err(ValidationError {
50            field: "name",
51            message: "must be 1-64 chars: lowercase alphanumeric plus '-'/'_', letter-first"
52                .to_string(),
53        });
54    }
55    Ok(())
56}
57
58fn validate_description_field(description: Option<&str>) -> Result<(), ValidationError> {
59    if let Some(desc) = description
60        && desc.chars().count() > MAX_GRANT_DESCRIPTION_LEN
61    {
62        return Err(ValidationError {
63            field: "description",
64            message: format!("must be at most {MAX_GRANT_DESCRIPTION_LEN} characters"),
65        });
66    }
67    Ok(())
68}
69
70impl Validate for CreateRoleRequest {
71    fn validate(&self) -> Result<(), ValidationError> {
72        validate_role_name(&self.name)?;
73        validate_description_field(self.description.as_deref())
74    }
75}
76
77impl Validate for UpdateRoleRequest {
78    fn validate(&self) -> Result<(), ValidationError> {
79        validate_role_name(&self.name)?;
80        validate_description_field(self.description.as_deref())
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    #![expect(
87        clippy::assertions_on_result_states,
88        reason = "test assertions — is_ok/is_err provides readable failure messages"
89    )]
90    use super::*;
91
92    #[test]
93    fn create_request_validation_name_bounds() {
94        let ok = CreateRoleRequest {
95            name: "custom-role_1".to_string(),
96            description: None,
97        };
98        assert!(ok.validate().is_ok());
99
100        let empty = CreateRoleRequest {
101            name: String::new(),
102            ..ok.clone()
103        };
104        assert!(empty.validate().is_err(), "empty name rejected");
105
106        let starts_with_digit = CreateRoleRequest {
107            name: "1role".to_string(),
108            ..ok.clone()
109        };
110        assert!(
111            starts_with_digit.validate().is_err(),
112            "must start with a letter"
113        );
114
115        let uppercase = CreateRoleRequest {
116            name: "Role".to_string(),
117            ..ok.clone()
118        };
119        assert!(uppercase.validate().is_err(), "must be lowercase");
120
121        let bad_char = CreateRoleRequest {
122            name: "role name".to_string(),
123            ..ok.clone()
124        };
125        assert!(bad_char.validate().is_err(), "space not allowed");
126
127        let too_long = CreateRoleRequest {
128            name: "a".repeat(65),
129            ..ok
130        };
131        assert!(too_long.validate().is_err(), "max 64 chars");
132    }
133
134    #[test]
135    fn update_request_validation_description_bound() {
136        let ok = UpdateRoleRequest {
137            name: "custom-role".to_string(),
138            description: Some("d".repeat(MAX_GRANT_DESCRIPTION_LEN)),
139        };
140        assert!(ok.validate().is_ok());
141
142        let too_long = UpdateRoleRequest {
143            description: Some("d".repeat(MAX_GRANT_DESCRIPTION_LEN + 1)),
144            ..ok
145        };
146        assert!(too_long.validate().is_err());
147    }
148}