Skip to main content

opentalk_client/
client.rs

1// SPDX-FileCopyrightText: OpenTalk GmbH <mail@opentalk.eu>
2//
3// SPDX-License-Identifier: EUPL-1.2
4
5use std::net::SocketAddr;
6
7use bytes::Bytes;
8use http_request_derive::HttpRequest;
9use http_request_derive_client::Client as _;
10use http_request_derive_client_reqwest::{ReqwestClient, ReqwestClientError};
11use http_request_derive_logging::HttpLogger;
12use itertools::Itertools as _;
13use opentalk_client_requests_api_v1::{auth::LoginGetRequest, response::ApiError};
14use opentalk_types_api_v1::auth::{GetLoginResponseBody, OidcProvider};
15use serde::{Deserialize, Serialize};
16use snafu::{ResultExt as _, Snafu, ensure};
17use url::Url;
18
19use crate::{
20    AuthenticatedClient, Authorization,
21    oidc::{OidcEndpoints, OidcWellKnownRequest},
22};
23
24const COMPATIBLE_VERSIONS: &[&str] = &["v1"];
25
26/// The error that can result from requests sent by the client.
27#[derive(Debug, Snafu)]
28pub enum ClientError {
29    /// The `http_request_derive` library `reqwest` integration returned an error.
30    ///
31    /// These are usually errors caused by either functionality in the
32    /// `reqwest` crate, or when handling the data returned from `reqwest`.
33    ///
34    /// They don't indicate a non-successful HTTP status code, that is indicated
35    /// by the [`ClientError::Api`] variant.
36    #[snafu(display("Reqwest returned an error"))]
37    Reqwest {
38        /// The source error.
39        source: ReqwestClientError,
40    },
41
42    /// The API returned an HTTP response with an HTTP status code which is
43    /// considered non-successful.
44    #[snafu(display("The API server returned an error"))]
45    Api {
46        /// The source error.
47        source: ApiError,
48    },
49
50    /// No compatible API version found under the well-known API endpoint.
51    #[snafu(display(
52        "No compatible API version found under the well-known API endpoint {url}. This client is compatible with API versions: {compatible_versions}."
53    ))]
54    NoCompatibleApiVersion {
55        /// The URL under which the API endpint was looked up.
56        url: Url,
57
58        /// The list of compatible API versions supported by this client implementation.
59        compatible_versions: String,
60    },
61
62    /// The OpenTalk API returned an invalid OIDC URL.
63    #[snafu(display("Invalid OIDC url found: {url:?}"))]
64    InvalidOidcUrl {
65        /// The URL that was returned from the API.
66        url: String,
67
68        /// The error that was encountered when attempting to parse the URL.
69        source: url::ParseError,
70    },
71
72    /// The OpenTalk API returned an OIDC URL which cannot be a base and is therefore invalid for usage in OIDC.
73    ///
74    /// This happens e.g. for `data:` URLs.
75    #[snafu(display(
76        "Discovered url {url} which cannot be a base and therefore is not a valid controller API url"
77    ))]
78    InvalidUrlDiscovered {
79        /// The invalid URL
80        url: Url,
81    },
82
83    /// A configured root certificate could not be parsed as PEM.
84    #[snafu(display("Failed to parse a root certificate as PEM"))]
85    CertificatePem {
86        /// The source error.
87        source: reqwest::Error,
88    },
89
90    /// The HTTP client could not be constructed from the given configuration.
91    #[snafu(display("Failed to build the HTTP client"))]
92    BuildReqwestClient {
93        /// The source error.
94        source: reqwest::Error,
95    },
96}
97
98impl From<ReqwestClientError> for ClientError {
99    fn from(source: ReqwestClientError) -> Self {
100        Self::Reqwest { source }
101    }
102}
103
104impl From<ApiError> for ClientError {
105    fn from(source: ApiError) -> Self {
106        Self::Api { source }
107    }
108}
109
110/// A client for interfacing with the OpenTalk API.
111#[derive(Debug, Clone)]
112pub struct Client {
113    inner: ReqwestClient,
114    #[allow(unused)]
115    oidc_url: Url,
116    #[allow(unused)]
117    api_url: Url,
118}
119
120impl Client {
121    /// Start building a [`Client`]
122    pub fn builder(url: Url) -> ClientBuilder {
123        ClientBuilder::new(url)
124    }
125
126    /// Builds a [`Client`] and discovers the OpenTalk API information based on the frontend or controller API URL.
127    pub async fn discover(url: Url) -> Result<Self, ClientError> {
128        Self::builder(url).discover().await
129    }
130
131    /// Builds a [`Client`] and discovers the OpenTalk API information based on the controller API URL.
132    pub async fn discover_controller(url: Url) -> Result<Self, ClientError> {
133        Self::builder(url).discover_controller().await
134    }
135
136    async fn discover_from(mut client: ReqwestClient) -> Result<Self, ClientError> {
137        match client
138            .execute(WellKnownFrontendRequest)
139            .await
140            .context(ReqwestSnafu)?
141        {
142            WellKnownFrontendResponse::Found(WellKnownFrontendBody {
143                opentalk_controller: ControllerBaseInfo { base_url },
144            }) => {
145                client.set_base_url(base_url);
146            }
147            WellKnownFrontendResponse::NotFound => {}
148        };
149        Self::discover_controller_from(client).await
150    }
151
152    async fn discover_controller_from(mut client: ReqwestClient) -> Result<Self, ClientError> {
153        let WellKnownApiBody {
154            opentalk_api: ApiInfo { v1 },
155        } = client
156            .execute(WellKnownApiRequest)
157            .await
158            .context(ReqwestSnafu)?;
159
160        let Some(VersionedApiInfo { base_url }) = v1 else {
161            return NoCompatibleApiVersionSnafu {
162                url: client.base_url().clone(),
163                compatible_versions: COMPATIBLE_VERSIONS.iter().join(", "),
164            }
165            .fail();
166        };
167
168        let api_url = match Url::parse(&base_url) {
169            Ok(url) => {
170                ensure!(!url.cannot_be_a_base(), InvalidUrlDiscoveredSnafu { url });
171                url
172            }
173            Err(_e) => {
174                let segments = base_url.trim_start_matches('/');
175                let url = client.base_url().clone();
176                let mut url = url;
177                _ = url.path_segments_mut().unwrap().push(segments);
178                url
179            }
180        };
181
182        client.set_base_url(api_url.clone());
183
184        let GetLoginResponseBody { oidc } = client
185            .execute(LoginGetRequest)
186            .await
187            .context(ReqwestSnafu)?;
188
189        let oidc_url = oidc
190            .url
191            .parse()
192            .context(InvalidOidcUrlSnafu { url: oidc.url })?;
193
194        Ok(Self {
195            oidc_url,
196            api_url,
197            inner: client,
198        })
199    }
200
201    /// Get the oidc endpoints from the OIDC provider.
202    pub async fn get_oidc_endpoints(&self) -> Result<OidcEndpoints, ClientError> {
203        let oidc_client = self.inner.clone().with_base_url(self.oidc_url.clone());
204        let oidc_endpoints = oidc_client
205            .execute(OidcWellKnownRequest)
206            .await
207            .context(ReqwestSnafu)?;
208        Ok(oidc_endpoints)
209    }
210
211    /// Query the OIDC provider information from the OpenTalk API
212    pub async fn get_oidc_provider(&self) -> Result<OidcProvider, ClientError> {
213        let GetLoginResponseBody { oidc } = self
214            .inner
215            .execute(LoginGetRequest)
216            .await
217            .context(ReqwestSnafu)?;
218        Ok(oidc)
219    }
220
221    /// execute request without authorization
222    pub async fn execute<R: HttpRequest + Send>(
223        &self,
224        request: R,
225    ) -> Result<R::Response, ReqwestClientError> {
226        self.inner.execute(request).await
227    }
228
229    /// execute request with authorization
230    pub async fn execute_authorized<R: HttpRequest + Send, A: Authorization + Sync>(
231        &self,
232        request: R,
233        authorization: A,
234    ) -> Result<R::Response, ReqwestClientError> {
235        let authenticated_client = AuthenticatedClient::new(self.inner.clone(), authorization);
236        authenticated_client.execute(request).await
237    }
238
239    /// Get inner [`ReqwestClient`]
240    pub fn reqwest_client(&self) -> &ReqwestClient {
241        &self.inner
242    }
243}
244
245/// Builder for constructing and discovering a [`Client`].
246///
247/// Collects optional configuration before building and discovery via [`ClientBuilder::discover`] or
248/// [`ClientBuilder::discover_controller`].
249///
250/// Pure `build` method is missing, as an undiscovered [`Client`] is useless
251#[derive(Debug)]
252pub struct ClientBuilder {
253    url: Url,
254    logger: Option<HttpLogger>,
255    certs: Vec<Vec<u8>>,
256    dns_overrides: Vec<(String, SocketAddr)>,
257}
258
259impl ClientBuilder {
260    /// Constructs a new [`ClientBuilder`].
261    ///
262    /// This is the same as [`Client::builder()`].
263    pub fn new(url: Url) -> Self {
264        ClientBuilder {
265            url,
266            logger: None,
267            certs: Vec::new(),
268            dns_overrides: Vec::new(),
269        }
270    }
271
272    /// Return a built [`Client`] that uses this [`ClientBuilder`] configuration with discovered OpenTalk
273    /// API information based on the frontend or controller API
274    pub async fn discover(self) -> Result<Client, ClientError> {
275        Client::discover_from(self.into_reqwest_client()?).await
276    }
277
278    /// Return a built [`Client`] that uses this [`ClientBuilder`] configuration with discovered OpenTalk
279    /// API information based on the controller API
280    pub async fn discover_controller(self) -> Result<Client, ClientError> {
281        Client::discover_controller_from(self.into_reqwest_client()?).await
282    }
283
284    /// Set a logger that is informed about all requests performed by the [`Client`].
285    pub fn with_logger(mut self, logger: HttpLogger) -> Self {
286        self.logger = Some(logger);
287        self
288    }
289
290    /// Add a custom PEM encoded certificate
291    pub fn add_pem_cert(mut self, pem: &[u8]) -> Self {
292        self.certs.push(pem.to_vec());
293        self
294    }
295
296    /// Add DNS override
297    pub fn add_dns_override(mut self, domain: &str, addr: SocketAddr) -> Self {
298        self.dns_overrides.push((domain.to_owned(), addr));
299        self
300    }
301
302    fn into_reqwest_client(self) -> Result<ReqwestClient, ClientError> {
303        let ClientBuilder {
304            url,
305            logger,
306            certs,
307            dns_overrides,
308        } = self;
309
310        let mut builder = reqwest::Client::builder();
311
312        for pem in &certs {
313            let cert = reqwest::Certificate::from_pem(pem).context(CertificatePemSnafu)?;
314            builder = builder.tls_certs_merge([cert]);
315        }
316        for (domain, addr) in &dns_overrides {
317            builder = builder.resolve(domain, *addr);
318        }
319
320        let client = builder.build().context(BuildReqwestClientSnafu)?;
321        let mut inner = ReqwestClient::from_reqwest_client(client, url);
322
323        if let Some(logger) = logger {
324            inner = inner.with_logger(logger);
325        }
326        Ok(inner)
327    }
328}
329#[derive(Debug, Clone, PartialEq, Eq, HttpRequest)]
330#[http_request(method="GET", response = WellKnownFrontendResponse, path=".well-known/opentalk/client")]
331struct WellKnownFrontendRequest;
332
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
334struct ControllerBaseInfo {
335    pub base_url: Url,
336}
337
338#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
339struct WellKnownFrontendBody {
340    pub opentalk_controller: ControllerBaseInfo,
341}
342
343enum WellKnownFrontendResponse {
344    NotFound,
345    Found(WellKnownFrontendBody),
346}
347
348impl http_request_derive::FromHttpResponse for WellKnownFrontendResponse {
349    fn from_http_response(
350        http_response: http::Response<Bytes>,
351    ) -> Result<Self, http_request_derive::Error>
352    where
353        Self: Sized,
354    {
355        match <WellKnownFrontendBody as http_request_derive::FromHttpResponse>::from_http_response(
356            http_response,
357        ) {
358            Ok(body) => Ok(Self::Found(body)),
359            Err(e) if e.is_not_found() => Ok(Self::NotFound),
360            Err(e) => Err(e),
361        }
362    }
363}
364
365#[derive(Debug, Clone, PartialEq, Eq, HttpRequest)]
366#[http_request(method="GET", response = WellKnownApiBody, path=".well-known/opentalk/api")]
367struct WellKnownApiRequest;
368
369#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
370struct VersionedApiInfo {
371    pub base_url: String,
372}
373
374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
375struct ApiInfo {
376    pub v1: Option<VersionedApiInfo>,
377}
378
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
380struct WellKnownApiBody {
381    pub opentalk_api: ApiInfo,
382}