Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
17 changes: 14 additions & 3 deletions libs/problematic/rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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"]
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions libs/problematic/rust/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 3 additions & 1 deletion libs/problematic/rust/docs/task-dependencies.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
{
"package": "@rust/problematic",
"dependencies": [],
"dependencies": [
"@rust/error-stack"
],
"tasks": {
"lint:clippy": {
"dependsOn": [],
Expand Down
3 changes: 3 additions & 0 deletions libs/problematic/rust/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,8 @@
"scripts": {
"lint:clippy": "just clippy",
"test:unit": "mise run test:unit @rust/problematic"
},
"dependencies": {
"@rust/error-stack": "workspace:*"
}
}
200 changes: 200 additions & 0 deletions libs/problematic/rust/src/error_stack.rs
Original file line number Diff line number Diff line change
@@ -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<P>(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<ProblemDetails<'_, impl Serialize + '_>>;
}

impl<C: ?Sized> ReportExt for Report<C> {
#[track_caller]
fn attach_problem<P>(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<ProblemDetails<'_, impl Serialize + '_>> {
self.downcast_ref::<AttachedProblem>()
.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<P>(self, problem: P) -> Result<Self::Ok, Report<Self::Context>>
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::<u64>().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<P, F>(self, problem: F) -> Result<Self::Ok, Report<Self::Context>>
where
P: Problem + Send + Sync + 'static,
for<'a> P::Extensions<'a>: Serialize,
F: FnOnce() -> P;
}

impl<T, E: IntoReport> ResultExt for Result<T, E> {
type Context = E::Context;
type Ok = T;

fn attach_problem_with<P, F>(self, problem: F) -> Result<T, Report<E::Context>>
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<dyn ErasedProblem + Send + Sync>);

trait ErasedProblem {
fn details(&self) -> ProblemDetails<'_, Box<dyn erased_serde::Serialize + '_>>;
}

impl<P> ErasedProblem for P
where
P: Problem + 'static,
for<'a> P::Extensions<'a>: Serialize,
{
fn details(&self) -> ProblemDetails<'_, Box<dyn erased_serde::Serialize + '_>> {
let ProblemDetails {
type_uri,
title,
status,
detail,
instance,
extensions,
} = Problem::details(self);

ProblemDetails {
type_uri,
title,
status,
detail,
instance,
extensions: Box::new(extensions),
}
}
}
6 changes: 6 additions & 0 deletions libs/problematic/rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -19,6 +24,7 @@ mod serde;
pub use http::StatusCode;

pub use self::{
problem::Problem,
problem_details::{NoExtensions, ProblemDetails},
problem_type::ProblemType,
};
Loading
Loading