Skip to main content

actpub_nodeinfo/
client.rs

1//! Asynchronous `NodeInfo` client built on [`reqwest`].
2
3use reqwest::{Client, header};
4use tracing::debug;
5use url::Url;
6
7use crate::{Discovery, Error, NodeInfo, Version};
8
9/// Fetches the `/.well-known/nodeinfo` discovery document from `host`.
10///
11/// `host` should be a full base URL (including scheme), e.g.
12/// `https://mastodon.social`.
13///
14/// # Errors
15///
16/// Returns [`Error::InvalidUrl`] if `host` cannot be joined with the
17/// well-known path, [`Error::Http`] for network failures, and
18/// [`Error::BadStatus`] / [`Error::Json`] for invalid responses.
19pub async fn fetch_discovery(host: &Url, client: &Client) -> Result<Discovery, Error> {
20    let url = host.join(crate::WELL_KNOWN_PATH)?;
21    debug!(%url, "fetching NodeInfo discovery");
22
23    let response = client
24        .get(url)
25        .header(header::ACCEPT, "application/json")
26        .send()
27        .await?;
28
29    let status = response.status();
30    if !status.is_success() {
31        return Err(Error::BadStatus(status.as_u16()));
32    }
33
34    Ok(response.json::<Discovery>().await?)
35}
36
37/// Fetches a [`NodeInfo`] document at `version` from `host`.
38///
39/// Resolves the concrete schema URL via the discovery document and then
40/// fetches and parses the `NodeInfo` JSON.
41///
42/// # Errors
43///
44/// Returns [`Error::VersionNotAdvertised`] if the server does not advertise
45/// the requested version, and propagates transport / parse errors via
46/// [`Error::Http`] / [`Error::Json`] / [`Error::BadStatus`].
47pub async fn fetch(host: &Url, version: Version, client: &Client) -> Result<NodeInfo, Error> {
48    let discovery = fetch_discovery(host, client).await?;
49    let link = discovery
50        .find_link(version)
51        .ok_or(Error::VersionNotAdvertised {
52            requested: version.as_str(),
53        })?;
54
55    debug!(url = %link.href, "fetching NodeInfo document");
56
57    let response = client
58        .get(link.href.clone())
59        .header(header::ACCEPT, "application/json")
60        .send()
61        .await?;
62
63    let status = response.status();
64    if !status.is_success() {
65        return Err(Error::BadStatus(status.as_u16()));
66    }
67
68    Ok(response.json::<NodeInfo>().await?)
69}
70
71#[cfg(test)]
72mod tests {
73    use pretty_assertions::assert_eq;
74    use serde_json::json;
75    use wiremock::matchers::{method, path};
76    use wiremock::{Mock, MockServer, ResponseTemplate};
77
78    use super::*;
79
80    #[tokio::test]
81    async fn end_to_end_fetch_via_mock_server() {
82        let server = MockServer::start().await;
83        let base: Url = server.uri().parse().unwrap();
84
85        let nodeinfo_url = format!("{base}nodeinfo/2.1");
86
87        Mock::given(method("GET"))
88            .and(path("/.well-known/nodeinfo"))
89            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
90                "links": [{
91                    "rel": "http://nodeinfo.diaspora.software/ns/schema/2.1",
92                    "href": nodeinfo_url
93                }]
94            })))
95            .mount(&server)
96            .await;
97
98        Mock::given(method("GET"))
99            .and(path("/nodeinfo/2.1"))
100            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
101                "version": "2.1",
102                "software": { "name": "mock-server", "version": "0.1.0" },
103                "protocols": ["activitypub"],
104                "openRegistrations": false,
105                "usage": {}
106            })))
107            .mount(&server)
108            .await;
109
110        let client = Client::new();
111        let info = fetch(&base, Version::V2_1, &client).await.unwrap();
112
113        assert_eq!(info.version, Version::V2_1);
114        assert_eq!(info.software.name, "mock-server");
115    }
116
117    #[tokio::test]
118    async fn missing_version_returns_specific_error() {
119        let server = MockServer::start().await;
120        let base: Url = server.uri().parse().unwrap();
121
122        Mock::given(method("GET"))
123            .and(path("/.well-known/nodeinfo"))
124            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
125                "links": [{
126                    "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
127                    "href": format!("{base}nodeinfo/2.0")
128                }]
129            })))
130            .mount(&server)
131            .await;
132
133        let client = Client::new();
134        let err = fetch(&base, Version::V2_1, &client).await.unwrap_err();
135        assert!(matches!(
136            err,
137            Error::VersionNotAdvertised { requested: "2.1" }
138        ));
139    }
140}