Skip to content

Commit 263c14b

Browse files
authored
refactor(acp-nats): extract value objects to own files, add type-safe per-VO errors (#21)
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent 48ab41d commit 263c14b

File tree

8 files changed

+253
-140
lines changed

8 files changed

+253
-140
lines changed

rsworkspace/crates/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Prefer domain-specific value objects over primitives (e.g. `AcpPrefix` not `String`). Each type's factory must guarantee correctness at construction—invalid instances should be unrepresentable. Validate per-type, not per-aggregate: avoid validating unrelated fields together in a single constructor.
22

3-
When validating strings for NATS subjects or domain constraints, introduce a value object in its own file (e.g. `ext_method_name.rs`, `session_id.rs`) rather than a standalone `validate_*` function or adding it to `config.rs`. The value object's constructor performs validation; invalid instances are unrepresentable.
3+
Every value object lives in its own file named after the type (e.g. `acp_prefix.rs`, `ext_method_name.rs`, `session_id.rs`). Never inline a value object into a config, aggregate, or service file.
44

55
You must use the `test-support` feature to share test helpers between crates.
66
Prefer one trait per operation over a single trait with multiple operations.
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
//! NATS-safe ACP prefix value object.
2+
//!
3+
//! The prefix is embedded in every NATS subject the bridge publishes:
4+
//! `{prefix}.agent.*`, `{prefix}.session.*`, etc.
5+
//! Validation follows [NATS subject naming](https://docs.nats.io/nats-concepts/subjects#characters-allowed-and-recommended-for-subject-names):
6+
//! rejects `*`, `>`, whitespace; allows dotted namespaces (e.g. `my.multi.part`) but rejects
7+
//! malformed dots (consecutive, leading, trailing). Max 128 bytes. Validity is guaranteed at
8+
//! construction.
9+
10+
use std::sync::Arc;
11+
12+
use crate::nats::token;
13+
use crate::subject_token_violation::SubjectTokenViolation;
14+
15+
const MAX_PREFIX_LENGTH: usize = 128;
16+
17+
/// Error returned when [`AcpPrefix`] validation fails.
18+
#[derive(Debug, Clone, PartialEq)]
19+
pub struct AcpPrefixError(pub SubjectTokenViolation);
20+
21+
impl std::fmt::Display for AcpPrefixError {
22+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23+
match &self.0 {
24+
SubjectTokenViolation::Empty => write!(f, "acp_prefix must not be empty"),
25+
SubjectTokenViolation::InvalidCharacter(ch) => {
26+
write!(f, "acp_prefix contains invalid character: {:?}", ch)
27+
}
28+
SubjectTokenViolation::TooLong(len) => {
29+
write!(f, "acp_prefix is too long: {} bytes (max 128)", len)
30+
}
31+
}
32+
}
33+
}
34+
35+
impl std::error::Error for AcpPrefixError {}
36+
37+
/// NATS-safe ACP prefix. Guarantees validity at construction—invalid instances are unrepresentable.
38+
#[derive(Clone)]
39+
pub struct AcpPrefix(Arc<str>);
40+
41+
impl AcpPrefix {
42+
pub fn new(s: impl Into<String>) -> Result<Self, AcpPrefixError> {
43+
let s = s.into();
44+
if s.is_empty() {
45+
return Err(AcpPrefixError(SubjectTokenViolation::Empty));
46+
}
47+
if let Some(ch) = token::has_wildcards_or_whitespace(&s) {
48+
return Err(AcpPrefixError(SubjectTokenViolation::InvalidCharacter(ch)));
49+
}
50+
if token::has_consecutive_or_boundary_dots(&s) {
51+
return Err(AcpPrefixError(SubjectTokenViolation::InvalidCharacter('.')));
52+
}
53+
if s.len() > MAX_PREFIX_LENGTH {
54+
return Err(AcpPrefixError(SubjectTokenViolation::TooLong(s.len())));
55+
}
56+
Ok(Self(s.into()))
57+
}
58+
59+
pub fn as_str(&self) -> &str {
60+
&self.0
61+
}
62+
}
63+
64+
#[cfg(test)]
65+
mod tests {
66+
use super::*;
67+
68+
#[test]
69+
fn acp_prefix_new_valid() {
70+
let p = AcpPrefix::new("acp").unwrap();
71+
assert_eq!(p.as_str(), "acp");
72+
assert_eq!(
73+
AcpPrefix::new("my.multi.part").unwrap().as_str(),
74+
"my.multi.part"
75+
);
76+
}
77+
78+
#[test]
79+
fn acp_prefix_new_invalid_returns_err() {
80+
assert!(AcpPrefix::new("").is_err());
81+
assert!(AcpPrefix::new("acp.*").is_err());
82+
assert!(AcpPrefix::new("acp.>").is_err());
83+
assert!(AcpPrefix::new("acp prefix").is_err());
84+
assert!(AcpPrefix::new("acp\t").is_err());
85+
assert!(AcpPrefix::new("acp\n").is_err());
86+
assert!(AcpPrefix::new("acp..foo").is_err());
87+
assert!(AcpPrefix::new(".acp").is_err());
88+
assert!(AcpPrefix::new("acp.").is_err());
89+
assert!(AcpPrefix::new("a".repeat(129)).is_err());
90+
}
91+
92+
#[test]
93+
fn acp_prefix_new_validates_direct() {
94+
assert!(AcpPrefix::new("acp").is_ok());
95+
assert!(AcpPrefix::new("a").is_ok());
96+
assert!(AcpPrefix::new("my.multi.part").is_ok());
97+
assert!(AcpPrefix::new("a".repeat(128)).is_ok());
98+
assert!(matches!(
99+
AcpPrefix::new(""),
100+
Err(AcpPrefixError(SubjectTokenViolation::Empty))
101+
));
102+
assert!(matches!(
103+
AcpPrefix::new("a".repeat(129)),
104+
Err(AcpPrefixError(SubjectTokenViolation::TooLong(129)))
105+
));
106+
}
107+
108+
#[test]
109+
fn acp_prefix_error_display() {
110+
assert_eq!(
111+
format!("{}", AcpPrefixError(SubjectTokenViolation::Empty)),
112+
"acp_prefix must not be empty"
113+
);
114+
assert_eq!(
115+
format!(
116+
"{}",
117+
AcpPrefixError(SubjectTokenViolation::InvalidCharacter('*'))
118+
),
119+
"acp_prefix contains invalid character: '*'"
120+
);
121+
assert_eq!(
122+
format!("{}", AcpPrefixError(SubjectTokenViolation::TooLong(200))),
123+
"acp_prefix is too long: 200 bytes (max 128)"
124+
);
125+
}
126+
}

