From ea0d2f8c66717a3ad482e25fceaa83ddf2719547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Tue, 13 May 2025 17:01:57 +0200 Subject: [PATCH] feat: expires header for `/active` nym-api responses (#5755) * refactor FormattedResponse to allow attaching additional headers * helper method for including expiration headers * add expires header for /active nodes responses * added additional 'with_expires_header_delta' builder to FormattedResponse to allow setting expiration header with time delta --- Cargo.lock | 1 + common/http-api-common/Cargo.toml | 10 +- common/http-api-common/src/lib.rs | 6 +- common/http-api-common/src/response.rs | 145 ------------- .../http-api-common/src/response/bincode.rs | 49 +++++ common/http-api-common/src/response/json.rs | 49 +++++ common/http-api-common/src/response/mod.rs | 198 ++++++++++++++++++ common/http-api-common/src/response/yaml.rs | 47 +++++ .../nym_nodes/handlers/unstable/skimmed.rs | 33 ++- nym-wallet/Cargo.lock | 1 - 10 files changed, 378 insertions(+), 161 deletions(-) delete mode 100644 common/http-api-common/src/response.rs create mode 100644 common/http-api-common/src/response/bincode.rs create mode 100644 common/http-api-common/src/response/json.rs create mode 100644 common/http-api-common/src/response/mod.rs create mode 100644 common/http-api-common/src/response/yaml.rs diff --git a/Cargo.lock b/Cargo.lock index c8db184347..48882a9f32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5819,6 +5819,7 @@ dependencies = [ "serde", "serde_yaml", "subtle 2.6.1", + "time", "tower 0.5.2", "tracing", "utoipa", diff --git a/common/http-api-common/Cargo.toml b/common/http-api-common/Cargo.toml index 49ee4b73d5..97d87d38a1 100644 --- a/common/http-api-common/Cargo.toml +++ b/common/http-api-common/Cargo.toml @@ -21,6 +21,7 @@ mime = { workspace = true, optional = true } serde = { workspace = true, features = ["derive"] } serde_yaml = { workspace = true, optional = true } subtle = { workspace = true, optional = true } +time = { workspace = true, optional = true, features = ["macros"] } tower = { workspace = true, optional = true } tracing.workspace = true utoipa = { workspace = true, optional = true } @@ -32,7 +33,9 @@ output = [ "axum", "bytes", "mime", - "serde_yaml" + "serde_yaml", + "time", + "time/formatting" ] middleware = [ @@ -46,4 +49,7 @@ middleware = [ ] utoipa = ["dep:utoipa"] -bincode = ["dep:bincode"] \ No newline at end of file +bincode = ["dep:bincode"] + +[lints] +workspace = true \ No newline at end of file diff --git a/common/http-api-common/src/lib.rs b/common/http-api-common/src/lib.rs index 32f79adb2b..7a4dd74ba3 100644 --- a/common/http-api-common/src/lib.rs +++ b/common/http-api-common/src/lib.rs @@ -13,9 +13,9 @@ pub use response::*; // be explicit about those values because bincode uses different defaults in different places #[cfg(feature = "bincode")] -pub fn make_bincode_serializer() -> impl bincode::Options { - use bincode::Options; - bincode::DefaultOptions::new() +pub fn make_bincode_serializer() -> impl ::bincode::Options { + use ::bincode::Options; + ::bincode::DefaultOptions::new() .with_little_endian() .with_varint_encoding() } diff --git a/common/http-api-common/src/response.rs b/common/http-api-common/src/response.rs deleted file mode 100644 index d5f3f736c6..0000000000 --- a/common/http-api-common/src/response.rs +++ /dev/null @@ -1,145 +0,0 @@ -// Copyright 2025 - Nym Technologies SA -// SPDX-License-Identifier: Apache-2.0 - -use axum::http::{header, HeaderValue, StatusCode}; -use axum::response::{IntoResponse, Response}; -use axum::Json; -use bytes::{BufMut, BytesMut}; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone)] -pub enum FormattedResponse { - Json(Json), - Yaml(Yaml), - #[cfg(feature = "bincode")] - Bincode(Bincode), -} -impl FormattedResponse { - pub fn into_inner(self) -> T { - match self { - FormattedResponse::Json(inner) => inner.0, - FormattedResponse::Yaml(inner) => inner.0, - #[cfg(feature = "bincode")] - FormattedResponse::Bincode(inner) => inner.0, - } - } -} - -impl IntoResponse for FormattedResponse -where - T: Serialize, -{ - fn into_response(self) -> Response { - match self { - FormattedResponse::Json(json_response) => json_response.into_response(), - FormattedResponse::Yaml(yaml_response) => yaml_response.into_response(), - #[cfg(feature = "bincode")] - FormattedResponse::Bincode(bincode_response) => bincode_response.into_response(), - } - } -} - -#[derive(Default, Debug, Serialize, Deserialize, Copy, Clone)] -#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] -#[serde(rename_all = "lowercase")] -pub enum Output { - #[default] - Json, - Yaml, - #[cfg(feature = "bincode")] - Bincode, -} - -#[derive(Default, Debug, Serialize, Deserialize, Copy, Clone)] -#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams, utoipa::ToSchema))] -#[serde(default)] -pub struct OutputParams { - pub output: Option, -} - -impl Output { - pub fn to_response(self, data: T) -> FormattedResponse { - match self { - Output::Json => FormattedResponse::Json(Json(data)), - Output::Yaml => FormattedResponse::Yaml(Yaml(data)), - #[cfg(feature = "bincode")] - Output::Bincode => FormattedResponse::Bincode(Bincode(data)), - } - } -} - -#[derive(Debug, Clone, Copy, Default)] -#[must_use] -pub struct Yaml(pub T); - -impl From for Yaml { - fn from(inner: T) -> Self { - Self(inner) - } -} - -impl IntoResponse for Yaml -where - T: Serialize, -{ - // replicates axum's Json - fn into_response(self) -> Response { - let mut buf = BytesMut::with_capacity(128).writer(); - match serde_yaml::to_writer(&mut buf, &self.0) { - Ok(()) => ( - [( - header::CONTENT_TYPE, - HeaderValue::from_static("application/yaml"), - )], - buf.into_inner().freeze(), - ) - .into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - [( - header::CONTENT_TYPE, - HeaderValue::from_static(mime::TEXT_PLAIN_UTF_8.as_ref()), - )], - err.to_string(), - ) - .into_response(), - } - } -} - -#[derive(Debug, Clone, Copy, Default)] -#[must_use] -#[cfg(feature = "bincode")] -pub struct Bincode(pub T); - -#[cfg(feature = "bincode")] -impl IntoResponse for Bincode -where - T: Serialize, -{ - // replicates axum's Json - fn into_response(self) -> Response { - use bincode::Options; - let mut buf = BytesMut::with_capacity(128).writer(); - - match crate::make_bincode_serializer().serialize_into(&mut buf, &self.0) { - Ok(()) => ( - [( - header::CONTENT_TYPE, - HeaderValue::from_static("application/bincode"), - )], - buf.into_inner().freeze(), - ) - .into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - [( - header::CONTENT_TYPE, - HeaderValue::from_static(mime::TEXT_PLAIN_UTF_8.as_ref()), - )], - err.to_string(), - ) - .into_response(), - } - } -} diff --git a/common/http-api-common/src/response/bincode.rs b/common/http-api-common/src/response/bincode.rs new file mode 100644 index 0000000000..4225924aec --- /dev/null +++ b/common/http-api-common/src/response/bincode.rs @@ -0,0 +1,49 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::response::{error_response, ResponseWrapper}; +use axum::http::header::IntoHeaderName; +use axum::http::{header, HeaderValue}; +use axum::response::{IntoResponse, Response}; +use bytes::{BufMut, BytesMut}; +use serde::Serialize; + +#[derive(Debug, Clone, Default)] +#[must_use] +pub struct Bincode(pub(crate) ResponseWrapper); + +impl From for Bincode { + fn from(response: T) -> Self { + Bincode(ResponseWrapper::new(response).with_header( + header::CONTENT_TYPE, + HeaderValue::from_static("application/bincode"), + )) + } +} + +impl Bincode { + pub(crate) fn with_header( + mut self, + name: impl IntoHeaderName, + value: impl Into, + ) -> Self { + self.0.headers.insert(name, value.into()); + self + } +} + +impl IntoResponse for Bincode +where + T: Serialize, +{ + // replicates axum's Json + fn into_response(self) -> Response { + use bincode::Options; + let mut buf = BytesMut::with_capacity(128).writer(); + + match crate::make_bincode_serializer().serialize_into(&mut buf, &self.0.data) { + Ok(()) => (self.0.headers, buf.into_inner().freeze()).into_response(), + Err(err) => error_response(err), + } + } +} diff --git a/common/http-api-common/src/response/json.rs b/common/http-api-common/src/response/json.rs new file mode 100644 index 0000000000..dd65e198c2 --- /dev/null +++ b/common/http-api-common/src/response/json.rs @@ -0,0 +1,49 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::response::{error_response, ResponseWrapper}; +use axum::http::header::IntoHeaderName; +use axum::http::{header, HeaderValue}; +use axum::response::{IntoResponse, Response}; +use bytes::{BufMut, BytesMut}; +use serde::Serialize; +use utoipa::gen::serde_json; + +// don't use axum's Json directly as we need to be able to define custom headers +#[derive(Debug, Clone, Default)] +#[must_use] +pub struct Json(pub(crate) ResponseWrapper); + +impl From for Json { + fn from(response: T) -> Self { + Json(ResponseWrapper::new(response).with_header( + header::CONTENT_TYPE, + HeaderValue::from_static(mime::APPLICATION_JSON.as_ref()), + )) + } +} + +impl Json { + pub(crate) fn with_header( + mut self, + name: impl IntoHeaderName, + value: impl Into, + ) -> Self { + self.0.headers.insert(name, value.into()); + self + } +} + +impl IntoResponse for Json +where + T: Serialize, +{ + fn into_response(self) -> Response { + let mut buf = BytesMut::with_capacity(128).writer(); + + match serde_json::to_writer(&mut buf, &self.0.data) { + Ok(()) => (self.0.headers, buf.into_inner().freeze()).into_response(), + Err(err) => error_response(err), + } + } +} diff --git a/common/http-api-common/src/response/mod.rs b/common/http-api-common/src/response/mod.rs new file mode 100644 index 0000000000..88e66c4dc5 --- /dev/null +++ b/common/http-api-common/src/response/mod.rs @@ -0,0 +1,198 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use axum::http::header::IntoHeaderName; +use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use time::format_description::BorrowedFormatItem; +use time::macros::{format_description, offset}; +use time::OffsetDateTime; + +#[cfg(feature = "bincode")] +pub mod bincode; +pub mod json; +pub mod yaml; + +pub use json::Json; +pub use yaml::Yaml; + +#[cfg(feature = "bincode")] +pub use bincode::Bincode; + +#[derive(Debug, Clone, Default)] +pub(crate) struct ResponseWrapper { + data: T, + headers: HeaderMap, +} + +impl ResponseWrapper { + pub(crate) fn new(response: T) -> ResponseWrapper { + ResponseWrapper { + data: response, + headers: Default::default(), + } + } + + #[must_use] + pub(crate) fn with_header( + mut self, + name: impl IntoHeaderName, + value: impl Into, + ) -> Self { + self.headers.insert(name, value.into()); + self + } +} + +#[derive(Debug, Clone)] +pub enum FormattedResponse { + Json(Json), + Yaml(Yaml), + #[cfg(feature = "bincode")] + Bincode(Bincode), +} + +impl FormattedResponse { + pub fn into_inner(self) -> T { + match self { + FormattedResponse::Json(inner) => inner.0.data, + FormattedResponse::Yaml(inner) => inner.0.data, + #[cfg(feature = "bincode")] + FormattedResponse::Bincode(inner) => inner.0.data, + } + } + + #[must_use] + pub fn with_header( + self, + name: impl IntoHeaderName, + value: impl Into, + ) -> FormattedResponse { + match self { + FormattedResponse::Json(inner) => { + FormattedResponse::Json(inner.with_header(name, value)) + } + FormattedResponse::Yaml(inner) => { + FormattedResponse::Yaml(inner.with_header(name, value)) + } + #[cfg(feature = "bincode")] + FormattedResponse::Bincode(inner) => { + FormattedResponse::Bincode(inner.with_header(name, value)) + } + } + } + + /// Set the `expires` header on the response to the provided expiration. + /// Internally it will perform conversions to make sure the value is set in GMT offset, + /// e.g. `Expires: Wed, 21 Oct 2015 07:28:00 GMT` + #[must_use] + pub fn with_expires_header(self, expiration: OffsetDateTime) -> FormattedResponse { + // as per RFC-7234 (section 5.3) EXPIRES header has to use value formatted + // as defined in RFC-7231 (section 7.1.1.1) + // (preferred format (IMF-fixdate) uses RFC-5322 (section 3.3) + let formatted = format_rfc5352(expiration); + + // SAFETY: our formatted datetime doesn't contain forbidden characters + #[allow(clippy::unwrap_used)] + self.with_header(header::EXPIRES, HeaderValue::try_from(formatted).unwrap()) + } + + /// Work similarly to `with_expires_header`, but rather than setting explicit expiration value, + /// it adds the provided time delta to the current time instead. + #[must_use] + pub fn with_expires_header_delta(self, expires_in: Duration) -> FormattedResponse { + self.with_expires_header(OffsetDateTime::now_utc() + expires_in) + } +} + +impl IntoResponse for FormattedResponse +where + T: Serialize, +{ + fn into_response(self) -> Response { + match self { + FormattedResponse::Json(json_response) => json_response.into_response(), + FormattedResponse::Yaml(yaml_response) => yaml_response.into_response(), + #[cfg(feature = "bincode")] + FormattedResponse::Bincode(bincode_response) => bincode_response.into_response(), + } + } +} + +#[derive(Default, Debug, Serialize, Deserialize, Copy, Clone)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "lowercase")] +pub enum Output { + #[default] + Json, + Yaml, + #[cfg(feature = "bincode")] + Bincode, +} + +#[derive(Default, Debug, Serialize, Deserialize, Copy, Clone)] +#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams, utoipa::ToSchema))] +#[serde(default)] +pub struct OutputParams { + pub output: Option, +} + +impl Output { + pub fn to_response(self, data: T) -> FormattedResponse { + match self { + Output::Json => FormattedResponse::Json(Json::from(data)), + Output::Yaml => FormattedResponse::Yaml(Yaml::from(data)), + #[cfg(feature = "bincode")] + Output::Bincode => FormattedResponse::Bincode(Bincode::from(data)), + } + } +} + +pub(crate) fn error_response(err: E) -> Response { + ( + StatusCode::INTERNAL_SERVER_ERROR, + [( + header::CONTENT_TYPE, + HeaderValue::from_static(mime::TEXT_PLAIN_UTF_8.as_ref()), + )], + err.to_string(), + ) + .into_response() +} + +// SAFETY: this hardcoded datetime formatter is valid +#[allow(clippy::unwrap_used)] +fn format_rfc5352(datetime: OffsetDateTime) -> String { + // the time must be using GMT (UTC) offset + let normalised = datetime.to_offset(offset!(UTC)); + normalised.format(&rfc5322()).unwrap() +} + +// NOTE: this function is purposely not made public as it cannot guarantee caller +// has correctly ensured their date is using correct GMT offset +fn rfc5322() -> &'static [BorrowedFormatItem<'static>] { + // D, d M Y H:i:s T + format_description!( + "[weekday repr:short], [day] [month repr:short] [year] [hour]:[minute]:[second] GMT" + ) +} + +#[cfg(test)] +mod tests { + use crate::response::format_rfc5352; + use time::macros::datetime; + + #[test] + fn rfc5322_formatting() { + let utc_date = datetime!(2021-08-23 12:13:14 UTC); + let non_utc_date = datetime!(2021-08-23 12:13:14 -1); + + assert_eq!("Mon, 23 Aug 2021 12:13:14 GMT", format_rfc5352(utc_date)); + assert_eq!( + "Mon, 23 Aug 2021 13:13:14 GMT", + format_rfc5352(non_utc_date) + ); + } +} diff --git a/common/http-api-common/src/response/yaml.rs b/common/http-api-common/src/response/yaml.rs new file mode 100644 index 0000000000..d0beaaeca8 --- /dev/null +++ b/common/http-api-common/src/response/yaml.rs @@ -0,0 +1,47 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::response::{error_response, ResponseWrapper}; +use axum::http::header::IntoHeaderName; +use axum::http::{header, HeaderValue}; +use axum::response::{IntoResponse, Response}; +use bytes::{BufMut, BytesMut}; +use serde::Serialize; + +#[derive(Debug, Clone, Default)] +#[must_use] +pub struct Yaml(pub(crate) ResponseWrapper); + +impl From for Yaml { + fn from(response: T) -> Self { + Yaml(ResponseWrapper::new(response).with_header( + header::CONTENT_TYPE, + HeaderValue::from_static("application/yaml"), + )) + } +} + +impl Yaml { + pub(crate) fn with_header( + mut self, + name: impl IntoHeaderName, + value: impl Into, + ) -> Self { + self.0.headers.insert(name, value.into()); + self + } +} + +impl IntoResponse for Yaml +where + T: Serialize, +{ + // replicates axum's Json + fn into_response(self) -> Response { + let mut buf = BytesMut::with_capacity(128).writer(); + match serde_yaml::to_writer(&mut buf, &self.0.data) { + Ok(()) => (self.0.headers, buf.into_inner().freeze()).into_response(), + Err(err) => error_response(err), + } + } +} diff --git a/nym-api/src/nym_nodes/handlers/unstable/skimmed.rs b/nym-api/src/nym_nodes/handlers/unstable/skimmed.rs index f64e937000..f7931db1cf 100644 --- a/nym-api/src/nym_nodes/handlers/unstable/skimmed.rs +++ b/nym-api/src/nym_nodes/handlers/unstable/skimmed.rs @@ -20,6 +20,7 @@ use nym_mixnet_contract_common::NodeId; use nym_topology::CachedEpochRewardedSet; use std::collections::HashMap; use std::future::Future; +use std::time::Duration; use tokio::sync::RwLockReadGuard; use tracing::trace; use utoipa::ToSchema; @@ -126,18 +127,20 @@ where // (ideally it'd be tied directly to the NI iterator, but I couldn't defeat the compiler) let describe_cache = state.describe_nodes_cache_data().await?; - let maybe_interval = state + let Some(interval) = state .nym_contract_cache() .current_interval() .await - .to_owned(); + .to_owned() + else { + // if we can't obtain interval information, it means caches are not valid + return Err(AxumErrorResponse::service_unavailable()); + }; // 4.0 If the client indicates that they already know about the current topology send empty response if let Some(client_known_epoch) = query_params.epoch_id { - if let Some(ref interval) = maybe_interval { - if client_known_epoch == interval.current_epoch_id() { - return Ok(output.to_response(PaginatedCachedNodesResponse::no_updates())); - } + if client_known_epoch == interval.current_epoch_id() { + return Ok(output.to_response(PaginatedCachedNodesResponse::no_updates())); } } @@ -155,7 +158,7 @@ where ]); return Ok(output.to_response( - PaginatedCachedNodesResponse::new_full(refreshed_at, nodes).fresh(maybe_interval), + PaginatedCachedNodesResponse::new_full(refreshed_at, nodes).fresh(Some(interval)), )); } @@ -178,9 +181,19 @@ where annotated_legacy_nodes.timestamp(), ]); - Ok(output.to_response( - PaginatedCachedNodesResponse::new_full(refreshed_at, nodes).fresh(maybe_interval), - )) + let base_response = output.to_response( + PaginatedCachedNodesResponse::new_full(refreshed_at, nodes).fresh(Some(interval)), + ); + + if !active_only { + return Ok(base_response); + } + + // if caller requested only active nodes, the response is valid until the epoch changes + // (but add 2 minutes due to epoch transition not being instantaneous + let epoch_end = interval.current_epoch_end(); + let expiration = epoch_end + Duration::from_secs(120); + Ok(base_response.with_expires_header(expiration)) } /// Deprecated query that gets ALL gateways diff --git a/nym-wallet/Cargo.lock b/nym-wallet/Cargo.lock index 2079f84062..862fa61df1 100644 --- a/nym-wallet/Cargo.lock +++ b/nym-wallet/Cargo.lock @@ -4036,7 +4036,6 @@ version = "0.6.0" dependencies = [ "const-str", "log", - "pretty_env_logger", "schemars", "serde", "utoipa",