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 bytes::Bytes;
6use http_request_derive::HttpRequest;
7use http_request_derive_client::Client as _;
8use http_request_derive_client_reqwest::{ReqwestClient, ReqwestClientError};
9use http_request_derive_logging::HttpLogger;
10use itertools::Itertools as _;
11use opentalk_client_requests_api_v1::{auth::LoginGetRequest, response::ApiError};
12use opentalk_types_api_v1::auth::{GetLoginResponseBody, OidcProvider};
13use serde::{Deserialize, Serialize};
14use snafu::{ResultExt as _, Snafu, ensure};
15use url::Url;
16
17use crate::{
18    AuthenticatedClient, Authorization,
19    oidc::{OidcEndpoints, OidcWellKnownRequest},
20};
21
22const COMPATIBLE_VERSIONS: &[&str] = &["v1"];
23
24/// The error that can result from requests sent by the client.
25#[derive(Debug, Snafu)]
26pub enum ClientError {
27    /// The `http_request_derive` library `reqwest` integration returned an error.
28    ///
29    /// These are usually errors caused by either functionality in the
30    /// `reqwest` crate, or when handling the data returned from `reqwest`.
31    ///
32    /// They don't indicate a non-successful HTTP status code, that is indicated
33    /// by the [`ClientError::Api`] variant.
34    #[snafu(display("Reqwest returned an error"))]
35    Reqwest {
36        /// The source error.
37        source: ReqwestClientError,
38    },
39
40    /// The API returned an HTTP response with an HTTP status code which is
41    /// considered non-successful.
42    #[snafu(display("The API server returned an error"))]
43    Api {
44        /// The source error.
45        source: ApiError,
46    },
47
48    /// No compatible API version found under the well-known API endpoint.
49    #[snafu(display(
50        "No compatible API version found under the well-known API endpoint {url}. This client is compatible with API versions: {compatible_versions}."
51    ))]
52    NoCompatibleApiVersion {
53        /// The URL under which the API endpint was looked up.
54        url: Url,
55
56        /// The list of compatible API versions supported by this client implementation.
57        compatible_versions: String,
58    },
59
60    /// The OpenTalk API returned an invalid OIDC URL.
61    #[snafu(display("Invalid OIDC url found: {url:?}"))]
62    InvalidOidcUrl {
63        /// The URL that was returned from the API.
64        url: String,
65
66        /// The error that was encountered when attempting to parse the URL.
67        source: url::ParseError,
68    },
69
70    /// The OpenTalk API returned an OIDC URL which cannot be a base and is therefore invalid for usage in OIDC.
71    ///
72    /// This happens e.g. for `data:` URLs.
73    #[snafu(display(
74        "Discovered url {url} which cannot be a base and therefore is not a valid controller API url"
75    ))]
76    InvalidUrlDiscovered {
77        /// The invalid URL
78        url: Url,
79    },
80}
81
82impl From<ReqwestClientError> for ClientError {
83    fn from(source: ReqwestClientError) -> Self {
84        Self::Reqwest { source }
85    }
86}
87
88impl From<ApiError> for ClientError {
89    fn from(source: ApiError) -> Self {
90        Self::Api { source }
91    }
92}
93
94/// A client for interfacing with the OpenTalk API.
95#[derive(Debug, Clone)]
96pub struct Client {
97    inner: ReqwestClient,
98    #[allow(unused)]
99    oidc_url: Url,
100    #[allow(unused)]
101    api_url: Url,
102}
103
104impl Client {
105    /// Discover the OpenTalk API information based on the frontend or controller API URL.
106    pub async fn discover(url: Url) -> Result<Self, ClientError> {
107        Self::discover_inner(ReqwestClient::new(url)).await
108    }
109
110    /// Discover the OpenTalk API information based on the frontend or controller API URL.
111    ///
112    /// When using this function for discovery, the logger will be informed about all requests
113    /// performed by this [`Client`].
114    pub async fn discover_with_logger(url: Url, logger: HttpLogger) -> Result<Self, ClientError> {
115        Self::discover_inner(ReqwestClient::new(url).with_logger(logger)).await
116    }
117
118    async fn discover_inner(mut client: ReqwestClient) -> Result<Self, ClientError> {
119        match client
120            .execute(WellKnownFrontendRequest)
121            .await
122            .context(ReqwestSnafu)?
123        {
124            WellKnownFrontendResponse::Found(WellKnownFrontendBody {
125                opentalk_controller: ControllerBaseInfo { base_url },
126            }) => {
127                client.set_base_url(base_url);
128            }
129            WellKnownFrontendResponse::NotFound => {}
130        };
131        Self::discover_controller_inner(client).await
132    }
133
134    /// Discover the OpenTalk API information based on the controller API URL.
135    pub async fn discover_controller(url: Url) -> Result<Self, ClientError> {
136        Self::discover_controller_inner(ReqwestClient::new(url)).await
137    }
138
139    /// Discover the OpenTalk API information based on the controller API URL.
140    ///
141    /// When using this function for discovery, the logger will be informed about all requests
142    /// performed by this [`Client`].
143    pub async fn discover_controller_with_logger(
144        url: Url,
145        logger: HttpLogger,
146    ) -> Result<Self, ClientError> {
147        Self::discover_controller_inner(ReqwestClient::new(url).with_logger(logger)).await
148    }
149
150    async fn discover_controller_inner(mut client: ReqwestClient) -> Result<Self, ClientError> {
151        let WellKnownApiBody {
152            opentalk_api: ApiInfo { v1 },
153        } = client
154            .execute(WellKnownApiRequest)
155            .await
156            .context(ReqwestSnafu)?;
157
158        let Some(VersionedApiInfo { base_url }) = v1 else {
159            return NoCompatibleApiVersionSnafu {
160                url: client.base_url().clone(),
161                compatible_versions: COMPATIBLE_VERSIONS.iter().join(", "),
162            }
163            .fail();
164        };
165
166        let api_url = match Url::parse(&base_url) {
167            Ok(url) => {
168                ensure!(!url.cannot_be_a_base(), InvalidUrlDiscoveredSnafu { url });
169                url
170            }
171            Err(_e) => {
172                let segments = base_url.trim_start_matches('/');
173                let url = client.base_url().clone();
174                let mut url = url;
175                _ = url.path_segments_mut().unwrap().push(segments);
176                url
177            }
178        };
179
180        client.set_base_url(api_url.clone());
181
182        let GetLoginResponseBody { oidc } = client
183            .execute(LoginGetRequest)
184            .await
185            .context(ReqwestSnafu)?;
186
187        let oidc_url = oidc
188            .url
189            .parse()
190            .context(InvalidOidcUrlSnafu { url: oidc.url })?;
191
192        Ok(Self {
193            oidc_url,
194            api_url,
195            inner: client,
196        })
197    }
198
199    /// Get the oidc endpoints from the OIDC provider.
200    pub async fn get_oidc_endpoints(&self) -> Result<OidcEndpoints, ClientError> {
201        let oidc_client = self.inner.clone().with_base_url(self.oidc_url.clone());
202        let oidc_endpoints = oidc_client
203            .execute(OidcWellKnownRequest)
204            .await
205            .context(ReqwestSnafu)?;
206        Ok(oidc_endpoints)
207    }
208
209    /// Query the OIDC provider information from the OpenTalk API
210    pub async fn get_oidc_provider(&self) -> Result<OidcProvider, ClientError> {
211        let GetLoginResponseBody { oidc } = self
212            .inner
213            .execute(LoginGetRequest)
214            .await
215            .context(ReqwestSnafu)?;
216        Ok(oidc)
217    }
218
219    /// execute request without authorization
220    pub async fn execute<R: HttpRequest + Send>(
221        &self,
222        request: R,
223    ) -> Result<R::Response, ReqwestClientError> {
224        self.inner.execute(request).await
225    }
226
227    /// execute request with authorization
228    pub async fn execute_authorized<R: HttpRequest + Send, A: Authorization + Sync>(
229        &self,
230        request: R,
231        authorization: A,
232    ) -> Result<R::Response, ReqwestClientError> {
233        let authenticated_client = AuthenticatedClient::new(self.inner.clone(), authorization);
234        authenticated_client.execute(request).await
235    }
236
237    // fn refresh_access_token(&self, instance_account_id: OpenTalkInstanceAccountId)
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, HttpRequest)]
241#[http_request(method="GET", response = WellKnownFrontendResponse, path=".well-known/opentalk/client")]
242struct WellKnownFrontendRequest;
243
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245struct ControllerBaseInfo {
246    pub base_url: Url,
247}
248
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250struct WellKnownFrontendBody {
251    pub opentalk_controller: ControllerBaseInfo,
252}
253
254enum WellKnownFrontendResponse {
255    NotFound,
256    Found(WellKnownFrontendBody),
257}
258
259impl http_request_derive::FromHttpResponse for WellKnownFrontendResponse {
260    fn from_http_response(
261        http_response: http::Response<Bytes>,
262    ) -> Result<Self, http_request_derive::Error>
263    where
264        Self: Sized,
265    {
266        match <WellKnownFrontendBody as http_request_derive::FromHttpResponse>::from_http_response(
267            http_response,
268        ) {
269            Ok(body) => Ok(Self::Found(body)),
270            Err(e) if e.is_not_found() => Ok(Self::NotFound),
271            Err(e) => Err(e),
272        }
273    }
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, HttpRequest)]
277#[http_request(method="GET", response = WellKnownApiBody, path=".well-known/opentalk/api")]
278struct WellKnownApiRequest;
279
280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
281struct VersionedApiInfo {
282    pub base_url: String,
283}
284
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286struct ApiInfo {
287    pub v1: Option<VersionedApiInfo>,
288}
289
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291struct WellKnownApiBody {
292    pub opentalk_api: ApiInfo,
293}