diff --git a/common/yaml/BUILD.bazel b/common/yaml/BUILD.bazel index c9c356f6530b..d6538bfaae4d 100644 --- a/common/yaml/BUILD.bazel +++ b/common/yaml/BUILD.bazel @@ -93,6 +93,16 @@ drake_cc_library( ], ) +drake_cc_googletest( + name = "json_test", + deps = [ + ":example_structs", + ":yaml_io", + "//common:temp_directory", + "//common/test_utilities:expect_throws_message", + ], +) + drake_cc_googletest( name = "yaml_doxygen_test", deps = [ diff --git a/common/yaml/test/json_test.cc b/common/yaml/test/json_test.cc new file mode 100644 index 000000000000..da2c306f2681 --- /dev/null +++ b/common/yaml/test/json_test.cc @@ -0,0 +1,116 @@ +#include + +#include + +#include "drake/common/temp_directory.h" +#include "drake/common/test_utilities/expect_throws_message.h" +#include "drake/common/yaml/test/example_structs.h" +#include "drake/common/yaml/yaml_io.h" + +namespace drake { +namespace yaml { +namespace test { +namespace { + +GTEST_TEST(YamlJsonTest, WriteScalars) { + AllScalarsStruct data; + constexpr char expected[] = + R"""({"some_bool":false,"some_double":1.2345,"some_float":1.2345,"some_int32":12,"some_int64":14,"some_string":"kNominalString","some_uint32":12,"some_uint64":15})"""; + EXPECT_EQ(SaveJsonString(data), expected); +} + +GTEST_TEST(YamlJsonTest, StringEscaping) { + StringStruct data; + data.value = "foo\n\t\"bar"; + EXPECT_EQ(SaveJsonString(data), + R"""({"value":"foo\u000a\u0009\u0022bar"})"""); +} + +GTEST_TEST(YamlJsonTest, NonFinite) { + DoubleStruct data; + + data.value = std::numeric_limits::infinity(); + EXPECT_EQ(SaveJsonString(data), R"""({"value":Infinity})"""); + + data.value = -data.value; + EXPECT_EQ(SaveJsonString(data), R"""({"value":-Infinity})"""); + + data.value = std::numeric_limits::quiet_NaN(); + EXPECT_EQ(SaveJsonString(data), R"""({"value":NaN})"""); +} + +GTEST_TEST(YamlJsonTest, WriteSequence) { + VectorStruct data; + data.value = {1.0, 2.0}; + EXPECT_EQ(SaveJsonString(data), R"""({"value":[1.0,2.0]})"""); +} + +GTEST_TEST(YamlJsonTest, WriteMapping) { + MapStruct data; + data.value.clear(); + data.value["a"] = 1.0; + data.value["b"] = 2.0; + EXPECT_EQ(SaveJsonString(data), R"""({"value":{"a":1.0,"b":2.0}})"""); +} + +GTEST_TEST(YamlJsonTest, WriteNested) { + OuterStruct data; + EXPECT_EQ( + SaveJsonString(data), + R"""({"inner_struct":{"inner_value":1.2345},"outer_value":1.2345})"""); +} + +GTEST_TEST(YamlJsonTest, WriteOptional) { + OptionalStruct data; + EXPECT_EQ(SaveJsonString(data), R"""({"value":1.2345})"""); + + data.value.reset(); + EXPECT_EQ(SaveJsonString(data), "{}"); +} + +GTEST_TEST(YamlJsonTest, WriteVariant) { + VariantStruct data; + + data.value = std::string{"foo"}; + EXPECT_EQ(SaveJsonString(data), R"""({"value":"foo"})"""); + + // It would be plausible here to use the `_tag` convention to annotate + // variant tags, matching what we do in our yaml.py conventions. + data.value = DoubleStruct{}; + DRAKE_EXPECT_THROWS_MESSAGE(SaveJsonString(data), + ".*SaveJsonString.*mapping.*tag.*"); +} + +struct IntVariant { + template + void Serialize(Archive* a) { + a->Visit(DRAKE_NVP(value)); + } + + std::variant value{0}; +}; + +GTEST_TEST(YamlJsonTest, WriteVariantScalar) { + IntVariant data; + + data.value.emplace(1); + EXPECT_EQ(SaveJsonString(data), R"""({"value":1})"""); + + data.value.emplace(22); + DRAKE_EXPECT_THROWS_MESSAGE(SaveJsonString(data), + ".*SaveJsonString.*scalar.*22.*tag.*"); +} + +GTEST_TEST(YamlJsonTest, FileRoundTrip) { + DoubleStruct data; + data.value = 22.25; + const std::string filename = temp_directory() + "/file_round_trip.json"; + SaveJsonFile(filename, data); + const auto readback = LoadYamlFile(filename); + EXPECT_EQ(readback.value, 22.25); +} + +} // namespace +} // namespace test +} // namespace yaml +} // namespace drake diff --git a/common/yaml/test/yaml_node_test.cc b/common/yaml/test/yaml_node_test.cc index 61d121a666c4..d9b053d21ea2 100644 --- a/common/yaml/test/yaml_node_test.cc +++ b/common/yaml/test/yaml_node_test.cc @@ -21,9 +21,10 @@ namespace { // Check that the tag constants exist. GTEST_TEST(YamlNodeTest, TagConstants) { - EXPECT_GT(Node::kTagFloat.size(), 0); - EXPECT_GT(Node::kTagInt.size(), 0); EXPECT_GT(Node::kTagNull.size(), 0); + EXPECT_GT(Node::kTagBool.size(), 0); + EXPECT_GT(Node::kTagInt.size(), 0); + EXPECT_GT(Node::kTagFloat.size(), 0); EXPECT_GT(Node::kTagStr.size(), 0); } @@ -103,7 +104,7 @@ TEST_P(YamlNodeParamaterizedTest, StaticTypeString) { EXPECT_EQ(Node::GetTypeString(GetExpectedType()), GetExpectedTypeString()); } -// Check tag getting and setting. +// Check generic tag getting and setting. TEST_P(YamlNodeParamaterizedTest, GetSetTag) { Node dut = MakeEmptyDut(); EXPECT_EQ(dut.GetTag(), ""); @@ -111,6 +112,19 @@ TEST_P(YamlNodeParamaterizedTest, GetSetTag) { EXPECT_EQ(dut.GetTag(), "tag"); } +// Check JSON Schema tag getting and setting. +TEST_P(YamlNodeParamaterizedTest, JsonSchemaTag) { + Node dut = MakeEmptyDut(); + dut.SetTag(JsonSchemaTag::kNull); + EXPECT_EQ(dut.GetTag(), Node::kTagNull); + dut.SetTag(JsonSchemaTag::kBool); + EXPECT_EQ(dut.GetTag(), Node::kTagBool); + dut.SetTag(JsonSchemaTag::kInt); + EXPECT_EQ(dut.GetTag(), Node::kTagInt); + dut.SetTag(JsonSchemaTag::kFloat); + EXPECT_EQ(dut.GetTag(), Node::kTagFloat); +} + // It is important for our YAML subsystem performance that the Node's move // operations actually move the stored data, instead of copying it. TEST_P(YamlNodeParamaterizedTest, EfficientMoveConstructor) { @@ -156,8 +170,15 @@ TEST_P(YamlNodeParamaterizedTest, EqualityPerTag) { Node dut = MakeEmptyDut(); Node dut2 = MakeEmptyDut(); EXPECT_TRUE(dut == dut2); + + // Different tag; not equal. dut2.SetTag("tag"); EXPECT_FALSE(dut == dut2); + + // Same tag, set via two different overloads; still equal. + dut.SetTag(JsonSchemaTag::kInt); + dut2.SetTag(std::string{Node::kTagInt}); + EXPECT_TRUE(dut == dut2); } // Check Scalar-specific operations. diff --git a/common/yaml/yaml_io.cc b/common/yaml/yaml_io.cc index 5c3cad3006af..9c5e143f43e1 100644 --- a/common/yaml/yaml_io.cc +++ b/common/yaml/yaml_io.cc @@ -6,16 +6,17 @@ namespace drake { namespace yaml { namespace internal { -void WriteFile(const std::string& filename, const std::string& data) { +void WriteFile(std::string_view function_name, const std::string& filename, + const std::string& data) { std::ofstream out(filename, std::ios::binary); if (out.fail()) { - throw std::runtime_error(fmt::format( - "SaveYamlFile() could not open '{}' for writing", filename)); + throw std::runtime_error(fmt::format("{}() could not open '{}' for writing", + function_name, filename)); } out << data; if (out.fail()) { throw std::runtime_error( - fmt::format("SaveYamlFile() could not write to '{}'", filename)); + fmt::format("{}() could not write to '{}'", function_name, filename)); } } diff --git a/common/yaml/yaml_io.h b/common/yaml/yaml_io.h index b90ca3f6df6b..9257903dfcf1 100644 --- a/common/yaml/yaml_io.h +++ b/common/yaml/yaml_io.h @@ -2,6 +2,7 @@ #include #include +#include #include #include "drake/common/yaml/yaml_io_options.h" @@ -115,9 +116,40 @@ std::string SaveYamlString( const std::optional& child_name = std::nullopt, const std::optional& defaults = std::nullopt); +/** Saves data as a JSON-formatted file. + +Refer to @ref yaml_serialization "YAML Serialization" for background. + +Note that there is no LoadJsonFile() function, because LoadYamlString() can +already load JSON data. + +@param data User data to be serialized. +@returns the JSON data as a string. + +@tparam Serializable must implement a @ref implementing_serialize "Serialize" + function. */ +template +void SaveJsonFile(const std::string& filename, const Serializable& data); + +/** Saves data as a JSON-formatted string. + +Refer to @ref yaml_serialization "YAML Serialization" for background. + +Note that there is no LoadJsonString() function, because LoadYamlString() can +already load JSON data. + +@param data User data to be serialized. +@returns the JSON data as a string. + +@tparam Serializable must implement a @ref implementing_serialize "Serialize" + function. */ +template +std::string SaveJsonString(const Serializable& data); + namespace internal { -void WriteFile(const std::string& filename, const std::string& data); +void WriteFile(std::string_view function_name, const std::string& filename, + const std::string& data); template static Serializable LoadNode(internal::Node node, @@ -168,7 +200,8 @@ template void SaveYamlFile(const std::string& filename, const Serializable& data, const std::optional& child_name, const std::optional& defaults) { - internal::WriteFile(filename, SaveYamlString(data, child_name, defaults)); + internal::WriteFile("SaveYamlFile", filename, + SaveYamlString(data, child_name, defaults)); } // (Implementation of a function declared above. This could be defined @@ -187,5 +220,21 @@ std::string SaveYamlString(const Serializable& data, return archive.EmitString(child_name.value_or(std::string())); } +// (Implementation of a function declared above. This could be defined +// inline, but we keep it with the others for consistency.) +template +void SaveJsonFile(const std::string& filename, const Serializable& data) { + internal::WriteFile("SaveJsonFile", filename, SaveJsonString(data)); +} + +// (Implementation of a function declared above. This could be defined +// inline, but we keep it with the others for consistency.) +template +std::string SaveJsonString(const Serializable& data) { + internal::YamlWriteArchive archive; + archive.Accept(data); + return archive.ToJson(); +} + } // namespace yaml } // namespace drake diff --git a/common/yaml/yaml_node.cc b/common/yaml/yaml_node.cc index 574800d616ff..648a929b28a1 100644 --- a/common/yaml/yaml_node.cc +++ b/common/yaml/yaml_node.cc @@ -57,7 +57,7 @@ Node Node::MakeMapping() { Node Node::MakeNull() { Node result; result.data_ = ScalarData{"null"}; - result.tag_ = kTagNull; + result.tag_ = JsonSchemaTag::kNull; return result; } @@ -113,7 +113,10 @@ bool Node::IsMapping() const { } bool operator==(const Node& a, const Node& b) { - return std::tie(a.tag_, a.data_) == std::tie(b.tag_, b.data_); + // We need to compare the canonical form of a tag (i.e., its string). + auto a_tag = a.GetTag(); + auto b_tag = b.GetTag(); + return std::tie(a_tag, a.data_) == std::tie(b_tag, b.data_); } bool operator==(const Node::ScalarData& a, const Node::ScalarData& b) { @@ -128,12 +131,42 @@ bool operator==(const Node::MappingData& a, const Node::MappingData& b) { return a.mapping == b.mapping; } -const std::string& Node::GetTag() const { - return tag_; +std::string_view Node::GetTag() const { + return std::visit( // BR + overloaded{ + [](std::monostate) -> std::string_view { + return {}; + }, + [](JsonSchemaTag tag) -> std::string_view { + switch (tag) { + case JsonSchemaTag::kNull: + return kTagNull; + case JsonSchemaTag::kBool: + return kTagBool; + case JsonSchemaTag::kInt: + return kTagInt; + case JsonSchemaTag::kFloat: + return kTagFloat; + } + DRAKE_UNREACHABLE(); + }, + [](const std::string& tag) -> std::string_view { + return tag; + }, + }, + tag_); +} + +void Node::SetTag(JsonSchemaTag tag) { + tag_ = tag; } void Node::SetTag(std::string tag) { - tag_ = std::move(tag); + if (tag.empty()) { + tag_ = {}; + } else { + tag_ = std::move(tag); + } } const std::string& Node::GetScalar() const { diff --git a/common/yaml/yaml_node.h b/common/yaml/yaml_node.h index 0d59d4b1d6a9..340fa1893040 100644 --- a/common/yaml/yaml_node.h +++ b/common/yaml/yaml_node.h @@ -55,6 +55,19 @@ enum class NodeType { kMapping, }; +/* Denotes one of the "JSON Schema" tags. +See https://yaml.org/spec/1.2.2/#json-schema. */ +enum class JsonSchemaTag { + // https://yaml.org/spec/1.2.2/#null + kNull, + // https://yaml.org/spec/1.2.2/#boolean + kBool, + // https://yaml.org/spec/1.2.2/#integer + kInt, + // https://yaml.org/spec/1.2.2/#floating-point + kFloat, +}; + /* Data type that represents a YAML node. A Node can hold one of three possible kinds of value at runtime: - Scalar @@ -120,7 +133,11 @@ class Node final { /* Gets this node's YAML tag. See https://yaml.org/spec/1.2.2/#tags. By default (i.e., at construction time), the tag will be empty. */ - const std::string& GetTag() const; + std::string_view GetTag() const; + + /* Sets this node's YAML tag to one of the "JSON Schema" tags. + See https://yaml.org/spec/1.2.2/#json-schema. */ + void SetTag(JsonSchemaTag); /* Sets this node's YAML tag. See https://yaml.org/spec/1.2.2/#tags. @@ -128,14 +145,17 @@ class Node final { type nor value. The caller is responsible for providing a valid tag. */ void SetTag(std::string); - // https://yaml.org/spec/1.2.2/#floating-point - static constexpr std::string_view kTagFloat{"tag:yaml.org,2002:float"}; + // https://yaml.org/spec/1.2.2/#null + static constexpr std::string_view kTagNull{"tag:yaml.org,2002:null"}; + + // https://yaml.org/spec/1.2.2/#boolean + static constexpr std::string_view kTagBool{"tag:yaml.org,2002:bool"}; // https://yaml.org/spec/1.2.2/#integer static constexpr std::string_view kTagInt{"tag:yaml.org,2002:int"}; - // https://yaml.org/spec/1.2.2/#null - static constexpr std::string_view kTagNull{"tag:yaml.org,2002:null"}; + // https://yaml.org/spec/1.2.2/#floating-point + static constexpr std::string_view kTagFloat{"tag:yaml.org,2002:float"}; // https://yaml.org/spec/1.2.2/#generic-string static constexpr std::string_view kTagStr{"tag:yaml.org,2002:str"}; @@ -237,9 +257,12 @@ class Node final { Node(); using Variant = std::variant; - - std::string tag_; Variant data_; + + // The YAML tag is not required, but can be set to either a well-known enum or + // a bespoke string. The representation here is not canonical -- it's possible + // to set a string value that is equivalent to an enum's implied string. + std::variant tag_; }; } // namespace internal diff --git a/common/yaml/yaml_read_archive.h b/common/yaml/yaml_read_archive.h index 96895c5666aa..4a8f69e35b11 100644 --- a/common/yaml/yaml_read_archive.h +++ b/common/yaml/yaml_read_archive.h @@ -282,13 +282,13 @@ class YamlReadArchive final { return; } // Figure out which variant<...> type we have based on the node's tag. - const std::string& tag = sub_node->GetTag(); + const std::string_view tag = sub_node->GetTag(); VariantHelper(tag, nvp.name(), nvp.value()); } // Steps through Types to extract 'size_t I' and 'typename T' for the Impl. template