Skip to main content

nula_core/message/
subscription_id.rs

1//! Opaque subscription identifier.
2//!
3//! Per NIP-01, a subscription id is a non-empty string of up to 64
4//! characters. The protocol does not restrict the character set further, but
5//! interoperable clients use lowercase hex random strings to avoid surprising
6//! relays that index the value as a database key.
7
8use std::fmt;
9use std::str::FromStr;
10
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12use thiserror::Error;
13
14use crate::util::rng::{self, RngError};
15
16/// Maximum length permitted by NIP-01, measured in **characters**
17/// (Unicode scalar values), not bytes.
18pub const MAX_LENGTH: usize = 64;
19
20/// Errors raised when constructing a [`SubscriptionId`].
21#[derive(Debug, Clone, Copy, Error)]
22#[non_exhaustive]
23pub enum SubscriptionIdError {
24    /// The input was empty.
25    #[error("subscription id must not be empty")]
26    Empty,
27    /// The input exceeded [`MAX_LENGTH`] characters.
28    #[error("subscription id too long: {0} characters (max {MAX_LENGTH})")]
29    TooLong(usize),
30    /// Random generation failed because the OS RNG was unavailable.
31    #[error("failed to generate subscription id: {0}")]
32    Rng(#[from] RngError),
33}
34
35/// Opaque subscription identifier.
36///
37/// `Display` and `serde` write the value as a plain JSON string.
38#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
39pub struct SubscriptionId(String);
40
41impl SubscriptionId {
42    /// Construct a subscription id from a string.
43    ///
44    /// # Errors
45    ///
46    /// Returns [`SubscriptionIdError::Empty`] for an empty input or
47    /// [`SubscriptionIdError::TooLong`] when longer than [`MAX_LENGTH`].
48    pub fn new<S>(value: S) -> Result<Self, SubscriptionIdError>
49    where
50        S: Into<String>,
51    {
52        let value = value.into();
53        Self::validate(&value)?;
54        Ok(Self(value))
55    }
56
57    /// Generate a random 32-character lowercase hex subscription id.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`SubscriptionIdError::Rng`] if the OS RNG fails.
62    pub fn generate() -> Result<Self, SubscriptionIdError> {
63        let id = rng::random_hex_string::<16>()?;
64        Ok(Self(id))
65    }
66
67    /// Borrow the value as a string slice.
68    #[must_use]
69    pub fn as_str(&self) -> &str {
70        &self.0
71    }
72
73    /// Decompose into the underlying [`String`].
74    #[must_use]
75    pub fn into_string(self) -> String {
76        self.0
77    }
78
79    fn validate(value: &str) -> Result<(), SubscriptionIdError> {
80        if value.is_empty() {
81            return Err(SubscriptionIdError::Empty);
82        }
83        // NIP-01 says "max length 64 chars". Count Unicode scalar values
84        // rather than UTF-8 bytes so a multi-byte sub_id ("ñ" * 64) does
85        // not get rejected for the wrong reason.
86        let chars = value.chars().count();
87        if chars > MAX_LENGTH {
88            return Err(SubscriptionIdError::TooLong(chars));
89        }
90        Ok(())
91    }
92}
93
94impl fmt::Display for SubscriptionId {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        f.write_str(&self.0)
97    }
98}
99
100impl FromStr for SubscriptionId {
101    type Err = SubscriptionIdError;
102
103    fn from_str(s: &str) -> Result<Self, Self::Err> {
104        Self::new(s.to_owned())
105    }
106}
107
108impl AsRef<str> for SubscriptionId {
109    fn as_ref(&self) -> &str {
110        &self.0
111    }
112}
113
114impl Serialize for SubscriptionId {
115    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
116    where
117        S: Serializer,
118    {
119        serializer.serialize_str(&self.0)
120    }
121}
122
123impl<'de> Deserialize<'de> for SubscriptionId {
124    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
125    where
126        D: Deserializer<'de>,
127    {
128        let raw = String::deserialize(deserializer)?;
129        Self::new(raw).map_err(serde::de::Error::custom)
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn new_round_trip() {
139        let id = SubscriptionId::new("abcdef").unwrap();
140        assert_eq!(id.as_str(), "abcdef");
141    }
142
143    #[test]
144    fn empty_is_rejected() {
145        let err = SubscriptionId::new("").unwrap_err();
146        assert!(matches!(err, SubscriptionIdError::Empty));
147    }
148
149    #[test]
150    fn too_long_is_rejected() {
151        let value = "a".repeat(MAX_LENGTH + 1);
152        let err = SubscriptionId::new(value).unwrap_err();
153        assert!(matches!(err, SubscriptionIdError::TooLong(_)));
154    }
155
156    #[test]
157    fn generate_unique() {
158        let lhs = SubscriptionId::generate().unwrap();
159        let rhs = SubscriptionId::generate().unwrap();
160        assert_ne!(lhs, rhs);
161        assert_eq!(lhs.as_str().len(), 32);
162    }
163
164    #[test]
165    fn serde_round_trip() {
166        let id = SubscriptionId::new("query-1").unwrap();
167        let json = serde_json::to_string(&id).unwrap();
168        assert_eq!(json, r#""query-1""#);
169        let parsed: SubscriptionId = serde_json::from_str(&json).unwrap();
170        assert_eq!(parsed, id);
171    }
172
173    #[test]
174    fn from_str_works() {
175        let id: SubscriptionId = "x".parse().unwrap();
176        assert_eq!(id.as_str(), "x");
177    }
178
179    #[test]
180    fn accepts_multi_byte_chars_at_limit() {
181        // "ñ" is a single Unicode scalar value but two UTF-8 bytes. A
182        // 64-char ID built out of "ñ" must be accepted.
183        let value = "ñ".repeat(MAX_LENGTH);
184        assert_eq!(value.chars().count(), MAX_LENGTH);
185        assert!(value.len() > MAX_LENGTH, "byte length must exceed cap");
186        let id = SubscriptionId::new(value.clone()).unwrap();
187        assert_eq!(id.as_str(), value);
188    }
189
190    #[test]
191    fn rejects_one_char_above_limit_in_chars() {
192        let value = "x".repeat(MAX_LENGTH + 1);
193        let err = SubscriptionId::new(value).unwrap_err();
194        assert!(matches!(err, SubscriptionIdError::TooLong(n) if n == MAX_LENGTH + 1));
195    }
196}