From 53aa3aa45c8b06fc901e4689ac0a7ec7cc91e7ae Mon Sep 17 00:00:00 2001 From: Tim Diekmann Date: Fri, 11 Sep 2026 17:34:00 +0200 Subject: [PATCH 1/4] Add the initial problematic crate --- Cargo.lock | 12 ++ Cargo.toml | 2 + libs/problematic/rust/CHANGELOG.md | 7 + libs/problematic/rust/Cargo.toml | 35 ++++ libs/problematic/rust/LICENSE-APACHE.md | 189 ++++++++++++++++++ libs/problematic/rust/LICENSE-MIT.md | 21 ++ libs/problematic/rust/LICENSE.md | 5 + libs/problematic/rust/README.md | 15 ++ .../rust/docs/task-dependencies.json | 18 ++ libs/problematic/rust/package.json | 11 + libs/problematic/rust/src/definition.rs | 102 ++++++++++ libs/problematic/rust/src/lib.rs | 11 + .../problematic/rust/tests/problem_details.rs | 174 ++++++++++++++++ .../problem_details__schema__schema_base.snap | 6 + ...lem_details__schema__schema_base.snap.json | 54 +++++ ...ails__schema__schema_typed_extensions.snap | 6 + ..._schema__schema_typed_extensions.snap.json | 61 ++++++ yarn.lock | 6 + 18 files changed, 735 insertions(+) create mode 100644 libs/problematic/rust/CHANGELOG.md create mode 100644 libs/problematic/rust/Cargo.toml create mode 100644 libs/problematic/rust/LICENSE-APACHE.md create mode 100644 libs/problematic/rust/LICENSE-MIT.md create mode 100644 libs/problematic/rust/LICENSE.md create mode 100644 libs/problematic/rust/README.md create mode 100644 libs/problematic/rust/docs/task-dependencies.json create mode 100644 libs/problematic/rust/package.json create mode 100644 libs/problematic/rust/src/definition.rs create mode 100644 libs/problematic/rust/src/lib.rs create mode 100644 libs/problematic/rust/tests/problem_details.rs create mode 100644 libs/problematic/rust/tests/snapshots/problem_details__schema__schema_base.snap create mode 100644 libs/problematic/rust/tests/snapshots/problem_details__schema__schema_base.snap.json create mode 100644 libs/problematic/rust/tests/snapshots/problem_details__schema__schema_typed_extensions.snap create mode 100644 libs/problematic/rust/tests/snapshots/problem_details__schema__schema_typed_extensions.snap.json diff --git a/Cargo.lock b/Cargo.lock index 0317b77037a..ca9cdb11272 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10860,6 +10860,18 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "problematic" +version = "0.0.0-reserved" +dependencies = [ + "http 1.4.2", + "insta", + "schemars 1.2.1", + "serde", + "serde_core", + "serde_json", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" diff --git a/Cargo.toml b/Cargo.toml index 99d1307bd39..d738d2f9350 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ members = [ "libs/darwin-kperf/sys", "libs/error-stack", "libs/error-stack/macros", + "libs/problematic/rust", "tests/graph/benches", "tests/graph/integration", "tests/graph/test-data/rust", @@ -106,6 +107,7 @@ hashql-hir.path = "libs/@local/hashql/hir" hashql-macros.path = "libs/@local/hashql/macros" hashql-mir.path = "libs/@local/hashql/mir" hashql-syntax-jexpr.path = "libs/@local/hashql/syntax-jexpr" +problematic.path = "libs/problematic/rust" type-system.path = "libs/@blockprotocol/type-system/rust" # External dependencies diff --git a/libs/problematic/rust/CHANGELOG.md b/libs/problematic/rust/CHANGELOG.md new file mode 100644 index 00000000000..00db2bc2f84 --- /dev/null +++ b/libs/problematic/rust/CHANGELOG.md @@ -0,0 +1,7 @@ +# Change Log + +All notable changes to `problematic` will be documented in this file. + +## 0.1.0 - Unreleased + +- Initial release diff --git a/libs/problematic/rust/Cargo.toml b/libs/problematic/rust/Cargo.toml new file mode 100644 index 00000000000..7bcc1419e32 --- /dev/null +++ b/libs/problematic/rust/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "problematic" +version = "0.0.0-reserved" +authors.workspace = true +edition.workspace = true +description = "HTTP Problem Details with typed extensions and JSON Schema support" +license = "MIT OR Apache-2.0" +documentation = "https://docs.rs/problematic" +repository = "https://github.com/hashintel/hash/tree/main/libs/problematic/rust" +exclude = ["package.json", "turbo.json", "docs/task-dependencies.json"] + +[dependencies] +# Public workspace dependencies + +# Public third-party dependencies +http = { workspace = true, public = true, features = ["std"] } +serde_core = { workspace = true, public = true } + +# Public third-party dependencies (optional) +schemars = { workspace = true, public = true, optional = true } + +# Private workspace dependencies + +# Private third-party dependencies +serde = { workspace = true, features = ["alloc", "derive"] } + +[dev-dependencies] +insta = { workspace = true, features = ["json"] } +serde_json = { workspace = true } + +[features] +schemars = ["dep:schemars"] + +[lints] +workspace = true diff --git a/libs/problematic/rust/LICENSE-APACHE.md b/libs/problematic/rust/LICENSE-APACHE.md new file mode 100644 index 00000000000..1d1b6f8da4d --- /dev/null +++ b/libs/problematic/rust/LICENSE-APACHE.md @@ -0,0 +1,189 @@ +# Apache License + +_Version 2.0, January 2004_ +_<>_ + +### Terms and Conditions for use, reproduction, and distribution + +#### 1. Definitions + +“License” shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +“Licensor” shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +“Legal Entity” shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, “control” means **(i)** the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the +outstanding shares, or **(iii)** beneficial ownership of such entity. + +“You” (or “Your”) shall mean an individual or Legal Entity exercising +permissions granted by this License. + +“Source” form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +“Object” form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +“Work” shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +“Derivative Works” shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +“Contribution” shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +“submitted” means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as “Not a Contribution.” + +“Contributor” shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +#### 2. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +#### 3. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +#### 4. Redistribution + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +- **(a)** You must give any other recipients of the Work or Derivative Works a copy of + this License; and +- **(b)** You must cause any modified files to carry prominent notices stating that You + changed the files; and +- **(c)** You must retain, in the Source form of any Derivative Works that You distribute, + all copyright, patent, trademark, and attribution notices from the Source form + of the Work, excluding those notices that do not pertain to any part of the + Derivative Works; and +- **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any + Derivative Works that You distribute must include a readable copy of the + attribution notices contained within such NOTICE file, excluding those notices + that do not pertain to any part of the Derivative Works, in at least one of the + following places: within a NOTICE text file distributed as part of the + Derivative Works; within the Source form or documentation, if provided along + with the Derivative Works; or, within a display generated by the Derivative + Works, if and wherever such third-party notices normally appear. The contents of + the NOTICE file are for informational purposes only and do not modify the + License. You may add Your own attribution notices within Derivative Works that + You distribute, alongside or as an addendum to the NOTICE text from the Work, + provided that such additional attribution notices cannot be construed as + modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +#### 5. Submission of Contributions + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +#### 6. Trademarks + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +#### 7. Disclaimer of Warranty + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +#### 8. Limitation of Liability + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +#### 9. Accepting Warranty or Additional Liability + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +_END OF TERMS AND CONDITIONS_ + +### APPENDIX: Apply the Apache License to a specific file + +To apply the Apache License to an individual file, attach the following notice. +The text should be enclosed in the appropriate comment syntax for the file +format. + + Copyright © 2022–, HASH + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/libs/problematic/rust/LICENSE-MIT.md b/libs/problematic/rust/LICENSE-MIT.md new file mode 100644 index 00000000000..e33c1d34b0e --- /dev/null +++ b/libs/problematic/rust/LICENSE-MIT.md @@ -0,0 +1,21 @@ +# MIT License + +Copyright © 2022–, HASH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/libs/problematic/rust/LICENSE.md b/libs/problematic/rust/LICENSE.md new file mode 100644 index 00000000000..8b31346dd55 --- /dev/null +++ b/libs/problematic/rust/LICENSE.md @@ -0,0 +1,5 @@ +# License + +Licensed under either of the [Apache License, Version 2.0](LICENSE-APACHE.md) or [MIT license](LICENSE-MIT.md) at your option. + +For more information about contributing to this crate, see our top-level [CONTRIBUTING](https://github.com/hashintel/hash/blob/main/.github/CONTRIBUTING.md) policy. diff --git a/libs/problematic/rust/README.md b/libs/problematic/rust/README.md new file mode 100644 index 00000000000..245ddee342c --- /dev/null +++ b/libs/problematic/rust/README.md @@ -0,0 +1,15 @@ +# problematic + +HTTP Problem Details with typed extension members. + +## Contributors + +`problematic` was created and is maintained by [HASH](https://hash.dev/). As an open-source project, we gratefully accept external contributions and have published a [contributing guide](https://github.com/hashintel/hash/blob/main/.github/CONTRIBUTING.md) that outlines the process. If you have questions, please create a [GitHub discussion](https://github.com/orgs/hashintel/discussions). + +## License + +`problematic` is available under either of the [Apache License, Version 2.0] or [MIT license] at your option. Please see the [LICENSE] file for more information. + +[Apache License, Version 2.0]: LICENSE-APACHE.md +[MIT license]: LICENSE-MIT.md +[LICENSE]: LICENSE.md diff --git a/libs/problematic/rust/docs/task-dependencies.json b/libs/problematic/rust/docs/task-dependencies.json new file mode 100644 index 00000000000..df0316a8f2c --- /dev/null +++ b/libs/problematic/rust/docs/task-dependencies.json @@ -0,0 +1,18 @@ +{ + "package": "@rust/problematic", + "dependencies": [], + "tasks": { + "lint:clippy": { + "dependsOn": [], + "env": [ + "GITHUB_EVENT_NAME" + ] + }, + "test:unit": { + "dependsOn": [], + "env": [ + "TEST_COVERAGE" + ] + } + } +} diff --git a/libs/problematic/rust/package.json b/libs/problematic/rust/package.json new file mode 100644 index 00000000000..2daae9787c7 --- /dev/null +++ b/libs/problematic/rust/package.json @@ -0,0 +1,11 @@ +{ + "name": "@rust/problematic", + "version": "0.0.0-reserved", + "private": true, + "description": "HTTP Problem Details with typed extensions and JSON Schema support", + "license": "MIT OR Apache-2.0", + "scripts": { + "lint:clippy": "just clippy", + "test:unit": "mise run test:unit @rust/problematic" + } +} diff --git a/libs/problematic/rust/src/definition.rs b/libs/problematic/rust/src/definition.rs new file mode 100644 index 00000000000..79db41cc09d --- /dev/null +++ b/libs/problematic/rust/src/definition.rs @@ -0,0 +1,102 @@ +use alloc::{borrow::Cow, string::String}; + +use http::StatusCode; +use serde::{Deserialize, Serialize}; + +/// An empty object for problem types without extensions. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect( + clippy::empty_structs_with_brackets, + reason = "Serde must emit an object, not null." +)] +pub struct NoExtensions {} + +/// Shared metadata for rendering and documenting a problem type. +/// +/// Definitions can borrow static text or own values constructed at runtime. Extension types +/// are supplied separately when creating an occurrence or registering its schema. +/// +/// ``` +/// use std::borrow::Cow; +/// +/// use http::StatusCode; +/// use problematic::ProblemType; +/// +/// 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, +/// }; +/// ``` +#[derive(Debug)] +pub struct ProblemType { + /// The stable URI identifying this problem type. + pub type_uri: Cow<'static, str>, + /// The title shared by occurrences of this problem type. + pub title: Cow<'static, str>, + /// The HTTP status used for occurrences and their response documentation. + pub status: StatusCode, +} + +/// An RFC 9457 problem details object with problem-specific extension members. +/// +/// Responses always include `type`, `title`, and `status`. `detail` and `instance` are included +/// only when supplied. +#[derive(Clone, Serialize, Deserialize)] +#[cfg_attr( + feature = "schemars", + derive(schemars::JsonSchema), + schemars(title = "Problem Details") +)] +pub struct ProblemDetails { + /// A URI reference identifying the problem type. `about:blank` means the problem has no + /// additional semantics beyond its HTTP status code. + #[serde(rename = "type")] + #[cfg_attr( + feature = "schemars", + schemars( + extend("format" = "uri-reference", "default" = "about:blank"), + example = "https://example.com/problems/wrong-actor-type" + ) + )] + pub type_uri: Cow<'static, str>, + + /// A short, human-readable summary of the problem type. It should remain the same across + /// occurrences, except for localization. + #[cfg_attr(feature = "schemars", schemars(example = "Wrong actor type"))] + pub title: Cow<'static, str>, + + /// The HTTP status code sent by the origin server for this occurrence. + #[cfg_attr( + feature = "schemars", + schemars(range(min = 100, max = 599), example = 403) + )] + pub status: u16, + + /// A human-readable explanation of this occurrence that helps the client correct the problem. + /// Clients should use extension members for structured information. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr( + feature = "schemars", + schemars(required, example = "This operation requires a machine actor.") + )] + pub detail: Option, + + /// A URI reference identifying this occurrence. It may identify the occurrence without + /// resolving to further information. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr( + feature = "schemars", + schemars( + required, + extend("format" = "uri-reference"), + example = "https://example.com/problem-occurrences/01J8M6Y7P9" + ) + )] + pub instance: Option, + + /// Problem-specific members included alongside the standard fields. + #[serde(flatten)] + pub extensions: E, +} diff --git a/libs/problematic/rust/src/lib.rs b/libs/problematic/rust/src/lib.rs new file mode 100644 index 00000000000..0bd18155790 --- /dev/null +++ b/libs/problematic/rust/src/lib.rs @@ -0,0 +1,11 @@ +//! HTTP Problem Details with typed extension members. +//! +//! [`ProblemType`] describes a problem's shared metadata. [`ProblemDetails`] represents an +//! occurrence as a JSON object, with extension members alongside the standard fields. +//! The `schemars` feature adds JSON Schema support to [`ProblemDetails`] and [`NoExtensions`]. + +extern crate alloc; + +mod definition; + +pub use self::definition::{NoExtensions, ProblemDetails, ProblemType}; diff --git a/libs/problematic/rust/tests/problem_details.rs b/libs/problematic/rust/tests/problem_details.rs new file mode 100644 index 00000000000..8b6ea0b5773 --- /dev/null +++ b/libs/problematic/rust/tests/problem_details.rs @@ -0,0 +1,174 @@ +extern crate alloc; + +use alloc::{borrow::Cow, string::String, vec::Vec}; + +use problematic::{NoExtensions, ProblemDetails}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +struct InvalidParameters { + parameters: Vec, +} + +/// Typed extensions and occurrence fields survive reading and writing a response document. +#[test] +fn details_typed_extensions() { + let body = json!({ + "type": "https://example.com/problems/invalid-parameters", + "title": "Invalid parameters", + "status": 400, + "detail": "The limit must be a positive integer.", + "instance": "https://example.com/problem-occurrences/01J8M6Y7P9", + "parameters": ["limit"] + }); + let encoded = serde_json::to_string(&body).expect("the response should serialize"); + let details: ProblemDetails = serde_json::from_str(&encoded) + .expect("the response should deserialize with typed extensions"); + assert_eq!( + details.extensions.parameters, + ["limit"], + "the extension should retain its typed fields" + ); + assert_eq!( + serde_json::to_value(details).expect("the details should serialize"), + body, + "the roundtrip should preserve standard fields and flattened extensions" + ); +} + +/// Missing occurrence fields remain absent when a response without extensions is read and written. +#[test] +fn details_empty_extensions() { + let body = json!({"type": "about:blank", "title": "Internal Server Error", "status": 500}); + let encoded = serde_json::to_string(&body).expect("the response should serialize"); + let details: ProblemDetails = + serde_json::from_str(&encoded).expect("the response should deserialize without extensions"); + assert_eq!( + serde_json::to_value(details).expect("the details should serialize"), + body, + "the roundtrip should preserve omitted occurrence fields" + ); +} + +/// Flattening preserves required fields of the extension type. +#[test] +fn details_extensions_missing() { + let body = json!({"type": "about:blank", "title": "Bad Request", "status": 400}); + let error = serde_json::from_value::>(body) + .err() + .expect("the missing extension field should fail to deserialize"); + + assert_eq!(error.to_string(), "missing field `parameters`"); +} + +/// Both the extension container and its elements retain their declared types. +#[test] +fn details_extensions_invalid_types() { + for (parameters, expected) in [ + (json!(null), "invalid type: null, expected a sequence"), + (json!(42), "invalid type: integer `42`, expected a sequence"), + (json!([42]), "invalid type: integer `42`, expected a string"), + ] { + let body = json!({ + "type": "about:blank", + "title": "Bad Request", + "status": 400, + "parameters": parameters + }); + let error = serde_json::from_value::>(body) + .err() + .expect("the invalid extension field should fail to deserialize"); + + assert_eq!(error.to_string(), expected); + } +} + +/// Scalar and sequence values cannot supply flattened object members. +#[test] +fn details_extensions_non_object_serialize() { + 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 error = serde_json::to_value(&details) + .expect_err("the non-object extensions should fail to serialize"); + + assert!( + error + .to_string() + .starts_with("can only flatten structs and maps"), + "the error should identify the unsupported extension shape: {error}" + ); + } +} + +/// Object members cannot be read into a scalar extension type. +#[test] +fn details_extensions_non_object_deserialize() { + let body = json!({"type": "about:blank", "title": "Bad Request", "status": 400}); + let error = serde_json::from_value::>(body) + .err() + .expect("the scalar extension type should fail to deserialize"); + + assert_eq!(error.to_string(), "can only flatten structs and maps"); +} + +#[cfg(feature = "schemars")] +mod schema { + use problematic::{NoExtensions, ProblemDetails}; + use serde_json::json; + + use super::InvalidParameters; + + macro_rules! assert_schema_snapshot { + ($type:ty) => {{ + let schema = schemars::schema_for!($type); + insta::assert_binary_snapshot!( + ".json", + serde_json::to_vec_pretty(&schema).expect("the schema should serialize") + ); + }}; + } + + #[test] + fn schema_base() { + assert_schema_snapshot!(ProblemDetails); + } + + #[test] + fn schema_typed_extensions() { + assert_schema_snapshot!(ProblemDetails); + } + + #[test] + fn schema_occurrence_members() { + let schema = schemars::schema_for!(ProblemDetails).to_value(); + let minimal = + json!({"type": "about:blank", "title": "Internal Server Error", "status": 500}); + let details: ProblemDetails = + serde_json::from_value(minimal).expect("the minimal response should deserialize"); + let minimal = serde_json::to_value(details).expect("the details should serialize"); + let required = schema["required"] + .as_array() + .expect("the response schema should declare required members"); + let members = minimal + .as_object() + .expect("the response should be an object"); + assert_eq!(required.len(), members.len()); + for name in members.keys() { + assert!(required.contains(&json!(name))); + } + + for name in ["detail", "instance"] { + assert!(!required.contains(&json!(name))); + assert_eq!(schema["properties"][name]["type"], "string"); + } + } +} diff --git a/libs/problematic/rust/tests/snapshots/problem_details__schema__schema_base.snap b/libs/problematic/rust/tests/snapshots/problem_details__schema__schema_base.snap new file mode 100644 index 00000000000..4b51f581edd --- /dev/null +++ b/libs/problematic/rust/tests/snapshots/problem_details__schema__schema_base.snap @@ -0,0 +1,6 @@ +--- +source: libs/problematic/rust/tests/problem_details.rs +expression: serde_json::to_vec_pretty(&schema).expect("the schema should serialize") +extension: json +snapshot_kind: binary +--- diff --git a/libs/problematic/rust/tests/snapshots/problem_details__schema__schema_base.snap.json b/libs/problematic/rust/tests/snapshots/problem_details__schema__schema_base.snap.json new file mode 100644 index 00000000000..b297c1515b7 --- /dev/null +++ b/libs/problematic/rust/tests/snapshots/problem_details__schema__schema_base.snap.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Problem Details", + "description": "An RFC 9457 problem details object with problem-specific extension members.\n\nResponses always include `type`, `title`, and `status`. `detail` and `instance` are included\nonly when supplied.", + "type": "object", + "properties": { + "detail": { + "description": "A human-readable explanation of this occurrence that helps the client correct the problem.\nClients should use extension members for structured information.", + "type": "string", + "examples": [ + "This operation requires a machine actor." + ] + }, + "instance": { + "description": "A URI reference identifying this occurrence. It may identify the occurrence without\nresolving to further information.", + "type": "string", + "format": "uri-reference", + "examples": [ + "https://example.com/problem-occurrences/01J8M6Y7P9" + ] + }, + "status": { + "description": "The HTTP status code sent by the origin server for this occurrence.", + "type": "integer", + "format": "uint16", + "examples": [ + 403 + ], + "maximum": 599, + "minimum": 100 + }, + "title": { + "description": "A short, human-readable summary of the problem type. It should remain the same across\noccurrences, except for localization.", + "type": "string", + "examples": [ + "Wrong actor type" + ] + }, + "type": { + "description": "A URI reference identifying the problem type. `about:blank` means the problem has no\nadditional semantics beyond its HTTP status code.", + "type": "string", + "format": "uri-reference", + "default": "about:blank", + "examples": [ + "https://example.com/problems/wrong-actor-type" + ] + } + }, + "required": [ + "type", + "title", + "status" + ] +} \ No newline at end of file diff --git a/libs/problematic/rust/tests/snapshots/problem_details__schema__schema_typed_extensions.snap b/libs/problematic/rust/tests/snapshots/problem_details__schema__schema_typed_extensions.snap new file mode 100644 index 00000000000..2a259d4fba9 --- /dev/null +++ b/libs/problematic/rust/tests/snapshots/problem_details__schema__schema_typed_extensions.snap @@ -0,0 +1,6 @@ +--- +source: libs/problematic/rust/tests/problem_details.rs +expression: "serde_json :: to_vec_pretty(& schema).expect(\"the schema should serialize\")" +extension: json +snapshot_kind: binary +--- diff --git a/libs/problematic/rust/tests/snapshots/problem_details__schema__schema_typed_extensions.snap.json b/libs/problematic/rust/tests/snapshots/problem_details__schema__schema_typed_extensions.snap.json new file mode 100644 index 00000000000..63f5e51e243 --- /dev/null +++ b/libs/problematic/rust/tests/snapshots/problem_details__schema__schema_typed_extensions.snap.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Problem Details", + "description": "An RFC 9457 problem details object with problem-specific extension members.\n\nResponses always include `type`, `title`, and `status`. `detail` and `instance` are included\nonly when supplied.", + "type": "object", + "properties": { + "detail": { + "description": "A human-readable explanation of this occurrence that helps the client correct the problem.\nClients should use extension members for structured information.", + "type": "string", + "examples": [ + "This operation requires a machine actor." + ] + }, + "instance": { + "description": "A URI reference identifying this occurrence. It may identify the occurrence without\nresolving to further information.", + "type": "string", + "format": "uri-reference", + "examples": [ + "https://example.com/problem-occurrences/01J8M6Y7P9" + ] + }, + "parameters": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "description": "The HTTP status code sent by the origin server for this occurrence.", + "type": "integer", + "format": "uint16", + "examples": [ + 403 + ], + "maximum": 599, + "minimum": 100 + }, + "title": { + "description": "A short, human-readable summary of the problem type. It should remain the same across\noccurrences, except for localization.", + "type": "string", + "examples": [ + "Wrong actor type" + ] + }, + "type": { + "description": "A URI reference identifying the problem type. `about:blank` means the problem has no\nadditional semantics beyond its HTTP status code.", + "type": "string", + "format": "uri-reference", + "default": "about:blank", + "examples": [ + "https://example.com/problems/wrong-actor-type" + ] + } + }, + "required": [ + "type", + "title", + "status", + "parameters" + ] +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 7d4c19bdd98..b5cd99f6408 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15427,6 +15427,12 @@ __metadata: languageName: unknown linkType: soft +"@rust/problematic@workspace:libs/problematic/rust": + version: 0.0.0-use.local + resolution: "@rust/problematic@workspace:libs/problematic/rust" + languageName: unknown + linkType: soft + "@sec-ant/readable-stream@npm:^0.4.1": version: 0.4.1 resolution: "@sec-ant/readable-stream@npm:0.4.1" From b4113f7c0531f23be1fcadf6fdb0374d95d1a181 Mon Sep 17 00:00:00 2001 From: Tim Diekmann Date: Fri, 11 Sep 2026 18:29:31 +0200 Subject: [PATCH 2/4] Validate problem extensions and support borrowed details --- libs/problematic/rust/src/definition.rs | 24 +- libs/problematic/rust/src/lib.rs | 5 + .../problematic/rust/src/serialization/key.rs | 157 ++++++++ .../problematic/rust/src/serialization/mod.rs | 227 ++++++++++++ libs/problematic/rust/tests/extensions.rs | 338 ++++++++++++++++++ .../problematic/rust/tests/problem_details.rs | 92 ++++- 6 files changed, 822 insertions(+), 21 deletions(-) create mode 100644 libs/problematic/rust/src/serialization/key.rs create mode 100644 libs/problematic/rust/src/serialization/mod.rs create mode 100644 libs/problematic/rust/tests/extensions.rs diff --git a/libs/problematic/rust/src/definition.rs b/libs/problematic/rust/src/definition.rs index 79db41cc09d..fb79bc69b87 100644 --- a/libs/problematic/rust/src/definition.rs +++ b/libs/problematic/rust/src/definition.rs @@ -1,8 +1,10 @@ -use alloc::{borrow::Cow, string::String}; +use alloc::borrow::Cow; use http::StatusCode; use serde::{Deserialize, Serialize}; +use crate::serialization::serialize_extensions; + /// An empty object for problem types without extensions. #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] @@ -49,7 +51,7 @@ pub struct ProblemType { derive(schemars::JsonSchema), schemars(title = "Problem Details") )] -pub struct ProblemDetails { +pub struct ProblemDetails<'kind, 'occurrence, E> { /// A URI reference identifying the problem type. `about:blank` means the problem has no /// additional semantics beyond its HTTP status code. #[serde(rename = "type")] @@ -60,12 +62,12 @@ pub struct ProblemDetails { example = "https://example.com/problems/wrong-actor-type" ) )] - pub type_uri: Cow<'static, str>, + pub type_uri: Cow<'kind, str>, /// A short, human-readable summary of the problem type. It should remain the same across /// occurrences, except for localization. #[cfg_attr(feature = "schemars", schemars(example = "Wrong actor type"))] - pub title: Cow<'static, str>, + pub title: Cow<'kind, str>, /// The HTTP status code sent by the origin server for this occurrence. #[cfg_attr( @@ -81,7 +83,7 @@ pub struct ProblemDetails { feature = "schemars", schemars(required, example = "This operation requires a machine actor.") )] - pub detail: Option, + pub detail: Option>, /// A URI reference identifying this occurrence. It may identify the occurrence without /// resolving to further information. @@ -94,9 +96,17 @@ pub struct ProblemDetails { example = "https://example.com/problem-occurrences/01J8M6Y7P9" ) )] - pub instance: Option, + pub instance: Option>, /// Problem-specific members included alongside the standard fields. - #[serde(flatten)] + /// + /// Serialization fails if the extensions are not an object, contain `type`, `title`, `status`, + /// `detail`, or `instance` at their top level, or fail to serialize themselves. Nested members + /// may use these names. The extension type must support Serde flattening for deserialization. + #[serde( + flatten, + serialize_with = "serialize_extensions", + bound(serialize = "E: Serialize") + )] pub extensions: E, } diff --git a/libs/problematic/rust/src/lib.rs b/libs/problematic/rust/src/lib.rs index 0bd18155790..abeabd2b24c 100644 --- a/libs/problematic/rust/src/lib.rs +++ b/libs/problematic/rust/src/lib.rs @@ -3,9 +3,14 @@ //! [`ProblemType`] describes a problem's shared metadata. [`ProblemDetails`] represents an //! occurrence as a JSON object, with extension members alongside the standard fields. //! The `schemars` feature adds JSON Schema support to [`ProblemDetails`] and [`NoExtensions`]. +//! +//! [`ProblemDetails`] can borrow the type URI and title from shared metadata and detail and +//! instance from the occurrence, using separate lifetimes. Deserialization produces owned strings +//! for these fields. extern crate alloc; mod definition; +mod serialization; pub use self::definition::{NoExtensions, ProblemDetails, ProblemType}; diff --git a/libs/problematic/rust/src/serialization/key.rs b/libs/problematic/rust/src/serialization/key.rs new file mode 100644 index 00000000000..35174d47ed9 --- /dev/null +++ b/libs/problematic/rust/src/serialization/key.rs @@ -0,0 +1,157 @@ +use alloc::string::String; +use core::fmt::{Display, Write as _}; + +use serde::{Serialize, Serializer, ser::Error as _}; + +use super::{ExtensionKey, check_member}; + +impl Serialize for ExtensionKey<'_, T> { + fn serialize(&self, serializer: S) -> Result { + self.0.serialize(KeySerializer(serializer)) + } +} + +struct KeySerializer(S); + +macro_rules! forward_scalar { + ($($method:ident($type:ty)),* $(,)?) => { + $( + fn $method(self, value: $type) -> Result { + self.0.$method(value) + } + )* + }; +} + +impl Serializer for KeySerializer { + type Error = S::Error; + type Ok = S::Ok; + type SerializeMap = S::SerializeMap; + type SerializeSeq = S::SerializeSeq; + type SerializeStruct = S::SerializeStruct; + type SerializeStructVariant = S::SerializeStructVariant; + type SerializeTuple = S::SerializeTuple; + type SerializeTupleStruct = S::SerializeTupleStruct; + type SerializeTupleVariant = S::SerializeTupleVariant; + + forward_scalar! { + serialize_bool(bool), + serialize_i8(i8), serialize_i16(i16), serialize_i32(i32), serialize_i64(i64), + serialize_i128(i128), + serialize_u8(u8), serialize_u16(u16), serialize_u32(u32), serialize_u64(u64), + serialize_u128(u128), + serialize_f32(f32), serialize_f64(f64), + serialize_char(char), serialize_bytes(&[u8]), + } + + fn serialize_str(self, v: &str) -> Result { + check_member::(v)?; + self.0.serialize_str(v) + } + + fn serialize_none(self) -> Result { + self.0.serialize_none() + } + + fn serialize_some(self, value: &T) -> Result { + self.0.serialize_some(&ExtensionKey(value)) + } + + fn serialize_unit(self) -> Result { + self.0.serialize_unit() + } + + fn serialize_unit_struct(self, name: &'static str) -> Result { + self.0.serialize_unit_struct(name) + } + + fn serialize_unit_variant( + self, + name: &'static str, + variant_index: u32, + variant: &'static str, + ) -> Result { + check_member::(variant)?; + self.0.serialize_unit_variant(name, variant_index, variant) + } + + fn serialize_newtype_struct( + self, + name: &'static str, + value: &T, + ) -> Result { + self.0.serialize_newtype_struct(name, &ExtensionKey(value)) + } + + fn serialize_newtype_variant( + self, + name: &'static str, + variant_index: u32, + variant: &'static str, + value: &T, + ) -> Result { + self.0 + .serialize_newtype_variant(name, variant_index, variant, value) + } + + fn serialize_seq(self, len: Option) -> Result { + self.0.serialize_seq(len) + } + + fn serialize_tuple(self, len: usize) -> Result { + self.0.serialize_tuple(len) + } + + fn serialize_tuple_struct( + self, + name: &'static str, + len: usize, + ) -> Result { + self.0.serialize_tuple_struct(name, len) + } + + fn serialize_tuple_variant( + self, + name: &'static str, + variant_index: u32, + variant: &'static str, + len: usize, + ) -> Result { + self.0 + .serialize_tuple_variant(name, variant_index, variant, len) + } + + fn serialize_map(self, len: Option) -> Result { + self.0.serialize_map(len) + } + + fn serialize_struct( + self, + name: &'static str, + len: usize, + ) -> Result { + self.0.serialize_struct(name, len) + } + + fn serialize_struct_variant( + self, + name: &'static str, + variant_index: u32, + variant: &'static str, + len: usize, + ) -> Result { + self.0 + .serialize_struct_variant(name, variant_index, variant, len) + } + + fn collect_str(self, value: &T) -> Result { + // Format once so that validation checks the exact key passed to the serializer. + let mut key = String::new(); + write!(&mut key, "{value}").map_err(Self::Error::custom)?; + self.serialize_str(&key) + } + + fn is_human_readable(&self) -> bool { + self.0.is_human_readable() + } +} diff --git a/libs/problematic/rust/src/serialization/mod.rs b/libs/problematic/rust/src/serialization/mod.rs new file mode 100644 index 00000000000..73943741dc6 --- /dev/null +++ b/libs/problematic/rust/src/serialization/mod.rs @@ -0,0 +1,227 @@ +use core::fmt::Display; + +use serde::{ + Serialize, Serializer, + ser::{Error, Impossible, SerializeMap, SerializeStruct}, +}; + +mod key; + +struct ExtensionKey<'a, T: ?Sized>(&'a T); + +/// Serializes an extension object, rejecting non-objects and reserved member names. +/// +/// # Errors +/// +/// Returns the serializer's error for invalid extensions or failures in the underlying serializer. +pub(crate) fn serialize_extensions( + extensions: &E, + serializer: S, +) -> Result { + extensions.serialize(ExtensionSerializer(serializer)) +} + +fn check_member(name: &str) -> Result<(), E> { + if matches!(name, "type" | "title" | "status" | "detail" | "instance") { + return Err(E::custom(format_args!( + "problem extension `{name}` conflicts with a standard member" + ))); + } + Ok(()) +} + +struct ExtensionSerializer(S); + +macro_rules! reject_scalar { + ($($method:ident($type:ty)),* $(,)?) => { + $( + fn $method(self, _: $type) -> Result { + Err(Self::Error::custom("problem extensions must serialize as an object")) + } + )* + }; +} + +impl Serializer for ExtensionSerializer { + type Error = S::Error; + type Ok = S::Ok; + type SerializeMap = ExtensionMembers; + type SerializeSeq = Impossible; + type SerializeStruct = ExtensionMembers; + type SerializeStructVariant = S::SerializeStructVariant; + type SerializeTuple = Impossible; + type SerializeTupleStruct = Impossible; + type SerializeTupleVariant = S::SerializeTupleVariant; + + reject_scalar! { + serialize_bool(bool), + serialize_i8(i8), serialize_i16(i16), serialize_i32(i32), serialize_i64(i64), + serialize_i128(i128), + serialize_u8(u8), serialize_u16(u16), serialize_u32(u32), serialize_u64(u64), + serialize_u128(u128), + serialize_f32(f32), serialize_f64(f64), + serialize_char(char), serialize_str(&str), serialize_bytes(&[u8]), + } + + fn serialize_none(self) -> Result { + self.serialize_unit() + } + + fn serialize_some(self, value: &T) -> Result { + value.serialize(self) + } + + fn serialize_unit(self) -> Result { + Err(Self::Error::custom( + "problem extensions must serialize as an object", + )) + } + + fn serialize_unit_struct(self, _: &'static str) -> Result { + self.serialize_unit() + } + + fn serialize_unit_variant( + self, + _: &'static str, + _: u32, + _: &'static str, + ) -> Result { + self.serialize_unit() + } + + fn serialize_newtype_struct( + self, + _: &'static str, + value: &T, + ) -> Result { + value.serialize(self) + } + + fn serialize_newtype_variant( + self, + name: &'static str, + variant_index: u32, + variant: &'static str, + value: &T, + ) -> Result { + check_member::(variant)?; + self.0 + .serialize_newtype_variant(name, variant_index, variant, value) + } + + fn serialize_seq(self, _: Option) -> Result { + Err(Self::Error::custom( + "problem extensions must serialize as an object", + )) + } + + fn serialize_tuple(self, _: usize) -> Result { + Err(Self::Error::custom( + "problem extensions must serialize as an object", + )) + } + + fn serialize_tuple_struct( + self, + _: &'static str, + _: usize, + ) -> Result { + Err(Self::Error::custom( + "problem extensions must serialize as an object", + )) + } + + fn serialize_tuple_variant( + self, + name: &'static str, + variant_index: u32, + variant: &'static str, + len: usize, + ) -> Result { + check_member::(variant)?; + self.0 + .serialize_tuple_variant(name, variant_index, variant, len) + } + + fn serialize_map(self, len: Option) -> Result { + self.0.serialize_map(len).map(ExtensionMembers) + } + + fn serialize_struct( + self, + name: &'static str, + len: usize, + ) -> Result { + self.0.serialize_struct(name, len).map(ExtensionMembers) + } + + fn serialize_struct_variant( + self, + name: &'static str, + variant_index: u32, + variant: &'static str, + len: usize, + ) -> Result { + check_member::(variant)?; + self.0 + .serialize_struct_variant(name, variant_index, variant, len) + } + + fn collect_str(self, _: &T) -> Result { + self.serialize_unit() + } + + fn is_human_readable(&self) -> bool { + self.0.is_human_readable() + } +} + +struct ExtensionMembers(S); + +impl SerializeStruct for ExtensionMembers { + type Error = S::Error; + type Ok = S::Ok; + + fn serialize_field( + &mut self, + key: &'static str, + value: &T, + ) -> Result<(), Self::Error> { + check_member::(key)?; + self.0.serialize_field(key, value) + } + + fn skip_field(&mut self, key: &'static str) -> Result<(), Self::Error> { + self.0.skip_field(key) + } + + fn end(self) -> Result { + self.0.end() + } +} + +impl SerializeMap for ExtensionMembers { + type Error = S::Error; + type Ok = S::Ok; + + fn serialize_key(&mut self, key: &T) -> Result<(), Self::Error> { + self.0.serialize_key(&ExtensionKey(key)) + } + + fn serialize_value(&mut self, value: &T) -> Result<(), Self::Error> { + self.0.serialize_value(value) + } + + fn serialize_entry( + &mut self, + key: &K, + value: &V, + ) -> Result<(), Self::Error> { + self.0.serialize_entry(&ExtensionKey(key), value) + } + + fn end(self) -> Result { + self.0.end() + } +} diff --git a/libs/problematic/rust/tests/extensions.rs b/libs/problematic/rust/tests/extensions.rs new file mode 100644 index 00000000000..375bc815fdb --- /dev/null +++ b/libs/problematic/rust/tests/extensions.rs @@ -0,0 +1,338 @@ +extern crate alloc; + +use alloc::{borrow::Cow, format}; +use core::{cell::Cell, fmt}; + +use problematic::ProblemDetails; +use serde::{ + Serialize, Serializer, + ser::{Error as _, SerializeMap as _}, +}; +use serde_json::{Value, json}; + +const fn details(extensions: E) -> ProblemDetails<'static, 'static, E> { + ProblemDetails { + type_uri: Cow::Borrowed("about:blank"), + title: Cow::Borrowed("Bad Request"), + status: 400, + detail: None, + instance: None, + extensions, + } +} + +fn assert_serialization_error(extensions: E, expected: &str) { + let details = details(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) + .expect_err("the invalid extensions should fail to serialize to a value"); + assert_eq!( + string_error.to_string(), + expected, + "the string error should explain the failure" + ); + assert_eq!( + value_error.to_string(), + expected, + "the value error should explain the failure" + ); +} + +fn assert_serialization(extensions: E, expected: &Value) { + let details = details(extensions); + let encoded = + serde_json::to_string(&details).expect("the extensions should serialize to a string"); + assert_eq!( + serde_json::from_str::(&encoded).expect("the document should be valid JSON"), + *expected, + "the string should contain the expected problem document" + ); + assert_eq!( + serde_json::to_value(&details).expect("the extensions should serialize to a value"), + *expected, + "the value should contain the expected problem document" + ); +} + +/// All standard names are reserved, including occurrence fields absent from the document. +#[test] +fn extensions_reserved_map_members() { + for name in ["type", "title", "status", "detail", "instance"] { + assert_serialization_error( + json!({name: "invalid"}), + &format!("problem extension `{name}` conflicts with a standard member"), + ); + } +} + +/// Validation uses emitted names, while skipped fields do not contribute members. +#[test] +fn extensions_reserved_struct_members() { + #[derive(Serialize)] + struct Extensions { + #[serde(rename = "status", skip_serializing_if = "Option::is_none")] + code: Option, + } + + assert_serialization_error( + Extensions { code: Some(499) }, + "problem extension `status` conflicts with a standard member", + ); + assert_serialization( + Extensions { code: None }, + &json!({"type": "about:blank", "title": "Bad Request", "status": 400}), + ); +} + +/// Only top-level names are reserved; nested objects retain their own member names. +#[test] +fn extensions_nested_members() { + assert_serialization( + json!({"context": {"type": "context", "title": "Context", "status": 1, "detail": "nested", "instance": "nested"}}), + &json!({"type": "about:blank", "title": "Bad Request", "status": 400, + "context": {"type": "context", "title": "Context", "status": 1, "detail": "nested", "instance": "nested"}}), + ); +} + +/// Wrapping an extension preserves both valid object contents and validation failures. +#[test] +fn extensions_wrapped_objects() { + #[derive(Serialize)] + struct Wrapped(T); + + assert_serialization( + Some(Wrapped(json!({"parameter": "limit"}))), + &json!({"type": "about:blank", "title": "Bad Request", "status": 400, "parameter": "limit"}), + ); + assert_serialization_error( + Wrapped(json!({"title": "invalid"})), + "problem extension `title` conflicts with a standard member", + ); + assert_serialization_error( + Wrapped(42), + "problem extensions must serialize as an object", + ); +} + +/// Nulls, scalars, and sequences fail consistently across the JSON serializers. +#[test] +fn extensions_non_objects() { + for value in [ + json!(null), + json!(true), + json!(42), + json!(1.5), + json!("invalid"), + json!([]), + ] { + assert_serialization_error(value, "problem extensions must serialize as an object"); + } + assert_serialization_error((), "problem extensions must serialize as an object"); + assert_serialization_error((1, 2), "problem extensions must serialize as an object"); +} + +/// Externally tagged variants contribute a single member named after the serialized variant. +#[test] +fn extensions_enum_members() { + #[derive(Serialize)] + enum Extensions { + #[serde(rename = "context")] + Context { + status: u16, + }, + #[serde(rename = "type")] + Newtype(u16), + #[serde(rename = "detail")] + Tuple(u16, u16), + #[serde(rename = "instance")] + Struct { + value: u16, + }, + Unit, + } + + assert_serialization( + Extensions::Context { status: 42 }, + &json!({"type": "about:blank", "title": "Bad Request", "status": 400, "context": {"status": 42}}), + ); + for (extensions, name) in [ + (Extensions::Newtype(1), "type"), + (Extensions::Tuple(1, 2), "detail"), + (Extensions::Struct { value: 1 }, "instance"), + ] { + assert_serialization_error( + extensions, + &format!("problem extension `{name}` conflicts with a standard member"), + ); + } + assert_serialization_error( + Extensions::Unit, + "problem extensions must serialize as an object", + ); +} + +struct Entry { + key: K, + split: bool, +} + +impl Serialize for Entry { + fn serialize(&self, serializer: S) -> Result { + let mut map = serializer.serialize_map(Some(1))?; + if self.split { + map.serialize_key(&self.key)?; + map.serialize_value(&42)?; + } else { + map.serialize_entry(&self.key, &42)?; + } + map.end() + } +} + +struct FormattedKey<'a>(&'a str); + +impl Serialize for FormattedKey<'_> { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(self.0) + } +} + +/// Both map APIs check names produced through strings, newtypes, enum keys, and formatting. +#[test] +fn extensions_map_key_representations() { + #[derive(Serialize)] + struct WrappedKey(&'static str); + + #[derive(Serialize)] + enum Key { + #[serde(rename = "status")] + Status, + } + + for split in [false, true] { + let expected = "problem extension `status` conflicts with a standard member"; + assert_serialization_error( + Entry { + key: "status", + split, + }, + expected, + ); + assert_serialization_error( + Entry { + key: WrappedKey("status"), + split, + }, + expected, + ); + assert_serialization_error( + Entry { + key: Key::Status, + split, + }, + expected, + ); + assert_serialization_error( + Entry { + key: FormattedKey("status"), + split, + }, + expected, + ); + assert_serialization( + Entry { + key: FormattedKey("parameter"), + split, + }, + &json!({"type": "about:blank", "title": "Bad Request", "status": 400, "parameter": 42}), + ); + } +} + +struct CountedKey<'a>(&'a Cell); + +impl Serialize for CountedKey<'_> { + fn serialize(&self, serializer: S) -> Result { + let previous = self.0.replace(self.0.get() + 1); + serializer.serialize_str(if previous == 0 { "parameter" } else { "status" }) + } +} + +/// A stateful key is validated during its only serialization, so its emitted name cannot change. +#[test] +fn extensions_map_key_once() { + for split in [false, true] { + let calls = Cell::new(0); + let details = details(Entry { + key: CountedKey(&calls), + split, + }); + let encoded = serde_json::to_string(&details).expect("the key should serialize once"); + assert_eq!(calls.get(), 1, "the key should serialize exactly once"); + assert_eq!( + serde_json::from_str::(&encoded).expect("the document should be valid JSON") + ["parameter"], + 42, + "the emitted key should match the validated key" + ); + calls.set(0); + let value = serde_json::to_value(&details).expect("the key should serialize once"); + assert_eq!(calls.get(), 1, "the key should serialize exactly once"); + assert_eq!( + value["parameter"], 42, + "the emitted key should match the validated key" + ); + } +} + +struct BrokenExtensions; + +impl Serialize for BrokenExtensions { + fn serialize(&self, _: S) -> Result { + Err(S::Error::custom("extension serialization failed")) + } +} + +/// Extension and nested value errors retain the underlying serializer's diagnostic. +#[test] +fn extensions_serializer_failures() { + #[derive(Serialize)] + struct Nested { + context: BrokenExtensions, + } + + assert_serialization_error(BrokenExtensions, "extension serialization failed"); + assert_serialization_error( + Nested { + context: BrokenExtensions, + }, + "extension serialization failed", + ); +} + +struct BrokenFormat; + +impl fmt::Display for BrokenFormat { + fn fmt(&self, _: &mut fmt::Formatter<'_>) -> fmt::Result { + Err(fmt::Error) + } +} + +impl Serialize for BrokenFormat { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(self) + } +} + +/// A formatting failure in a map key propagates as a Serde error. +#[test] +fn extensions_key_format_failure() { + assert_serialization_error( + Entry { + key: BrokenFormat, + split: false, + }, + &fmt::Error.to_string(), + ); +} diff --git a/libs/problematic/rust/tests/problem_details.rs b/libs/problematic/rust/tests/problem_details.rs index 8b6ea0b5773..77b82c6706e 100644 --- a/libs/problematic/rust/tests/problem_details.rs +++ b/libs/problematic/rust/tests/problem_details.rs @@ -24,7 +24,7 @@ fn details_typed_extensions() { "parameters": ["limit"] }); let encoded = serde_json::to_string(&body).expect("the response should serialize"); - let details: ProblemDetails = serde_json::from_str(&encoded) + let details: ProblemDetails<'_, '_, InvalidParameters> = serde_json::from_str(&encoded) .expect("the response should deserialize with typed extensions"); assert_eq!( details.extensions.parameters, @@ -43,7 +43,7 @@ fn details_typed_extensions() { fn details_empty_extensions() { let body = json!({"type": "about:blank", "title": "Internal Server Error", "status": 500}); let encoded = serde_json::to_string(&body).expect("the response should serialize"); - let details: ProblemDetails = + let details: ProblemDetails<'_, '_, NoExtensions> = serde_json::from_str(&encoded).expect("the response should deserialize without extensions"); assert_eq!( serde_json::to_value(details).expect("the details should serialize"), @@ -52,11 +52,76 @@ fn details_empty_extensions() { ); } +/// Metadata and occurrence strings can be borrowed from values constructed at runtime. +#[test] +fn details_borrowed_fields() { + let type_uri = String::from("https://example.com/problems/invalid-parameters"); + let title = String::from("Invalid parameters"); + let detail = String::from("The limit must be a positive integer."); + let instance = String::from("https://example.com/problem-occurrences/01J8M6Y7P9"); + let details = ProblemDetails { + type_uri: Cow::Borrowed(&type_uri), + title: Cow::Borrowed(&title), + status: 400, + detail: Some(Cow::Borrowed(&detail)), + instance: Some(Cow::Borrowed(&instance)), + extensions: NoExtensions {}, + }; + + assert_eq!( + serde_json::to_value(&details).expect("the borrowed details should serialize"), + json!({ + "type": type_uri, + "title": title, + "status": 400, + "detail": detail, + "instance": instance + }), + "the borrowed fields should retain their contents" + ); +} + +/// Deserialization owns standard strings and supports APIs requiring `DeserializeOwned`. +#[test] +fn details_owned_deserialize() { + let body = json!({ + "type": "https://example.com/problems/invalid-parameters", + "title": "Invalid parameters", + "status": 400, + "detail": "The limit must be a positive integer.", + "instance": "https://example.com/problem-occurrences/01J8M6Y7P9" + }); + let encoded = serde_json::to_string(&body).expect("the response should serialize"); + let from_string: ProblemDetails<'static, 'static, NoExtensions> = + serde_json::from_str(&encoded).expect("the response should deserialize from a string"); + let from_value: ProblemDetails<'static, 'static, NoExtensions> = + serde_json::from_value(body.clone()).expect("the response should deserialize from a value"); + let from_reader: ProblemDetails<'static, 'static, NoExtensions> = + serde_json::from_reader(encoded.as_bytes()) + .expect("the response should deserialize from a reader"); + drop(encoded); + + for details in [from_string, 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(_))), + "the deserialized strings should be owned" + ); + assert_eq!( + serde_json::to_value(details).expect("the owned details should serialize"), + body, + "the owned fields should retain their contents" + ); + } +} + /// Flattening preserves required fields of the extension type. #[test] fn details_extensions_missing() { let body = json!({"type": "about:blank", "title": "Bad Request", "status": 400}); - let error = serde_json::from_value::>(body) + let error = serde_json::from_value::>(body) .err() .expect("the missing extension field should fail to deserialize"); @@ -77,7 +142,7 @@ fn details_extensions_invalid_types() { "status": 400, "parameters": parameters }); - let error = serde_json::from_value::>(body) + let error = serde_json::from_value::>(body) .err() .expect("the invalid extension field should fail to deserialize"); @@ -100,11 +165,10 @@ fn details_extensions_non_object_serialize() { let error = serde_json::to_value(&details) .expect_err("the non-object extensions should fail to serialize"); - assert!( - error - .to_string() - .starts_with("can only flatten structs and maps"), - "the error should identify the unsupported extension shape: {error}" + assert_eq!( + error.to_string(), + "problem extensions must serialize as an object", + "the error should identify the unsupported extension shape" ); } } @@ -113,7 +177,7 @@ fn details_extensions_non_object_serialize() { #[test] fn details_extensions_non_object_deserialize() { let body = json!({"type": "about:blank", "title": "Bad Request", "status": 400}); - let error = serde_json::from_value::>(body) + let error = serde_json::from_value::>(body) .err() .expect("the scalar extension type should fail to deserialize"); @@ -139,20 +203,20 @@ mod schema { #[test] fn schema_base() { - assert_schema_snapshot!(ProblemDetails); + assert_schema_snapshot!(ProblemDetails<'_, '_, NoExtensions>); } #[test] fn schema_typed_extensions() { - assert_schema_snapshot!(ProblemDetails); + assert_schema_snapshot!(ProblemDetails<'_, '_, InvalidParameters>); } #[test] fn schema_occurrence_members() { - let schema = schemars::schema_for!(ProblemDetails).to_value(); + let schema = schemars::schema_for!(ProblemDetails<'_, '_, NoExtensions>).to_value(); let minimal = json!({"type": "about:blank", "title": "Internal Server Error", "status": 500}); - let details: ProblemDetails = + let details: ProblemDetails<'_, '_, NoExtensions> = serde_json::from_value(minimal).expect("the minimal response should deserialize"); let minimal = serde_json::to_value(details).expect("the details should serialize"); let required = schema["required"] From 0ef5f6850e4fc621ba2c59e2963e4dee6af4155c Mon Sep 17 00:00:00 2001 From: Tim Diekmann Date: Sat, 12 Sep 2026 14:47:08 +0200 Subject: [PATCH 3/4] Set version to `0.0.0` --- Cargo.lock | 2 +- libs/problematic/rust/Cargo.toml | 2 +- libs/problematic/rust/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ca9cdb11272..d24d0a9c9ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10862,7 +10862,7 @@ dependencies = [ [[package]] name = "problematic" -version = "0.0.0-reserved" +version = "0.0.0" dependencies = [ "http 1.4.2", "insta", diff --git a/libs/problematic/rust/Cargo.toml b/libs/problematic/rust/Cargo.toml index 7bcc1419e32..a0d587fe4b8 100644 --- a/libs/problematic/rust/Cargo.toml +++ b/libs/problematic/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "problematic" -version = "0.0.0-reserved" +version = "0.0.0" authors.workspace = true edition.workspace = true description = "HTTP Problem Details with typed extensions and JSON Schema support" diff --git a/libs/problematic/rust/package.json b/libs/problematic/rust/package.json index 2daae9787c7..718c1913083 100644 --- a/libs/problematic/rust/package.json +++ b/libs/problematic/rust/package.json @@ -1,6 +1,6 @@ { "name": "@rust/problematic", - "version": "0.0.0-reserved", + "version": "0.0.0", "private": true, "description": "HTTP Problem Details with typed extensions and JSON Schema support", "license": "MIT OR Apache-2.0", From 0a39b4605161c39eb067fa0044094b4607e2e8c3 Mon Sep 17 00:00:00 2001 From: Tim Diekmann Date: Sat, 12 Sep 2026 15:06:15 +0200 Subject: [PATCH 4/4] Use one lifetime for problem details --- libs/problematic/rust/src/definition.rs | 10 ++-- libs/problematic/rust/src/lib.rs | 3 +- libs/problematic/rust/tests/extensions.rs | 2 +- .../problematic/rust/tests/problem_details.rs | 60 +++++++++++++++---- 4 files changed, 54 insertions(+), 21 deletions(-) diff --git a/libs/problematic/rust/src/definition.rs b/libs/problematic/rust/src/definition.rs index fb79bc69b87..4d29f7fbe1f 100644 --- a/libs/problematic/rust/src/definition.rs +++ b/libs/problematic/rust/src/definition.rs @@ -51,7 +51,7 @@ pub struct ProblemType { derive(schemars::JsonSchema), schemars(title = "Problem Details") )] -pub struct ProblemDetails<'kind, 'occurrence, E> { +pub struct ProblemDetails<'a, E> { /// A URI reference identifying the problem type. `about:blank` means the problem has no /// additional semantics beyond its HTTP status code. #[serde(rename = "type")] @@ -62,12 +62,12 @@ pub struct ProblemDetails<'kind, 'occurrence, E> { example = "https://example.com/problems/wrong-actor-type" ) )] - pub type_uri: Cow<'kind, str>, + pub type_uri: Cow<'a, str>, /// A short, human-readable summary of the problem type. It should remain the same across /// occurrences, except for localization. #[cfg_attr(feature = "schemars", schemars(example = "Wrong actor type"))] - pub title: Cow<'kind, str>, + pub title: Cow<'a, str>, /// The HTTP status code sent by the origin server for this occurrence. #[cfg_attr( @@ -83,7 +83,7 @@ pub struct ProblemDetails<'kind, 'occurrence, E> { feature = "schemars", schemars(required, example = "This operation requires a machine actor.") )] - pub detail: Option>, + pub detail: Option>, /// A URI reference identifying this occurrence. It may identify the occurrence without /// resolving to further information. @@ -96,7 +96,7 @@ pub struct ProblemDetails<'kind, 'occurrence, E> { example = "https://example.com/problem-occurrences/01J8M6Y7P9" ) )] - pub instance: Option>, + pub instance: Option>, /// Problem-specific members included alongside the standard fields. /// diff --git a/libs/problematic/rust/src/lib.rs b/libs/problematic/rust/src/lib.rs index abeabd2b24c..5745a7e452c 100644 --- a/libs/problematic/rust/src/lib.rs +++ b/libs/problematic/rust/src/lib.rs @@ -5,8 +5,7 @@ //! The `schemars` feature adds JSON Schema support to [`ProblemDetails`] and [`NoExtensions`]. //! //! [`ProblemDetails`] can borrow the type URI and title from shared metadata and detail and -//! instance from the occurrence, using separate lifetimes. Deserialization produces owned strings -//! for these fields. +//! instance from the occurrence. Deserialization produces owned strings for these fields. extern crate alloc; diff --git a/libs/problematic/rust/tests/extensions.rs b/libs/problematic/rust/tests/extensions.rs index 375bc815fdb..21f311537d3 100644 --- a/libs/problematic/rust/tests/extensions.rs +++ b/libs/problematic/rust/tests/extensions.rs @@ -10,7 +10,7 @@ use serde::{ }; use serde_json::{Value, json}; -const fn details(extensions: E) -> ProblemDetails<'static, 'static, E> { +const fn details(extensions: E) -> ProblemDetails<'static, E> { ProblemDetails { type_uri: Cow::Borrowed("about:blank"), title: Cow::Borrowed("Bad Request"), diff --git a/libs/problematic/rust/tests/problem_details.rs b/libs/problematic/rust/tests/problem_details.rs index 77b82c6706e..f65f993bb24 100644 --- a/libs/problematic/rust/tests/problem_details.rs +++ b/libs/problematic/rust/tests/problem_details.rs @@ -2,7 +2,8 @@ extern crate alloc; use alloc::{borrow::Cow, string::String, vec::Vec}; -use problematic::{NoExtensions, ProblemDetails}; +use http::StatusCode; +use problematic::{NoExtensions, ProblemDetails, ProblemType}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -24,7 +25,7 @@ fn details_typed_extensions() { "parameters": ["limit"] }); let encoded = serde_json::to_string(&body).expect("the response should serialize"); - let details: ProblemDetails<'_, '_, InvalidParameters> = serde_json::from_str(&encoded) + let details: ProblemDetails<'_, InvalidParameters> = serde_json::from_str(&encoded) .expect("the response should deserialize with typed extensions"); assert_eq!( details.extensions.parameters, @@ -43,7 +44,7 @@ fn details_typed_extensions() { fn details_empty_extensions() { let body = json!({"type": "about:blank", "title": "Internal Server Error", "status": 500}); let encoded = serde_json::to_string(&body).expect("the response should serialize"); - let details: ProblemDetails<'_, '_, NoExtensions> = + let details: ProblemDetails<'_, NoExtensions> = serde_json::from_str(&encoded).expect("the response should deserialize without extensions"); assert_eq!( serde_json::to_value(details).expect("the details should serialize"), @@ -81,6 +82,39 @@ fn details_borrowed_fields() { ); } +/// Static metadata and locally borrowed occurrence strings share one lifetime without copying. +#[test] +fn details_static_metadata() { + 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 detail = String::from("The limit must be a positive integer."); + let instance = String::from("https://example.com/problem-occurrences/01J8M6Y7P9"); + 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 {}, + }; + + assert_eq!( + serde_json::to_value(&details).expect("the mixed-lifetime details should serialize"), + json!({ + "type": "https://example.com/problems/invalid-parameters", + "title": "Invalid parameters", + "status": 400, + "detail": detail, + "instance": instance + }), + "the static and local fields should retain their contents" + ); +} + /// Deserialization owns standard strings and supports APIs requiring `DeserializeOwned`. #[test] fn details_owned_deserialize() { @@ -92,11 +126,11 @@ fn details_owned_deserialize() { "instance": "https://example.com/problem-occurrences/01J8M6Y7P9" }); let encoded = serde_json::to_string(&body).expect("the response should serialize"); - let from_string: ProblemDetails<'static, 'static, NoExtensions> = + let from_string: ProblemDetails<'static, NoExtensions> = serde_json::from_str(&encoded).expect("the response should deserialize from a string"); - let from_value: ProblemDetails<'static, 'static, NoExtensions> = + let from_value: ProblemDetails<'static, NoExtensions> = serde_json::from_value(body.clone()).expect("the response should deserialize from a value"); - let from_reader: ProblemDetails<'static, 'static, NoExtensions> = + let from_reader: ProblemDetails<'static, NoExtensions> = serde_json::from_reader(encoded.as_bytes()) .expect("the response should deserialize from a reader"); drop(encoded); @@ -121,7 +155,7 @@ fn details_owned_deserialize() { #[test] fn details_extensions_missing() { let body = json!({"type": "about:blank", "title": "Bad Request", "status": 400}); - let error = serde_json::from_value::>(body) + let error = serde_json::from_value::>(body) .err() .expect("the missing extension field should fail to deserialize"); @@ -142,7 +176,7 @@ fn details_extensions_invalid_types() { "status": 400, "parameters": parameters }); - let error = serde_json::from_value::>(body) + let error = serde_json::from_value::>(body) .err() .expect("the invalid extension field should fail to deserialize"); @@ -177,7 +211,7 @@ fn details_extensions_non_object_serialize() { #[test] fn details_extensions_non_object_deserialize() { let body = json!({"type": "about:blank", "title": "Bad Request", "status": 400}); - let error = serde_json::from_value::>(body) + let error = serde_json::from_value::>(body) .err() .expect("the scalar extension type should fail to deserialize"); @@ -203,20 +237,20 @@ mod schema { #[test] fn schema_base() { - assert_schema_snapshot!(ProblemDetails<'_, '_, NoExtensions>); + assert_schema_snapshot!(ProblemDetails<'_, NoExtensions>); } #[test] fn schema_typed_extensions() { - assert_schema_snapshot!(ProblemDetails<'_, '_, InvalidParameters>); + assert_schema_snapshot!(ProblemDetails<'_, InvalidParameters>); } #[test] fn schema_occurrence_members() { - let schema = schemars::schema_for!(ProblemDetails<'_, '_, NoExtensions>).to_value(); + let schema = schemars::schema_for!(ProblemDetails<'_, NoExtensions>).to_value(); let minimal = json!({"type": "about:blank", "title": "Internal Server Error", "status": 500}); - let details: ProblemDetails<'_, '_, NoExtensions> = + let details: ProblemDetails<'_, NoExtensions> = serde_json::from_value(minimal).expect("the minimal response should deserialize"); let minimal = serde_json::to_value(details).expect("the details should serialize"); let required = schema["required"]