hexser_potions/auth/
mod.rs

1//! Auth potions: simple signup flow showcasing a Repository and a Directive.
2//!
3//! This module provides a minimal end-to-end example of a signup use case:
4//! - A domain entity (User)
5//! - A repository port (UserRepository)
6//! - An adapter (InMemoryUserRepository)
7//! - A directive (SignUpUser) with validation
8//! - A small application function to wire it together
9//!
10//! Copy, paste, and adapt as needed.
11//!
12//! Revision History
13//! - 2025-10-07T11:43:00Z @AI: Migrate to v0.4 QueryRepository API; remove id-centric methods; add filter-based querying; fix ID generation.
14
15use hexser::prelude::*;
16
17/// Domain entity representing a user.
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct User {
20  pub id: String,
21  pub email: String,
22}
23
24impl HexEntity for User {
25  type Id = String;
26}
27
28/// Repository port for users, extending the generic Repository.
29pub trait UserRepository: Repository<User> {
30  fn find_by_email(&self, email: &str) -> HexResult<Option<User>>;
31}
32
33/// A simple in-memory adapter implementing the user repository.
34#[derive(Default)]
35pub struct InMemoryUserRepository {
36  pub users: Vec<User>,
37}
38
39impl Repository<User> for InMemoryUserRepository {
40  fn save(&mut self, entity: User) -> HexResult<()> {
41    // overwrite if exists, else push
42    if let Some(existing) = self.users.iter_mut().find(|u| u.id == entity.id) {
43      *existing = entity;
44    } else {
45      self.users.push(entity);
46    }
47    Ok(())
48  }
49}
50
51impl UserRepository for InMemoryUserRepository {
52  fn find_by_email(&self, email: &str) -> HexResult<Option<User>> {
53    Ok(self.users.iter().find(|u| u.email == email).cloned())
54  }
55}
56
57#[derive(Clone, Debug, PartialEq, Eq)]
58pub enum UserFilter {
59  All,
60  ByEmail(String),
61  ById(String),
62}
63
64#[derive(Clone, Copy, Debug, PartialEq, Eq)]
65pub enum UserSortKey {
66  Email,
67  Id,
68}
69
70impl hexser::ports::repository::QueryRepository<User> for InMemoryUserRepository {
71  type Filter = UserFilter;
72  type SortKey = UserSortKey;
73
74  fn find_one(&self, filter: &UserFilter) -> HexResult<Option<User>> {
75    let found = match filter {
76      UserFilter::All => self.users.first().cloned(),
77      UserFilter::ByEmail(e) => self.users.iter().find(|u| &u.email == e).cloned(),
78      UserFilter::ById(id) => self.users.iter().find(|u| &u.id == id).cloned(),
79    };
80    Ok(found)
81  }
82
83  fn find(
84    &self,
85    filter: &UserFilter,
86    opts: hexser::ports::repository::FindOptions<UserSortKey>,
87  ) -> HexResult<Vec<User>> {
88    let mut items: Vec<User> = match filter {
89      UserFilter::All => self.users.clone(),
90      UserFilter::ByEmail(e) => self
91        .users
92        .iter()
93        .filter(|u| &u.email == e)
94        .cloned()
95        .collect(),
96      UserFilter::ById(id) => self.users.iter().filter(|u| &u.id == id).cloned().collect(),
97    };
98    if let Some(mut sorts) = opts.sort {
99      for s in sorts.drain(..).rev() {
100        items.sort_by(|a, b| {
101          let mut ord = match s.key {
102            UserSortKey::Email => a.email.cmp(&b.email),
103            UserSortKey::Id => a.id.cmp(&b.id),
104          };
105          if let hexser::ports::repository::Direction::Desc = s.direction {
106            ord = ord.reverse();
107          }
108          ord
109        });
110      }
111    }
112    if let Some(offset) = opts.offset {
113      let offset_usize: usize = std::convert::TryInto::try_into(offset).unwrap_or(usize::MAX);
114      if offset_usize < items.len() {
115        items = items.split_off(offset_usize);
116      } else {
117        items.clear();
118      }
119    }
120    if let Some(limit) = opts.limit {
121      let limit_usize: usize = std::convert::TryInto::try_into(limit).unwrap_or(usize::MAX);
122      if items.len() > limit_usize {
123        items.truncate(limit_usize);
124      }
125    }
126    Ok(items)
127  }
128
129  fn exists(&self, filter: &UserFilter) -> HexResult<bool> {
130    Ok(self.find_one(filter)?.is_some())
131  }
132
133  fn count(&self, filter: &UserFilter) -> HexResult<u64> {
134    let n = match filter {
135      UserFilter::All => self.users.len(),
136      UserFilter::ByEmail(e) => self.users.iter().filter(|u| &u.email == e).count(),
137      UserFilter::ById(id) => self.users.iter().filter(|u| &u.id == id).count(),
138    };
139    Ok(n as u64)
140  }
141
142  fn delete_where(&mut self, filter: &UserFilter) -> HexResult<u64> {
143    let before = self.users.len();
144    match filter {
145      UserFilter::All => {
146        self.users.clear();
147      }
148      UserFilter::ByEmail(e) => {
149        self.users.retain(|u| &u.email != e);
150      }
151      UserFilter::ById(id) => {
152        self.users.retain(|u| &u.id != id);
153      }
154    }
155    let removed = before.saturating_sub(self.users.len());
156    Ok(removed as u64)
157  }
158}
159
160/// Directive representing a signup request.
161pub struct SignUpUser {
162  pub email: String,
163}
164
165impl Directive for SignUpUser {
166  fn validate(&self) -> HexResult<()> {
167    if self.email.contains('@') {
168      Ok(())
169    } else {
170      Err(hexser::Hexserror::validation_field(
171        "Invalid email",
172        "email",
173      ))
174    }
175  }
176}
177
178/// Application helper that executes the signup flow.
179/// - Validates the directive
180/// - Ensures email is unique
181/// - Creates and persists a new user
182pub fn execute_signup<R>(repo: &mut R, cmd: SignUpUser) -> HexResult<User>
183where
184  R: UserRepository + hexser::ports::repository::QueryRepository<User, Filter = UserFilter>,
185{
186  cmd.validate()?;
187
188  if repo.find_by_email(&cmd.email)?.is_some() {
189    return Err(hexser::Hexserror::domain(
190      "E_HEXSER_POTIONS_EMAIL_TAKEN",
191      "Email already registered",
192    ));
193  }
194
195  // naive ID generation without dependencies: count existing users via QueryRepository
196  let count =
197    <R as hexser::ports::repository::QueryRepository<User>>::count(&*repo, &UserFilter::All)?;
198  let next_id = std::format!("user-{}", count + 1);
199
200  let user = User {
201    id: next_id,
202    email: cmd.email,
203  };
204
205  repo.save(user.clone())?;
206  Ok(user)
207}
208
209#[cfg(test)]
210mod tests {
211  use super::*;
212
213  #[test]
214  fn signup_happy_path() {
215    let mut repo = InMemoryUserRepository::default();
216    let user = execute_signup(
217      &mut repo,
218      SignUpUser {
219        email: "a@b.com".into(),
220      },
221    )
222    .unwrap();
223    assert_eq!(user.email, "a@b.com");
224    assert!(repo.find_by_email("a@b.com").unwrap().is_some());
225  }
226
227  #[test]
228  fn signup_rejects_invalid_email() {
229    let mut repo = InMemoryUserRepository::default();
230    let res = execute_signup(
231      &mut repo,
232      SignUpUser {
233        email: "not-an-email".into(),
234      },
235    );
236    assert!(res.is_err());
237  }
238
239  #[test]
240  fn signup_rejects_duplicates() {
241    let mut repo = InMemoryUserRepository::default();
242    execute_signup(
243      &mut repo,
244      SignUpUser {
245        email: "a@b.com".into(),
246      },
247    )
248    .unwrap();
249    let duplicate = execute_signup(
250      &mut repo,
251      SignUpUser {
252        email: "a@b.com".into(),
253      },
254    );
255    assert!(duplicate.is_err());
256  }
257}