From 53aa3aa45c8b06fc901e4689ac0a7ec7cc91e7ae Mon Sep 17 00:00:00 2001 From: Tim Diekmann Date: Fri, 11 Sep 2026 17:34:00 +0200 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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"] From adf04440c3bef7c30f8563ebb4c67dfe4cb38d05 Mon Sep 17 00:00:00 2001 From: Tim Diekmann Date: Wed, 16 Sep 2026 11:06:10 +0200 Subject: [PATCH 5/5] Support borrowed problem deserialization and independent features --- Cargo.toml | 2 +- libs/@local/graph/authentication/Cargo.toml | 2 +- libs/@local/middleware/Cargo.toml | 2 +- libs/problematic/rust/CHANGELOG.md | 2 - libs/problematic/rust/Cargo.toml | 28 +- libs/problematic/rust/README.md | 9 +- libs/problematic/rust/src/definition.rs | 112 -------- libs/problematic/rust/src/lib.rs | 20 +- libs/problematic/rust/src/problem_details.rs | 118 +++++++++ libs/problematic/rust/src/problem_type.rs | 26 ++ libs/problematic/rust/src/serde/mod.rs | 23 ++ .../rust/src/{ => serde}/serialization/key.rs | 18 +- .../rust/src/{ => serde}/serialization/mod.rs | 59 +++-- libs/problematic/rust/tests/extensions.rs | 28 +- .../problematic/rust/tests/problem_details.rs | 245 ++++++++++++------ libs/problematic/rust/tests/schema.rs | 70 +++++ .../problem_details__schema__schema_base.snap | 6 - ...tensions.snap => schema__schema_base.snap} | 3 +- ...nap.json => schema__schema_base.snap.json} | 13 +- .../schema__schema_typed_extensions.snap | 7 + ...schema__schema_typed_extensions.snap.json} | 13 +- 21 files changed, 537 insertions(+), 269 deletions(-) delete mode 100644 libs/problematic/rust/src/definition.rs create mode 100644 libs/problematic/rust/src/problem_details.rs create mode 100644 libs/problematic/rust/src/problem_type.rs create mode 100644 libs/problematic/rust/src/serde/mod.rs rename libs/problematic/rust/src/{ => serde}/serialization/key.rs (91%) rename libs/problematic/rust/src/{ => serde}/serialization/mod.rs (81%) create mode 100644 libs/problematic/rust/tests/schema.rs delete mode 100644 libs/problematic/rust/tests/snapshots/problem_details__schema__schema_base.snap rename libs/problematic/rust/tests/snapshots/{problem_details__schema__schema_typed_extensions.snap => schema__schema_base.snap} (67%) rename libs/problematic/rust/tests/snapshots/{problem_details__schema__schema_base.snap.json => schema__schema_base.snap.json} (67%) create mode 100644 libs/problematic/rust/tests/snapshots/schema__schema_typed_extensions.snap rename libs/problematic/rust/tests/snapshots/{problem_details__schema__schema_typed_extensions.snap.json => schema__schema_typed_extensions.snap.json} (69%) diff --git a/Cargo.toml b/Cargo.toml index d738d2f9350..5c7020db71b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -187,7 +187,7 @@ hifijson = { version = "0.4.0", default-features = fal hkdf = { version = "0.13.0", default-features = false } hmac = { version = "0.13.0", default-features = false } hostname = { version = "0.4.2", default-features = false } -http = { version = "1.3.1", default-features = false } +http = { version = "1.3.1", default-features = false, features = ["std"] } humansize = { version = "2.1.3", default-features = false } hyper = { version = "1.7.0", default-features = false } include_dir = { version = "0.7.4", default-features = false } diff --git a/libs/@local/graph/authentication/Cargo.toml b/libs/@local/graph/authentication/Cargo.toml index 31a24439a9c..4d41287fee5 100644 --- a/libs/@local/graph/authentication/Cargo.toml +++ b/libs/@local/graph/authentication/Cargo.toml @@ -15,7 +15,7 @@ hash-middleware = { workspace = true, public = true } type-system = { workspace = true, public = true } # Public third-party dependencies -http = { workspace = true, public = true, features = ["std"] } +http = { workspace = true, public = true } jsonwebtoken = { workspace = true, public = true, features = ["aws_lc_rs"] } opentelemetry = { workspace = true, public = true, features = ["metrics"] } reqwest = { workspace = true, public = true } diff --git a/libs/@local/middleware/Cargo.toml b/libs/@local/middleware/Cargo.toml index c1c9e2939cc..f0e76571b3b 100644 --- a/libs/@local/middleware/Cargo.toml +++ b/libs/@local/middleware/Cargo.toml @@ -14,7 +14,7 @@ type-system = { workspace = true, public = true } # Public third-party dependencies axum = { workspace = true, public = true } axum-core = { workspace = true, public = true } -http = { workspace = true, public = true, features = ["std"] } +http = { workspace = true, public = true } opentelemetry = { workspace = true, public = true, features = ["metrics"] } tower-layer = { workspace = true, public = true } tower-service = { workspace = true, public = true } diff --git a/libs/problematic/rust/CHANGELOG.md b/libs/problematic/rust/CHANGELOG.md index 00db2bc2f84..06730371150 100644 --- a/libs/problematic/rust/CHANGELOG.md +++ b/libs/problematic/rust/CHANGELOG.md @@ -1,7 +1,5 @@ # 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 index a0d587fe4b8..f1c8f048506 100644 --- a/libs/problematic/rust/Cargo.toml +++ b/libs/problematic/rust/Cargo.toml @@ -13,23 +13,41 @@ exclude = ["package.json", "turbo.json", "docs/task-dependencies.json" # Public workspace dependencies # Public third-party dependencies -http = { workspace = true, public = true, features = ["std"] } -serde_core = { workspace = true, public = true } +http = { workspace = true, public = true } # Public third-party dependencies (optional) -schemars = { workspace = true, public = true, optional = true } +schemars = { workspace = true, public = true, optional = true } +serde_core = { workspace = true, public = true, optional = true } # Private workspace dependencies # Private third-party dependencies -serde = { workspace = true, features = ["alloc", "derive"] } +serde = { workspace = true, optional = true, features = ["alloc", "derive"] } [dev-dependencies] insta = { workspace = true, features = ["json"] } -serde_json = { workspace = true } +serde_json = { workspace = true, features = ["arbitrary_precision"] } + +[[test]] +name = "extensions" +required-features = ["serde"] + +[[test]] +name = "problem_details" +required-features = ["serde"] + +[[test]] +name = "schema" +required-features = ["schemars"] [features] schemars = ["dep:schemars"] +serde = ["dep:serde_core", "dep:serde"] [lints] workspace = true + +[package.metadata.docs.rs] +all-features = true +cargo-args = ["-Z", "unstable-options", "-Z", "rustdoc-scrape-examples"] +targets = ["x86_64-unknown-linux-gnu"] diff --git a/libs/problematic/rust/README.md b/libs/problematic/rust/README.md index 245ddee342c..bac9a0b80ef 100644 --- a/libs/problematic/rust/README.md +++ b/libs/problematic/rust/README.md @@ -2,9 +2,16 @@ HTTP Problem Details with typed extension members. +## Features + +- `serde` enables serialization and deserialization, including borrowing strings from the input. +- `schemars` enables JSON Schema generation independently of `serde`. + +No features are enabled by default. + ## 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). +[HASH](https://hash.dev/) created and maintains `problematic`. Contributions are welcome. Follow the [contributing guide](https://github.com/hashintel/hash/blob/main/.github/CONTRIBUTING.md) to contribute. Ask questions in [GitHub Discussions](https://github.com/orgs/hashintel/discussions). ## License diff --git a/libs/problematic/rust/src/definition.rs b/libs/problematic/rust/src/definition.rs deleted file mode 100644 index 4d29f7fbe1f..00000000000 --- a/libs/problematic/rust/src/definition.rs +++ /dev/null @@ -1,112 +0,0 @@ -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))] -#[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, 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")] - #[cfg_attr( - feature = "schemars", - schemars( - extend("format" = "uri-reference", "default" = "about:blank"), - example = "https://example.com/problems/wrong-actor-type" - ) - )] - 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<'a, 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. - /// - /// 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 5745a7e452c..add395312c3 100644 --- a/libs/problematic/rust/src/lib.rs +++ b/libs/problematic/rust/src/lib.rs @@ -2,14 +2,22 @@ //! //! [`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. Deserialization produces owned strings for these fields. +//! The optional `serde` feature enables serialization and deserialization. The `schemars` feature +//! independently adds JSON Schema support to [`ProblemDetails`] and [`NoExtensions`]. + +#![cfg_attr(doc, feature(doc_cfg))] extern crate alloc; -mod definition; -mod serialization; +mod problem_details; +mod problem_type; +#[cfg(feature = "serde")] +mod serde; + +pub use http::StatusCode; -pub use self::definition::{NoExtensions, ProblemDetails, ProblemType}; +pub use self::{ + problem_details::{NoExtensions, ProblemDetails}, + problem_type::ProblemType, +}; diff --git a/libs/problematic/rust/src/problem_details.rs b/libs/problematic/rust/src/problem_details.rs new file mode 100644 index 00000000000..872b2e16877 --- /dev/null +++ b/libs/problematic/rust/src/problem_details.rs @@ -0,0 +1,118 @@ +use alloc::borrow::Cow; + +#[cfg(feature = "serde")] +use ::serde::{Deserialize, Serialize}; + +#[cfg(feature = "serde")] +use crate::serde::{deserialize_optional_cow, serialize_extensions}; + +/// An empty object for problem types without extensions. +#[derive(Debug, Clone, Copy)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[expect( + clippy::empty_structs_with_brackets, + reason = "The empty struct must serialize as an object." +)] +pub struct NoExtensions {} + +#[cfg(any(feature = "serde", feature = "schemars"))] +const fn default_type_uri() -> Cow<'static, str> { + Cow::Borrowed("about:blank") +} + +/// An RFC 9457 problem details object with problem-specific extension members. +/// +/// Serialization includes `type`, `title`, and `status`, plus `detail` and `instance` when +/// supplied. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr( + feature = "schemars", + derive(schemars::JsonSchema), + schemars(title = "Problem Details") +)] +pub struct ProblemDetails<'a, E> { + /// 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`. + #[cfg_attr( + any(feature = "serde", feature = "schemars"), + serde(rename = "type", borrow, default = "default_type_uri") + )] + #[cfg_attr( + feature = "schemars", + schemars( + extend("format" = "uri-reference"), + example = "https://example.com/problems/wrong-actor-type" + ) + )] + pub type_uri: Cow<'a, str>, + + /// A short, human-readable summary of the problem type. Keep it the same across occurrences, + /// except for localization. + #[cfg_attr(any(feature = "serde", feature = "schemars"), serde(borrow))] + #[cfg_attr(feature = "schemars", schemars(example = "Wrong actor type"))] + pub title: Cow<'a, str>, + + /// The HTTP status code sent with 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. + /// Use extension members for structured information. + #[cfg_attr( + any(feature = "serde", feature = "schemars"), + serde( + borrow, + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_optional_cow" + ) + )] + #[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. + #[cfg_attr( + any(feature = "serde", feature = "schemars"), + serde( + borrow, + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_optional_cow" + ) + )] + #[cfg_attr( + feature = "schemars", + schemars( + required, + extend("format" = "uri-reference"), + example = "https://example.com/problem-occurrences/42" + ) + )] + pub instance: Option>, + + /// Problem-specific members included alongside the standard fields. + /// + /// Serialization fails for non-object values or top-level members named `type`, `title`, + /// `status`, `detail`, or `instance`. Nested members may use these names. Errors from the + /// extension serializer propagate. The extension type must support Serde flattening for + /// deserialization. + #[cfg_attr( + any(feature = "serde", feature = "schemars"), + serde( + flatten, + serialize_with = "serialize_extensions", + bound(serialize = "E: Serialize") + ) + )] + pub extensions: E, +} diff --git a/libs/problematic/rust/src/problem_type.rs b/libs/problematic/rust/src/problem_type.rs new file mode 100644 index 00000000000..a4a405a37d0 --- /dev/null +++ b/libs/problematic/rust/src/problem_type.rs @@ -0,0 +1,26 @@ +use alloc::borrow::Cow; + +use crate::StatusCode; + +/// Metadata shared by occurrences of a problem type. +/// +/// ``` +/// use std::borrow::Cow; +/// +/// use problematic::{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, +/// }; +/// ``` +#[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 code for occurrences of this problem type. + pub status: StatusCode, +} diff --git a/libs/problematic/rust/src/serde/mod.rs b/libs/problematic/rust/src/serde/mod.rs new file mode 100644 index 00000000000..0e75fd9b3b1 --- /dev/null +++ b/libs/problematic/rust/src/serde/mod.rs @@ -0,0 +1,23 @@ +use alloc::borrow::Cow; + +use serde::{Deserialize, Deserializer}; + +mod serialization; + +pub(crate) use self::serialization::serialize_extensions; + +// Serde enables borrowing for a direct Cow field, but not for Cow nested inside Option. +#[derive(Deserialize)] +#[serde(transparent)] +struct BorrowCow<'a>(#[serde(borrow)] Cow<'a, str>); + +/// Borrows optional strings when the deserializer can lend them. +/// +/// # Errors +/// +/// Returns the deserializer's error if the value is neither a string nor null. +pub(crate) fn deserialize_optional_cow<'de: 'a, 'a, D: Deserializer<'de>>( + deserializer: D, +) -> Result>, D::Error> { + Option::>::deserialize(deserializer).map(|value| value.map(|value| value.0)) +} diff --git a/libs/problematic/rust/src/serialization/key.rs b/libs/problematic/rust/src/serde/serialization/key.rs similarity index 91% rename from libs/problematic/rust/src/serialization/key.rs rename to libs/problematic/rust/src/serde/serialization/key.rs index 35174d47ed9..8bac3e26535 100644 --- a/libs/problematic/rust/src/serialization/key.rs +++ b/libs/problematic/rust/src/serde/serialization/key.rs @@ -1,7 +1,7 @@ use alloc::string::String; use core::fmt::{Display, Write as _}; -use serde::{Serialize, Serializer, ser::Error as _}; +use serde_core::{Serialize, Serializer, ser::Error as _}; use super::{ExtensionKey, check_member}; @@ -36,12 +36,20 @@ impl Serializer for KeySerializer { forward_scalar! { serialize_bool(bool), - serialize_i8(i8), serialize_i16(i16), serialize_i32(i32), serialize_i64(i64), + 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_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]), + serialize_f32(f32), + serialize_f64(f64), + serialize_char(char), + serialize_bytes(&[u8]), } fn serialize_str(self, v: &str) -> Result { diff --git a/libs/problematic/rust/src/serialization/mod.rs b/libs/problematic/rust/src/serde/serialization/mod.rs similarity index 81% rename from libs/problematic/rust/src/serialization/mod.rs rename to libs/problematic/rust/src/serde/serialization/mod.rs index 73943741dc6..286cd7cb366 100644 --- a/libs/problematic/rust/src/serialization/mod.rs +++ b/libs/problematic/rust/src/serde/serialization/mod.rs @@ -1,6 +1,6 @@ use core::fmt::Display; -use serde::{ +use serde_core::{ Serialize, Serializer, ser::{Error, Impossible, SerializeMap, SerializeStruct}, }; @@ -9,11 +9,11 @@ mod key; struct ExtensionKey<'a, T: ?Sized>(&'a T); -/// Serializes an extension object, rejecting non-objects and reserved member names. +/// Checks extension member names while serializing an object. /// /// # Errors /// -/// Returns the serializer's error for invalid extensions or failures in the underlying serializer. +/// Returns an error for non-object extensions, reserved member names, or serializer failures. pub(crate) fn serialize_extensions( extensions: &E, serializer: S, @@ -30,13 +30,18 @@ fn check_member(name: &str) -> Result<(), E> { Ok(()) } +#[inline] +fn non_object_error() -> E { + E::custom("problem extensions must serialize as an object") +} + 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")) + Err(non_object_error()) } )* }; @@ -55,16 +60,25 @@ impl Serializer for ExtensionSerializer { reject_scalar! { serialize_bool(bool), - serialize_i8(i8), serialize_i16(i16), serialize_i32(i32), serialize_i64(i64), + 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_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]), + serialize_f32(f32), + serialize_f64(f64), + serialize_char(char), + serialize_bytes(&[u8]), + serialize_str(&str), } fn serialize_none(self) -> Result { - self.serialize_unit() + Err(non_object_error()) } fn serialize_some(self, value: &T) -> Result { @@ -72,13 +86,11 @@ impl Serializer for ExtensionSerializer { } fn serialize_unit(self) -> Result { - Err(Self::Error::custom( - "problem extensions must serialize as an object", - )) + Err(non_object_error()) } fn serialize_unit_struct(self, _: &'static str) -> Result { - self.serialize_unit() + Err(non_object_error()) } fn serialize_unit_variant( @@ -87,7 +99,7 @@ impl Serializer for ExtensionSerializer { _: u32, _: &'static str, ) -> Result { - self.serialize_unit() + Err(non_object_error()) } fn serialize_newtype_struct( @@ -111,15 +123,11 @@ impl Serializer for ExtensionSerializer { } fn serialize_seq(self, _: Option) -> Result { - Err(Self::Error::custom( - "problem extensions must serialize as an object", - )) + Err(non_object_error()) } fn serialize_tuple(self, _: usize) -> Result { - Err(Self::Error::custom( - "problem extensions must serialize as an object", - )) + Err(non_object_error()) } fn serialize_tuple_struct( @@ -127,9 +135,7 @@ impl Serializer for ExtensionSerializer { _: &'static str, _: usize, ) -> Result { - Err(Self::Error::custom( - "problem extensions must serialize as an object", - )) + Err(non_object_error()) } fn serialize_tuple_variant( @@ -153,6 +159,11 @@ impl Serializer for ExtensionSerializer { name: &'static str, len: usize, ) -> Result { + // With arbitrary_precision, serde_json represents numbers as private structs. + // Flattening would emit the marker field instead of rejecting the number. + if name == "$serde_json::private::Number" { + return Err(non_object_error()); + } self.0.serialize_struct(name, len).map(ExtensionMembers) } @@ -169,7 +180,7 @@ impl Serializer for ExtensionSerializer { } fn collect_str(self, _: &T) -> Result { - self.serialize_unit() + Err(non_object_error()) } fn is_human_readable(&self) -> bool { diff --git a/libs/problematic/rust/tests/extensions.rs b/libs/problematic/rust/tests/extensions.rs index 21f311537d3..e0d3bd41160 100644 --- a/libs/problematic/rust/tests/extensions.rs +++ b/libs/problematic/rust/tests/extensions.rs @@ -8,7 +8,7 @@ use serde::{ Serialize, Serializer, ser::{Error as _, SerializeMap as _}, }; -use serde_json::{Value, json}; +use serde_json::{Number, Value, json}; const fn details(extensions: E) -> ProblemDetails<'static, E> { ProblemDetails { @@ -55,7 +55,6 @@ fn assert_serialization(extensions: E, expected: &Value) { ); } -/// 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"] { @@ -66,7 +65,6 @@ fn extensions_reserved_map_members() { } } -/// Validation uses emitted names, while skipped fields do not contribute members. #[test] fn extensions_reserved_struct_members() { #[derive(Serialize)] @@ -85,7 +83,6 @@ fn extensions_reserved_struct_members() { ); } -/// Only top-level names are reserved; nested objects retain their own member names. #[test] fn extensions_nested_members() { assert_serialization( @@ -95,7 +92,6 @@ fn extensions_nested_members() { ); } -/// Wrapping an extension preserves both valid object contents and validation failures. #[test] fn extensions_wrapped_objects() { #[derive(Serialize)] @@ -115,7 +111,6 @@ fn extensions_wrapped_objects() { ); } -/// Nulls, scalars, and sequences fail consistently across the JSON serializers. #[test] fn extensions_non_objects() { for value in [ @@ -128,11 +123,26 @@ fn extensions_non_objects() { ] { assert_serialization_error(value, "problem extensions must serialize as an object"); } + assert_serialization_error(None::, "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_number_members() { + #[derive(Serialize)] + struct Extensions { + count: Number, + } + + assert_serialization( + Extensions { + count: Number::from(42), + }, + &json!({"type": "about:blank", "title": "Bad Request", "status": 400, "count": 42}), + ); +} + #[test] fn extensions_enum_members() { #[derive(Serialize)] @@ -198,7 +208,6 @@ impl Serialize for FormattedKey<'_> { } } -/// Both map APIs check names produced through strings, newtypes, enum keys, and formatting. #[test] fn extensions_map_key_representations() { #[derive(Serialize)] @@ -259,7 +268,6 @@ impl Serialize for CountedKey<'_> { } } -/// 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] { @@ -294,7 +302,6 @@ impl Serialize for BrokenExtensions { } } -/// Extension and nested value errors retain the underlying serializer's diagnostic. #[test] fn extensions_serializer_failures() { #[derive(Serialize)] @@ -325,7 +332,6 @@ impl Serialize for BrokenFormat { } } -/// A formatting failure in a map key propagates as a Serde error. #[test] fn extensions_key_format_failure() { assert_serialization_error( diff --git a/libs/problematic/rust/tests/problem_details.rs b/libs/problematic/rust/tests/problem_details.rs index f65f993bb24..6857addc5b8 100644 --- a/libs/problematic/rust/tests/problem_details.rs +++ b/libs/problematic/rust/tests/problem_details.rs @@ -2,18 +2,15 @@ extern crate alloc; use alloc::{borrow::Cow, string::String, vec::Vec}; -use http::StatusCode; -use problematic::{NoExtensions, ProblemDetails, ProblemType}; +use problematic::{NoExtensions, ProblemDetails, ProblemType, StatusCode}; 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!({ @@ -21,7 +18,7 @@ fn details_typed_extensions() { "title": "Invalid parameters", "status": 400, "detail": "The limit must be a positive integer.", - "instance": "https://example.com/problem-occurrences/01J8M6Y7P9", + "instance": "https://example.com/problem-occurrences/42", "parameters": ["limit"] }); let encoded = serde_json::to_string(&body).expect("the response should serialize"); @@ -39,7 +36,6 @@ fn details_typed_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}); @@ -53,13 +49,39 @@ fn details_empty_extensions() { ); } -/// Metadata and occurrence strings can be borrowed from values constructed at runtime. +#[test] +fn details_default_type() { + let encoded = r#"{"title":"Internal Server Error","status":500}"#; + 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_eq!( + serde_json::to_value(details).expect("the details should serialize"), + json!({"type": "about:blank", "title": "Internal Server Error", "status": 500}) + ); +} + +#[test] +fn details_invalid_type() { + for (type_uri, expected) in [ + (json!(null), "invalid type: null, expected a string"), + (json!(42), "invalid type: number, expected a string"), + ] { + let body = json!({"type": type_uri, "title": "Internal Server Error", "status": 500}); + let error = ProblemDetails::<'_, NoExtensions>::deserialize(body) + .expect_err("the non-string type should fail to deserialize"); + + assert_eq!(error.to_string(), expected); + } +} + #[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 instance = String::from("https://example.com/problem-occurrences/42"); let details = ProblemDetails { type_uri: Cow::Borrowed(&type_uri), title: Cow::Borrowed(&title), @@ -82,7 +104,6 @@ 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 { @@ -92,7 +113,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/01J8M6Y7P9"); + 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(), @@ -115,27 +136,110 @@ fn details_static_metadata() { ); } -/// Deserialization owns standard strings and supports APIs requiring `DeserializeOwned`. #[test] -fn details_owned_deserialize() { +fn details_borrowed_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" + "instance": "https://example.com/problem-occurrences/42" }); let encoded = serde_json::to_string(&body).expect("the response should serialize"); - let from_string: ProblemDetails<'static, NoExtensions> = + let from_string: ProblemDetails<'_, NoExtensions> = serde_json::from_str(&encoded).expect("the response should deserialize from a string"); + let from_slice: ProblemDetails<'_, NoExtensions> = serde_json::from_slice(encoded.as_bytes()) + .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(_))), + "the strings should be borrowed from the input" + ); + for text in [ + details.type_uri.as_ref(), + details.title.as_ref(), + details + .detail + .as_deref() + .expect("the response should contain a detail"), + details + .instance + .as_deref() + .expect("the response should contain an instance"), + ] { + assert!( + encoded.as_bytes().as_ptr_range().contains(&text.as_ptr()), + "the borrowed string should point into the original JSON buffer" + ); + } + assert_eq!( + serde_json::to_value(details).expect("the borrowed details should serialize"), + body, + "the borrowed fields should retain their contents" + ); + } +} + +#[test] +fn details_escaped_deserialize() { + let encoded = r#"{ + "type": "https:\/\/example.com/problems/invalid-parameters", + "title": "Invalid \u0070arameters", + "status": 400, + "detail": "The limit must be a positive \u0069nteger.", + "instance": "https:\/\/example.com/problem-occurrences/42" + }"#; + 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/42" + }); + 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(_))), + "the decoded strings should be owned" + ); + assert_eq!( + serde_json::to_value(details).expect("the decoded details should serialize"), + body, + "the decoded fields should retain their contents" + ); +} + +#[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/42" + }); + let encoded = serde_json::to_string(&body).expect("the response should serialize"); let from_value: ProblemDetails<'static, NoExtensions> = - serde_json::from_value(body.clone()).expect("the response should deserialize from a value"); + ProblemDetails::deserialize(body.clone()) + .expect("the response should deserialize from a value"); + let mut deserializer = serde_json::Deserializer::from_reader(encoded.as_bytes()); let from_reader: ProblemDetails<'static, NoExtensions> = - serde_json::from_reader(encoded.as_bytes()) + ProblemDetails::deserialize(&mut deserializer) .expect("the response should deserialize from a reader"); + deserializer + .end() + .expect("the reader should contain a single response"); drop(encoded); - for details in [from_string, from_value, from_reader] { + for details in [from_value, from_reader] { assert!( matches!(details.type_uri, Cow::Owned(_)) && matches!(details.title, Cow::Owned(_)) @@ -151,18 +255,57 @@ fn details_owned_deserialize() { } } -/// Flattening preserves required fields of the extension type. +#[test] +fn details_null_occurrence_fields() { + let details: ProblemDetails<'_, NoExtensions> = ProblemDetails::deserialize(json!({ + "type": "about:blank", + "title": "Bad Request", + "status": 400, + "detail": null, + "instance": null + })) + .expect("the null occurrence fields should deserialize"); + + assert!( + details.detail.is_none() && details.instance.is_none(), + "the null occurrence fields should be absent" + ); + assert_eq!( + serde_json::to_value(details).expect("the details should serialize"), + json!({"type": "about:blank", "title": "Bad Request", "status": 400}), + "the absent occurrence fields should be omitted" + ); +} + +#[test] +fn details_invalid_occurrence_fields() { + for name in ["detail", "instance"] { + let error = ProblemDetails::<'_, NoExtensions>::deserialize(json!({ + "type": "about:blank", + "title": "Bad Request", + "status": 400, + name: 42 + })) + .expect_err("the non-string occurrence field should fail to deserialize"); + + assert_eq!( + error.to_string(), + "invalid type: number, expected a string", + "the error should identify the unsupported occurrence field 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 = ProblemDetails::<'_, InvalidParameters>::deserialize(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 [ @@ -176,7 +319,7 @@ fn details_extensions_invalid_types() { "status": 400, "parameters": parameters }); - let error = serde_json::from_value::>(body) + let error = ProblemDetails::<'_, InvalidParameters>::deserialize(body) .err() .expect("the invalid extension field should fail to deserialize"); @@ -184,7 +327,6 @@ fn details_extensions_invalid_types() { } } -/// 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"])] { @@ -207,66 +349,11 @@ fn details_extensions_non_object_serialize() { } } -/// 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"); + let error = ProblemDetails::<'_, u32>::deserialize(body) + .expect_err("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<'_, NoExtensions>); - } - - #[test] - fn schema_typed_extensions() { - assert_schema_snapshot!(ProblemDetails<'_, InvalidParameters>); - } - - #[test] - fn schema_occurrence_members() { - 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> = - 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/schema.rs b/libs/problematic/rust/tests/schema.rs new file mode 100644 index 00000000000..fa18fb9ce16 --- /dev/null +++ b/libs/problematic/rust/tests/schema.rs @@ -0,0 +1,70 @@ +extern crate alloc; + +use alloc::{string::String, vec::Vec}; + +use problematic::{NoExtensions, ProblemDetails}; +#[cfg(feature = "serde")] +use schemars::generate::SchemaSettings; +#[cfg(feature = "serde")] +use serde::Deserialize as _; +#[cfg(feature = "serde")] +use serde_json::json; + +#[derive(schemars::JsonSchema)] +struct InvalidParameters { + #[expect(dead_code, reason = "The test only generates the extension's schema.")] + parameters: Vec, +} + +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<'_, NoExtensions>); +} + +#[test] +fn schema_typed_extensions() { + assert_schema_snapshot!(ProblemDetails<'_, InvalidParameters>); +} + +#[cfg(feature = "serde")] +#[test] +fn schema_occurrence_members() { + let input = json!({"title": "Internal Server Error", "status": 500}); + let details: ProblemDetails<'_, NoExtensions> = ProblemDetails::deserialize(input.clone()) + .expect("the minimal response should deserialize"); + let output = serde_json::to_value(details).expect("the details should serialize"); + for (settings, document) in [ + (SchemaSettings::default(), input), + (SchemaSettings::default().for_serialize(), output), + ] { + let schema = settings + .into_generator() + .into_root_schema_for::>() + .to_value(); + let required = schema["required"] + .as_array() + .expect("the response schema should declare required members"); + let members = document + .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 deleted file mode 100644 index 4b51f581edd..00000000000 --- a/libs/problematic/rust/tests/snapshots/problem_details__schema__schema_base.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -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 b/libs/problematic/rust/tests/snapshots/schema__schema_base.snap similarity index 67% rename from libs/problematic/rust/tests/snapshots/problem_details__schema__schema_typed_extensions.snap rename to libs/problematic/rust/tests/snapshots/schema__schema_base.snap index 2a259d4fba9..c66a98a643d 100644 --- a/libs/problematic/rust/tests/snapshots/problem_details__schema__schema_typed_extensions.snap +++ b/libs/problematic/rust/tests/snapshots/schema__schema_base.snap @@ -1,5 +1,6 @@ --- -source: libs/problematic/rust/tests/problem_details.rs +source: libs/problematic/rust/tests/schema.rs +assertion_line: 31 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/schema__schema_base.snap.json similarity index 67% rename from libs/problematic/rust/tests/snapshots/problem_details__schema__schema_base.snap.json rename to libs/problematic/rust/tests/snapshots/schema__schema_base.snap.json index b297c1515b7..b7f99b808f3 100644 --- a/libs/problematic/rust/tests/snapshots/problem_details__schema__schema_base.snap.json +++ b/libs/problematic/rust/tests/snapshots/schema__schema_base.snap.json @@ -1,11 +1,11 @@ { "$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.", + "description": "An RFC 9457 problem details object with problem-specific extension members.\n\nSerialization includes `type`, `title`, and `status`, plus `detail` and `instance` when\nsupplied.", "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.", + "description": "A human-readable explanation of this occurrence that helps the client correct the problem.\nUse extension members for structured information.", "type": "string", "examples": [ "This operation requires a machine actor." @@ -16,11 +16,11 @@ "type": "string", "format": "uri-reference", "examples": [ - "https://example.com/problem-occurrences/01J8M6Y7P9" + "https://example.com/problem-occurrences/42" ] }, "status": { - "description": "The HTTP status code sent by the origin server for this occurrence.", + "description": "The HTTP status code sent with this occurrence.", "type": "integer", "format": "uint16", "examples": [ @@ -30,14 +30,14 @@ "minimum": 100 }, "title": { - "description": "A short, human-readable summary of the problem type. It should remain the same across\noccurrences, except for localization.", + "description": "A short, human-readable summary of the problem type. Keep it the same across occurrences,\nexcept 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.", + "description": "A URI reference identifying the problem type. Use `about:blank` when the HTTP status code\nfully describes the problem type. If `type` is omitted during deserialization, it defaults\nto `about:blank`.", "type": "string", "format": "uri-reference", "default": "about:blank", @@ -47,7 +47,6 @@ } }, "required": [ - "type", "title", "status" ] diff --git a/libs/problematic/rust/tests/snapshots/schema__schema_typed_extensions.snap b/libs/problematic/rust/tests/snapshots/schema__schema_typed_extensions.snap new file mode 100644 index 00000000000..be56bee1d91 --- /dev/null +++ b/libs/problematic/rust/tests/snapshots/schema__schema_typed_extensions.snap @@ -0,0 +1,7 @@ +--- +source: libs/problematic/rust/tests/schema.rs +assertion_line: 36 +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/schema__schema_typed_extensions.snap.json similarity index 69% rename from libs/problematic/rust/tests/snapshots/problem_details__schema__schema_typed_extensions.snap.json rename to libs/problematic/rust/tests/snapshots/schema__schema_typed_extensions.snap.json index 63f5e51e243..ed57bf5249c 100644 --- a/libs/problematic/rust/tests/snapshots/problem_details__schema__schema_typed_extensions.snap.json +++ b/libs/problematic/rust/tests/snapshots/schema__schema_typed_extensions.snap.json @@ -1,11 +1,11 @@ { "$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.", + "description": "An RFC 9457 problem details object with problem-specific extension members.\n\nSerialization includes `type`, `title`, and `status`, plus `detail` and `instance` when\nsupplied.", "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.", + "description": "A human-readable explanation of this occurrence that helps the client correct the problem.\nUse extension members for structured information.", "type": "string", "examples": [ "This operation requires a machine actor." @@ -16,7 +16,7 @@ "type": "string", "format": "uri-reference", "examples": [ - "https://example.com/problem-occurrences/01J8M6Y7P9" + "https://example.com/problem-occurrences/42" ] }, "parameters": { @@ -26,7 +26,7 @@ } }, "status": { - "description": "The HTTP status code sent by the origin server for this occurrence.", + "description": "The HTTP status code sent with this occurrence.", "type": "integer", "format": "uint16", "examples": [ @@ -36,14 +36,14 @@ "minimum": 100 }, "title": { - "description": "A short, human-readable summary of the problem type. It should remain the same across\noccurrences, except for localization.", + "description": "A short, human-readable summary of the problem type. Keep it the same across occurrences,\nexcept 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.", + "description": "A URI reference identifying the problem type. Use `about:blank` when the HTTP status code\nfully describes the problem type. If `type` is omitted during deserialization, it defaults\nto `about:blank`.", "type": "string", "format": "uri-reference", "default": "about:blank", @@ -53,7 +53,6 @@ } }, "required": [ - "type", "title", "status", "parameters"