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 itertools::Itertools as _;
10use opentalk_client_requests_api_v1::{auth::LoginGetRequest, response::ApiError};
11use opentalk_types_api_v1::auth::{GetLoginResponseBody, OidcProvider};
12use serde::{Deserialize, Serialize};
13use snafu::{ResultExt as _, Snafu, ensure};
14use url::Url;
15
16use crate::{
17    AuthenticatedClient, Authorization,
18    oidc::{OidcEndpoints, OidcWellKnownRequest},
19};
20
21const COMPATIBLE_VERSIONS: &[&str] = &["v1"];
22
23#[derive(Debug, Snafu)]
24pub enum ClientError {
25    #[snafu(display("Reqwest returned an error"))]
26    Reqwest { source: ReqwestClientError },
27
28    #[snafu(display("The API server returned an error"))]
29    Api { source: ApiError },
30
31    #[snafu(display(
32        "No compatible API version found under the well-known API endpoint {url}. This client is compatible with API versions: {compatible_versions}."
33    ))]
34    NoCompatibleApiVersion {
35        url: Url,
36        compatible_versions: String,
37    },
38
39    #[snafu(display("Invalid OIDC url found: {url:?}"))]
40    InvalidOidcUrl {
41        url: String,
42        source: url::ParseError,
43    },
44
45    #[snafu(display(
46        "Discovered url {url} which cannot be a base and therefore is not a valid controller API url"
47    ))]
48    InvalidUrlDiscovered { url: Url },
49}
50
51/// A client for interfacing with the OpenTalk API.
52#[derive(Debug, Clone)]
53pub struct Client {
54    inner: ReqwestClient,
55    #[allow(unused)]
56    oidc_url: Url,
57    #[allow(unused)]
58    api_url: Url,
59}
60
61impl Client {
62    /// Discover the OpenTalk API information based on the frontend or controller api URL.
63    pub async fn discover(url: Url) -> Result<Self, ClientError> {
64        let discovery_client = ReqwestClient::new(url.clone());
65
66        match discovery_client
67            .execute(WellKnownFrontendRequest)
68            .await
69            .context(ReqwestSnafu)?
70        {
71            WellKnownFrontendResponse::Found(WellKnownFrontendBody {
72                opentalk_controller: ControllerBaseInfo { base_url },
73            }) => Self::discover_controller(base_url).await,
74            WellKnownFrontendResponse::NotFound => Self::discover_controller(url).await,
75        }
76    }
77
78    /// Discover the OpenTalk API information based on the controller api URL.
79    pub async fn discover_controller(url: Url) -> Result<Self, ClientError> {
80        let discovery_client = ReqwestClient::new(url.clone());
81
82        let WellKnownApiBody {
83            opentalk_api: ApiInfo { v1 },
84        } = discovery_client
85            .execute(WellKnownApiRequest)
86            .await
87            .context(ReqwestSnafu)?;
88
89        let Some(VersionedApiInfo { base_url }) = v1 else {
90            return NoCompatibleApiVersionSnafu {
91                url,
92                compatible_versions: COMPATIBLE_VERSIONS.iter().join(", "),
93            }
94            .fail();
95        };
96
97        let api_url = match Url::parse(&base_url) {
98            Ok(url) => {
99                ensure!(!url.cannot_be_a_base(), InvalidUrlDiscoveredSnafu { url });
100                url
101            }
102            Err(_e) => {
103                let segments = base_url.trim_start_matches('/');
104                let mut url = url;
105                _ = url.path_segments_mut().unwrap().push(segments);
106                url
107            }
108        };
109
110        let inner = ReqwestClient::new(api_url.clone());
111
112        let GetLoginResponseBody { oidc } =
113            inner.execute(LoginGetRequest).await.context(ReqwestSnafu)?;
114
115        let oidc_url = oidc
116            .url
117            .parse()
118            .context(InvalidOidcUrlSnafu { url: oidc.url })?;
119
120        Ok(Self {
121            oidc_url,
122            api_url,
123            inner,
124        })
125    }
126
127    /// Get the oidc endpoints from the OIDC provider.
128    pub async fn get_oidc_endpoints(&self) -> Result<OidcEndpoints, ClientError> {
129        let oidc_client = ReqwestClient::new(self.oidc_url.clone());
130        let oidc_endpoints = oidc_client
131            .execute(OidcWellKnownRequest)
132            .await
133            .context(ReqwestSnafu)?;
134        Ok(oidc_endpoints)
135    }
136
137    /// Query the OIDC provider information from the OpenTalk API
138    pub async fn get_oidc_provider(&self) -> Result<OidcProvider, ClientError> {
139        let GetLoginResponseBody { oidc } = self
140            .inner
141            .execute(LoginGetRequest)
142            .await
143            .context(ReqwestSnafu)?;
144        Ok(oidc)
145    }
146
147    /// execute request without authorization
148    pub async fn execute<R: HttpRequest + Send>(
149        &self,
150        request: R,
151    ) -> Result<R::Response, ReqwestClientError> {
152        self.inner.execute(request).await
153    }
154
155    /// execute request with authorization
156    pub async fn execute_authorized<R: HttpRequest + Send, A: Authorization + Sync>(
157        &self,
158        request: R,
159        authorization: A,
160    ) -> Result<R::Response, ReqwestClientError> {
161        let authenticated_client = AuthenticatedClient::new(self.inner.clone(), authorization);
162        authenticated_client.execute(request).await
163    }
164
165    // fn refresh_access_token(&self, instance_account_id: OpenTalkInstanceAccountId)
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, HttpRequest)]
169#[http_request(method="GET", response = WellKnownFrontendResponse, path=".well-known/opentalk/client")]
170struct WellKnownFrontendRequest;
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173struct ControllerBaseInfo {
174    pub base_url: Url,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178struct WellKnownFrontendBody {
179    pub opentalk_controller: ControllerBaseInfo,
180}
181
182enum WellKnownFrontendResponse {
183    NotFound,
184    Found(WellKnownFrontendBody),
185}
186
187impl http_request_derive::FromHttpResponse for WellKnownFrontendResponse {
188    fn from_http_response(
189        http_response: http::Response<Bytes>,
190    ) -> Result<Self, http_request_derive::Error>
191    where
192        Self: Sized,
193    {
194        match <WellKnownFrontendBody as http_request_derive::FromHttpResponse>::from_http_response(
195            http_response,
196        ) {
197            Ok(body) => Ok(Self::Found(body)),
198            Err(e) if e.is_not_found() => Ok(Self::NotFound),
199            Err(e) => Err(e),
200        }
201    }
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, HttpRequest)]
205#[http_request(method="GET", response = WellKnownApiBody, path=".well-known/opentalk/api")]
206struct WellKnownApiRequest;
207
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
209struct VersionedApiInfo {
210    pub base_url: String,
211}
212
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214struct ApiInfo {
215    pub v1: Option<VersionedApiInfo>,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219struct WellKnownApiBody {
220    pub opentalk_api: ApiInfo,
221}