ascom_alpaca/client/
mod.rs1#[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(¶ms), 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#[derive(Debug)]
170pub struct Client {
171 inner: RawClient,
172}
173
174impl Client {
175 pub fn new(base_url: impl IntoUrl) -> eyre::Result<Self> {
177 RawClient::new(base_url.into_url()?).map(|inner| Self { inner })
178 }
179
180 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 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 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}