Skip to content

Commit

Permalink
feat: implement API version negotiation for QS API
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
boxdot committed Feb 19, 2025
1 parent c287e8e commit 54be631
Show file tree
Hide file tree
Showing 13 changed files with 402 additions and 86 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apiclient/src/ds_api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ impl ApiClient {
Ok(res) => {
match res.status().as_u16() {
// Success!
x if (200..=299).contains(&x) => {
_ if res.status().is_success() => {
let ds_proc_res_bytes =
res.bytes().await.map_err(|_| DsRequestError::BadResponse)?;
let ds_proc_res =
Expand Down
15 changes: 13 additions & 2 deletions apiclient/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@

//! HTTP client for the server REST API
use std::time::Duration;
use std::{sync::Arc, time::Duration};

use phnxtypes::{endpoint_paths::ENDPOINT_HEALTH_CHECK, DEFAULT_PORT_HTTP, DEFAULT_PORT_HTTPS};
use reqwest::{Client, ClientBuilder, StatusCode, Url};
use thiserror::Error;
use url::ParseError;
use version::NegotiatedApiVersions;

pub mod as_api;
pub mod ds_api;
pub mod qs_api;
pub(crate) mod version;

/// Defines the type of protocol used for a specific endpoint.
pub enum Protocol {
Expand Down Expand Up @@ -45,6 +47,7 @@ pub type HttpClient = reqwest::Client;
pub struct ApiClient {
client: HttpClient,
url: Url,
api_versions: Arc<NegotiatedApiVersions>,
}

impl ApiClient {
Expand Down Expand Up @@ -88,7 +91,11 @@ impl ApiClient {
}
Err(_) => return Err(ApiClientInitError::UrlParsingError(domain_string.clone())),
};
Ok(Self { client, url })
Ok(Self {
client,
url,
api_versions: Arc::new(NegotiatedApiVersions::new()),
})
}

/// Builds a URL for a given endpoint.
Expand Down Expand Up @@ -143,4 +150,8 @@ impl ApiClient {
false
}
}

pub(crate) fn negotiated_versions(&self) -> &NegotiatedApiVersions {
&self.api_versions
}
}
10 changes: 6 additions & 4 deletions apiclient/src/qs_api/api_migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

use phnxtypes::messages::client_qs::{
QsProcessResponseIn, QsVersionedProcessResponseIn, VersionError,
ClientToQsMessageTbs, QsProcessResponseIn, QsVersionedProcessResponseIn, VersionError,
};

use super::QsRequestError;
Expand All @@ -13,8 +13,10 @@ pub(super) fn migrate_qs_process_response(
) -> Result<QsProcessResponseIn, QsRequestError> {
match response {
QsVersionedProcessResponseIn::Alpha(response) => Ok(response),
QsVersionedProcessResponseIn::Other(version) => {
Err(VersionError::from_unsupported_version(version.into()).into())
}
QsVersionedProcessResponseIn::Other(version) => Err(VersionError::new(
version,
ClientToQsMessageTbs::SUPPORTED_API_VERSIONS.to_vec(),
)
.into()),
}
}
130 changes: 88 additions & 42 deletions apiclient/src/qs_api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ use phnxtypes::{
identifiers::{QsClientId, QsUserId},
messages::{
client_qs::{
ClientKeyPackageParams, ClientKeyPackageResponse, CreateClientRecordResponse,
CreateUserRecordResponse, DeleteClientRecordParams, DeleteUserRecordParams,
DequeueMessagesParams, DequeueMessagesResponse, EncryptionKeyResponse,
KeyPackageParams, KeyPackageResponseIn, QsProcessResponseIn,
ClientKeyPackageParams, ClientKeyPackageResponse, ClientToQsMessageTbs,
CreateClientRecordResponse, CreateUserRecordResponse, DeleteClientRecordParams,
DeleteUserRecordParams, DequeueMessagesParams, DequeueMessagesResponse,
EncryptionKeyResponse, KeyPackageParams, KeyPackageResponseIn, QsProcessResponseIn,
QsVersionedProcessResponseIn, UpdateClientRecordParams, UpdateUserRecordParams,
VersionError,
},
Expand All @@ -32,13 +32,13 @@ use phnxtypes::{
CreateUserRecordParamsOut, PublishKeyPackagesParamsOut, QsRequestParamsOut,
},
push_token::EncryptedPushToken,
FriendshipToken,
ApiVersion, FriendshipToken,
},
};
use thiserror::Error;
use tls_codec::{DeserializeBytes, Serialize};

use crate::{ApiClient, Protocol};
use crate::{version::api_version_negotiation, ApiClient, Protocol};

pub mod ws;

Expand Down Expand Up @@ -77,43 +77,31 @@ impl ApiClient {
request_params: QsRequestParamsOut,
token_or_signing_key: AuthenticationMethod<'_, T>,
) -> Result<QsProcessResponseIn, QsRequestError> {
let tbs = ClientToQsMessageTbsOut::new(request_params);
let message = match token_or_signing_key {
AuthenticationMethod::Token(token) => ClientToQsMessageOut::from_token(tbs, token),
AuthenticationMethod::SigningKey(signing_key) => tbs
.sign(signing_key)
.map_err(|_| QsRequestError::LibraryError)?,
AuthenticationMethod::None => ClientToQsMessageOut::without_signature(tbs),
let api_version = self.negotiated_versions().qs_api_version();

let message = sign_params(request_params, &token_or_signing_key, api_version)?;
let endpoint = self.build_url(Protocol::Http, ENDPOINT_QS);
let response = send_qs_message(&self.client, &endpoint, &message).await?;

// check if we need to negotiate a new API version
let Some(accepted_version) = api_version_negotiation(
&response,
api_version,
ClientToQsMessageTbs::SUPPORTED_API_VERSIONS,
)
.transpose()?
else {
return process_response(response).await;
};
let message_bytes = message
.tls_serialize_detached()
.map_err(|_| QsRequestError::LibraryError)?;
match self
.client
.post(self.build_url(Protocol::Http, ENDPOINT_QS))
.body(message_bytes)
.send()
.await
{
Ok(res) => {
let status = res.status();
if status.is_success() {
// Success!
let ds_proc_res_bytes = res.bytes().await.map_err(QsRequestError::Reqwest)?;
let ds_proc_res = QsVersionedProcessResponseIn::tls_deserialize_exact_bytes(
&ds_proc_res_bytes,
)
.map_err(QsRequestError::Tls)?;
migrate_qs_process_response(ds_proc_res)
} else {
// Error
let error = res.text().await.map_err(QsRequestError::Reqwest)?;
Err(QsRequestError::RequestFailed { status, error })
}
}
// A network error occurred.
Err(err) => Err(QsRequestError::NetworkError(err.to_string())),
}

self.negotiated_versions()
.set_qs_api_version(accepted_version);

let request_params = message.into_payload().into_unversioned_params();
let message = sign_params(request_params, &token_or_signing_key, accepted_version)?;

let response = send_qs_message(&self.client, &endpoint, &message).await?;
process_response(response).await
}

pub async fn qs_create_user(
Expand Down Expand Up @@ -391,3 +379,61 @@ impl ApiClient {
})
}
}

async fn process_response(
response: reqwest::Response,
) -> Result<QsProcessResponseIn, QsRequestError> {
let status = response.status();
if status.is_success() {
let bytes = response.bytes().await.map_err(QsRequestError::Reqwest)?;
let qs_response = QsVersionedProcessResponseIn::tls_deserialize_exact_bytes(&bytes)
.map_err(QsRequestError::Tls)?;
migrate_qs_process_response(qs_response)
} else {
let error = response
.text()
.await
.unwrap_or_else(|error| format!("unprocessable response body due to: {error}"));
Err(QsRequestError::RequestFailed { status, error })
}
}

async fn send_qs_message(
client: &reqwest::Client,
endpoint: &str,
message: &ClientToQsMessageOut,
) -> Result<reqwest::Response, QsRequestError> {
client
.post(endpoint)
.body(
message
.tls_serialize_detached()
.map_err(|_| QsRequestError::LibraryError)?,
)
.send()
.await
.map_err(From::from)
}

fn sign_params<T: SigningKeyBehaviour>(
request_params: QsRequestParamsOut,
token_or_signing_key: &AuthenticationMethod<'_, T>,
api_version: ApiVersion,
) -> Result<ClientToQsMessageOut, QsRequestError> {
let tbs = ClientToQsMessageTbsOut::with_api_version(api_version, request_params).ok_or_else(
|| {
VersionError::new(
api_version,
ClientToQsMessageTbs::SUPPORTED_API_VERSIONS.to_vec(),
)
},
)?;
let message = match token_or_signing_key {
AuthenticationMethod::Token(token) => ClientToQsMessageOut::from_token(tbs, token.clone()),
AuthenticationMethod::SigningKey(signing_key) => tbs
.sign(*signing_key)
.map_err(|_| QsRequestError::LibraryError)?,
AuthenticationMethod::None => ClientToQsMessageOut::without_signature(tbs),
};
Ok(message)
}
Loading

0 comments on commit 54be631

Please sign in to comment.