ascom_alpaca/client/
mod.rs

1#[cfg(feature = "criterion")]
2mod benches;
3#[cfg(feature = "criterion")]
4pub use benches::benches;
5
6mod discovery;
7pub use discovery::{BoundClient as BoundDiscoveryClient, Client as DiscoveryClient};
8
9mod transaction;
10pub(crate) use transaction::*;
11
12mod response;
13pub(crate) use response::Response;
14
15#[cfg(feature = "test")]
16pub(crate) mod test;
17
18use crate::api::{ConfiguredDevice, DeviceType, ServerInfo, TypedDevice};
19use crate::params::{Action, ActionParams, Method};
20use crate::response::ValueResponse;
21use crate::{ASCOMError, ASCOMResult};
22use eyre::ContextCompat;
23use mime::Mime;
24use reqwest::header::CONTENT_TYPE;
25use reqwest::{IntoUrl, RequestBuilder};
26use serde::{Deserialize, Serialize};
27use std::fmt::Debug;
28use std::net::SocketAddr;
29use std::num::NonZeroU32;
30use std::sync::{Arc, LazyLock};
31use tracing::Instrument;
32
33#[derive(Deserialize)]
34#[serde(untagged)]
35enum FallibleDeviceType {
36    Known(DeviceType),
37    Unknown(String),
38}
39
40#[derive(Debug)]
41pub(crate) struct RawDeviceClient {
42    pub(crate) inner: RawClient,
43    pub(crate) name: String,
44    pub(crate) unique_id: String,
45}
46
47impl RawDeviceClient {
48    pub(crate) async fn exec_action<Resp>(&self, action: impl Action) -> ASCOMResult<Resp>
49    where
50        ASCOMResult<Resp>: Response,
51    {
52        self.inner
53            .request::<ASCOMResult<Resp>>(action.into_parts())
54            .await
55            .unwrap_or_else(|err| Err(ASCOMError::unspecified(err)))
56    }
57}
58
59pub(crate) static REQWEST: LazyLock<reqwest::Client> = LazyLock::new(|| {
60    reqwest::Client::builder()
61        .user_agent("ascom-alpaca-rs")
62        .build()
63        .expect("failed to create reqwest client")
64});
65
66#[derive(Clone, derive_more::Debug)]
67pub(crate) struct RawClient {
68    #[debug(r#""{base_url}""#)]
69    pub(crate) base_url: reqwest::Url,
70    pub(crate) client_id: NonZeroU32,
71}
72
73impl RawClient {
74    pub(crate) fn new(base_url: reqwest::Url) -> eyre::Result<Self> {
75        eyre::ensure!(
76            !base_url.cannot_be_a_base(),
77            "{} is not a valid base URL",
78            base_url,
79        );
80        Ok(Self {
81            base_url,
82            client_id: rand::random(),
83        })
84    }
85
86    pub(crate) async fn request<Resp: Response>(
87        &self,
88        ActionParams {
89            action,
90            method,
91            params,
92        }: ActionParams<impl Serialize + Send>,
93    ) -> eyre::Result<Resp> {
94        let request_transaction = RequestTransaction::new(self.client_id);
95
96        let span = tracing::error_span!(
97            "Alpaca transaction",
98            action,
99            client_transaction_id = request_transaction.client_transaction_id,
100            client_id = request_transaction.client_id,
101        );
102
103        async move {
104            tracing::debug!(?method, params = ?serdebug::debug(&params), base_url = %self.base_url, "Sending request");
105
106            let mut request = REQWEST.request(method.into(), self.base_url.join(action)?);
107
108            let add_params = match method {
109                Method::Get => RequestBuilder::query,
110                Method::Put => RequestBuilder::form,
111            };
112            request = add_params(
113                request,
114                &RequestWithTransaction {
115                    transaction: request_transaction,
116                    params,
117                },
118            );
119
120            request = Resp::prepare_reqwest(request);
121
122            let response = request.send().await?.error_for_status()?;
123            let mime_type = response
124                .headers()
125                .get(CONTENT_TYPE)
126                .context("Missing Content-Type header")?
127                .to_str()?
128                .parse::<Mime>()?;
129            let bytes = response.bytes().await?;
130            let ResponseWithTransaction {
131                transaction: response_transaction,
132                response,
133            } = Resp::from_reqwest(mime_type, &bytes)?;
134
135            tracing::debug!(
136                server_transaction_id = response_transaction.server_transaction_id,
137                "Received response",
138            );
139
140            match response_transaction.client_transaction_id {
141                Some(received_client_transaction_id)
142                    if received_client_transaction_id
143                        != request_transaction.client_transaction_id =>
144                {
145                    tracing::warn!(
146                        sent = request_transaction.client_transaction_id,
147                        received = received_client_transaction_id,
148                        "ClientTransactionID mismatch",
149                    );
150                }
151                _ => {}
152            }
153
154            Ok::<_, eyre::Error>(response)
155        }
156        .instrument(span)
157        .await
158    }
159
160    pub(crate) fn join_url(&self, path: &str) -> eyre::Result<Self> {
161        Ok(Self {
162            base_url: self.base_url.join(path)?,
163            client_id: self.client_id,
164        })
165    }
166}
167
168/// Alpaca client.
169#[derive(Debug)]
170pub struct Client {
171    inner: RawClient,
172}
173
174impl Client {
175    /// Create a new client with given server URL.
176    pub fn new(base_url: impl IntoUrl) -> eyre::Result<Self> {
177        RawClient::new(base_url.into_url()?).map(|inner| Self { inner })
178    }
179
180    /// Create a new client with given server address.
181    pub fn new_from_addr(addr: impl Into<SocketAddr>) -> Self {
182        Self::new(format!("http://{}/", addr.into()))
183            .expect("creating client from an address should always succeed")
184    }
185
186    /// Get a list of all devices registered on the server.
187    pub async fn get_devices(&self) -> eyre::Result<impl Iterator<Item = TypedDevice> + use<>> {
188        let api_client = self.inner.join_url("api/v1/")?;
189
190        Ok(self
191            .inner
192            .request::<ValueResponse<Vec<ConfiguredDevice<FallibleDeviceType>>>>(ActionParams {
193                action: "management/v1/configureddevices",
194                method: Method::Get,
195                params: (),
196            })
197            .await?
198            .value
199            .into_iter()
200            .filter_map(move |device| match device.ty {
201                FallibleDeviceType::Known(device_type) => Some(
202                    Arc::new(RawDeviceClient {
203                        inner: api_client
204                            .join_url(&format!(
205                                "{device_type}/{device_number}/",
206                                device_number = device.number
207                            ))
208                            .expect("internal error: failed to join device URL"),
209                        name: device.name,
210                        unique_id: device.unique_id,
211                    })
212                    .into_typed_client(device_type),
213                ),
214                FallibleDeviceType::Unknown(ty) => {
215                    tracing::warn!(%ty, "Skipping device with unsupported type");
216                    None
217                }
218            }))
219    }
220
221    /// Get general server information.
222    pub async fn get_server_info(&self) -> eyre::Result<ServerInfo> {
223        self.inner
224            .request::<ValueResponse<ServerInfo>>(ActionParams {
225                action: "management/v1/description",
226                method: Method::Get,
227                params: (),
228            })
229            .await
230            .map(|value_response| value_response.value)
231    }
232}