From 7fa991495e12c0de55ee23aa6b43b5af20cbb72c Mon Sep 17 00:00:00 2001 From: ambujsingh Date: Wed, 12 Aug 2026 16:43:06 +0530 Subject: [PATCH] feat(json): propagate field name in FromJsonAny deserialization errors JsonDeserializeStructImpl now annotates every deserialization failure with the name of the struct field that caused it, stored in the error's UserMessage(). Previously, field identity was lost and only a generic error code was returned. - Type mismatch: UserMessage() = failing field name (e.g. "pdu_id") - Missing mandatory field: UserMessage() = absent field name - Nested struct failure: UserMessage() = outermost containing field name field_name is a static const char* from struct_visitable, so no heap allocation occurs and no string_view lifetime issue is introduced. Add three tests covering type mismatch, missing key, and nested struct error propagation cases. --- score/json/json_serializer.h | 5 +- score/json/json_serializer_test.cpp | 85 +++++++++++++++++++++++++++++ score/result/error.h | 7 +++ score/result/error_test.cpp | 28 ++++++++++ 4 files changed, 123 insertions(+), 2 deletions(-) diff --git a/score/json/json_serializer.h b/score/json/json_serializer.h index a8b3c29058..116c6190b9 100644 --- a/score/json/json_serializer.h +++ b/score/json/json_serializer.h @@ -205,7 +205,8 @@ inline void JsonDeserializeStructImpl(DeserializeAsJson& visitor, Field& field, } else { - score::cpp::ignore = visitor.error.emplace(std::move(field_content).error()); + // Annotate with the failing field name; field_name is a static const char* from struct_visitable. + score::cpp::ignore = visitor.error.emplace(field_content.error().WithUserMessage(field_name)); } } else @@ -220,7 +221,7 @@ inline void JsonDeserializeStructImpl(DeserializeAsJson& visitor, Field& field, } else { - score::cpp::ignore = visitor.error.emplace(Error::kKeyNotFound, "Missing mandatory field in JSON object"); + score::cpp::ignore = visitor.error.emplace(MakeError(Error::kKeyNotFound, field_name)); } } } diff --git a/score/json/json_serializer_test.cpp b/score/json/json_serializer_test.cpp index dddf36d378..4220602e4c 100644 --- a/score/json/json_serializer_test.cpp +++ b/score/json/json_serializer_test.cpp @@ -719,5 +719,90 @@ TEST(JsonSerializerTest, UseCustomSerializationOnVisitableStruct) EXPECT_EQ(serialized.As()->get().count("foo"), 1); } +TEST(JsonSerializerTest, TypeMismatchErrorContainsFieldName) +{ + RecordProperty("TestType", "interface-test"); + RecordProperty("Verifies", "::score::json::FromJsonAny"); + RecordProperty("Description", + "Verify that a type-mismatch deserialization error carries the name of the failing field in its " + "UserMessage"); + RecordProperty("ASIL", "QM"); + RecordProperty("Priority", "3"); + RecordProperty("DerivationTechnique", "error-guessing"); + + // Given a JSON where string_val holds a number instead of a string + auto source = R"({ +"integer_val": 42, +"string_val": 999, +"nested_val": { + "nested_int": 43, + "nested_bool": true, + "nested_array": [44, 45] + } +} )"_json; + + // When deserializing into the struct + auto unit{FromJsonAny(std::move(source))}; + + // Then the error code is WrongType and the UserMessage names the offending field + ASSERT_FALSE(unit.has_value()); + EXPECT_EQ(unit.error(), Error::kWrongType); + EXPECT_EQ(unit.error().UserMessage(), "string_val"); +} + +TEST(JsonSerializerTest, MissingMandatoryFieldErrorContainsFieldName) +{ + RecordProperty("TestType", "interface-test"); + RecordProperty("Verifies", "::score::json::FromJsonAny"); + RecordProperty("Description", + "Verify that a missing-mandatory-field deserialization error carries the name of the absent field " + "in its UserMessage"); + RecordProperty("ASIL", "QM"); + RecordProperty("Priority", "3"); + RecordProperty("DerivationTechnique", "error-guessing"); + + // Given a JSON where mandatory integer_val is absent + auto source = R"({"string_val": "hello"})"_json; + + // When deserializing into the struct + auto unit{FromJsonAny(std::move(source))}; + + // Then the error code is KeyNotFound and the UserMessage names the missing field + ASSERT_FALSE(unit.has_value()); + EXPECT_EQ(unit.error(), Error::kKeyNotFound); + EXPECT_EQ(unit.error().UserMessage(), "integer_val"); +} + +TEST(JsonSerializerTest, NestedTypeMismatchErrorContainsOutermostFieldName) +{ + RecordProperty("TestType", "control-flow-analysis"); // data flow + RecordProperty("Verifies", "::score::json::FromJsonAny"); + RecordProperty("Description", + "Verify that a type-mismatch inside a nested struct is reported under the parent field name, " + "because each deserialization level re-stamps the error with its own field key"); + RecordProperty("ASIL", "QM"); + RecordProperty("Priority", "3"); + RecordProperty("DerivationTechnique", "error-guessing"); + + // Given a JSON where nested_val.nested_bool holds a string instead of a bool + auto source = R"({ +"integer_val": 42, +"string_val": "ok", +"nested_val": { + "nested_int": 43, + "nested_bool": "not-a-bool", + "nested_array": [44, 45] + } +} )"_json; + + // When deserializing into the struct + auto unit{FromJsonAny(std::move(source))}; + + // Then the error code is WrongType and the UserMessage names the parent struct field, not the inner field + ASSERT_FALSE(unit.has_value()); + EXPECT_EQ(unit.error(), Error::kWrongType); + EXPECT_EQ(unit.error().UserMessage(), "nested_val"); +} + } // namespace } // namespace score::json::test diff --git a/score/result/error.h b/score/result/error.h index 136ac27db5..349363b9bd 100644 --- a/score/result/error.h +++ b/score/result/error.h @@ -103,6 +103,13 @@ class Error final return user_messages_; } + /// \brief Returns a copy of this error with an updated user message. + /// \return Copy preserving code and domain with the provided user message + [[nodiscard]] constexpr Error WithUserMessage(const std::string_view user_message) const noexcept + { + return Error{code_, *domain_, user_message}; + } + private: score::result::ErrorCode code_; const score::result::ErrorDomain* domain_; diff --git a/score/result/error_test.cpp b/score/result/error_test.cpp index 0007928986..9639febcb5 100644 --- a/score/result/error_test.cpp +++ b/score/result/error_test.cpp @@ -186,6 +186,34 @@ TEST(Error, CanLogCustomMessageToOstream) EXPECT_EQ(stream.str(), "Error Second Error! occurred with message Foo"); } +TEST(Error, WithUserMessagePreservesCodeAndDomain) +{ + // Given an error with a known code/domain + const score::result::Error original{MyErrorCode::kSecondError, "original message"}; + + // When creating a copy with a new user message + const score::result::Error updated = original.WithUserMessage("updated message"); + + // Then code/domain are preserved while the user message is replaced + EXPECT_EQ(updated, original); + EXPECT_EQ(*updated, *original); + EXPECT_EQ(updated.Message(), original.Message()); + EXPECT_EQ(updated.UserMessage(), "updated message"); +} + +TEST(Error, WithUserMessageCanClearUserMessage) +{ + // Given an error with an existing user message + const score::result::Error original{MyErrorCode::kFirstError, "has message"}; + + // When creating a copy with an empty user message + const score::result::Error updated = original.WithUserMessage(""); + + // Then the user message is cleared + EXPECT_EQ(updated, original); + EXPECT_TRUE(updated.UserMessage().empty()); +} + } // namespace } // namespace result } // namespace score