Skip to main content

api_bones/
org_id.rs

1// SPDX-License-Identifier: MIT
2//! Tenant identifier newtype, transported via the `X-Org-Id` HTTP header.
3//!
4//! # Example
5//!
6//! ```rust
7//! use api_bones::org_id::OrgId;
8//! use api_bones::header_id::HeaderId;
9//!
10//! let id = OrgId::generate();
11//! assert_eq!(id.inner().get_version_num(), 4);
12//! assert_eq!(OrgId::HEADER_NAME, "X-Org-Id");
13//! ```
14
15#[cfg(all(not(feature = "std"), feature = "alloc"))]
16use alloc::string::{String, ToString};
17#[cfg(all(not(feature = "std"), feature = "alloc"))]
18use alloc::vec::Vec;
19use core::fmt;
20use core::str::FromStr;
21#[cfg(feature = "serde")]
22use serde::{Deserialize, Deserializer, Serialize};
23
24// ---------------------------------------------------------------------------
25// OrgIdError
26// ---------------------------------------------------------------------------
27
28/// Error returned when parsing an [`OrgId`] from a string fails.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum OrgIdError {
31    /// The string is not a valid UUID.
32    InvalidUuid(uuid::Error),
33}
34
35impl fmt::Display for OrgIdError {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        match self {
38            Self::InvalidUuid(e) => write!(f, "invalid org ID: {e}"),
39        }
40    }
41}
42
43#[cfg(feature = "std")]
44impl std::error::Error for OrgIdError {
45    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
46        match self {
47            Self::InvalidUuid(e) => Some(e),
48        }
49    }
50}
51
52// ---------------------------------------------------------------------------
53// OrgId
54// ---------------------------------------------------------------------------
55
56/// A UUID v4 tenant identifier, typically propagated via the `X-Org-Id`
57/// HTTP header.
58///
59/// Use [`OrgId::generate`] to create a fresh identifier, or [`FromStr`] /
60/// [`TryFrom`] to parse one from an incoming header.
61///
62/// The `Display` implementation produces the canonical hyphenated UUID string
63/// (e.g. `550e8400-e29b-41d4-a716-446655440000`).
64#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
65#[cfg_attr(feature = "serde", derive(Serialize), serde(transparent))]
66#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
67#[cfg_attr(feature = "utoipa", schema(value_type = String, format = "uuid"))]
68#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
69pub struct OrgId(uuid::Uuid);
70
71impl OrgId {
72    /// Wrap an existing [`uuid::Uuid`] as an `OrgId`.
73    ///
74    /// ```rust
75    /// use api_bones::org_id::OrgId;
76    ///
77    /// let id = OrgId::new(uuid::Uuid::nil());
78    /// assert_eq!(id.to_string(), "00000000-0000-0000-0000-000000000000");
79    /// ```
80    #[must_use]
81    pub const fn new(id: uuid::Uuid) -> Self {
82        Self(id)
83    }
84
85    /// Generate a new random `OrgId` (UUID v4).
86    ///
87    /// ```rust
88    /// use api_bones::org_id::OrgId;
89    ///
90    /// let id = OrgId::generate();
91    /// assert_eq!(id.inner().get_version_num(), 4);
92    /// ```
93    #[must_use]
94    pub fn generate() -> Self {
95        Self(uuid::Uuid::new_v4())
96    }
97
98    /// Return the inner [`uuid::Uuid`].
99    ///
100    /// ```rust
101    /// use api_bones::org_id::OrgId;
102    ///
103    /// let uuid = uuid::Uuid::nil();
104    /// let id = OrgId::new(uuid);
105    /// assert_eq!(id.inner(), uuid);
106    /// ```
107    #[must_use]
108    pub fn inner(&self) -> uuid::Uuid {
109        self.0
110    }
111}
112
113// ---------------------------------------------------------------------------
114// HeaderId trait impl
115// ---------------------------------------------------------------------------
116
117#[cfg(feature = "std")]
118impl crate::header_id::HeaderId for OrgId {
119    const HEADER_NAME: &'static str = "X-Org-Id";
120
121    fn as_str(&self) -> std::borrow::Cow<'_, str> {
122        std::borrow::Cow::Owned(self.0.to_string())
123    }
124}
125
126#[cfg(all(not(feature = "std"), feature = "alloc"))]
127impl crate::header_id::HeaderId for OrgId {
128    const HEADER_NAME: &'static str = "X-Org-Id";
129
130    fn as_str(&self) -> alloc::borrow::Cow<'_, str> {
131        alloc::borrow::Cow::Owned(self.0.to_string())
132    }
133}
134
135// ---------------------------------------------------------------------------
136// Standard trait impls
137// ---------------------------------------------------------------------------
138
139impl Default for OrgId {
140    fn default() -> Self {
141        Self::generate()
142    }
143}
144
145impl fmt::Display for OrgId {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        fmt::Display::fmt(&self.0, f)
148    }
149}
150
151impl From<uuid::Uuid> for OrgId {
152    fn from(id: uuid::Uuid) -> Self {
153        Self(id)
154    }
155}
156
157impl From<OrgId> for uuid::Uuid {
158    fn from(o: OrgId) -> Self {
159        o.0
160    }
161}
162
163impl FromStr for OrgId {
164    type Err = OrgIdError;
165
166    fn from_str(s: &str) -> Result<Self, Self::Err> {
167        uuid::Uuid::parse_str(s)
168            .map(Self)
169            .map_err(OrgIdError::InvalidUuid)
170    }
171}
172
173impl TryFrom<&str> for OrgId {
174    type Error = OrgIdError;
175
176    fn try_from(s: &str) -> Result<Self, Self::Error> {
177        s.parse()
178    }
179}
180
181impl TryFrom<String> for OrgId {
182    type Error = OrgIdError;
183
184    fn try_from(s: String) -> Result<Self, Self::Error> {
185        s.parse()
186    }
187}
188
189// ---------------------------------------------------------------------------
190// Serde
191// ---------------------------------------------------------------------------
192
193#[cfg(feature = "serde")]
194impl<'de> Deserialize<'de> for OrgId {
195    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
196        let s = String::deserialize(deserializer)?;
197        s.parse::<Self>().map_err(serde::de::Error::custom)
198    }
199}
200
201// ---------------------------------------------------------------------------
202// Header parser (non-extractor; for callers without an AuthLayer)
203// ---------------------------------------------------------------------------
204
205/// Error returned by [`OrgId::try_from_headers`].
206#[cfg(feature = "http")]
207#[derive(Debug, Clone, PartialEq, Eq)]
208#[non_exhaustive]
209pub enum OrgIdHeaderError {
210    /// The `X-Org-Id` header was not present.
211    Missing,
212    /// The `X-Org-Id` header value was not valid UTF-8.
213    NotUtf8,
214    /// The `X-Org-Id` header value was not a valid UUID.
215    Invalid(OrgIdError),
216}
217
218#[cfg(feature = "http")]
219impl fmt::Display for OrgIdHeaderError {
220    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
221        match self {
222            Self::Missing => write!(f, "missing required header: X-Org-Id"),
223            Self::NotUtf8 => write!(f, "header X-Org-Id contains non-UTF-8 bytes"),
224            Self::Invalid(e) => write!(f, "invalid X-Org-Id: {e}"),
225        }
226    }
227}
228
229#[cfg(all(feature = "http", feature = "std"))]
230impl std::error::Error for OrgIdHeaderError {
231    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
232        match self {
233            Self::Invalid(e) => Some(e),
234            Self::Missing | Self::NotUtf8 => None,
235        }
236    }
237}
238
239#[cfg(feature = "http")]
240impl OrgId {
241    /// Parse an [`OrgId`] from the `X-Org-Id` entry of an [`http::HeaderMap`].
242    ///
243    /// This parser is intended for callers that do **not** run behind an
244    /// `AuthLayer` — e.g. webhook-signature verifiers, out-of-band tooling —
245    /// and therefore cannot use [`OrganizationContext`]. Handlers served by
246    /// axum routers must consume `OrganizationContext` instead, per
247    /// ADR platform/0015.
248    ///
249    /// If the header carries multiple values, the first one wins (standard
250    /// [`http::HeaderMap::get`] semantics).
251    ///
252    /// # Errors
253    ///
254    /// - [`OrgIdHeaderError::Missing`] — no `X-Org-Id` header on the map
255    /// - [`OrgIdHeaderError::NotUtf8`] — header value contains bytes outside ASCII/UTF-8
256    /// - [`OrgIdHeaderError::Invalid`] — header value is not a well-formed UUID
257    ///
258    /// # Examples
259    ///
260    /// ```
261    /// use api_bones::org_id::OrgId;
262    /// use http::HeaderMap;
263    ///
264    /// let mut headers = HeaderMap::new();
265    /// headers.insert("x-org-id", "550e8400-e29b-41d4-a716-446655440000".parse().unwrap());
266    /// let id = OrgId::try_from_headers(&headers).unwrap();
267    /// assert_eq!(id.to_string(), "550e8400-e29b-41d4-a716-446655440000");
268    /// ```
269    pub fn try_from_headers(headers: &http::HeaderMap) -> Result<Self, OrgIdHeaderError> {
270        let raw = headers
271            .get("x-org-id")
272            .ok_or(OrgIdHeaderError::Missing)?
273            .to_str()
274            .map_err(|_| OrgIdHeaderError::NotUtf8)?;
275        raw.parse::<Self>().map_err(OrgIdHeaderError::Invalid)
276    }
277}
278
279/// Compile-fail proof that the bare `OrgId` axum extractor has been removed
280/// (ADR platform/0015). Any reintroduction of the extractor will cause this
281/// doctest to start compiling, which fails the test.
282///
283/// ```compile_fail
284/// use api_bones::OrgId;
285/// use axum::extract::FromRequestParts;
286/// use axum::http::request::Parts;
287///
288/// async fn _proof(mut parts: Parts) {
289///     let _ = <OrgId as FromRequestParts<()>>::from_request_parts(&mut parts, &()).await;
290/// }
291/// ```
292#[cfg(feature = "axum")]
293#[doc(hidden)]
294pub fn __adr_platform_0015_proof() {}
295
296// ---------------------------------------------------------------------------
297// OrgPath — X-Org-Path header newtype
298// ---------------------------------------------------------------------------
299
300/// An ordered org-path (root to self, inclusive), transported via `X-Org-Path`.
301///
302/// Wire format: comma-separated UUID strings, e.g.
303/// `"550e8400-e29b-41d4-a716-446655440000,660e8400-e29b-41d4-a716-446655440001"`.
304#[derive(Clone, PartialEq, Eq, Debug, Default)]
305#[cfg_attr(feature = "serde", derive(Serialize, Deserialize), serde(transparent))]
306#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
307#[cfg_attr(feature = "utoipa", schema(value_type = String))]
308#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
309pub struct OrgPath(Vec<OrgId>);
310
311impl OrgPath {
312    /// Construct from a vec of `OrgId`s.
313    #[must_use]
314    pub fn new(path: Vec<OrgId>) -> Self {
315        Self(path)
316    }
317
318    /// Borrow the path as a slice.
319    #[must_use]
320    pub fn as_slice(&self) -> &[OrgId] {
321        &self.0
322    }
323
324    /// Consume and return the inner Vec.
325    #[must_use]
326    pub fn into_inner(self) -> Vec<OrgId> {
327        self.0
328    }
329}
330
331#[cfg(feature = "std")]
332impl crate::header_id::HeaderId for OrgPath {
333    const HEADER_NAME: &'static str = "X-Org-Path";
334    fn as_str(&self) -> std::borrow::Cow<'_, str> {
335        std::borrow::Cow::Owned(
336            self.0
337                .iter()
338                .map(std::string::ToString::to_string)
339                .collect::<Vec<_>>()
340                .join(","),
341        )
342    }
343}
344
345#[cfg(all(not(feature = "std"), feature = "alloc"))]
346impl crate::header_id::HeaderId for OrgPath {
347    const HEADER_NAME: &'static str = "X-Org-Path";
348    fn as_str(&self) -> alloc::borrow::Cow<'_, str> {
349        alloc::borrow::Cow::Owned(
350            self.0
351                .iter()
352                .map(|id| id.to_string())
353                .collect::<Vec<_>>()
354                .join(","),
355        )
356    }
357}
358
359/// Parse `OrgPath` from a comma-separated UUID header value.
360impl FromStr for OrgPath {
361    type Err = OrgIdError;
362    fn from_str(s: &str) -> Result<Self, Self::Err> {
363        if s.is_empty() {
364            return Ok(Self(Vec::new()));
365        }
366        s.split(',')
367            .map(|part| part.trim().parse::<OrgId>())
368            .collect::<Result<Vec<_>, _>>()
369            .map(Self)
370    }
371}
372
373#[cfg(feature = "axum")]
374impl<S: Send + Sync> axum::extract::FromRequestParts<S> for OrgPath {
375    type Rejection = crate::error::ApiError;
376    async fn from_request_parts(
377        parts: &mut axum::http::request::Parts,
378        _state: &S,
379    ) -> Result<Self, Self::Rejection> {
380        let raw = parts
381            .headers
382            .get("x-org-path")
383            .ok_or_else(|| {
384                crate::error::ApiError::bad_request("missing required header: x-org-path")
385            })?
386            .to_str()
387            .map_err(|_| {
388                crate::error::ApiError::bad_request("header x-org-path contains non-UTF-8 bytes")
389            })?;
390        raw.parse::<Self>()
391            .map_err(|e| crate::error::ApiError::bad_request(format!("invalid X-Org-Path: {e}")))
392    }
393}
394
395// ---------------------------------------------------------------------------
396// Tests
397// ---------------------------------------------------------------------------
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use crate::header_id::HeaderId as _;
403
404    #[test]
405    fn new_wraps_uuid() {
406        let uuid = uuid::Uuid::nil();
407        let id = OrgId::new(uuid);
408        assert_eq!(id.inner(), uuid);
409    }
410
411    #[test]
412    fn generate_is_v4() {
413        let id = OrgId::generate();
414        assert_eq!(id.inner().get_version_num(), 4);
415    }
416
417    #[test]
418    fn display_is_hyphenated_uuid() {
419        let id = OrgId::new(uuid::Uuid::nil());
420        assert_eq!(id.to_string(), "00000000-0000-0000-0000-000000000000");
421    }
422
423    #[test]
424    fn from_str_valid() {
425        let s = "550e8400-e29b-41d4-a716-446655440000";
426        let id: OrgId = s.parse().unwrap();
427        assert_eq!(id.to_string(), s);
428    }
429
430    #[test]
431    fn from_str_invalid() {
432        assert!("not-a-uuid".parse::<OrgId>().is_err());
433    }
434
435    #[test]
436    fn from_into_uuid_roundtrip() {
437        let uuid = uuid::Uuid::new_v4();
438        let id = OrgId::from(uuid);
439        let back: uuid::Uuid = id.into();
440        assert_eq!(back, uuid);
441    }
442
443    #[test]
444    fn default_generates_v4() {
445        let id = OrgId::default();
446        assert_eq!(id.inner().get_version_num(), 4);
447    }
448
449    #[test]
450    fn error_display() {
451        let err = "not-a-uuid".parse::<OrgId>().unwrap_err();
452        let s = err.to_string();
453        assert!(s.contains("invalid org ID"));
454    }
455
456    #[cfg(feature = "std")]
457    #[test]
458    fn error_source_is_some() {
459        use std::error::Error as _;
460        let err = "not-a-uuid".parse::<OrgId>().unwrap_err();
461        assert!(err.source().is_some());
462    }
463
464    #[test]
465    fn try_from_str_valid() {
466        let s = "00000000-0000-0000-0000-000000000000";
467        let id = OrgId::try_from(s).unwrap();
468        assert_eq!(id.to_string(), s);
469    }
470
471    #[test]
472    fn try_from_string_valid() {
473        let s = "550e8400-e29b-41d4-a716-446655440000".to_owned();
474        let id = OrgId::try_from(s).unwrap();
475        assert_eq!(id.to_string(), "550e8400-e29b-41d4-a716-446655440000");
476    }
477
478    #[cfg(feature = "serde")]
479    #[test]
480    fn serde_roundtrip() {
481        let id = OrgId::new(uuid::Uuid::nil());
482        let json = serde_json::to_string(&id).unwrap();
483        assert_eq!(json, r#""00000000-0000-0000-0000-000000000000""#);
484        let back: OrgId = serde_json::from_str(&json).unwrap();
485        assert_eq!(back, id);
486    }
487
488    #[cfg(feature = "serde")]
489    #[test]
490    fn serde_invalid_rejects() {
491        let result: Result<OrgId, _> = serde_json::from_str(r#""not-a-uuid""#);
492        assert!(result.is_err());
493    }
494
495    #[test]
496    fn header_name_const() {
497        use crate::header_id::HeaderId as _;
498        let id = OrgId::new(uuid::Uuid::nil());
499        assert_eq!(OrgId::HEADER_NAME, "X-Org-Id");
500        assert_eq!(id.as_str().as_ref(), "00000000-0000-0000-0000-000000000000");
501    }
502
503    #[cfg(all(feature = "http", not(miri)))]
504    #[test]
505    fn try_from_headers_valid() {
506        use http::HeaderMap;
507        let mut headers = HeaderMap::new();
508        headers.insert(
509            "x-org-id",
510            "550e8400-e29b-41d4-a716-446655440000".parse().unwrap(),
511        );
512        let id = OrgId::try_from_headers(&headers).unwrap();
513        assert_eq!(id.to_string(), "550e8400-e29b-41d4-a716-446655440000");
514    }
515
516    #[cfg(all(feature = "http", not(miri)))]
517    #[test]
518    fn try_from_headers_malformed() {
519        use http::HeaderMap;
520        let mut headers = HeaderMap::new();
521        headers.insert("x-org-id", "not-a-uuid".parse().unwrap());
522        let result = OrgId::try_from_headers(&headers);
523        assert!(matches!(result, Err(OrgIdHeaderError::Invalid(_))));
524    }
525
526    #[cfg(all(feature = "http", not(miri)))]
527    #[test]
528    fn try_from_headers_missing() {
529        use http::HeaderMap;
530        let headers = HeaderMap::new();
531        let result = OrgId::try_from_headers(&headers);
532        assert_eq!(result, Err(OrgIdHeaderError::Missing));
533    }
534
535    #[cfg(all(feature = "http", not(miri)))]
536    #[test]
537    fn try_from_headers_empty() {
538        use http::HeaderMap;
539        let mut headers = HeaderMap::new();
540        headers.insert("x-org-id", "".parse().unwrap());
541        let result = OrgId::try_from_headers(&headers);
542        assert!(matches!(result, Err(OrgIdHeaderError::Invalid(_))));
543    }
544
545    #[cfg(all(feature = "http", not(miri)))]
546    #[test]
547    fn try_from_headers_multiple_values_uses_first() {
548        use http::HeaderMap;
549        let mut headers = HeaderMap::new();
550        headers.append(
551            "x-org-id",
552            "550e8400-e29b-41d4-a716-446655440000".parse().unwrap(),
553        );
554        headers.append(
555            "x-org-id",
556            "660e8400-e29b-41d4-a716-446655440001".parse().unwrap(),
557        );
558        let id = OrgId::try_from_headers(&headers).unwrap();
559        assert_eq!(id.to_string(), "550e8400-e29b-41d4-a716-446655440000");
560    }
561
562    #[cfg(all(feature = "http", not(miri)))]
563    #[test]
564    fn try_from_headers_non_utf8() {
565        use http::{HeaderMap, HeaderValue};
566        let mut headers = HeaderMap::new();
567        headers.insert("x-org-id", HeaderValue::from_bytes(&[0xFF, 0xFE]).unwrap());
568        let result = OrgId::try_from_headers(&headers);
569        assert_eq!(result, Err(OrgIdHeaderError::NotUtf8));
570    }
571
572    #[cfg(all(feature = "http", not(miri)))]
573    #[test]
574    fn try_from_headers_error_display_missing() {
575        let err = OrgIdHeaderError::Missing;
576        let s = err.to_string();
577        assert!(s.contains("missing"));
578        assert!(s.contains("X-Org-Id"));
579    }
580
581    #[cfg(all(feature = "http", not(miri)))]
582    #[test]
583    fn try_from_headers_error_display_not_utf8() {
584        let err = OrgIdHeaderError::NotUtf8;
585        let s = err.to_string();
586        assert!(s.contains("non-UTF-8"));
587    }
588
589    #[cfg(all(feature = "http", not(miri)))]
590    #[test]
591    fn try_from_headers_error_display_invalid() {
592        let err = OrgIdHeaderError::Invalid(OrgIdError::InvalidUuid(
593            uuid::Uuid::parse_str("not-a-uuid").unwrap_err(),
594        ));
595        let s = err.to_string();
596        assert!(s.contains("invalid"));
597    }
598
599    #[cfg(all(feature = "http", feature = "std", not(miri)))]
600    #[test]
601    fn try_from_headers_error_source_for_invalid() {
602        use std::error::Error as _;
603        let err = OrgIdHeaderError::Invalid(OrgIdError::InvalidUuid(
604            uuid::Uuid::parse_str("not-a-uuid").unwrap_err(),
605        ));
606        assert!(err.source().is_some());
607    }
608
609    #[cfg(all(feature = "http", feature = "std", not(miri)))]
610    #[test]
611    fn try_from_headers_error_source_for_missing() {
612        use std::error::Error as _;
613        let err = OrgIdHeaderError::Missing;
614        assert!(err.source().is_none());
615    }
616
617    // -- OrgPath tests -------------------------------------------------------
618
619    #[test]
620    fn org_path_new_and_as_slice() {
621        let id1 = OrgId::generate();
622        let id2 = OrgId::generate();
623        let path = OrgPath::new(vec![id1, id2]);
624        assert_eq!(path.as_slice().len(), 2);
625        assert_eq!(path.as_slice()[0], id1);
626        assert_eq!(path.as_slice()[1], id2);
627    }
628
629    #[test]
630    fn org_path_into_inner() {
631        let id = OrgId::generate();
632        let path = OrgPath::new(vec![id]);
633        let inner = path.into_inner();
634        assert_eq!(inner.len(), 1);
635        assert_eq!(inner[0], id);
636    }
637
638    #[test]
639    fn org_path_header_name() {
640        use crate::header_id::HeaderId as _;
641        assert_eq!(OrgPath::HEADER_NAME, "X-Org-Path");
642    }
643
644    #[test]
645    fn org_path_header_as_str_empty() {
646        let path = OrgPath::new(Vec::new());
647        assert_eq!(path.as_str().as_ref(), "");
648    }
649
650    #[test]
651    fn org_path_header_as_str_single() {
652        let id = OrgId::new(uuid::Uuid::nil());
653        let path = OrgPath::new(vec![id]);
654        assert_eq!(
655            path.as_str().as_ref(),
656            "00000000-0000-0000-0000-000000000000"
657        );
658    }
659
660    #[test]
661    fn org_path_header_as_str_multiple() {
662        let id1 = OrgId::new(uuid::Uuid::nil());
663        let id2 = OrgId::generate();
664        let path = OrgPath::new(vec![id1, id2]);
665        let s = path.as_str();
666        assert!(s.as_ref().contains("00000000-0000-0000-0000-000000000000"));
667        assert!(s.as_ref().contains(','));
668    }
669
670    #[test]
671    fn org_path_from_str_empty() {
672        let path: OrgPath = "".parse().unwrap();
673        assert!(path.as_slice().is_empty());
674    }
675
676    #[test]
677    fn org_path_from_str_single() {
678        let s = "550e8400-e29b-41d4-a716-446655440000";
679        let path: OrgPath = s.parse().unwrap();
680        assert_eq!(path.as_slice().len(), 1);
681        assert_eq!(path.as_slice()[0].to_string(), s);
682    }
683
684    #[test]
685    fn org_path_from_str_multiple() {
686        let s = "550e8400-e29b-41d4-a716-446655440000,660e8400-e29b-41d4-a716-446655440001";
687        let path: OrgPath = s.parse().unwrap();
688        assert_eq!(path.as_slice().len(), 2);
689    }
690
691    #[test]
692    fn org_path_from_str_invalid() {
693        let result: Result<OrgPath, _> = "not-a-uuid".parse();
694        assert!(result.is_err());
695    }
696
697    #[cfg(feature = "axum")]
698    #[tokio::test]
699    async fn org_path_axum_extractor_valid() {
700        use axum::extract::FromRequestParts;
701        use axum::http::Request;
702
703        let uuid_str = "550e8400-e29b-41d4-a716-446655440000";
704        let req = Request::builder()
705            .header("x-org-path", uuid_str)
706            .body(())
707            .unwrap();
708        let (mut parts, ()) = req.into_parts();
709        let path = OrgPath::from_request_parts(&mut parts, &()).await.unwrap();
710        assert_eq!(path.as_slice().len(), 1);
711        assert_eq!(path.as_slice()[0].to_string(), uuid_str);
712    }
713
714    #[cfg(feature = "axum")]
715    #[tokio::test]
716    async fn org_path_axum_extractor_missing_header() {
717        use axum::extract::FromRequestParts;
718        use axum::http::Request;
719
720        let req = Request::builder().body(()).unwrap();
721        let (mut parts, ()) = req.into_parts();
722        let result = OrgPath::from_request_parts(&mut parts, &()).await;
723        assert!(result.is_err());
724        let err = result.unwrap_err();
725        assert_eq!(err.status, 400);
726    }
727
728    #[cfg(feature = "axum")]
729    #[tokio::test]
730    async fn org_path_axum_extractor_invalid_uuid() {
731        use axum::extract::FromRequestParts;
732        use axum::http::Request;
733
734        let req = Request::builder()
735            .header("x-org-path", "not-a-uuid")
736            .body(())
737            .unwrap();
738        let (mut parts, ()) = req.into_parts();
739        let result = OrgPath::from_request_parts(&mut parts, &()).await;
740        assert!(result.is_err());
741        let err = result.unwrap_err();
742        assert_eq!(err.status, 400);
743    }
744
745    #[cfg(feature = "axum")]
746    #[tokio::test]
747    async fn org_path_axum_extractor_non_utf8_returns_400() {
748        use axum::extract::FromRequestParts;
749        use axum::http::{Request, header::HeaderValue};
750
751        let mut req = Request::builder().body(()).unwrap();
752        req.headers_mut().insert(
753            "x-org-path",
754            HeaderValue::from_bytes(&[0xFF, 0xFE]).unwrap(),
755        );
756        let (mut parts, ()) = req.into_parts();
757        let result = OrgPath::from_request_parts(&mut parts, &()).await;
758        assert!(result.is_err());
759        let err = result.unwrap_err();
760        assert_eq!(err.status, 400);
761    }
762}