rsworkspace/crates/acp-nats/src/agent/load_session.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use super::Bridge;
2-
use crate::config::AcpPrefix;
2+
use crate::acp_prefix::AcpPrefix;
33
use crate::error::AGENT_UNAVAILABLE;
44
use crate::nats::{
55
self, ExtSessionReady, FlushClient, FlushPolicy, PublishClient, PublishOptions, RequestClient,
Lines changed: 1 addition & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,9 @@
1-
use std::sync::Arc;
21
use std::time::Duration;
32
use trogon_nats::NatsConfig;
43

5-
use crate::nats::token;
4+
use crate::acp_prefix::AcpPrefix;
65

76
const DEFAULT_OPERATION_TIMEOUT: Duration = Duration::from_secs(30);
8-
const MAX_PREFIX_LENGTH: usize = 128;
9-
10-
/// Errors returned when [`Config`] values contain empty strings or NATS-unsafe characters.
11-
#[derive(Debug, Clone, PartialEq)]
12-
pub enum ValidationError {
13-
EmptyValue(&'static str),
14-
InvalidCharacter(&'static str, char),
15-
TooLong(&'static str, usize),
16-
}
17-
18-
impl std::fmt::Display for ValidationError {
19-
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20-
match self {
21-
ValidationError::EmptyValue(field) => write!(f, "{} must not be empty", field),
22-
ValidationError::InvalidCharacter(field, ch) => {
23-
write!(f, "{} contains invalid character: {:?}", field, ch)
24-
}
25-
ValidationError::TooLong(field, len) => {
26-
write!(f, "{} is too long: {} bytes (max 128)", field, len)
27-
}
28-
}
29-
}
30-
}
31-
32-
impl std::error::Error for ValidationError {}
33-
34-
#[derive(Clone)]
35-
pub struct AcpPrefix(Arc<str>);
36-
37-
impl AcpPrefix {
38-
pub fn new(s: impl Into<String>) -> Result<Self, ValidationError> {
39-
let s = s.into();
40-
if s.is_empty() {
41-
return Err(ValidationError::EmptyValue("acp_prefix"));
42-
}
43-
if let Some(ch) = token::has_wildcards_or_whitespace(&s) {
44-
return Err(ValidationError::InvalidCharacter("acp_prefix", ch));
45-
}
46-
if token::has_consecutive_or_boundary_dots(&s) {
47-
return Err(ValidationError::InvalidCharacter("acp_prefix", '.'));
48-
}
49-
if s.len() > MAX_PREFIX_LENGTH {
50-
return Err(ValidationError::TooLong("acp_prefix", s.len()));
51-
}
52-
Ok(Self(s.into()))
53-
}
54-
55-
pub fn as_str(&self) -> &str {
56-
&self.0
57-
}
58-
}
597

608
#[derive(Clone)]
619
pub struct Config {
@@ -102,7 +50,6 @@ impl Config {
10250

10351
#[cfg(test)]
10452
mod tests {
105-
use std::error::Error;
10653
use std::time::Duration;
10754

10855
use super::*;
@@ -120,62 +67,6 @@ mod tests {
12067
assert_eq!(config.acp_prefix(), "acp");
12168
}
12269

123-
#[test]
124-
fn acp_prefix_new_valid() {
125-
let p = AcpPrefix::new("acp").unwrap();
126-
assert_eq!(p.as_str(), "acp");
127-
assert_eq!(
128-
AcpPrefix::new("my.multi.part").unwrap().as_str(),
129-
"my.multi.part"
130-
);
131-
}
132-
133-
#[test]
134-
fn acp_prefix_new_invalid_returns_err() {
135-
assert!(AcpPrefix::new("").is_err());
136-
assert!(AcpPrefix::new("acp.*").is_err());
137-
assert!(AcpPrefix::new("acp.>").is_err());
138-
assert!(AcpPrefix::new("acp prefix").is_err());
139-
assert!(AcpPrefix::new("acp\t").is_err());
140-
assert!(AcpPrefix::new("acp\n").is_err());
141-
assert!(AcpPrefix::new("acp..foo").is_err());
142-
assert!(AcpPrefix::new(".acp").is_err());
143-
assert!(AcpPrefix::new("acp.").is_err());
144-
assert!(AcpPrefix::new("a".repeat(129)).is_err());
145-
}
146-
147-
#[test]
148-
fn acp_prefix_new_validates_direct() {
149-
assert!(AcpPrefix::new("acp").is_ok());
150-
assert!(AcpPrefix::new("a").is_ok());
151-
assert!(AcpPrefix::new("my.multi.part").is_ok());
152-
assert!(AcpPrefix::new("a".repeat(128)).is_ok());
153-
assert!(matches!(
154-
AcpPrefix::new(""),
155-
Err(ValidationError::EmptyValue(_))
156-
));
157-
assert!(matches!(
158-
AcpPrefix::new("a".repeat(129)),
159-
Err(ValidationError::TooLong(_, 129))
160-
));
161-
}
162-
163-
#[test]
164-
fn validation_error_display() {
165-
assert_eq!(
166-
format!("{}", ValidationError::EmptyValue("acp_prefix")),
167-
"acp_prefix must not be empty"
168-
);
169-
assert_eq!(
170-
format!("{}", ValidationError::InvalidCharacter("acp_prefix", '*')),
171-
"acp_prefix contains invalid character: '*'"
172-
);
173-
assert_eq!(
174-
format!("{}", ValidationError::TooLong("acp_prefix", 200)),
175-
"acp_prefix is too long: 200 bytes (max 128)"
176-
);
177-
}
178-
17970
#[test]
18071
fn config_with_operation_timeout() {
18172
let config = Config::new(AcpPrefix::new("acp").unwrap(), default_nats())
@@ -189,10 +80,4 @@ mod tests {
18980
assert_eq!(config.nats().servers.len(), 1);
19081
assert_eq!(config.nats().servers[0], "localhost:4222");
19182
}
192-
193-
#[test]
194-
fn validation_error_impl_error() {
195-
let err = ValidationError::EmptyValue("field");
196-
assert!(err.source().is_none());
197-
}
19883
}

rsworkspace/crates/acp-nats/src/ext_method_name.rs

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,31 +7,55 @@
77
88
use std::sync::Arc;
99

10-
use crate::config::ValidationError;
1110
use crate::nats::token;
11+
use crate::subject_token_violation::SubjectTokenViolation;
1212

1313
const MAX_METHOD_NAME_LENGTH: usize = 128;
1414

15+
/// Error returned when [`ExtMethodName`] validation fails.
16+
#[derive(Debug, Clone, PartialEq)]
17+
pub struct ExtMethodNameError(pub SubjectTokenViolation);
18+
19+
impl std::fmt::Display for ExtMethodNameError {
20+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21+
match &self.0 {
22+
SubjectTokenViolation::Empty => write!(f, "method must not be empty"),
23+
SubjectTokenViolation::InvalidCharacter(ch) => {
24+
write!(f, "method contains invalid character: {:?}", ch)
25+
}
26+
SubjectTokenViolation::TooLong(len) => {
27+
write!(f, "method is too long: {} bytes (max 128)", len)
28+
}
29+
}
30+
}
31+
}
32+
33+
impl std::error::Error for ExtMethodNameError {}
34+
1535
/// NATS-safe extension method name. Guarantees validity at construction—invalid instances are unrepresentable.
1636
///
1737
/// Rejects empty, too-long, wildcard, whitespace, and malformed dotted names.
1838
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
1939
pub struct ExtMethodName(Arc<str>);
2040

2141
impl ExtMethodName {
22-
pub fn new(method: impl AsRef<str>) -> Result<Self, ValidationError> {
42+
pub fn new(method: impl AsRef<str>) -> Result<Self, ExtMethodNameError> {
2343
let s = method.as_ref();
2444
if s.is_empty() {
25-
return Err(ValidationError::EmptyValue("method"));
45+
return Err(ExtMethodNameError(SubjectTokenViolation::Empty));
2646
}
2747
if s.len() > MAX_METHOD_NAME_LENGTH {
28-
return Err(ValidationError::TooLong("method", s.len()));
48+
return Err(ExtMethodNameError(SubjectTokenViolation::TooLong(s.len())));
2949
}
3050
if let Some(ch) = token::has_wildcards_or_whitespace(s) {
31-
return Err(ValidationError::InvalidCharacter("method", ch));
51+
return Err(ExtMethodNameError(SubjectTokenViolation::InvalidCharacter(
52+
ch,
53+
)));
3254
}
3355
if token::has_consecutive_or_boundary_dots(s) {
34-
return Err(ValidationError::InvalidCharacter("method", '.'));
56+
return Err(ExtMethodNameError(SubjectTokenViolation::InvalidCharacter(
57+
'.',
58+
)));
3559
}
3660
Ok(Self(s.into()))
3761
}
@@ -70,7 +94,7 @@ mod tests {
7094
fn ext_method_name_too_long_returns_err() {
7195
let long = "a".repeat(129);
7296
let err = ExtMethodName::new(&long).err().unwrap();
73-
assert_eq!(err, ValidationError::TooLong("method", 129));
97+
assert_eq!(err, ExtMethodNameError(SubjectTokenViolation::TooLong(129)));
7498
}
7599

76100
#[test]
@@ -92,7 +116,7 @@ mod tests {
92116
#[test]
93117
fn ext_method_name_empty_returns_err() {
94118
let err = ExtMethodName::new("").err().unwrap();
95-
assert_eq!(err, ValidationError::EmptyValue("method"));
119+
assert_eq!(err, ExtMethodNameError(SubjectTokenViolation::Empty));
96120
}
97121

98122
#[test]
@@ -114,4 +138,26 @@ mod tests {
114138
assert_eq!(name.len(), 9);
115139
assert!(name.starts_with("my"));
116140
}
141+
142+
#[test]
143+
fn ext_method_name_error_display() {
144+
assert_eq!(
145+
format!("{}", ExtMethodNameError(SubjectTokenViolation::Empty)),
146+
"method must not be empty"
147+
);
148+
assert_eq!(
149+
format!(
150+
"{}",
151+
ExtMethodNameError(SubjectTokenViolation::InvalidCharacter(' '))
152+
),
153+
"method contains invalid character: ' '"
154+
);
155+
assert_eq!(
156+
format!(
157+
"{}",
158+
ExtMethodNameError(SubjectTokenViolation::TooLong(200))
159+
),
160+
"method is too long: 200 bytes (max 128)"
161+
);
162+
}
117163
}

0 commit comments

Comments
 (0)