Skip to main content

webfinger_rs/
axum.rs

1//! Axum integration for WebFinger request extraction and JRD responses.
2//!
3//! Enable the `axum` feature to:
4//!
5//! - extract [`WebFingerRequest`] from handlers mounted for `GET` requests to
6//!   [`crate::WELL_KNOWN_PATH`]; and
7//! - return [`WebFingerResponse`] directly from Axum handlers as `application/jrd+json` with the
8//!   WebFinger CORS header.
9//!
10//! The extractor expects the standard WebFinger query shape from [RFC 7033 section 4.1]:
11//!
12//! - a required `resource` query parameter; and
13//! - zero or more `rel` query parameters, encoded as repeated keys rather than a list.
14//!
15//! The `resource` value must be an absolute URI such as `acct:carol@example.com` or
16//! `https://example.com/users/carol`; relative references are rejected as malformed requests.
17//!
18//! In practice, route handlers should usually be mounted like this:
19//!
20//! ```rust
21//! use axum::{Router, routing::get};
22//! use webfinger_rs::{WELL_KNOWN_PATH, WebFingerRequest, WebFingerResponse};
23//!
24//! async fn webfinger(_request: WebFingerRequest) -> WebFingerResponse {
25//!     WebFingerResponse::new("acct:carol@example.com")
26//! }
27//!
28//! let app = Router::<()>::new().route(WELL_KNOWN_PATH, get(webfinger));
29//! # let _ = app;
30//! ```
31//!
32//! The Axum router owns path and method matching. Mounting the handler with `get` at
33//! [`crate::WELL_KNOWN_PATH`] rejects other paths and non-`GET` methods before this extractor runs.
34//! The extractor itself validates the WebFinger request metadata available inside the handler:
35//! host, query parameters, percent encoding, and the `resource` URI.
36//!
37//! RFC 7033 requires HTTPS for WebFinger. Axum request parts do not reliably identify the
38//! externally visible scheme when the application runs behind TLS termination or a reverse proxy, so
39//! this extractor does not enforce scheme. Configure TLS and forwarded-proto handling at your
40//! server or proxy boundary.
41//!
42//! If extraction fails, Axum receives [`Rejection`], which returns `400 Bad Request` with a plain
43//! text message for missing or duplicated `resource`, missing host values, invalid percent
44//! encoding, relative resource references, or invalid resource URIs.
45//!
46//! See also [`WebFingerRequest`] for the extractor impl, [`WebFingerResponse`] for the responder
47//! impl, and the [Axum example] for a runnable server.
48//!
49//! [RFC 7033 section 4.1]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.1
50//! [Axum example]:
51//!     https://github.com/joshka/webfinger-rs/blob/main/webfinger-rs/examples/axum.rs
52
53use axum::Json;
54use axum::extract::FromRequestParts;
55use axum::response::{IntoResponse, Response as AxumResponse};
56use http::header::{self, HOST};
57use http::request::Parts;
58use http::{HeaderValue, StatusCode};
59use tracing::trace;
60
61use crate::http::CORS_ALLOW_ORIGIN;
62use crate::query::{RequestParams, RequestParamsError};
63use crate::{Rel, ResourceError, WebFingerRequest, WebFingerResponse};
64
65const JRD_CONTENT_TYPE: HeaderValue = HeaderValue::from_static("application/jrd+json");
66const CORS_ALLOW_ORIGIN_HEADER: HeaderValue = HeaderValue::from_static(CORS_ALLOW_ORIGIN);
67
68impl IntoResponse for WebFingerResponse {
69    /// Converts a [`WebFingerResponse`] into an Axum response.
70    ///
71    /// This serializes the body as JSON, sets the `Content-Type` header to
72    /// `application/jrd+json`, and allows cross-origin browser requests with
73    /// `Access-Control-Allow-Origin: *` as recommended by RFC 7033 section 5.
74    ///
75    /// Handlers can therefore return [`WebFingerResponse`] directly without manually wrapping it in
76    /// [`axum::Json`] or setting the response header themselves.
77    ///
78    /// Mount the route at [`crate::WELL_KNOWN_PATH`] so the handler matches the standard WebFinger
79    /// endpoint path.
80    ///
81    /// See also the [`crate::axum`] module docs and the [Axum example].
82    ///
83    /// # Example
84    ///
85    /// ```rust
86    /// use axum::{Router, routing::get};
87    /// use http::StatusCode;
88    /// use webfinger_rs::{Link, Rel, WELL_KNOWN_PATH, WebFingerRequest, WebFingerResponse};
89    ///
90    /// async fn webfinger(request: WebFingerRequest) -> axum::response::Result<WebFingerResponse> {
91    ///     let subject = request.resource.to_string();
92    ///     if subject != "acct:carol@example.com" {
93    ///         return Err((StatusCode::NOT_FOUND, "not found").into());
94    ///     }
95    ///
96    ///     let rel = Rel::new("http://webfinger.net/rel/profile-page");
97    ///     let response = if request.rels.is_empty() || request.rels.contains(&rel) {
98    ///         let link = Link::builder(rel).href("https://example.com/users/carol");
99    ///         WebFingerResponse::builder(subject).link(link).build()
100    ///     } else {
101    ///         WebFingerResponse::builder(subject).build()
102    ///     };
103    ///     Ok(response)
104    /// }
105    ///
106    /// let app = Router::<()>::new().route(WELL_KNOWN_PATH, get(webfinger));
107    /// # let _ = app;
108    /// ```
109    ///
110    /// [Axum example]:
111    ///     https://github.com/joshka/webfinger-rs/blob/main/webfinger-rs/examples/axum.rs
112    fn into_response(self) -> AxumResponse {
113        (
114            [
115                (header::CONTENT_TYPE, JRD_CONTENT_TYPE),
116                (
117                    header::ACCESS_CONTROL_ALLOW_ORIGIN,
118                    CORS_ALLOW_ORIGIN_HEADER,
119                ),
120            ],
121            Json(self),
122        )
123            .into_response()
124    }
125}
126
127/// Rejection type for WebFinger requests.
128///
129/// This represents errors that can occur while extracting [`WebFingerRequest`] from Axum request
130/// parts.
131///
132/// Each variant maps to `400 Bad Request` when converted into an Axum response:
133///
134/// - [`Rejection::MissingHost`] when neither the request URI nor the `Host` header provides an
135///   authority;
136/// - [`Rejection::InvalidQueryString`] when the query string is missing `resource`, contains more
137///   than one `resource`, or contains malformed percent encoding;
138/// - [`Rejection::InvalidResource`] when the `resource` value is not an absolute URI.
139#[derive(Debug)]
140pub enum Rejection {
141    /// The WebFinger query string is missing required data or is malformed.
142    InvalidQueryString(String),
143
144    /// The `resource` query parameter is not an absolute URI.
145    InvalidResource(ResourceError),
146
147    /// The `Host` header is missing.
148    MissingHost,
149
150    /// A `rel` query parameter is invalid.
151    InvalidRel(crate::Error),
152}
153
154impl IntoResponse for Rejection {
155    /// Converts the rejection into a `400 Bad Request` Axum response.
156    ///
157    /// The body is a plain text error message intended to make local debugging and simple server
158    /// implementations straightforward.
159    ///
160    /// See also the [`crate::axum`] module docs.
161    fn into_response(self) -> AxumResponse {
162        let message = match self {
163            Rejection::MissingHost => "missing host".to_string(),
164            Rejection::InvalidQueryString(error) => error,
165            Rejection::InvalidResource(error) => format!("invalid resource: {error}"),
166            Rejection::InvalidRel(error) => error.to_string(),
167        };
168        (StatusCode::BAD_REQUEST, message).into_response()
169    }
170}
171
172impl From<RequestParamsError> for Rejection {
173    fn from(error: RequestParamsError) -> Self {
174        match error {
175            RequestParamsError::InvalidResource(error) => Rejection::InvalidResource(error),
176            error => Rejection::InvalidQueryString(error.to_string()),
177        }
178    }
179}
180
181impl<S: Send + Sync> FromRequestParts<S> for WebFingerRequest {
182    type Rejection = Rejection;
183
184    /// Extracts a [`WebFingerRequest`] from Axum request parts.
185    ///
186    /// The extractor expects a request routed to [`crate::WELL_KNOWN_PATH`] with:
187    ///
188    /// - a `resource` query parameter containing the target resource URI; and
189    /// - zero or more repeated `rel` query parameters.
190    ///
191    /// Host resolution follows this order:
192    ///
193    /// 1. Use the authority from `parts.uri` when the request URI is absolute.
194    /// 1. Otherwise, fall back to the HTTP `Host` header.
195    ///
196    /// The extracted host, parsed resource, and collected relations are used to construct the
197    /// resulting [`WebFingerRequest`].
198    ///
199    /// # Errors
200    ///
201    /// - If the request has neither a URI authority nor a `Host` header, extraction fails with
202    ///   `Rejection::MissingHost`.
203    /// - If the query string is missing `resource`, contains more than one `resource`, or contains
204    ///   malformed percent encoding, extraction fails with `Rejection::InvalidQueryString`.
205    /// - If `resource` is present but cannot be parsed as a URI, extraction fails with
206    ///   `Rejection::InvalidResource`.
207    ///
208    /// See also the [`crate::axum`] module docs and the [Axum example].
209    ///
210    /// # Example
211    ///
212    /// ```rust
213    /// use axum::{Router, routing::get};
214    /// use webfinger_rs::{WELL_KNOWN_PATH, WebFingerRequest, WebFingerResponse};
215    ///
216    /// async fn webfinger(request: WebFingerRequest) -> WebFingerResponse {
217    ///     WebFingerResponse::new(request.resource.to_string())
218    /// }
219    ///
220    /// let app = Router::<()>::new().route(WELL_KNOWN_PATH, get(webfinger));
221    /// # let _ = app;
222    /// ```
223    ///
224    /// [Axum example]:
225    ///     https://github.com/joshka/webfinger-rs/blob/main/webfinger-rs/examples/axum.rs
226    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
227        trace!("request parts: {:?}", parts);
228
229        let host = parts
230            .uri
231            .host()
232            .or_else(|| parts.headers.get(HOST).and_then(|host| host.to_str().ok()))
233            .map(str::to_string)
234            .ok_or(Rejection::MissingHost)?;
235
236        let query: RequestParams = parts.uri.query().unwrap_or("").parse()?;
237        let rels = query
238            .rel
239            .into_iter()
240            .map(Rel::try_new)
241            .collect::<Result<Vec<_>, _>>()
242            .map_err(Rejection::InvalidRel)?;
243
244        Ok(WebFingerRequest {
245            host,
246            resource: query.resource,
247            rels,
248        })
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use axum::body::Body;
255    use axum::routing::get;
256    use http::{Method, Request, Response};
257    use http_body_util::BodyExt;
258    use tower::ServiceExt;
259
260    use super::*;
261    use crate::WELL_KNOWN_PATH;
262
263    type Result<T = (), E = Box<dyn std::error::Error>> = std::result::Result<T, E>;
264
265    /// A small helper trait to convert a response body into a string.
266    trait IntoText {
267        /// Consumes the response body and decodes it as UTF-8 text.
268        async fn into_text(self) -> Result<String>;
269    }
270
271    impl IntoText for Response<Body> {
272        async fn into_text(self) -> Result<String> {
273            let body = self.into_body().collect().await?.to_bytes();
274            let string = String::from_utf8(body.to_vec())?;
275            Ok(string)
276        }
277    }
278
279    /// Builds a test router using the resource-echoing WebFinger handler.
280    fn app() -> axum::Router {
281        axum::Router::new().route(WELL_KNOWN_PATH, get(webfinger))
282    }
283
284    /// Builds a test router using the relation-echoing WebFinger handler.
285    fn rels_app() -> axum::Router {
286        axum::Router::new().route(WELL_KNOWN_PATH, get(webfinger_rels))
287    }
288
289    /// Returns a minimal JRD response so tests can assert resource extraction through Axum.
290    async fn webfinger(request: WebFingerRequest) -> impl IntoResponse {
291        WebFingerResponse::builder(&request.resource).build()
292    }
293
294    /// Returns extracted relation filters so tests can assert RFC 7033 repeated `rel` handling.
295    ///
296    /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4.1>.
297    async fn webfinger_rels(request: WebFingerRequest) -> impl IntoResponse {
298        let rels = request
299            .rels
300            .iter()
301            .map(ToString::to_string)
302            .collect::<Vec<_>>();
303        Json(rels)
304    }
305
306    const VALID_RESOURCE: &str = "acct:carol@example.com";
307
308    /// Accepts an ordinary `acct:` resource from an absolute request URI.
309    ///
310    /// This covers the common Axum path where the request URI already contains the authority, so host
311    /// extraction should not depend on the `Host` header.
312    ///
313    /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4>.
314    #[tokio::test]
315    async fn valid_request() -> Result {
316        let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource={VALID_RESOURCE}");
317        let request = Request::builder().uri(uri).body(Body::empty())?;
318
319        let response = app().oneshot(request).await?;
320
321        assert_eq!(response.status(), StatusCode::OK, "{response:?}");
322        let body = response.into_text().await?;
323        assert_eq!(body, r#"{"subject":"acct:carol@example.com","links":[]}"#);
324        Ok(())
325    }
326
327    /// Includes the RFC 7033 CORS header on successful JRD responses.
328    ///
329    /// WebFinger resources must be queryable from browsers, and RFC 7033 section 5 recommends the
330    /// least restrictive `Access-Control-Allow-Origin` value for public WebFinger resources.
331    ///
332    /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-5>.
333    #[tokio::test]
334    async fn successful_response_sets_cors_header() -> Result {
335        let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource={VALID_RESOURCE}");
336        let request = Request::builder().uri(uri).body(Body::empty())?;
337
338        let response = app().oneshot(request).await?;
339
340        assert_eq!(response.status(), StatusCode::OK, "{response:?}");
341        assert_eq!(
342            response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN),
343            Some(&CORS_ALLOW_ORIGIN_HEADER),
344        );
345        Ok(())
346    }
347
348    /// Accepts an ordinary `acct:` resource when only the `Host` header carries the authority.
349    ///
350    /// Axum tests usually build origin-form request URIs, so this catches regressions where the
351    /// extractor ignores the fallback authority that HTTP/1.1 clients send in `Host`.
352    ///
353    /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4>.
354    #[tokio::test]
355    async fn valid_request_with_host_header() -> Result {
356        let request = Request::builder()
357            .uri(format!("{WELL_KNOWN_PATH}?resource={VALID_RESOURCE}"))
358            .header(HOST, "example.com")
359            .body(Body::empty())?;
360
361        let response = app().oneshot(request).await?;
362
363        assert_eq!(response.status(), StatusCode::OK, "{response:?}");
364        let body = response.into_text().await?;
365        assert_eq!(body, r#"{"subject":"acct:carol@example.com","links":[]}"#);
366        Ok(())
367    }
368
369    /// Relies on Axum routing to reject non-WebFinger paths before extraction.
370    ///
371    /// RFC 7033 sections 4 and 10.1 define `/.well-known/webfinger` as the WebFinger resource.
372    /// Path matching stays in the router so applications get normal Axum `404 Not Found` behavior.
373    ///
374    /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4> and
375    /// <https://www.rfc-editor.org/rfc/rfc7033.html#section-10.1>.
376    #[tokio::test]
377    async fn wrong_path_is_not_routed() -> Result {
378        let request = Request::builder()
379            .uri(format!("/webfinger?resource={VALID_RESOURCE}"))
380            .header(HOST, "example.com")
381            .body(Body::empty())?;
382
383        let response = app().oneshot(request).await?;
384
385        assert_eq!(response.status(), StatusCode::NOT_FOUND, "{response:?}");
386        Ok(())
387    }
388
389    /// Relies on Axum routing to reject non-`GET` requests before extraction.
390    ///
391    /// RFC 7033 section 4.2 specifies a `GET` request. Method matching stays in the router so
392    /// applications get normal Axum `405 Method Not Allowed` behavior.
393    ///
394    /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4.2>.
395    #[tokio::test]
396    async fn wrong_method_is_not_routed() -> Result {
397        let request = Request::builder()
398            .method(Method::POST)
399            .uri(format!("{WELL_KNOWN_PATH}?resource={VALID_RESOURCE}"))
400            .header(HOST, "example.com")
401            .body(Body::empty())?;
402
403        let response = app().oneshot(request).await?;
404
405        assert_eq!(
406            response.status(),
407            StatusCode::METHOD_NOT_ALLOWED,
408            "{response:?}"
409        );
410        Ok(())
411    }
412
413    /// Rejects requests where neither the URI nor `Host` header provides an authority.
414    ///
415    /// The request host is significant to WebFinger query routing.
416    ///
417    /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4>.
418    #[tokio::test]
419    async fn request_with_no_host() -> Result {
420        let uri = format!("{WELL_KNOWN_PATH}?resource={VALID_RESOURCE}");
421        let request = Request::builder().uri(uri).body(Body::empty())?;
422
423        let response = app().oneshot(request).await?;
424
425        assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
426        let body = response.into_text().await?;
427        assert_eq!(body, "missing host");
428        Ok(())
429    }
430
431    /// Rejects requests that omit the required `resource` parameter.
432    ///
433    /// RFC 7033 section 4.2 treats absent `resource` parameters as bad requests. This prevents the
434    /// Axum adapter from relying on framework deserialization wording or accepting an empty target.
435    ///
436    /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4.2>.
437    #[tokio::test]
438    async fn request_with_missing_resource() -> Result {
439        let request = Request::builder()
440            .uri(WELL_KNOWN_PATH)
441            .header(HOST, "example.com")
442            .body(Body::empty())?;
443
444        let response = app().oneshot(request).await?;
445
446        assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
447        let body = response.into_text().await?;
448        assert_eq!(body, "missing resource parameter");
449        Ok(())
450    }
451
452    /// Converts malformed resource values into Axum bad-request responses.
453    ///
454    /// RFC 7033 section 4.2 requires malformed `resource` parameters to be treated as bad requests
455    /// instead of panicking inside extraction.
456    ///
457    /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4.2>.
458    #[tokio::test]
459    async fn request_with_invalid_resource() -> Result {
460        let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource=http%3A%2F%2F%5B%3A%3A1");
461        let request = Request::builder().uri(uri).body(Body::empty())?;
462
463        let response = app().oneshot(request).await?;
464
465        assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
466        let body = response.into_text().await?;
467        assert_eq!(body, "invalid resource: invalid authority");
468        Ok(())
469    }
470
471    /// Preserves the typed resource parse error until Axum renders the rejection.
472    #[test]
473    fn invalid_resource_rejection_preserves_resource_error() {
474        let error = "resource=/relative".parse::<RequestParams>().unwrap_err();
475        let rejection = Rejection::from(error);
476
477        assert!(matches!(
478            rejection,
479            Rejection::InvalidResource(ResourceError::RelativeReference)
480        ));
481    }
482
483    /// Rejects relative resource references at the Axum extractor boundary.
484    ///
485    /// RFC 7033 identifies the WebFinger query target as a URI, not a relative reference. Axum
486    /// handlers should not receive ambiguous targets such as local paths or bare names.
487    ///
488    /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4.1> and
489    /// <https://www.rfc-editor.org/rfc/rfc3986.html#section-4.1>.
490    #[tokio::test]
491    async fn relative_resource_is_bad_request() -> Result {
492        let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource=/relative");
493        let request = Request::builder().uri(uri).body(Body::empty())?;
494
495        let response = app().oneshot(request).await?;
496
497        assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
498        let body = response.into_text().await?;
499        assert_eq!(body, "invalid resource: resource must be an absolute URI");
500        Ok(())
501    }
502
503    /// Accepts a percent-encoded `acct:` resource without panicking.
504    ///
505    /// The resource query value is percent-encoded under RFC 7033 section 4.1, then parsed as a URI
506    /// query target under RFC 7033 section 4.2.
507    ///
508    /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4.1> and
509    /// <https://www.rfc-editor.org/rfc/rfc7033.html#section-4.2>.
510    #[tokio::test]
511    async fn valid_percent_encoded_resource() -> Result {
512        let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource=acct%3Abad%40example.org");
513        let request = Request::builder().uri(uri).body(Body::empty())?;
514
515        let response = app().oneshot(request).await?;
516
517        assert_eq!(response.status(), StatusCode::OK, "{response:?}");
518        let body = response.into_text().await?;
519        assert_eq!(body, r#"{"subject":"acct:bad@example.org","links":[]}"#);
520        Ok(())
521    }
522
523    /// Preserves repeated `rel` parameters instead of collapsing them.
524    ///
525    /// WebFinger clients use repeated `rel` keys to request multiple relation filters. A generic
526    /// map-shaped query parser can easily keep only one value, which would make handlers see an
527    /// incomplete request.
528    ///
529    /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4.1>.
530    #[tokio::test]
531    async fn valid_request_with_repeated_rel_params() -> Result {
532        let resource = "acct%3Acarol%40example.org";
533        let uri = format!(
534            "https://example.com{WELL_KNOWN_PATH}?resource={resource}&rel=profile&rel=avatar"
535        );
536        let request = Request::builder().uri(uri).body(Body::empty())?;
537
538        let response = rels_app().oneshot(request).await?;
539
540        assert_eq!(response.status(), StatusCode::OK, "{response:?}");
541        let body = response.into_text().await?;
542        assert_eq!(body, r#"["profile","avatar"]"#);
543        Ok(())
544    }
545
546    /// Exposes decoded relation URIs to Axum handlers.
547    ///
548    /// The shared parser owns the RFC 3986 percent-decoding rule; this adapter test proves Axum
549    /// handlers receive decoded `Rel` values rather than raw query text.
550    ///
551    /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4.1> and
552    /// <https://www.rfc-editor.org/rfc/rfc3986.html#section-2.1>.
553    #[tokio::test]
554    async fn rel_params_are_percent_decoded() -> Result {
555        let resource = "acct%3Acarol%40example.org";
556        let rel = "http%3A%2F%2Fwebfinger.example%2Frel%2Fprofile-page";
557        let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource={resource}&rel={rel}");
558        let request = Request::builder().uri(uri).body(Body::empty())?;
559
560        let response = rels_app().oneshot(request).await?;
561
562        assert_eq!(response.status(), StatusCode::OK, "{response:?}");
563        let body = response.into_text().await?;
564        assert_eq!(body, r#"["http://webfinger.example/rel/profile-page"]"#);
565        Ok(())
566    }
567
568    /// Rejects relation values that are neither one registered relation type nor one URI.
569    ///
570    /// RFC 7033 section 4.4.4.1 allows one relation type per `rel` member. Multiple relation
571    /// filters should be encoded as repeated `rel` parameters, not as whitespace-separated values.
572    #[tokio::test]
573    async fn invalid_rel_is_bad_request() -> Result {
574        let resource = "acct%3Acarol%40example.org";
575        let uri =
576            format!("https://example.com{WELL_KNOWN_PATH}?resource={resource}&rel=author%20avatar");
577        let request = Request::builder().uri(uri).body(Body::empty())?;
578
579        let response = rels_app().oneshot(request).await?;
580
581        assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
582        let body = response.into_text().await?;
583        assert_eq!(body, "invalid relation type: author avatar");
584        Ok(())
585    }
586
587    /// Converts invalid UTF-8 after percent decoding into an Axum bad-request response.
588    ///
589    /// The shared parser owns the byte-level validation; this adapter test proves malformed
590    /// percent-encoded bytes do not reach an Axum handler as relation strings.
591    ///
592    /// See <https://www.rfc-editor.org/rfc/rfc3986.html#section-2.1>.
593    #[tokio::test]
594    async fn invalid_percent_encoded_rel_is_bad_request() -> Result {
595        let resource = "acct%3Acarol%40example.org";
596        let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource={resource}&rel=%FF");
597        let request = Request::builder().uri(uri).body(Body::empty())?;
598
599        let response = rels_app().oneshot(request).await?;
600
601        assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
602        let body = response.into_text().await?;
603        assert_eq!(body, "invalid percent-encoded query parameter");
604        Ok(())
605    }
606
607    /// Rejects malformed percent escape syntax instead of treating `%` literally.
608    ///
609    /// The shared query parser owns the RFC 3986 check; this Axum test proves that parser errors are
610    /// converted into `400 Bad Request` responses instead of escaping the extractor boundary.
611    ///
612    /// See <https://www.rfc-editor.org/rfc/rfc3986.html#section-2.1>.
613    #[tokio::test]
614    async fn malformed_percent_escape_is_bad_request() -> Result {
615        let resource = "acct%3Acarol%40example.org";
616        let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource={resource}&rel=%GG");
617        let request = Request::builder().uri(uri).body(Body::empty())?;
618
619        let response = rels_app().oneshot(request).await?;
620
621        assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
622        let body = response.into_text().await?;
623        assert_eq!(body, "invalid percent-encoded query parameter");
624        Ok(())
625    }
626
627    /// Accepts `resource` in any query parameter position through the Axum extractor.
628    ///
629    /// RFC 7033 section 4.1 does not make parameter order significant. This adapter test proves
630    /// Axum handlers still receive relation filters when `resource` appears after them.
631    ///
632    /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4.1>.
633    #[tokio::test]
634    async fn resource_parameter_order_does_not_matter() -> Result {
635        let resource = "acct%3Acarol%40example.org";
636        let uri = format!("https://example.com{WELL_KNOWN_PATH}?rel=profile&resource={resource}");
637        let request = Request::builder().uri(uri).body(Body::empty())?;
638
639        let response = rels_app().oneshot(request).await?;
640
641        assert_eq!(response.status(), StatusCode::OK, "{response:?}");
642        let body = response.into_text().await?;
643        assert_eq!(body, r#"["profile"]"#);
644        Ok(())
645    }
646
647    /// Keeps encoded `=` and `&` inside handler-visible resource values.
648    ///
649    /// Resource URIs may contain query strings of their own. This adapter test proves Axum receives
650    /// the decoded target resource without splitting encoded inner delimiters into WebFinger
651    /// parameters.
652    ///
653    /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4.1>.
654    #[tokio::test]
655    async fn encoded_delimiters_stay_inside_resource() -> Result {
656        let resource = "https%3A%2F%2Fexample.org%2Fprofile%3Fa%3D1%26b%3D2";
657        let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource={resource}");
658        let request = Request::builder().uri(uri).body(Body::empty())?;
659
660        let response = app().oneshot(request).await?;
661
662        assert_eq!(response.status(), StatusCode::OK, "{response:?}");
663        let body = response.into_text().await?;
664        assert_eq!(
665            body,
666            r#"{"subject":"https://example.org/profile?a=1&b=2","links":[]}"#,
667        );
668        Ok(())
669    }
670
671    /// Preserves literal `+` in Axum handler-visible resources.
672    ///
673    /// Framework form-query extractors are not used here because WebFinger follows RFC 3986 query
674    /// semantics, where `+` remains data instead of becoming a space.
675    ///
676    /// See <https://www.rfc-editor.org/rfc/rfc3986.html#section-3.4>.
677    #[tokio::test]
678    async fn plus_is_not_decoded_as_space() -> Result {
679        let uri =
680            format!("https://example.com{WELL_KNOWN_PATH}?resource=acct%3Acarol+tag%40example.org");
681        let request = Request::builder().uri(uri).body(Body::empty())?;
682
683        let response = app().oneshot(request).await?;
684
685        assert_eq!(response.status(), StatusCode::OK, "{response:?}");
686        let body = response.into_text().await?;
687        assert_eq!(
688            body,
689            r#"{"subject":"acct:carol+tag@example.org","links":[]}"#
690        );
691        Ok(())
692    }
693
694    /// Rejects duplicate `resource` parameters at the Axum extractor boundary.
695    ///
696    /// The parser owns the RFC 7033 section 4.2 rule that there is exactly one target. This adapter
697    /// test proves ambiguous requests become `400 Bad Request` responses rather than arbitrary
698    /// handler inputs.
699    ///
700    /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4.2>.
701    #[tokio::test]
702    async fn request_with_multiple_resources() -> Result {
703        let carol = "acct%3Acarol%40example.org";
704        let alice = "acct%3Aalice%40example.org";
705        let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource={carol}&resource={alice}");
706        let request = Request::builder().uri(uri).body(Body::empty())?;
707
708        let response = app().oneshot(request).await?;
709
710        assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
711        let body = response.into_text().await?;
712        assert_eq!(body, "multiple resource parameters");
713        Ok(())
714    }
715}