From 4b8d637f6c172b0f883ec99f8608b21943b18e0d Mon Sep 17 00:00:00 2001 From: Tim Diekmann Date: Wed, 16 Sep 2026 13:46:56 +0200 Subject: [PATCH 1/2] Add const fluent construction for problem details --- libs/problematic/rust/src/lib.rs | 1 + libs/problematic/rust/src/problem_details.rs | 35 +++++- libs/problematic/rust/src/problem_type.rs | 102 +++++++++++++++++- .../problematic/rust/tests/problem_details.rs | 9 +- 4 files changed, 136 insertions(+), 11 deletions(-) diff --git a/libs/problematic/rust/src/lib.rs b/libs/problematic/rust/src/lib.rs index add395312c3..cab2754e8bc 100644 --- a/libs/problematic/rust/src/lib.rs +++ b/libs/problematic/rust/src/lib.rs @@ -6,6 +6,7 @@ //! The optional `serde` feature enables serialization and deserialization. The `schemars` feature //! independently adds JSON Schema support to [`ProblemDetails`] and [`NoExtensions`]. +#![feature(const_convert, const_destruct, const_trait_impl)] #![cfg_attr(doc, feature(doc_cfg))] extern crate alloc; diff --git a/libs/problematic/rust/src/problem_details.rs b/libs/problematic/rust/src/problem_details.rs index 872b2e16877..21a20d0969f 100644 --- a/libs/problematic/rust/src/problem_details.rs +++ b/libs/problematic/rust/src/problem_details.rs @@ -1,4 +1,5 @@ use alloc::borrow::Cow; +use core::marker::Destruct; #[cfg(feature = "serde")] use ::serde::{Deserialize, Serialize}; @@ -32,7 +33,7 @@ const fn default_type_uri() -> Cow<'static, str> { derive(schemars::JsonSchema), schemars(title = "Problem Details") )] -pub struct ProblemDetails<'a, E> { +pub struct ProblemDetails<'a, E = NoExtensions> { /// A URI reference identifying the problem type. Use `about:blank` when the HTTP status code /// fully describes the problem type. If `type` is omitted during deserialization, it defaults /// to `about:blank`. @@ -116,3 +117,35 @@ pub struct ProblemDetails<'a, E> { )] pub extensions: E, } + +impl<'a, E> ProblemDetails<'a, E> { + /// Sets the human-readable explanation of this occurrence. + #[must_use] + pub const fn detail(mut self, detail: impl [const] Into>) -> Self { + self.detail = Some(detail.into()); + self + } + + /// Sets the URI reference identifying this occurrence. + #[must_use] + pub const fn instance(mut self, instance: impl [const] Into>) -> Self { + self.instance = Some(instance.into()); + self + } + + /// Replaces the extension members, changing their type. + #[must_use] + pub const fn extensions(self, extensions: F) -> ProblemDetails<'a, F> + where + E: [const] Destruct, + { + ProblemDetails { + type_uri: self.type_uri, + title: self.title, + status: self.status, + detail: self.detail, + instance: self.instance, + extensions, + } + } +} diff --git a/libs/problematic/rust/src/problem_type.rs b/libs/problematic/rust/src/problem_type.rs index a4a405a37d0..aab38c84a59 100644 --- a/libs/problematic/rust/src/problem_type.rs +++ b/libs/problematic/rust/src/problem_type.rs @@ -1,19 +1,43 @@ use alloc::borrow::Cow; -use crate::StatusCode; +use crate::{NoExtensions, ProblemDetails, StatusCode}; /// Metadata shared by occurrences of a problem type. /// +/// Occurrences created from this definition borrow its type URI and title. +/// Use [`Cow::Borrowed`] for string literals in const contexts. +/// /// ``` +/// # #![feature(const_convert, const_trait_impl)] /// use std::borrow::Cow; /// -/// use problematic::{ProblemType, StatusCode}; +/// use problematic::{ProblemDetails, ProblemType, StatusCode}; /// /// const WRONG_ACTOR_TYPE: ProblemType = ProblemType { /// type_uri: Cow::Borrowed("https://example.com/problems/wrong-actor-type"), /// title: Cow::Borrowed("Wrong actor type"), /// status: StatusCode::FORBIDDEN, /// }; +/// +/// const DETAILS: ProblemDetails<'static> = WRONG_ACTOR_TYPE +/// .detail(Cow::Borrowed("This operation requires a machine actor.")) +/// .instance(Cow::Borrowed("/problem-occurrences/42")); +/// +/// let details = WRONG_ACTOR_TYPE +/// .detail("This operation requires a machine actor.") +/// .instance("/problem-occurrences/42"); +/// +/// let details = ProblemDetails::from(&WRONG_ACTOR_TYPE); +/// +/// struct WrongActorType { +/// required_actor_type: &'static str, +/// } +/// +/// let details = WRONG_ACTOR_TYPE +/// .detail("This operation requires a machine actor.") +/// .extensions(WrongActorType { +/// required_actor_type: "machine", +/// }); /// ``` #[derive(Debug)] pub struct ProblemType { @@ -24,3 +48,77 @@ pub struct ProblemType { /// The HTTP status code for occurrences of this problem type. pub status: StatusCode, } + +impl ProblemType { + /// Creates an occurrence with a human-readable explanation. + #[must_use] + pub const fn detail<'a>( + &'a self, + detail: impl [const] Into>, + ) -> ProblemDetails<'a> { + ProblemDetails::from(self).detail(detail) + } + + /// Creates an occurrence identified by the supplied URI reference. + #[must_use] + pub const fn instance<'a>( + &'a self, + instance: impl [const] Into>, + ) -> ProblemDetails<'a> { + ProblemDetails::from(self).instance(instance) + } + + /// Creates an occurrence with the supplied extension members. + #[must_use] + pub const fn extensions(&self, extensions: E) -> ProblemDetails<'_, E> { + ProblemDetails::from(self).extensions(extensions) + } +} + +const impl<'a> From<&'a ProblemType> for ProblemDetails<'a> { + fn from(definition: &'a ProblemType) -> Self { + Self { + type_uri: Cow::Borrowed(match &definition.type_uri { + Cow::Borrowed(uri) => uri, + Cow::Owned(uri) => uri.as_str(), + }), + title: Cow::Borrowed(match &definition.title { + Cow::Borrowed(title) => title, + Cow::Owned(title) => title.as_str(), + }), + status: definition.status.as_u16(), + detail: None, + instance: None, + extensions: NoExtensions {}, + } + } +} + +#[cfg(test)] +mod tests { + use alloc::{borrow::Cow, string::String}; + use core::ptr; + + use crate::{ProblemDetails, ProblemType, StatusCode}; + + #[test] + fn from_owned_metadata() { + let definition = ProblemType { + type_uri: Cow::Owned(String::from( + "https://example.com/problems/invalid-parameters", + )), + title: Cow::Owned(String::from("Invalid parameters")), + status: StatusCode::BAD_REQUEST, + }; + let details = ProblemDetails::from(&definition); + + assert!( + ptr::eq(details.type_uri.as_ref(), definition.type_uri.as_ref()), + "the type URI should reuse the definition's allocation" + ); + assert!( + ptr::eq(details.title.as_ref(), definition.title.as_ref()), + "the title should reuse the definition's allocation" + ); + } +} diff --git a/libs/problematic/rust/tests/problem_details.rs b/libs/problematic/rust/tests/problem_details.rs index 6857addc5b8..5d5b827e782 100644 --- a/libs/problematic/rust/tests/problem_details.rs +++ b/libs/problematic/rust/tests/problem_details.rs @@ -114,14 +114,7 @@ fn details_static_metadata() { let detail = String::from("The limit must be a positive integer."); let instance = String::from("https://example.com/problem-occurrences/42"); - let details = ProblemDetails { - type_uri: INVALID_PARAMETERS.type_uri.clone(), - title: INVALID_PARAMETERS.title.clone(), - status: INVALID_PARAMETERS.status.as_u16(), - detail: Some(Cow::Borrowed(&detail)), - instance: Some(Cow::Borrowed(&instance)), - extensions: NoExtensions {}, - }; + let details = INVALID_PARAMETERS.detail(&detail).instance(&instance); assert_eq!( serde_json::to_value(&details).expect("the mixed-lifetime details should serialize"), From a05f5167b3675768cb72392160718f21832bc179 Mon Sep 17 00:00:00 2001 From: Tim Diekmann Date: Wed, 16 Sep 2026 14:22:15 +0200 Subject: [PATCH 2/2] Complete problem details conversions and usage examples --- libs/problematic/rust/src/problem_details.rs | 82 +++++++++++++++++++ libs/problematic/rust/src/problem_type.rs | 75 +++++++++++------ libs/problematic/rust/tests/extensions.rs | 23 ++---- .../problematic/rust/tests/problem_details.rs | 77 +++++++++++------ 4 files changed, 195 insertions(+), 62 deletions(-) diff --git a/libs/problematic/rust/src/problem_details.rs b/libs/problematic/rust/src/problem_details.rs index 21a20d0969f..b59bf45a094 100644 --- a/libs/problematic/rust/src/problem_details.rs +++ b/libs/problematic/rust/src/problem_details.rs @@ -120,6 +120,29 @@ pub struct ProblemDetails<'a, E = NoExtensions> { impl<'a, E> ProblemDetails<'a, E> { /// Sets the human-readable explanation of this occurrence. + /// + /// # Examples + /// + /// Pass a reference to borrow an explanation assembled at runtime: + /// + /// ``` + /// use std::borrow::Cow; + /// + /// use problematic::{ProblemDetails, ProblemType, StatusCode}; + /// + /// const INVALID_PARAMETERS: ProblemType = ProblemType { + /// type_uri: Cow::Borrowed("https://example.com/problems/invalid-parameters"), + /// title: Cow::Borrowed("Invalid parameters"), + /// status: StatusCode::BAD_REQUEST, + /// }; + /// + /// let parameter = "limit"; + /// let explanation = format!("The {parameter} parameter must be positive."); + /// let details = ProblemDetails::from(&INVALID_PARAMETERS).detail(&explanation); + /// + /// assert_eq!(details.detail.as_deref(), Some(explanation.as_str())); + /// # core::assert_matches!(details.detail, Some(Cow::Borrowed(_))); + /// ``` #[must_use] pub const fn detail(mut self, detail: impl [const] Into>) -> Self { self.detail = Some(detail.into()); @@ -127,6 +150,29 @@ impl<'a, E> ProblemDetails<'a, E> { } /// Sets the URI reference identifying this occurrence. + /// + /// # Examples + /// + /// Move a URI built at runtime into the occurrence: + /// + /// ``` + /// use std::borrow::Cow; + /// + /// use problematic::{ProblemDetails, ProblemType, StatusCode}; + /// + /// const WRONG_ACTOR_TYPE: ProblemType = ProblemType { + /// type_uri: Cow::Borrowed("https://example.com/problems/wrong-actor-type"), + /// title: Cow::Borrowed("Wrong actor type"), + /// status: StatusCode::FORBIDDEN, + /// }; + /// + /// let occurrence_id = 42; + /// let details = ProblemDetails::from(&WRONG_ACTOR_TYPE) + /// .instance(format!("/problem-occurrences/{occurrence_id}")); + /// + /// assert_eq!(details.instance.as_deref(), Some("/problem-occurrences/42")); + /// # core::assert_matches!(details.instance, Some(Cow::Owned(_))); + /// ``` #[must_use] pub const fn instance(mut self, instance: impl [const] Into>) -> Self { self.instance = Some(instance.into()); @@ -134,6 +180,42 @@ impl<'a, E> ProblemDetails<'a, E> { } /// Replaces the extension members, changing their type. + /// + /// # Examples + /// + /// Extension members serialize alongside the standard fields: + /// + /// ``` + /// use std::borrow::Cow; + /// + /// use problematic::{ProblemDetails, ProblemType, StatusCode}; + /// + /// #[derive(serde::Serialize)] + /// struct WrongActorType { + /// required_actor_type: &'static str, + /// } + /// + /// const WRONG_ACTOR_TYPE: ProblemType = ProblemType { + /// type_uri: Cow::Borrowed("https://example.com/problems/wrong-actor-type"), + /// title: Cow::Borrowed("Wrong actor type"), + /// status: StatusCode::FORBIDDEN, + /// }; + /// + /// let details = ProblemDetails::from(&WRONG_ACTOR_TYPE) + /// .detail("This operation requires a machine actor.") + /// .extensions(WrongActorType { + /// required_actor_type: "machine", + /// }); + /// + /// assert_eq!(serde_json::to_value(&details)?, serde_json::json!({ + /// "type": "https://example.com/problems/wrong-actor-type", + /// "title": "Wrong actor type", + /// "status": 403, + /// "detail": "This operation requires a machine actor.", + /// "required_actor_type": "machine" + /// })); + /// # Ok::<(), serde_json::Error>(()) + /// ``` #[must_use] pub const fn extensions(self, extensions: F) -> ProblemDetails<'a, F> where diff --git a/libs/problematic/rust/src/problem_type.rs b/libs/problematic/rust/src/problem_type.rs index aab38c84a59..3e3965b4295 100644 --- a/libs/problematic/rust/src/problem_type.rs +++ b/libs/problematic/rust/src/problem_type.rs @@ -4,14 +4,13 @@ use crate::{NoExtensions, ProblemDetails, StatusCode}; /// Metadata shared by occurrences of a problem type. /// -/// Occurrences created from this definition borrow its type URI and title. -/// Use [`Cow::Borrowed`] for string literals in const contexts. +/// The [`detail()`](Self::detail), [`instance()`](Self::instance), and +/// [`extensions()`](Self::extensions) methods borrow the definition's type URI and title. /// /// ``` -/// # #![feature(const_convert, const_trait_impl)] /// use std::borrow::Cow; /// -/// use problematic::{ProblemDetails, ProblemType, StatusCode}; +/// use problematic::{ProblemType, StatusCode}; /// /// const WRONG_ACTOR_TYPE: ProblemType = ProblemType { /// type_uri: Cow::Borrowed("https://example.com/problems/wrong-actor-type"), @@ -19,25 +18,33 @@ use crate::{NoExtensions, ProblemDetails, StatusCode}; /// status: StatusCode::FORBIDDEN, /// }; /// -/// const DETAILS: ProblemDetails<'static> = WRONG_ACTOR_TYPE -/// .detail(Cow::Borrowed("This operation requires a machine actor.")) -/// .instance(Cow::Borrowed("/problem-occurrences/42")); -/// /// let details = WRONG_ACTOR_TYPE /// .detail("This operation requires a machine actor.") /// .instance("/problem-occurrences/42"); +/// ``` /// -/// let details = ProblemDetails::from(&WRONG_ACTOR_TYPE); +/// Use [`ProblemDetails::from`] to create an occurrence with only the shared metadata. +/// Add typed extension members with [`extensions()`](Self::extensions). /// -/// struct WrongActorType { -/// required_actor_type: &'static str, -/// } +/// For const construction, enable the const trait features and pass string literals as +/// [`Cow::Borrowed`]: /// -/// let details = WRONG_ACTOR_TYPE -/// .detail("This operation requires a machine actor.") -/// .extensions(WrongActorType { -/// required_actor_type: "machine", -/// }); +/// ``` +/// #![feature(const_convert, const_trait_impl)] +/// +/// use std::borrow::Cow; +/// +/// use problematic::{ProblemDetails, ProblemType, StatusCode}; +/// +/// const WRONG_ACTOR_TYPE: ProblemType = ProblemType { +/// type_uri: Cow::Borrowed("https://example.com/problems/wrong-actor-type"), +/// title: Cow::Borrowed("Wrong actor type"), +/// status: StatusCode::FORBIDDEN, +/// }; +/// +/// const DETAILS: ProblemDetails<'static> = WRONG_ACTOR_TYPE +/// .detail(Cow::Borrowed("This operation requires a machine actor.")) +/// .instance(Cow::Borrowed("/problem-occurrences/42")); /// ``` #[derive(Debug)] pub struct ProblemType { @@ -51,6 +58,9 @@ pub struct ProblemType { impl ProblemType { /// Creates an occurrence with a human-readable explanation. + /// + /// See [`ProblemDetails::detail()`] for an example borrowing an explanation assembled + /// at runtime. #[must_use] pub const fn detail<'a>( &'a self, @@ -60,6 +70,8 @@ impl ProblemType { } /// Creates an occurrence identified by the supplied URI reference. + /// + /// See [`ProblemDetails::instance()`] for an example using a URI built at runtime. #[must_use] pub const fn instance<'a>( &'a self, @@ -69,12 +81,27 @@ impl ProblemType { } /// Creates an occurrence with the supplied extension members. + /// + /// See [`ProblemDetails::extensions()`] for an example serializing typed extension members. #[must_use] pub const fn extensions(&self, extensions: E) -> ProblemDetails<'_, E> { ProblemDetails::from(self).extensions(extensions) } } +const impl<'a> From for ProblemDetails<'a> { + fn from(definition: ProblemType) -> Self { + Self { + type_uri: definition.type_uri, + title: definition.title, + status: definition.status.as_u16(), + detail: None, + instance: None, + extensions: NoExtensions {}, + } + } +} + const impl<'a> From<&'a ProblemType> for ProblemDetails<'a> { fn from(definition: &'a ProblemType) -> Self { Self { @@ -97,7 +124,7 @@ const impl<'a> From<&'a ProblemType> for ProblemDetails<'a> { #[cfg(test)] mod tests { use alloc::{borrow::Cow, string::String}; - use core::ptr; + use core::{assert_matches, ptr}; use crate::{ProblemDetails, ProblemType, StatusCode}; @@ -112,13 +139,13 @@ mod tests { }; let details = ProblemDetails::from(&definition); - assert!( - ptr::eq(details.type_uri.as_ref(), definition.type_uri.as_ref()), - "the type URI should reuse the definition's allocation" + 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!( - ptr::eq(details.title.as_ref(), definition.title.as_ref()), - "the title should reuse 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/extensions.rs b/libs/problematic/rust/tests/extensions.rs index e0d3bd41160..2da34c7250b 100644 --- a/libs/problematic/rust/tests/extensions.rs +++ b/libs/problematic/rust/tests/extensions.rs @@ -3,26 +3,21 @@ extern crate alloc; use alloc::{borrow::Cow, format}; use core::{cell::Cell, fmt}; -use problematic::ProblemDetails; +use problematic::{ProblemType, StatusCode}; use serde::{ Serialize, Serializer, ser::{Error as _, SerializeMap as _}, }; use serde_json::{Number, Value, json}; -const fn details(extensions: E) -> ProblemDetails<'static, E> { - ProblemDetails { - type_uri: Cow::Borrowed("about:blank"), - title: Cow::Borrowed("Bad Request"), - status: 400, - detail: None, - instance: None, - extensions, - } -} +const BAD_REQUEST: ProblemType = ProblemType { + type_uri: Cow::Borrowed("about:blank"), + title: Cow::Borrowed("Bad Request"), + status: StatusCode::BAD_REQUEST, +}; fn assert_serialization_error(extensions: E, expected: &str) { - let details = details(extensions); + let details = BAD_REQUEST.extensions(extensions); let string_error = serde_json::to_string(&details) .expect_err("the invalid extensions should fail to serialize to a string"); let value_error = serde_json::to_value(&details) @@ -40,7 +35,7 @@ fn assert_serialization_error(extensions: E, expected: &str) { } fn assert_serialization(extensions: E, expected: &Value) { - let details = details(extensions); + let details = BAD_REQUEST.extensions(extensions); let encoded = serde_json::to_string(&details).expect("the extensions should serialize to a string"); assert_eq!( @@ -272,7 +267,7 @@ impl Serialize for CountedKey<'_> { fn extensions_map_key_once() { for split in [false, true] { let calls = Cell::new(0); - let details = details(Entry { + let details = BAD_REQUEST.extensions(Entry { key: CountedKey(&calls), split, }); diff --git a/libs/problematic/rust/tests/problem_details.rs b/libs/problematic/rust/tests/problem_details.rs index 5d5b827e782..a0f410ebcb3 100644 --- a/libs/problematic/rust/tests/problem_details.rs +++ b/libs/problematic/rust/tests/problem_details.rs @@ -1,6 +1,7 @@ extern crate alloc; use alloc::{borrow::Cow, string::String, vec::Vec}; +use core::{assert_matches, ptr}; use problematic::{NoExtensions, ProblemDetails, ProblemType, StatusCode}; use serde::{Deserialize, Serialize}; @@ -55,7 +56,7 @@ fn details_default_type() { let details: ProblemDetails<'_, NoExtensions> = serde_json::from_str(encoded).expect("the response should deserialize without a type"); - assert!(matches!(details.type_uri, Cow::Borrowed("about:blank"))); + assert_matches!(details.type_uri, Cow::Borrowed("about:blank")); assert_eq!( serde_json::to_value(details).expect("the details should serialize"), json!({"type": "about:blank", "title": "Internal Server Error", "status": 500}) @@ -116,6 +117,23 @@ fn details_static_metadata() { let instance = String::from("https://example.com/problem-occurrences/42"); let details = INVALID_PARAMETERS.detail(&detail).instance(&instance); + assert_matches!( + details, + ProblemDetails { + type_uri: Cow::Borrowed(_), + title: Cow::Borrowed(_), + .. + }, + "the metadata should be borrowed from the definition" + ); + assert_matches!( + details.detail, Some(Cow::Borrowed(value)) if ptr::eq(value, detail.as_str()), + "the detail should borrow the original string" + ); + assert_matches!( + details.instance, Some(Cow::Borrowed(value)) if ptr::eq(value, instance.as_str()), + "the instance should borrow the original string" + ); assert_eq!( serde_json::to_value(&details).expect("the mixed-lifetime details should serialize"), json!({ @@ -145,11 +163,15 @@ fn details_borrowed_deserialize() { .expect("the response should deserialize from a byte slice"); for details in [from_string, from_slice] { - assert!( - matches!(details.type_uri, Cow::Borrowed(_)) - && matches!(details.title, Cow::Borrowed(_)) - && matches!(details.detail, Some(Cow::Borrowed(_))) - && matches!(details.instance, Some(Cow::Borrowed(_))), + assert_matches!( + details, + ProblemDetails { + type_uri: Cow::Borrowed(_), + title: Cow::Borrowed(_), + detail: Some(Cow::Borrowed(_)), + instance: Some(Cow::Borrowed(_)), + .. + }, "the strings should be borrowed from the input" ); for text in [ @@ -196,11 +218,15 @@ fn details_escaped_deserialize() { let details: ProblemDetails<'_, NoExtensions> = serde_json::from_str(encoded).expect("the escaped response should deserialize"); - assert!( - matches!(details.type_uri, Cow::Owned(_)) - && matches!(details.title, Cow::Owned(_)) - && matches!(details.detail, Some(Cow::Owned(_))) - && matches!(details.instance, Some(Cow::Owned(_))), + assert_matches!( + details, + ProblemDetails { + type_uri: Cow::Owned(_), + title: Cow::Owned(_), + detail: Some(Cow::Owned(_)), + instance: Some(Cow::Owned(_)), + .. + }, "the decoded strings should be owned" ); assert_eq!( @@ -233,11 +259,15 @@ fn details_owned_deserialize() { drop(encoded); for details in [from_value, from_reader] { - assert!( - matches!(details.type_uri, Cow::Owned(_)) - && matches!(details.title, Cow::Owned(_)) - && matches!(details.detail, Some(Cow::Owned(_))) - && matches!(details.instance, Some(Cow::Owned(_))), + assert_matches!( + details, + ProblemDetails { + type_uri: Cow::Owned(_), + title: Cow::Owned(_), + detail: Some(Cow::Owned(_)), + instance: Some(Cow::Owned(_)), + .. + }, "the deserialized strings should be owned" ); assert_eq!( @@ -322,15 +352,14 @@ fn details_extensions_invalid_types() { #[test] fn details_extensions_non_object_serialize() { + const BAD_REQUEST: ProblemType = ProblemType { + type_uri: Cow::Borrowed("about:blank"), + title: Cow::Borrowed("Bad Request"), + status: StatusCode::BAD_REQUEST, + }; + for extensions in [json!(42), json!("invalid"), json!(["invalid"])] { - let details = ProblemDetails { - type_uri: Cow::Borrowed("about:blank"), - title: Cow::Borrowed("Bad Request"), - status: 400, - detail: None, - instance: None, - extensions, - }; + let details = BAD_REQUEST.extensions(extensions); let error = serde_json::to_value(&details) .expect_err("the non-object extensions should fail to serialize");