diff --git a/Cargo.lock b/Cargo.lock index d24d0a9c9ed..6edee4d4bb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10864,6 +10864,8 @@ dependencies = [ name = "problematic" version = "0.0.0" dependencies = [ + "erased-serde", + "error-stack", "http 1.4.2", "insta", "schemars 1.2.1", diff --git a/Cargo.toml b/Cargo.toml index 5c7020db71b..5d2d8845cc5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -163,6 +163,7 @@ email_address = { version = "0.2.9", default-features = fal ena = { version = "0.14.3", default-features = false } enum-iterator = { version = "2.1.0", default-features = false } enumflags2 = { version = "0.7.12", default-features = false } +erased-serde = { version = "0.4.10", default-features = false } expect-test = { version = "1.5.1", default-features = false } figment = { version = "0.10.19", default-features = false } foldhash = { version = "0.2.0", default-features = false } diff --git a/libs/problematic/rust/Cargo.toml b/libs/problematic/rust/Cargo.toml index f1c8f048506..8436a30c56b 100644 --- a/libs/problematic/rust/Cargo.toml +++ b/libs/problematic/rust/Cargo.toml @@ -11,6 +11,7 @@ exclude = ["package.json", "turbo.json", "docs/task-dependencies.json" [dependencies] # Public workspace dependencies +error-stack = { workspace = true, public = true, optional = true } # Public third-party dependencies http = { workspace = true, public = true } @@ -22,16 +23,25 @@ serde_core = { workspace = true, public = true, optional = true } # Private workspace dependencies # Private third-party dependencies -serde = { workspace = true, optional = true, features = ["alloc", "derive"] } +erased-serde = { workspace = true, optional = true, features = ["alloc"] } +serde = { workspace = true, optional = true, features = ["alloc", "derive"] } [dev-dependencies] insta = { workspace = true, features = ["json"] } serde_json = { workspace = true, features = ["arbitrary_precision"] } +[[test]] +name = "error_stack" +required-features = ["error-stack"] + [[test]] name = "extensions" required-features = ["serde"] +[[test]] +name = "problem" +required-features = ["serde"] + [[test]] name = "problem_details" required-features = ["serde"] @@ -41,8 +51,9 @@ name = "schema" required-features = ["schemars"] [features] -schemars = ["dep:schemars"] -serde = ["dep:serde_core", "dep:serde"] +error-stack = ["dep:error-stack", "dep:erased-serde", "serde"] +schemars = ["dep:schemars"] +serde = ["dep:serde_core", "dep:serde"] [lints] workspace = true diff --git a/libs/problematic/rust/README.md b/libs/problematic/rust/README.md index bac9a0b80ef..330a6ca37ee 100644 --- a/libs/problematic/rust/README.md +++ b/libs/problematic/rust/README.md @@ -6,6 +6,7 @@ HTTP Problem Details with typed extension members. - `serde` enables serialization and deserialization, including borrowing strings from the input. - `schemars` enables JSON Schema generation independently of `serde`. +- `error-stack` enables attaching problems to reports and includes `serde`. No features are enabled by default. diff --git a/libs/problematic/rust/docs/task-dependencies.json b/libs/problematic/rust/docs/task-dependencies.json index df0316a8f2c..6854544ee37 100644 --- a/libs/problematic/rust/docs/task-dependencies.json +++ b/libs/problematic/rust/docs/task-dependencies.json @@ -1,6 +1,8 @@ { "package": "@rust/problematic", - "dependencies": [], + "dependencies": [ + "@rust/error-stack" + ], "tasks": { "lint:clippy": { "dependsOn": [], diff --git a/libs/problematic/rust/package.json b/libs/problematic/rust/package.json index 718c1913083..ae2020910ee 100644 --- a/libs/problematic/rust/package.json +++ b/libs/problematic/rust/package.json @@ -7,5 +7,8 @@ "scripts": { "lint:clippy": "just clippy", "test:unit": "mise run test:unit @rust/problematic" + }, + "dependencies": { + "@rust/error-stack": "workspace:*" } } diff --git a/libs/problematic/rust/src/error_stack.rs b/libs/problematic/rust/src/error_stack.rs new file mode 100644 index 00000000000..136d7602976 --- /dev/null +++ b/libs/problematic/rust/src/error_stack.rs @@ -0,0 +1,200 @@ +//! Public problem details attached to [`Report`] values. + +use alloc::boxed::Box; + +use error_stack::{IntoReport, Report}; +use serde_core::Serialize; + +use crate::{Problem, ProblemDetails}; + +/// Attaches and retrieves public problems independently of a report's current context. +pub trait ReportExt: Sized { + /// Attaches the problem to expose to the client. + /// + /// The attachment survives [`Report::change_context`]. Attaching another problem replaces the + /// complete public representation returned by [`Self::problem_details`]; earlier problems + /// remain in the report's frames. + /// + /// The report owns the problem. Its details can borrow from it and are serialized only when the + /// returned [`ProblemDetails`] is serialized. + /// + /// # Examples + /// + /// ``` + /// use std::{borrow::Cow, fmt, io}; + /// + /// use error_stack::Report; + /// use problematic::{ProblemType, StatusCode, error_stack::ReportExt as _}; + /// + /// const INVALID_PARAMETER: ProblemType = ProblemType { + /// type_uri: Cow::Borrowed("https://example.com/problems/invalid-parameter"), + /// title: Cow::Borrowed("Invalid parameter"), + /// status: StatusCode::BAD_REQUEST, + /// }; + /// + /// let report = Report::new(fmt::Error) + /// .attach_problem(INVALID_PARAMETER.detail("The limit must be positive.")) + /// .change_context(io::Error::other("request failed")); + /// let details = report + /// .problem_details() + /// .expect("the problem should be attached"); + /// + /// assert_eq!(details.status, 400); + /// assert_eq!( + /// details.detail.as_deref(), + /// Some("The limit must be positive.") + /// ); + /// ``` + #[must_use] + fn attach_problem

(self, problem: P) -> Self + where + P: Problem + Send + Sync + 'static, + for<'a> P::Extensions<'a>: Serialize; + + /// Returns the attached problem's details, or [`None`] if no problem was attached. + /// + /// Selects the first problem in [`Report::frames`] order. For a single chain of contexts, this + /// is the most recently attached problem. For reports combined with [`Report::append`], the + /// frame traversal order determines which branch supplies the problem. + /// + /// The details borrow from the report. Extension validation runs when they are serialized and + /// returns a serializer error for invalid extensions. + #[must_use] + fn problem_details(&self) -> Option>; +} + +impl ReportExt for Report { + #[track_caller] + fn attach_problem

(self, problem: P) -> Self + where + P: Problem + Send + Sync + 'static, + for<'a> P::Extensions<'a>: Serialize, + { + self.attach_opaque(AttachedProblem(Box::new(problem))) + } + + fn problem_details(&self) -> Option> { + self.downcast_ref::() + .map(|problem| problem.0.details()) + } +} + +/// Attaches public problems to the error variant of a [`Result`]. +pub trait ResultExt: Sized { + /// The context type of the resulting [`Report`]. + type Context: ?Sized; + + /// The successful value carried by the result. + type Ok; + + /// Attaches `problem` to the error, converting it to a [`Report`] if needed. + /// + /// Uses [`ReportExt::attach_problem`] for errors and preserves successful values. + /// + /// # Errors + /// + /// Returns the original error as a [`Report`] with the problem attached. + #[track_caller] + fn attach_problem

(self, problem: P) -> Result> + where + P: Problem + Send + Sync + 'static, + for<'a> P::Extensions<'a>: Serialize, + { + self.attach_problem_with(|| problem) + } + + /// Creates and attaches a problem only when the result is [`Err`]. + /// + /// Calls `problem` once on error, then uses [`ReportExt::attach_problem`]. On [`Ok`], the + /// closure is dropped without being called. + /// + /// # Errors + /// + /// Returns the original error as a [`Report`] with the created problem attached. + /// + /// # Examples + /// + /// ``` + /// use std::borrow::Cow; + /// + /// use problematic::{ + /// ProblemType, StatusCode, + /// error_stack::{ReportExt as _, ResultExt as _}, + /// }; + /// + /// const INVALID_PARAMETER: ProblemType = ProblemType { + /// type_uri: Cow::Borrowed("https://example.com/problems/invalid-parameter"), + /// title: Cow::Borrowed("Invalid parameter"), + /// status: StatusCode::BAD_REQUEST, + /// }; + /// + /// let input = "zero"; + /// let result = input.parse::().attach_problem_with(|| { + /// INVALID_PARAMETER.detail(format!("The limit `{input}` is not an integer.")) + /// }); + /// let report = result.expect_err("the input should fail to parse"); + /// let details = report + /// .problem_details() + /// .expect("the problem should be attached"); + /// + /// assert_eq!( + /// details.detail.as_deref(), + /// Some("The limit `zero` is not an integer.") + /// ); + /// ``` + #[track_caller] + fn attach_problem_with(self, problem: F) -> Result> + where + P: Problem + Send + Sync + 'static, + for<'a> P::Extensions<'a>: Serialize, + F: FnOnce() -> P; +} + +impl ResultExt for Result { + type Context = E::Context; + type Ok = T; + + fn attach_problem_with(self, problem: F) -> Result> + where + P: Problem + Send + Sync + 'static, + for<'a> P::Extensions<'a>: Serialize, + F: FnOnce() -> P, + { + match self { + Ok(value) => Ok(value), + Err(error) => Err(error.into_report().attach_problem(problem())), + } + } +} + +struct AttachedProblem(Box); + +trait ErasedProblem { + fn details(&self) -> ProblemDetails<'_, Box>; +} + +impl

ErasedProblem for P +where + P: Problem + 'static, + for<'a> P::Extensions<'a>: Serialize, +{ + fn details(&self) -> ProblemDetails<'_, Box> { + let ProblemDetails { + type_uri, + title, + status, + detail, + instance, + extensions, + } = Problem::details(self); + + ProblemDetails { + type_uri, + title, + status, + detail, + instance, + extensions: Box::new(extensions), + } + } +} diff --git a/libs/problematic/rust/src/lib.rs b/libs/problematic/rust/src/lib.rs index cab2754e8bc..78a21b86f9b 100644 --- a/libs/problematic/rust/src/lib.rs +++ b/libs/problematic/rust/src/lib.rs @@ -2,15 +2,20 @@ //! //! [`ProblemType`] describes a problem's shared metadata. [`ProblemDetails`] represents an //! occurrence as a JSON object, with extension members alongside the standard fields. +//! [`Problem`] lets a failure provide the details exposed to the client. //! //! The optional `serde` feature enables serialization and deserialization. The `schemars` feature //! independently adds JSON Schema support to [`ProblemDetails`] and [`NoExtensions`]. +//! The `error-stack` feature enables attaching problems to reports and includes `serde`. #![feature(const_convert, const_destruct, const_trait_impl)] #![cfg_attr(doc, feature(doc_cfg))] extern crate alloc; +#[cfg(feature = "error-stack")] +pub mod error_stack; +mod problem; mod problem_details; mod problem_type; #[cfg(feature = "serde")] @@ -19,6 +24,7 @@ mod serde; pub use http::StatusCode; pub use self::{ + problem::Problem, problem_details::{NoExtensions, ProblemDetails}, problem_type::ProblemType, }; diff --git a/libs/problematic/rust/src/problem.rs b/libs/problematic/rust/src/problem.rs new file mode 100644 index 00000000000..b3e078e9a0b --- /dev/null +++ b/libs/problematic/rust/src/problem.rs @@ -0,0 +1,145 @@ +use alloc::borrow::Cow; + +use crate::{NoExtensions, ProblemDetails, ProblemType}; + +/// A failure that provides problem details for the client. +/// +/// The details and extension members can borrow from the failure. +/// +/// # Examples +/// +/// ``` +/// use std::borrow::Cow; +/// +/// use problematic::{Problem, ProblemDetails, ProblemType, StatusCode}; +/// +/// struct InvalidParameter { +/// parameter: String, +/// explanation: String, +/// } +/// +/// struct InvalidParameterExtensions<'a> { +/// parameter: &'a str, +/// } +/// +/// const INVALID_PARAMETER: ProblemType = ProblemType { +/// type_uri: Cow::Borrowed("https://example.com/problems/invalid-parameter"), +/// title: Cow::Borrowed("Invalid parameter"), +/// status: StatusCode::BAD_REQUEST, +/// }; +/// +/// impl Problem for InvalidParameter { +/// type Extensions<'a> = InvalidParameterExtensions<'a>; +/// +/// fn details(&self) -> ProblemDetails<'_, Self::Extensions<'_>> { +/// INVALID_PARAMETER +/// .detail(&self.explanation) +/// .extensions(InvalidParameterExtensions { +/// parameter: &self.parameter, +/// }) +/// } +/// } +/// +/// let error = InvalidParameter { +/// parameter: "limit".to_owned(), +/// explanation: "The limit must be positive.".to_owned(), +/// }; +/// let details = error.details(); +/// +/// assert_eq!( +/// details.detail.as_deref(), +/// Some("The limit must be positive.") +/// ); +/// assert_eq!(details.extensions.parameter, "limit"); +/// ``` +pub trait Problem { + /// The extension members, which may borrow from this failure for `'a`. + type Extensions<'a> + where + Self: 'a; + + /// Returns the problem details exposed to the client. + #[must_use] + fn details(&self) -> ProblemDetails<'_, Self::Extensions<'_>>; +} + +impl Problem for ProblemType { + type Extensions<'a> = NoExtensions; + + fn details(&self) -> ProblemDetails<'_, Self::Extensions<'_>> { + ProblemDetails::from(self) + } +} + +impl Problem for ProblemDetails<'_, E> { + type Extensions<'a> + = &'a E + where + Self: 'a; + + fn details(&self) -> ProblemDetails<'_, Self::Extensions<'_>> { + ProblemDetails { + type_uri: Cow::Borrowed(&self.type_uri), + title: Cow::Borrowed(&self.title), + status: self.status, + detail: self.detail.as_deref().map(Cow::Borrowed), + instance: self.instance.as_deref().map(Cow::Borrowed), + extensions: &self.extensions, + } + } +} + +#[cfg(test)] +mod tests { + use alloc::{borrow::Cow, string::String}; + use core::{assert_matches, ptr}; + + use crate::{Problem, ProblemDetails}; + + #[derive(Debug)] + struct Extensions<'a> { + parameter: &'a str, + } + + fn details(problem: &P) -> ProblemDetails<'_, P::Extensions<'_>> { + problem.details() + } + + #[test] + fn details_borrowed() { + let parameter = String::from("limit"); + let source = ProblemDetails { + type_uri: Cow::Owned(String::from( + "https://example.com/problems/invalid-parameter", + )), + title: Cow::Owned(String::from("Invalid parameter")), + status: 400, + detail: Some(Cow::Owned(String::from("The limit must be positive."))), + instance: Some(Cow::Owned(String::from("/problem-occurrences/42"))), + extensions: Extensions { + parameter: ¶meter, + }, + }; + let borrowed = details(&source); + + assert_matches!( + borrowed, + ProblemDetails { + type_uri: Cow::Borrowed(_), + title: Cow::Borrowed(_), + detail: Some(Cow::Borrowed(_)), + instance: Some(Cow::Borrowed(_)), + .. + }, + "the details should borrow the source strings" + ); + assert!( + ptr::eq(borrowed.extensions, &raw const source.extensions), + "the details should borrow the source extensions" + ); + assert!( + ptr::eq(borrowed.extensions.parameter, parameter.as_str()), + "the extension member should retain its original borrow" + ); + } +} diff --git a/libs/problematic/rust/src/problem_type.rs b/libs/problematic/rust/src/problem_type.rs index 3e3965b4295..34bbf71588e 100644 --- a/libs/problematic/rust/src/problem_type.rs +++ b/libs/problematic/rust/src/problem_type.rs @@ -126,10 +126,10 @@ mod tests { use alloc::{borrow::Cow, string::String}; use core::{assert_matches, ptr}; - use crate::{ProblemDetails, ProblemType, StatusCode}; + use crate::{Problem as _, ProblemDetails, ProblemType, StatusCode}; #[test] - fn from_owned_metadata() { + fn details_owned_metadata() { let definition = ProblemType { type_uri: Cow::Owned(String::from( "https://example.com/problems/invalid-parameters", @@ -137,15 +137,15 @@ mod tests { title: Cow::Owned(String::from("Invalid parameters")), status: StatusCode::BAD_REQUEST, }; - let details = ProblemDetails::from(&definition); - - assert_matches!( - details.type_uri, Cow::Borrowed(uri) if ptr::eq(uri, definition.type_uri.as_ref()), - "the type URI should borrow the definition's allocation" - ); - assert_matches!( - details.title, Cow::Borrowed(title) if ptr::eq(title, definition.title.as_ref()), - "the title should borrow the definition's allocation" - ); + for details in [ProblemDetails::from(&definition), definition.details()] { + assert_matches!( + details.type_uri, Cow::Borrowed(uri) if ptr::eq(uri, definition.type_uri.as_ref()), + "the type URI should borrow the definition's allocation" + ); + assert_matches!( + details.title, Cow::Borrowed(title) if ptr::eq(title, definition.title.as_ref()), + "the title should borrow the definition's allocation" + ); + } } } diff --git a/libs/problematic/rust/tests/error_stack.rs b/libs/problematic/rust/tests/error_stack.rs new file mode 100644 index 00000000000..9a403a0ed93 --- /dev/null +++ b/libs/problematic/rust/tests/error_stack.rs @@ -0,0 +1,332 @@ +extern crate alloc; + +use alloc::{borrow::Cow, string::String, sync::Arc}; +use core::{ + assert_matches, fmt, + panic::Location, + sync::atomic::{AtomicUsize, Ordering}, +}; +use std::io; + +use error_stack::{Report, ResultExt as _}; +use problematic::{ + Problem, ProblemDetails, ProblemType, StatusCode, + error_stack::{ReportExt as _, ResultExt as _}, +}; +use serde::{Serialize, Serializer, ser::Error as _}; +use serde_json::json; + +const INVALID_PARAMETER: ProblemType = ProblemType { + type_uri: Cow::Borrowed("https://example.com/problems/invalid-parameter"), + title: Cow::Borrowed("Invalid parameter"), + status: StatusCode::BAD_REQUEST, +}; + +const UNAVAILABLE: ProblemType = ProblemType { + type_uri: Cow::Borrowed("https://example.com/problems/unavailable"), + title: Cow::Borrowed("Unavailable"), + status: StatusCode::SERVICE_UNAVAILABLE, +}; + +struct InvalidParameter { + parameter: String, + explanation: String, +} + +#[derive(Serialize)] +struct ParameterExtensions<'a> { + parameter: &'a str, +} + +impl Problem for InvalidParameter { + type Extensions<'a> = ParameterExtensions<'a>; + + fn details(&self) -> ProblemDetails<'_, Self::Extensions<'_>> { + INVALID_PARAMETER + .detail(&self.explanation) + .extensions(ParameterExtensions { + parameter: &self.parameter, + }) + } +} + +#[test] +fn details_changed_context() { + let problem = InvalidParameter { + parameter: String::from("limit"), + explanation: String::from("The limit must be positive."), + }; + let explanation = problem.explanation.as_ptr(); + let report = Report::new(fmt::Error) + .attach_problem(problem) + .change_context(io::Error::other("request failed")) + .attach_opaque("diagnostic attachment") + .change_context(fmt::Error); + + let details = report + .problem_details() + .expect("the problem should survive context changes"); + + assert_matches!( + details.detail, + Some(Cow::Borrowed(_)), + "the detail should borrow from the attached problem" + ); + assert_eq!( + details.detail.as_deref().map(str::as_ptr), + Some(explanation), + "the detail should retain the attached problem's allocation" + ); + assert_eq!( + serde_json::to_value(details).expect("the attached details should serialize"), + json!({ + "type": "https://example.com/problems/invalid-parameter", + "title": "Invalid parameter", + "status": 400, + "detail": "The limit must be positive.", + "parameter": "limit" + }), + "the details and borrowed extensions should survive context changes" + ); +} + +#[test] +fn details_latest_attachment() { + let report = Report::new(fmt::Error) + .attach_problem(InvalidParameter { + parameter: String::from("limit"), + explanation: String::from("The limit must be positive."), + }) + .change_context(io::Error::other("request failed")) + .attach_problem(UNAVAILABLE) + .change_context(fmt::Error); + + let details = report + .problem_details() + .expect("the most recent problem should be attached"); + + assert_eq!( + serde_json::to_value(details).expect("the attached details should serialize"), + json!({ + "type": "https://example.com/problems/unavailable", + "title": "Unavailable", + "status": 503 + }), + "the most recent problem should replace the complete public representation" + ); +} + +#[test] +fn details_no_attachment() { + let report = Report::new(fmt::Error) + .attach_opaque("diagnostic attachment") + .change_context(io::Error::other("request failed")); + + assert!( + report.problem_details().is_none(), + "a report without an attached problem should return no details" + ); +} + +#[test] +fn details_combined_reports() { + let mut report = Report::new(fmt::Error) + .attach_problem(INVALID_PARAMETER.detail("The limit must be positive.")) + .expand(); + report.append( + Report::new(fmt::Error) + .attach_problem(UNAVAILABLE.instance("/problem-occurrences/42")) + .expand(), + ); + + assert_eq!( + report + .problem_details() + .expect("the first branch should contain a problem") + .status, + 400, + "the first problem in frame traversal order should take precedence" + ); + + let report = report.attach_problem(UNAVAILABLE.detail("Please retry later.")); + assert_eq!( + report + .problem_details() + .expect("the combined report should contain the new problem") + .detail + .as_deref(), + Some("Please retry later."), + "a problem attached to the combined report should take precedence over both branches" + ); +} + +#[test] +fn serialize_invalid_extensions() { + for (extensions, message) in [ + (json!(42), "problem extensions must serialize as an object"), + ( + json!({"status": 200}), + "problem extension `status` conflicts with a standard member", + ), + ] { + let report = + Report::new(fmt::Error).attach_problem(INVALID_PARAMETER.extensions(extensions)); + let details = report + .problem_details() + .expect("the problem should be attached before serialization"); + let error = + serde_json::to_value(details).expect_err("invalid extensions should fail to serialize"); + + assert_eq!( + error.to_string(), + message, + "extension validation should remain effective through type erasure" + ); + } +} + +struct FailingExtensions { + calls: Arc, +} + +impl Serialize for FailingExtensions { + fn serialize(&self, _: S) -> Result { + self.calls.fetch_add(1, Ordering::Relaxed); + Err(S::Error::custom("extension serialization failed")) + } +} + +#[test] +fn serialize_extension_failure() { + let calls = Arc::new(AtomicUsize::new(0)); + let report = + Report::new(fmt::Error).attach_problem(INVALID_PARAMETER.extensions(FailingExtensions { + calls: Arc::clone(&calls), + })); + let details = report + .problem_details() + .expect("the problem should be attached before serialization"); + + assert_eq!( + calls.load(Ordering::Relaxed), + 0, + "attaching and retrieving a problem should not serialize its extensions" + ); + + let error = serde_json::to_value(details) + .expect_err("the extension serializer failure should propagate"); + assert_eq!( + error.to_string(), + "extension serialization failed", + "the caller should receive the original serializer error" + ); + assert_eq!( + calls.load(Ordering::Relaxed), + 1, + "the extensions should be serialized once" + ); +} + +#[test] +fn result_attach_ok() { + let result = Ok::<_, fmt::Error>(42) + .attach_problem(INVALID_PARAMETER.detail("The limit must be positive.")); + + assert_eq!( + result.expect("the result should remain successful"), + 42, + "attaching a problem should preserve the successful value" + ); +} + +#[test] +fn result_attach_error() { + let result = Err::<(), _>(fmt::Error) + .attach_problem(INVALID_PARAMETER.detail("The limit must be positive.")); + let report: Report = result.expect_err("the result should remain an error"); + + assert_eq!( + report + .problem_details() + .expect("the problem should be attached") + .detail + .as_deref(), + Some("The limit must be positive."), + "a plain error should become a report with the attached problem" + ); + assert_eq!( + report + .downcast_ref::>() + .expect("the report should contain its creation location") + .file(), + file!(), + "the report should record the caller rather than the extension's implementation" + ); +} + +#[test] +fn result_attach_existing_report() { + let report = Report::new(fmt::Error) + .attach_opaque(String::from("original diagnostic")) + .expand(); + let result: Result<(), Report<[fmt::Error]>> = + Err(report).attach_problem(INVALID_PARAMETER.detail("The limit must be positive.")); + let report = result.expect_err("the result should retain its report"); + + assert_eq!( + report.downcast_ref::().map(String::as_str), + Some("original diagnostic"), + "the existing report's frames should be preserved" + ); + assert_eq!( + report + .problem_details() + .expect("the problem should be attached") + .status, + 400, + "the attachment should support reports with multiple contexts" + ); +} + +#[test] +fn result_attach_with_ok() { + let mut calls = 0; + let result = Ok::<_, fmt::Error>(42).attach_problem_with(|| { + calls += 1; + INVALID_PARAMETER.detail("The limit must be positive.") + }); + + assert_eq!( + calls, 0, + "the problem factory should not run for a successful result" + ); + assert_eq!( + result.expect("the result should remain successful"), + 42, + "lazy attachment should preserve the successful value" + ); +} + +#[test] +fn result_attach_with_error() { + let mut calls = 0; + let explanation = String::from("The limit must be positive."); + let result = Err::<(), _>(fmt::Error) + .attach_problem_with(|| { + calls += 1; + INVALID_PARAMETER.detail(explanation) + }) + .change_context(io::Error::other("request failed")); + let report = result.expect_err("the result should remain an error"); + + assert_eq!(calls, 1, "the problem factory should run once on error"); + assert_eq!( + report + .problem_details() + .expect("the problem should survive the context change") + .detail + .as_deref(), + Some("The limit must be positive."), + "the factory should be able to move captured data into the problem" + ); +} diff --git a/libs/problematic/rust/tests/problem.rs b/libs/problematic/rust/tests/problem.rs new file mode 100644 index 00000000000..360873d3222 --- /dev/null +++ b/libs/problematic/rust/tests/problem.rs @@ -0,0 +1,48 @@ +extern crate alloc; + +use alloc::{borrow::Cow, string::String}; + +use problematic::{Problem, ProblemType, StatusCode}; +use serde::Serialize; +use serde_json::{Value, json}; + +#[derive(Serialize)] +struct Extensions<'a> { + parameter: &'a str, +} + +fn serialize<'a, P: Problem + ?Sized>(problem: &'a P) -> Value +where + P::Extensions<'a>: Serialize, +{ + serde_json::to_value(problem.details()).expect("the problem details should serialize") +} + +#[test] +fn details_borrowed_serialize() { + const INVALID_PARAMETER: ProblemType = ProblemType { + type_uri: Cow::Borrowed("https://example.com/problems/invalid-parameter"), + title: Cow::Borrowed("Invalid parameter"), + status: StatusCode::BAD_REQUEST, + }; + + let parameter = String::from("limit"); + let explanation = String::from("The limit must be positive."); + let problem = INVALID_PARAMETER + .detail(&explanation) + .extensions(Extensions { + parameter: ¶meter, + }); + + assert_eq!( + serialize(&problem), + json!({ + "type": "https://example.com/problems/invalid-parameter", + "title": "Invalid parameter", + "status": 400, + "detail": "The limit must be positive.", + "parameter": "limit" + }), + "the generic consumer should serialize the borrowed details and extension members" + ); +} diff --git a/yarn.lock b/yarn.lock index 7863fa6a9f4..315e16756d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15580,6 +15580,8 @@ __metadata: "@rust/problematic@workspace:libs/problematic/rust": version: 0.0.0-use.local resolution: "@rust/problematic@workspace:libs/problematic/rust" + dependencies: + "@rust/error-stack": "workspace:*" languageName: unknown linkType: soft