Skip to main content

known_types/
handle.rs

1// This is free and unencumbered software released into the public domain.
2
3//! Shared validation and errors for social media handles.
4//!
5//! The platform crates expose owned handle types behind their `alloc` feature.
6//! Each type documents its own syntax, normalization, length bounds, and upstream
7//! references. See, for example, [`XHandle`] and [`LinkedinHandle`].
8//!
9//! # Construction and migration
10//!
11//! Construct a handle with [`FromStr`](core::str::FromStr), `TryFrom<&str>`, or
12//! `TryFrom<String>`. The inner string is private: `as_str()` borrows the stored
13//! spelling, `Display` prints it, and `into_string()` consumes the handle to
14//! recover it. Parsing the stored spelling again preserves it exactly.
15//!
16//! Parsing and fallible conversions return [`ParseHandleError`]. Serde, GraphQL,
17//! and SQLx decoders use the same parser and report failures through their own
18//! error types; GraphQL cursor decoding returns `ParseHandleError` directly.
19//! Validation is local: it does not establish account availability, ownership,
20//! canonical capitalization, or acceptance of registration-only reserved names.
21//!
22//! To migrate from the former infallible `From` conversions (which could panic
23//! for LinkedIn), replace `Handle::from(text)` / `text.into()` with
24//! `text.parse()?` or `Handle::try_from(text)?`.
25//!
26//! # Length, normalization, and identity
27//!
28//! `MIN_LENGTH` and `MAX_LENGTH` are inclusive representational bounds, not
29//! necessarily current registration limits. X accepts legacy handles up to 20
30//! characters despite a current registration maximum of 15; Telegram includes
31//! four-character collectible usernames. Intro.co, local.ai, and Luma use
32//! documented 1–100 fallback bounds where an upstream contract is unavailable.
33//!
34//! Length counts Unicode scalar values after parser normalization, not UTF-8
35//! bytes or grapheme clusters. Platforms accepting a displayed `@` remove it
36//! exactly once. LinkedIn instead trims outer whitespace and strictly
37//! percent-decodes UTF-8 once before validation; malformed escapes and decoded
38//! forbidden characters are rejected.
39//!
40//! Equality, ordering, and hashing agree about identity. Some types lowercase
41//! their stored spelling; others preserve it while comparing case-insensitively.
42//! Facebook additionally ignores periods. The three fallback types are
43//! case-sensitive. Consult the type's contract before using handles as keys.
44//!
45//! # Integrations
46//!
47//! These features are implemented by all handle crates and are opt-in. The
48//! `all` feature is empty. Default features enable `std`, which enables `alloc`;
49//! for `no_std` handles with Serde, explicitly select `alloc,serde`.
50//!
51//! Feature | Behavior
52//! --- | ---
53//! `serde` | Serialize as a string; validate and normalize when deserializing. Requires `alloc` for the handle type.
54//! `async-graphql` | String scalar and connection cursor; enables `std` and `alloc` without requiring the handle crate's `serde` feature.
55//! `sqlx` | `Type`, `Encode`, and validating `Decode` over `String`; enables `std` and `alloc`.
56//! `sqlx-postgres` | Enables `sqlx`, the PostgreSQL driver, and text-array support.
57//! `sqlx-mysql` | Enables `sqlx` and the MySQL driver.
58//! `sqlx-sqlite` | Enables `sqlx` and the SQLite driver.
59//!
60//! GraphQL scalar names match the Rust type, such as `XHandle` or
61//! `LinkedinHandle`. Scalars accept only strings and implement `ScalarType`,
62//! `InputType`, and `OutputType`; handles also work in `InputObject`,
63//! `SimpleObject`, `Option<Handle>`, and `Vec<Handle>`.
64//!
65//! `connection::CursorType` encodes the stored string verbatim, without base64,
66//! and validates on decode. Handles can therefore be used in
67//! `Connection<Handle, Node>` and `Edge<Handle, Node>`. The application must supply
68//! deterministic ordering and decide how renames affect pagination; a handle
69//! cursor is suitable when the handle is the connection's unique ordering key.
70//!
71//! SQLx accepts handles by value or reference and supports `Option<Handle>` for
72//! nullable columns. PostgreSQL additionally supports `Vec<Handle>` with
73//! `sqlx-postgres`. If the application already enables its SQLx driver, the
74//! handle crate's `sqlx` feature is sufficient for scalar values. Applications
75//! select the SQLx runtime appropriate to their executor.
76//!
77//! See the [`known-types-x` crate documentation][recipes] for executable Serde,
78//! GraphQL, cursor, and SQLx examples; the same patterns apply to every handle.
79//!
80//! [`XHandle`]: https://docs.rs/known-types-x/latest/known_types_x/struct.XHandle.html
81//! [`LinkedinHandle`]: https://docs.rs/known-types-linkedin/latest/known_types_linkedin/struct.LinkedinHandle.html
82//! [recipes]: https://docs.rs/known-types-x/latest/known_types_x/
83
84use core::fmt;
85
86/// An invalid social media handle.
87#[derive(Clone, Copy, Debug, Eq, PartialEq)]
88#[non_exhaustive]
89pub enum ParseHandleError {
90    /// The handle has fewer than the required number of characters.
91    TooShort { min: usize },
92    /// The handle exceeds the maximum number of characters.
93    TooLong { max: usize },
94    /// The handle contains a character that the upstream does not permit.
95    InvalidCharacter(char),
96    /// The characters are individually permitted, but their arrangement is not.
97    InvalidFormat,
98    /// A percent escape is incomplete or contains non-hexadecimal digits.
99    InvalidPercentEncoding,
100    /// The percent-decoded bytes are not UTF-8.
101    InvalidUtf8,
102}
103
104impl fmt::Display for ParseHandleError {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        match self {
107            Self::TooShort { min } => write!(f, "handle must contain at least {min} characters"),
108            Self::TooLong { max } => write!(f, "handle must contain at most {max} characters"),
109            Self::InvalidCharacter(c) => write!(f, "invalid character in handle: {c:?}"),
110            Self::InvalidFormat => f.write_str("invalid handle format"),
111            Self::InvalidPercentEncoding => f.write_str("invalid percent escape in handle"),
112            Self::InvalidUtf8 => f.write_str("percent-decoded handle is not UTF-8"),
113        }
114    }
115}
116
117impl core::error::Error for ParseHandleError {}
118
119/// Validate length in Unicode scalar values, rather than UTF-8 bytes.
120pub fn validate_length(input: &str, min: usize, max: usize) -> Result<(), ParseHandleError> {
121    let length = input.chars().take(max.saturating_add(1)).count();
122    if length < min {
123        Err(ParseHandleError::TooShort { min })
124    } else if length > max {
125        Err(ParseHandleError::TooLong { max })
126    } else {
127        Ok(())
128    }
129}
130
131/// Validate an ASCII alphanumeric handle with an upstream-specific set of punctuation.
132pub fn validate_ascii(input: &str, punctuation: &str) -> Result<(), ParseHandleError> {
133    match input
134        .chars()
135        .find(|c| !c.is_ascii_alphanumeric() && !punctuation.contains(*c))
136    {
137        Some(c) => Err(ParseHandleError::InvalidCharacter(c)),
138        None => Ok(()),
139    }
140}
141
142// Kept in one place so that a new integration cannot accidentally construct an
143// unchecked handle. Feature gates are evaluated in the invoking handle crate.
144#[doc(hidden)]
145#[macro_export]
146macro_rules! impl_handle {
147    ($handle:ident, $min:expr, $max:expr, $scalar_name:literal) => {
148        impl $handle {
149            /// Minimum length of the normalized handle, in Unicode scalar values.
150            pub const MIN_LENGTH: usize = $min;
151            /// Maximum representational length, in Unicode scalar values.
152            ///
153            /// This is the widest known current or historical upstream bound,
154            /// not necessarily the current registration limit.
155            pub const MAX_LENGTH: usize = $max;
156
157            /// Borrow the validated, normalized handle.
158            pub fn as_str(&self) -> &str {
159                &self.0
160            }
161
162            /// Consume the handle and return its stored spelling.
163            pub fn into_string(self) -> alloc::string::String {
164                self.0
165            }
166        }
167
168        impl AsRef<str> for $handle {
169            fn as_ref(&self) -> &str {
170                self.as_str()
171            }
172        }
173
174        impl TryFrom<&str> for $handle {
175            type Error = $crate::handle::ParseHandleError;
176
177            fn try_from(input: &str) -> Result<Self, Self::Error> {
178                input.parse()
179            }
180        }
181
182        impl TryFrom<alloc::string::String> for $handle {
183            type Error = $crate::handle::ParseHandleError;
184
185            fn try_from(input: alloc::string::String) -> Result<Self, Self::Error> {
186                input.parse()
187            }
188        }
189
190        #[cfg(feature = "serde")]
191        impl serde::Serialize for $handle {
192            fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
193                serializer.serialize_str(self.as_str())
194            }
195        }
196
197        #[cfg(feature = "serde")]
198        impl<'de> serde::Deserialize<'de> for $handle {
199            fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
200                let input = <alloc::string::String as serde::Deserialize>::deserialize(deserializer)?;
201                input.parse().map_err(serde::de::Error::custom)
202            }
203        }
204
205        #[cfg(feature = "async-graphql")]
206        #[async_graphql::Scalar(name = $scalar_name)]
207        impl async_graphql::ScalarType for $handle {
208            fn parse(value: async_graphql::Value) -> async_graphql::InputValueResult<Self> {
209                match value {
210                    async_graphql::Value::String(input) => input
211                        .parse()
212                        .map_err(async_graphql::InputValueError::custom),
213                    value => Err(async_graphql::InputValueError::expected_type(value)),
214                }
215            }
216
217            fn is_valid(value: &async_graphql::Value) -> bool {
218                matches!(value, async_graphql::Value::String(input) if input.parse::<Self>().is_ok())
219            }
220
221            fn to_value(&self) -> async_graphql::Value {
222                async_graphql::Value::String(self.0.clone())
223            }
224        }
225
226        #[cfg(feature = "async-graphql")]
227        impl async_graphql::connection::CursorType for $handle {
228            type Error = $crate::handle::ParseHandleError;
229
230            fn decode_cursor(input: &str) -> Result<Self, Self::Error> {
231                input.parse()
232            }
233
234            fn encode_cursor(&self) -> alloc::string::String {
235                self.0.clone()
236            }
237        }
238
239        #[cfg(feature = "sqlx")]
240        impl<DB: sqlx::Database> sqlx::Type<DB> for $handle
241        where
242            alloc::string::String: sqlx::Type<DB>,
243        {
244            fn type_info() -> DB::TypeInfo {
245                <alloc::string::String as sqlx::Type<DB>>::type_info()
246            }
247
248            fn compatible(ty: &DB::TypeInfo) -> bool {
249                <alloc::string::String as sqlx::Type<DB>>::compatible(ty)
250            }
251        }
252
253        #[cfg(feature = "sqlx-postgres")]
254        impl sqlx::postgres::PgHasArrayType for $handle {
255            fn array_type_info() -> sqlx::postgres::PgTypeInfo {
256                <alloc::string::String as sqlx::postgres::PgHasArrayType>::array_type_info()
257            }
258
259            fn array_compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool {
260                <alloc::string::String as sqlx::postgres::PgHasArrayType>::array_compatible(ty)
261            }
262        }
263
264        #[cfg(feature = "sqlx")]
265        impl<'q, DB: sqlx::Database> sqlx::Encode<'q, DB> for $handle
266        where
267            alloc::string::String: sqlx::Encode<'q, DB>,
268        {
269            fn encode_by_ref(
270                &self,
271                buf: &mut DB::ArgumentBuffer,
272            ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
273                <alloc::string::String as sqlx::Encode<DB>>::encode_by_ref(&self.0, buf)
274            }
275
276            fn produces(&self) -> Option<DB::TypeInfo> {
277                <alloc::string::String as sqlx::Encode<DB>>::produces(&self.0)
278            }
279
280            fn size_hint(&self) -> usize {
281                <alloc::string::String as sqlx::Encode<DB>>::size_hint(&self.0)
282            }
283        }
284
285        #[cfg(feature = "sqlx")]
286        impl<'r, DB: sqlx::Database> sqlx::Decode<'r, DB> for $handle
287        where
288            alloc::string::String: sqlx::Decode<'r, DB>,
289        {
290            fn decode(value: DB::ValueRef<'r>) -> Result<Self, sqlx::error::BoxDynError> {
291                let input = <alloc::string::String as sqlx::Decode<DB>>::decode(value)?;
292                input.parse().map_err(Into::into)
293            }
294        }
295
296        #[cfg(test)]
297        mod handle_tests {
298            use super::$handle;
299            use alloc::string::ToString;
300
301            #[test]
302            fn length_bounds_and_fallible_conversions() {
303                for length in [$handle::MIN_LENGTH, $handle::MAX_LENGTH] {
304                    let input = "a".repeat(length);
305                    let handle: $handle = input.parse().expect("valid boundary length");
306                    assert_eq!(handle.as_str(), input);
307                    assert_eq!(handle.to_string(), input);
308                    assert_eq!(handle.clone().into_string(), input);
309                    assert_eq!($handle::try_from(input.as_str()), Ok(handle.clone()));
310                    assert_eq!($handle::try_from(input), Ok(handle));
311                }
312                for length in [0, $handle::MIN_LENGTH - 1, $handle::MAX_LENGTH + 1] {
313                    let input = "a".repeat(length);
314                    assert!(input.parse::<$handle>().is_err());
315                    assert!($handle::try_from(input.as_str()).is_err());
316                    assert!($handle::try_from(input).is_err());
317                }
318            }
319
320            #[cfg(feature = "serde")]
321            #[test]
322            fn serde_cannot_bypass_validation() {
323                use serde::{Deserialize, de::value::{Error, StringDeserializer}};
324
325                for input in [alloc::string::String::new(), "a".repeat($handle::MAX_LENGTH + 1)] {
326                    assert!($handle::deserialize(StringDeserializer::<Error>::new(input)).is_err());
327                }
328                let decoded = $handle::deserialize(StringDeserializer::<Error>::new("Alice123".into()))
329                    .expect("valid handle");
330                assert_eq!(decoded.as_str(), "Alice123".parse::<$handle>().expect("valid handle").as_str());
331            }
332
333            #[cfg(feature = "async-graphql")]
334            #[test]
335            fn graphql_and_cursors_cannot_bypass_validation() {
336                use async_graphql::{InputType, ScalarType, Value, connection::CursorType};
337
338                assert_eq!(<$handle as InputType>::type_name(), stringify!($handle));
339                for input in [alloc::string::String::new(), "a".repeat($handle::MAX_LENGTH + 1)] {
340                    let value = Value::String(input.clone());
341                    assert!(!<$handle as ScalarType>::is_valid(&value));
342                    assert!(<$handle as ScalarType>::parse(value).is_err());
343                    assert!($handle::decode_cursor(&input).is_err());
344                }
345                let handle: $handle = "Alice123".parse().expect("valid handle");
346                let decoded = $handle::decode_cursor(&handle.encode_cursor()).expect("valid cursor");
347                assert_eq!(decoded.as_str(), handle.as_str());
348            }
349        }
350    };
351}
352
353// Case-preserving handles supply a comparison key; all identity traits must
354// use that same key, including when the upstream ignores punctuation as well.
355#[doc(hidden)]
356#[macro_export]
357macro_rules! impl_handle_comparison {
358    ($handle:ident) => {
359        impl PartialEq for $handle {
360            fn eq(&self, other: &Self) -> bool {
361                self.comparison_key() == other.comparison_key()
362            }
363        }
364
365        impl PartialOrd for $handle {
366            fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
367                Some(self.cmp(other))
368            }
369        }
370
371        impl Ord for $handle {
372            fn cmp(&self, other: &Self) -> core::cmp::Ordering {
373                self.comparison_key().cmp(&other.comparison_key())
374            }
375        }
376
377        impl core::hash::Hash for $handle {
378            fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
379                core::hash::Hash::hash(&self.comparison_key(), state);
380            }
381        }
382
383        #[cfg(test)]
384        mod comparison_tests {
385            extern crate std;
386
387            use super::$handle;
388            use std::collections::{BTreeSet, HashSet};
389
390            #[test]
391            fn case_preserving_identity_in_collections() {
392                let upper: $handle = "Alice123".parse().expect("valid handle");
393                let lower: $handle = "alice123".parse().expect("valid handle");
394                assert_eq!(upper.as_str(), "Alice123");
395                assert_eq!(lower.as_str(), "alice123");
396                assert_eq!(upper, lower);
397                assert_eq!(upper.cmp(&lower), core::cmp::Ordering::Equal);
398                assert_eq!(upper.partial_cmp(&lower), Some(core::cmp::Ordering::Equal));
399                assert_eq!(HashSet::from([upper.clone(), lower.clone()]).len(), 1);
400                assert_eq!(BTreeSet::from([upper, lower]).len(), 1);
401            }
402        }
403    };
404}