Skip to main content

wip_http/
endpoint.rs

1use std::error::Error;
2use std::fmt::{self, Display, Formatter};
3
4use url::Url;
5
6/// The media type emitted for every WIP request and every response with a body.
7pub const JSON_CONTENT_TYPE: &str = "application/json; charset=utf-8";
8
9/// A fixed standard WIP over HTTP v1 route.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum Route {
12    /// Observe one object and its indexable descendants.
13    Observe,
14    /// Fetch an interface descriptor.
15    FetchInterface,
16    /// Call one operation.
17    CallOperation,
18}
19
20impl Route {
21    /// Returns the RFC 3986 relative reference used below an endpoint prefix.
22    #[must_use]
23    pub const fn relative_reference(self) -> &'static str {
24        match self {
25            Self::Observe => "v1/observe",
26            Self::FetchInterface => "v1/fetch_interface",
27            Self::CallOperation => "v1/call_operation",
28        }
29    }
30}
31
32/// A canonical absolute hierarchical base URL whose path always ends in `/`.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Endpoint(Url);
35
36impl Endpoint {
37    /// Parses and canonicalizes an endpoint.
38    ///
39    /// The input must be an absolute hierarchical URL with a host and without a
40    /// query or fragment. Its path is an endpoint prefix, not a Worldspace path.
41    /// A trailing slash is appended when absent so RFC 3986 relative-reference
42    /// resolution preserves the final prefix segment. The supplied scheme is
43    /// preserved; scheme and transport security are deployment concerns.
44    pub fn parse(input: &str) -> Result<Self, EndpointError> {
45        let mut url = Url::parse(input).map_err(EndpointError::InvalidUrl)?;
46        if url.cannot_be_a_base() || url.host_str().is_none() {
47            return Err(EndpointError::NotAbsoluteBase);
48        }
49        if url.query().is_some() {
50            return Err(EndpointError::HasQuery);
51        }
52        if url.fragment().is_some() {
53            return Err(EndpointError::HasFragment);
54        }
55        if !url.path().ends_with('/') {
56            let path = format!("{}/", url.path());
57            url.set_path(&path);
58        }
59        Ok(Self(url))
60    }
61
62    /// Returns the canonical base URL.
63    #[must_use]
64    pub fn as_url(&self) -> &Url {
65        &self.0
66    }
67
68    /// Resolves one fixed v1 route below this endpoint prefix.
69    #[must_use]
70    pub fn route(&self, route: Route) -> Url {
71        self.0
72            .join(route.relative_reference())
73            .expect("fixed relative WIP routes are valid URL references")
74    }
75
76    /// Recognizes an exact standard route below this endpoint.
77    ///
78    /// Query strings, fragments, additional path segments, and Worldspace paths
79    /// are not route components and therefore never match.
80    #[must_use]
81    pub fn recognize(&self, candidate: &Url) -> Option<Route> {
82        const ROUTES: [Route; 3] = [Route::Observe, Route::FetchInterface, Route::CallOperation];
83        if candidate.query().is_some() || candidate.fragment().is_some() {
84            return None;
85        }
86        ROUTES
87            .into_iter()
88            .find(|route| self.route(*route) == *candidate)
89    }
90}
91
92impl Display for Endpoint {
93    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
94        self.0.fmt(formatter)
95    }
96}
97
98impl TryFrom<&str> for Endpoint {
99    type Error = EndpointError;
100
101    fn try_from(value: &str) -> Result<Self, Self::Error> {
102        Self::parse(value)
103    }
104}
105
106/// Why an endpoint is not a valid standard WIP over HTTP base.
107#[derive(Debug)]
108pub enum EndpointError {
109    /// URL parsing failed.
110    InvalidUrl(url::ParseError),
111    /// The URL is not a hierarchical absolute URL with an authority.
112    NotAbsoluteBase,
113    /// A query component was supplied.
114    HasQuery,
115    /// A fragment component was supplied.
116    HasFragment,
117}
118
119impl Display for EndpointError {
120    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
121        match self {
122            Self::InvalidUrl(error) => write!(formatter, "invalid endpoint URL: {error}"),
123            Self::NotAbsoluteBase => {
124                formatter.write_str("WIP endpoint must be an absolute base URL")
125            }
126            Self::HasQuery => formatter.write_str("WIP endpoint must not contain a query"),
127            Self::HasFragment => formatter.write_str("WIP endpoint must not contain a fragment"),
128        }
129    }
130}
131
132impl Error for EndpointError {
133    fn source(&self) -> Option<&(dyn Error + 'static)> {
134        match self {
135            Self::InvalidUrl(error) => Some(error),
136            _ => None,
137        }
138    }
139}