Skip to content

Commit 54be631

Browse files
committed
feat: implement API version negotiation for QS API
If the QS backend does not support the API version used by the client, it responds with a `406 Not Acceptable` status and includes a header, `x-accepted-api-versions`, which contains a comma-separated list of supported API versions. The client handles the `406` status code by attempting to resend the request using the highest supported API version, if possible. The latest supported API version is stored in the client's memory.
1 parent c287e8e commit 54be631

File tree

13 files changed

+402
-86
lines changed

13 files changed

+402
-86
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apiclient/src/ds_api/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ impl ApiClient {
8181
Ok(res) => {
8282
match res.status().as_u16() {
8383
// Success!
84-
x if (200..=299).contains(&x) => {
84+
_ if res.status().is_success() => {
8585
let ds_proc_res_bytes =
8686
res.bytes().await.map_err(|_| DsRequestError::BadResponse)?;
8787
let ds_proc_res =

apiclient/src/lib.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,18 @@
44

55
//! HTTP client for the server REST API
66
7-
use std::time::Duration;
7+
use std::{sync::Arc, time::Duration};
88

99
use phnxtypes::{endpoint_paths::ENDPOINT_HEALTH_CHECK, DEFAULT_PORT_HTTP, DEFAULT_PORT_HTTPS};
1010
use reqwest::{Client, ClientBuilder, StatusCode, Url};
1111
use thiserror::Error;
1212
use url::ParseError;
13+
use version::NegotiatedApiVersions;
1314

1415
pub mod as_api;
1516
pub mod ds_api;
1617
pub mod qs_api;
18+
pub(crate) mod version;
1719

1820
/// Defines the type of protocol used for a specific endpoint.
1921
pub enum Protocol {
@@ -45,6 +47,7 @@ pub type HttpClient = reqwest::Client;
4547
pub struct ApiClient {
4648
client: HttpClient,
4749
url: Url,
50+
api_versions: Arc<NegotiatedApiVersions>,
4851
}
4952

5053
impl ApiClient {
@@ -88,7 +91,11 @@ impl ApiClient {
8891
}
8992
Err(_) => return Err(ApiClientInitError::UrlParsingError(domain_string.clone())),
9093
};
91-
Ok(Self { client, url })
94+
Ok(Self {
95+
client,
96+
url,
97+
api_versions: Arc::new(NegotiatedApiVersions::new()),
98+
})
9299
}
93100

94101
/// Builds a URL for a given endpoint.
@@ -143,4 +150,8 @@ impl ApiClient {
143150
false
144151
}
145152
}
153+
154+
pub(crate) fn negotiated_versions(&self) -> &NegotiatedApiVersions {
155+
&self.api_versions
156+
}
146157
}

apiclient/src/qs_api/api_migrations.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// SPDX-License-Identifier: AGPL-3.0-or-later
44

55
use phnxtypes::messages::client_qs::{
6-
QsProcessResponseIn, QsVersionedProcessResponseIn, VersionError,
6+
ClientToQsMessageTbs, QsProcessResponseIn, QsVersionedProcessResponseIn, VersionError,
77
};
88

99
use super::QsRequestError;
@@ -13,8 +13,10 @@ pub(super) fn migrate_qs_process_response(
1313
) -> Result<QsProcessResponseIn, QsRequestError> {
1414
match response {
1515
QsVersionedProcessResponseIn::Alpha(response) => Ok(response),
16-
QsVersionedProcessResponseIn::Other(version) => {
17-
Err(VersionError::from_unsupported_version(version.into()).into())
18-
}
16+
QsVersionedProcessResponseIn::Other(version) => Err(VersionError::new(
17+
version,
18+
ClientToQsMessageTbs::SUPPORTED_API_VERSIONS.to_vec(),
19+
)
20+
.into()),
1921
}
2022
}

apiclient/src/qs_api/mod.rs

Lines changed: 88 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ use phnxtypes::{
2020
identifiers::{QsClientId, QsUserId},
2121
messages::{
2222
client_qs::{
23-
ClientKeyPackageParams, ClientKeyPackageResponse, CreateClientRecordResponse,
24-
CreateUserRecordResponse, DeleteClientRecordParams, DeleteUserRecordParams,
25-
DequeueMessagesParams, DequeueMessagesResponse, EncryptionKeyResponse,
26-
KeyPackageParams, KeyPackageResponseIn, QsProcessResponseIn,
23+
ClientKeyPackageParams, ClientKeyPackageResponse, ClientToQsMessageTbs,
24+
CreateClientRecordResponse, CreateUserRecordResponse, DeleteClientRecordParams,
25+
DeleteUserRecordParams, DequeueMessagesParams, DequeueMessagesResponse,
26+
EncryptionKeyResponse, KeyPackageParams, KeyPackageResponseIn, QsProcessResponseIn,
2727
QsVersionedProcessResponseIn, UpdateClientRecordParams, UpdateUserRecordParams,
2828
VersionError,
2929
},
@@ -32,13 +32,13 @@ use phnxtypes::{
3232
CreateUserRecordParamsOut, PublishKeyPackagesParamsOut, QsRequestParamsOut,
3333
},
3434
push_token::EncryptedPushToken,
35-
FriendshipToken,
35+
ApiVersion, FriendshipToken,
3636
},
3737
};
3838
use thiserror::Error;
3939
use tls_codec::{DeserializeBytes, Serialize};
4040

41-
use crate::{ApiClient, Protocol};
41+
use crate::{version::api_version_negotiation, ApiClient, Protocol};
4242

4343
pub mod ws;
4444

@@ -77,43 +77,31 @@ impl ApiClient {
7777
request_params: QsRequestParamsOut,
7878
token_or_signing_key: AuthenticationMethod<'_, T>,
7979
) -> Result<QsProcessResponseIn, QsRequestError> {
80-
let tbs = ClientToQsMessageTbsOut::new(request_params);
81-
let message = match token_or_signing_key {
82-
AuthenticationMethod::Token(token) => ClientToQsMessageOut::from_token(tbs, token),
83-
AuthenticationMethod::SigningKey(signing_key) => tbs
84-
.sign(signing_key)
85-
.map_err(|_| QsRequestError::LibraryError)?,
86-
AuthenticationMethod::None => ClientToQsMessageOut::without_signature(tbs),
80+
let api_version = self.negotiated_versions().qs_api_version();
81+
82+
let message = sign_params(request_params, &token_or_signing_key, api_version)?;
83+
let endpoint = self.build_url(Protocol::Http, ENDPOINT_QS);
84+
let response = send_qs_message(&self.client, &endpoint, &message).await?;
85+
86+
// check if we need to negotiate a new API version
87+
let Some(accepted_version) = api_version_negotiation(
88+
&response,
89+
api_version,
90+
ClientToQsMessageTbs::SUPPORTED_API_VERSIONS,
91+
)
92+
.transpose()?
93+
else {
94+
return process_response(response).await;
8795
};
88-
let message_bytes = message
89-
.tls_serialize_detached()
90-
.map_err(|_| QsRequestError::LibraryError)?;
91-
match self
92-
.client
93-
.post(self.build_url(Protocol::Http, ENDPOINT_QS))
94-
.body(message_bytes)
95-
.send()
96-
.await
97-
{
98-
Ok(res) => {
99-
let status = res.status();
100-
if status.is_success() {
101-
// Success!
102-
let ds_proc_res_bytes = res.bytes().await.map_err(QsRequestError::Reqwest)?;
103-
let ds_proc_res = QsVersionedProcessResponseIn::tls_deserialize_exact_bytes(
104-
&ds_proc_res_bytes,
105-
)
106-
.map_err(QsRequestError::Tls)?;
107-
migrate_qs_process_response(ds_proc_res)
108-
} else {
109-
// Error
110-
let error = res.text().await.map_err(QsRequestError::Reqwest)?;
111-
Err(QsRequestError::RequestFailed { status, error })
112-
}
113-
}
114-
// A network error occurred.
115-
Err(err) => Err(QsRequestError::NetworkError(err.to_string())),
116-
}
96+
97+
self.negotiated_versions()
98+
.set_qs_api_version(accepted_version);
99+
100+
let request_params = message.into_payload().into_unversioned_params();
101+
let message = sign_params(request_params, &token_or_signing_key, accepted_version)?;
102+
103+
let response = send_qs_message(&self.client, &endpoint, &message).await?;
104+
process_response(response).await
117105
}
118106

119107
pub async fn qs_create_user(
@@ -391,3 +379,61 @@ impl ApiClient {
391379
})
392380
}
393381
}
382+
383+
async fn process_response(
384+
response: reqwest::Response,
385+
) -> Result<QsProcessResponseIn, QsRequestError> {
386+
let status = response.status();
387+
if status.is_success() {
388+
let bytes = response.bytes().await.map_err(QsRequestError::Reqwest)?;
389+
let qs_response = QsVersionedProcessResponseIn::tls_deserialize_exact_bytes(&bytes)
390+
.map_err(QsRequestError::Tls)?;
391+
migrate_qs_process_response(qs_response)
392+
} else {
393+
let error = response
394+
.text()
395+
.await
396+
.unwrap_or_else(|error| format!("unprocessable response body due to: {error}"));
397+
Err(QsRequestError::RequestFailed { status, error })
398+
}
399+
}
400+
401+
async fn send_qs_message(
402+
client: &reqwest::Client,
403+
endpoint: &str,
404+
message: &ClientToQsMessageOut,
405+
) -> Result<reqwest::Response, QsRequestError> {
406+
client
407+
.post(endpoint)
408+
.body(
409+
message
410+
.tls_serialize_detached()
411+
.map_err(|_| QsRequestError::LibraryError)?,
412+
)
413+
.send()
414+
.await
415+
.map_err(From::from)
416+
}
417+
418+
fn sign_params<T: SigningKeyBehaviour>(
419+
request_params: QsRequestParamsOut,
420+
token_or_signing_key: &AuthenticationMethod<'_, T>,
421+
api_version: ApiVersion,
422+
) -> Result<ClientToQsMessageOut, QsRequestError> {
423+
let tbs = ClientToQsMessageTbsOut::with_api_version(api_version, request_params).ok_or_else(
424+
|| {
425+
VersionError::new(
426+
api_version,
427+
ClientToQsMessageTbs::SUPPORTED_API_VERSIONS.to_vec(),
428+
)
429+
},
430+
)?;
431+
let message = match token_or_signing_key {
432+
AuthenticationMethod::Token(token) => ClientToQsMessageOut::from_token(tbs, token.clone()),
433+
AuthenticationMethod::SigningKey(signing_key) => tbs
434+
.sign(*signing_key)
435+
.map_err(|_| QsRequestError::LibraryError)?,
436+
AuthenticationMethod::None => ClientToQsMessageOut::without_signature(tbs),
437+
};
438+
Ok(message)
439+
}

0 commit comments

Comments
 (0)