uptrakit_web_api_types/
roles.rs1use 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#[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 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#[derive(Debug, Clone, Serialize, Deserialize)]
28#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
29pub struct CreateRoleRequest {
30 pub name: String,
32 pub description: Option<String>,
33}
34
35#[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}