webfinger_rs/
axum.rs

1use axum::{
2    extract::FromRequestParts,
3    response::{IntoResponse, Response as AxumResponse},
4    Json,
5};
6use axum_extra::extract::{Query, QueryRejection};
7use http::{
8    header::{self, HOST},
9    request::Parts,
10    uri::InvalidUri,
11    HeaderValue, StatusCode,
12};
13use tracing::trace;
14
15use crate::{Rel, WebFingerRequest, WebFingerResponse};
16
17const JRD_CONTENT_TYPE: HeaderValue = HeaderValue::from_static("application/jrd+json");
18
19impl IntoResponse for WebFingerResponse {
20    /// Converts a WebFinger response into an axum response.
21    ///
22    /// This is used to convert a [`WebFingerResponse`] into an axum response in an axum route
23    /// handler. The response will be serialized as JSON and the `Content-Type` header will be set
24    /// to `application/jrd+json`.
25    ///
26    /// See the [axum example] for more information.
27    ///
28    /// # Example
29    ///
30    /// ```rust
31    /// use axum::response::IntoResponse;
32    /// use webfinger_rs::{Link, WebFingerRequest, WebFingerResponse};
33    ///
34    /// async fn handler(request: WebFingerRequest) -> impl IntoResponse {
35    ///     // ... your code to handle the webfinger request ...
36    ///     let subject = request.resource.to_string();
37    ///     let link = Link::builder("http://webfinger.net/rel/profile-page")
38    ///         .href(format!("https://example.com/profile/{subject}"));
39    ///     WebFingerResponse::builder(subject).link(link).build()
40    /// }
41    /// ```
42    ///
43    /// [axum example]:
44    ///     http://github.com/joshka/webfinger-rs/blob/main/webfinger-rs/examples/axum.rs
45    fn into_response(self) -> AxumResponse {
46        ([(header::CONTENT_TYPE, JRD_CONTENT_TYPE)], Json(self)).into_response()
47    }
48}
49
50/// The query parameters for a WebFinger request.
51#[derive(Debug, serde::Deserialize)]
52struct RequestParams {
53    resource: String,
54
55    #[serde(default)]
56    rel: Vec<String>,
57}
58
59/// Rejection type for WebFinger requests.
60///
61/// This is used to represent errors that can occur when extracting a WebFinger request from the
62/// request parts in an axum route handler.
63pub enum Rejection {
64    /// The `resource` query parameter is missing or invalid.
65    InvalidQueryString(String),
66
67    /// The `Host` header is missing.
68    MissingHost,
69
70    /// The `resource` query parameter is invalid.
71    InvalidResource(InvalidUri),
72}
73
74impl IntoResponse for Rejection {
75    /// Converts a WebFinger rejection into an axum response.
76    fn into_response(self) -> AxumResponse {
77        let message = match self {
78            Rejection::MissingHost => "missing host".to_string(),
79            Rejection::InvalidQueryString(e) => format!("{e}"),
80            Rejection::InvalidResource(e) => format!("invalid resource: {e}"),
81        };
82        (StatusCode::BAD_REQUEST, message).into_response()
83    }
84}
85
86impl From<QueryRejection> for Rejection {
87    fn from(rejection: QueryRejection) -> Self {
88        Rejection::InvalidQueryString(rejection.to_string())
89    }
90}
91
92impl<S: Send + Sync> FromRequestParts<S> for WebFingerRequest {
93    type Rejection = Rejection;
94
95    /// Extracts a [`WebFingerRequest`] from the request parts.
96    ///
97    /// # Errors
98    ///
99    /// - If the request is missing the `Host` header, it will return a Bad Request response with
100    /// the message "missing host".
101    ///
102    /// - If the `resource` query parameter is missing or invalid, it will return a Bad Request
103    /// response with the message "invalid resource: {error}".
104    ///
105    /// - If the `rel` query parameter is invalid, it will return a Bad Request response with the
106    /// message "invalid query string: {error}".
107    ///
108    /// See the [axum example] for more information.
109    ///
110    /// # Example
111    ///
112    /// ```rust
113    /// use axum::response::IntoResponse;
114    /// use webfinger_rs::WebFingerRequest;
115    ///
116    /// async fn handler(request: WebFingerRequest) -> impl IntoResponse {
117    ///     let WebFingerRequest {
118    ///         host,
119    ///         resource,
120    ///         rels,
121    ///     } = request;
122    ///     // ... your code to handle the webfinger request ...
123    /// # webfinger_rs::WebFingerResponse::new(resource.to_string())
124    /// }
125    /// ```
126    ///
127    /// [axum example]:
128    ///     https://github.com/joshka/webfinger-rs/blob/main/webfinger-rs/examples/axum.rs
129    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
130        trace!("request parts: {:?}", parts);
131
132        let host = parts
133            .uri
134            .host()
135            .or_else(|| parts.headers.get(HOST).and_then(|host| host.to_str().ok()))
136            .map(str::to_string)
137            .ok_or(Rejection::MissingHost)?;
138
139        // use axum::extract::Query instead of axum::extract::Query, so that we can accept multiple
140        // rel query parameters rather than this being provided as a sequence (`rel=[a,b,c]`).
141        let query = Query::<RequestParams>::from_request_parts(parts, state).await?;
142        let resource = query.resource.parse().map_err(Rejection::InvalidResource)?;
143        let rels = query.rel.clone().into_iter().map(Rel::from).collect();
144
145        Ok(WebFingerRequest {
146            host,
147            resource,
148            rels,
149        })
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use axum::{body::Body, routing::get};
156    use http::{Request, Response};
157    use http_body_util::BodyExt;
158    use tower::ServiceExt;
159
160    use crate::WELL_KNOWN_PATH;
161
162    use super::*;
163
164    type Result<T = (), E = Box<dyn std::error::Error>> = std::result::Result<T, E>;
165
166    /// A small helper trait to convert a response body into a string.
167    trait IntoText {
168        async fn into_text(self) -> Result<String>;
169    }
170
171    impl IntoText for Response<Body> {
172        async fn into_text(self) -> Result<String> {
173            let body = self.into_body().collect().await?.to_bytes();
174            let string = String::from_utf8(body.to_vec())?;
175            Ok(string)
176        }
177    }
178
179    fn app() -> axum::Router {
180        axum::Router::new().route(WELL_KNOWN_PATH, get(webfinger))
181    }
182
183    async fn webfinger(request: WebFingerRequest) -> impl IntoResponse {
184        WebFingerResponse::builder(request.resource.to_string()).build()
185    }
186
187    const VALID_RESOURCE: &str = "acct:carol@example.com";
188
189    #[tokio::test]
190    async fn valid_request() -> Result {
191        let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource={VALID_RESOURCE}");
192        let request = Request::builder().uri(uri).body(Body::empty())?;
193
194        let response = app().oneshot(request).await?;
195
196        assert_eq!(response.status(), StatusCode::OK, "{response:?}");
197        let body = response.into_text().await?;
198        assert_eq!(body, r#"{"subject":"acct:carol@example.com","links":[]}"#);
199        Ok(())
200    }
201
202    #[tokio::test]
203    async fn valid_request_with_host_header() -> Result {
204        let request = Request::builder()
205            .uri(format!("{WELL_KNOWN_PATH}?resource={VALID_RESOURCE}"))
206            .header(HOST, "example.com")
207            .body(Body::empty())?;
208
209        let response = app().oneshot(request).await?;
210
211        assert_eq!(response.status(), StatusCode::OK, "{response:?}");
212        let body = response.into_text().await?;
213        assert_eq!(body, r#"{"subject":"acct:carol@example.com","links":[]}"#);
214        Ok(())
215    }
216
217    #[tokio::test]
218    async fn request_with_no_host() -> Result {
219        let uri = format!("{WELL_KNOWN_PATH}?resource={VALID_RESOURCE}");
220        let request = Request::builder().uri(uri).body(Body::empty())?;
221
222        let response = app().oneshot(request).await?;
223
224        assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
225        let body = response.into_text().await?;
226        assert_eq!(body, "missing host");
227        Ok(())
228    }
229
230    #[tokio::test]
231    async fn request_with_missing_resource() -> Result {
232        let request = Request::builder()
233            .uri(WELL_KNOWN_PATH)
234            .header(HOST, "example.com")
235            .body(Body::empty())?;
236
237        let response = app().oneshot(request).await?;
238
239        assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
240        let body = response.into_text().await?;
241        assert_eq!(
242            body,
243            "Failed to deserialize query string: missing field `resource`",
244        );
245        Ok(())
246    }
247
248    #[tokio::test]
249    async fn request_with_invalid_resource() -> Result {
250        let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource=%");
251        let request = Request::builder().uri(uri).body(Body::empty())?;
252
253        let response = app().oneshot(request).await?;
254
255        assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
256        let body = response.into_text().await?;
257        assert_eq!(body, "invalid resource: invalid authority");
258        Ok(())
259    }
260}