From 5a8d2743980fbb141a267f6271c0d7d5139254d6 Mon Sep 17 00:00:00 2001 From: Oleg Morozenkov Date: Tue, 3 Nov 2026 09:26:34 +0300 Subject: [PATCH] Fix RichText deserialization Fixes #369 --- api/codegen.yaml | 4 +++ api_codegen/generate.py | 8 +++-- api_codegen/templates/types.h.j2 | 2 +- api_codegen/tests/test_generate.py | 21 +++++++++++++ include/tgbot/Types.h | 2 +- tests/tgbot/TypesJson.cpp | 47 ++++++++++++++++++++++++++++++ 6 files changed, 79 insertions(+), 5 deletions(-) diff --git a/api/codegen.yaml b/api/codegen.yaml index 270c9c39..514c3475 100644 --- a/api/codegen.yaml +++ b/api/codegen.yaml @@ -34,6 +34,10 @@ types: TextMention: text_mention CustomEmoji: custom_emoji DateTime: date_time + RichText: + extra_union_types: + - std::string + - std::vector> Sticker: fields: type: diff --git a/api_codegen/generate.py b/api_codegen/generate.py index b29e1517..05d54187 100644 --- a/api_codegen/generate.py +++ b/api_codegen/generate.py @@ -307,7 +307,7 @@ def _dependencies( @staticmethod def _standard_headers(fields: tuple[_FieldModel, ...], union_members: tuple[str, ...]) -> tuple[str, ...]: - cpp_types = " ".join(field.cpp_type for field in fields) + cpp_types = " ".join([field.cpp_type for field in fields] + list(union_members)) headers = {"memory"} for cpp_type, header in ( ("std::int", "cstdint"), @@ -348,13 +348,15 @@ def _union_members(self, name: str, schema: Schema) -> tuple[str, ...]: if schema.get("properties"): return () - return tuple( + members = tuple( dict.fromkeys( - line.strip() + f"std::shared_ptr<{line.strip()}>" for line in schema.get("description", "").splitlines() if line.strip() in self._names and line.strip() != name ) ) + # Append extra representations to preserve existing variant indices and defaults. + return members + tuple(self._type_config.get(name, {}).get("extra_union_types", ())) class _MethodModelBuilder(_BaseModelBuilder): diff --git a/api_codegen/templates/types.h.j2 b/api_codegen/templates/types.h.j2 index 5b292b7b..21c78a7b 100644 --- a/api_codegen/templates/types.h.j2 +++ b/api_codegen/templates/types.h.j2 @@ -47,7 +47,7 @@ struct {{ type.name }} { {% if type.union_members %} std::variant< {% for member in type.union_members %} - std::shared_ptr<{{ member }}>{{ "," if not loop.last else "" }} + {{ member }}{{ "," if not loop.last else "" }} {% endfor %} > value; diff --git a/api_codegen/tests/test_generate.py b/api_codegen/tests/test_generate.py index 1f1e7866..eff20156 100644 --- a/api_codegen/tests/test_generate.py +++ b/api_codegen/tests/test_generate.py @@ -290,6 +290,27 @@ def test_union_header_includes_variant() -> None: assert "variant" in _TypeModelBuilder._standard_headers((), ("Message",)) +def test_rich_text_union_appends_plain_text_and_recursive_array() -> None: + components = { + "RichText": {"description": "String or Array of RichText, or:\nRichTextBold\nRichTextItalic"}, + "RichTextBold": {}, + "RichTextItalic": {}, + } + rich_text = next(model for model in _TypeModelBuilder(components).build() if model.name == "RichText") + + assert rich_text.union_members == ( + "std::shared_ptr", + "std::shared_ptr", + "std::string", + "std::vector>", + ) + assert rich_text.dependencies == ("RichTextBold", "RichTextItalic") + assert {"string", "vector", "variant"} <= set(rich_text.standard_headers) + header = _OpenApiGenerator._render("types.h.j2", types=(rich_text,), methods=()) + assert "std::vector>" in header + assert "std::shared_ptr" not in header + + def test_generator_renders_types_methods_and_documentation( tmp_path: Path, ) -> None: diff --git a/include/tgbot/Types.h b/include/tgbot/Types.h index 97318c12..a0a0693d 100644 --- a/include/tgbot/Types.h +++ b/include/tgbot/Types.h @@ -11576,7 +11576,7 @@ struct RichText { std::shared_ptr, std::shared_ptr, std::shared_ptr, std::shared_ptr, std::shared_ptr, std::shared_ptr, std::shared_ptr, std::shared_ptr, std::shared_ptr, - std::shared_ptr> + std::shared_ptr, std::string, std::vector>> value; }; diff --git a/tests/tgbot/TypesJson.cpp b/tests/tgbot/TypesJson.cpp index 4ccdea9f..7975744b 100644 --- a/tests/tgbot/TypesJson.cpp +++ b/tests/tgbot/TypesJson.cpp @@ -1,11 +1,58 @@ #include #include +#include #include #include #include "tgbot/Types.h" +TEST(TypesJson, RichTextPreservesExistingVariantIndicesAndDefault) { + using Value = decltype(TgBot::RichText::value); + static_assert(std::is_same_v, std::shared_ptr>); + static_assert(std::is_same_v, std::shared_ptr>); + const TgBot::RichText text; + EXPECT_EQ(text.value.index(), 0); +} + +TEST(TypesJson, RichTextRoundTripsPlainText) { + for (const auto& text : { std::string(), std::string("plain text") }) { + const nlohmann::json json = text; + const auto richText = json.get(); + ASSERT_TRUE(std::holds_alternative(richText.value)); + EXPECT_EQ(std::get(richText.value), text); + EXPECT_EQ(nlohmann::json(richText), json); + } +} + +TEST(TypesJson, RichTextRoundTripsRecursiveMixedArray) { + const auto json = nlohmann::json::parse(R"(["plain text", {"type":"bold","text":"bold text"}, ["nested", []]])"); + const auto richText = json.get(); + const auto& items = std::get>(richText.value); + ASSERT_EQ(items.size(), 3); + ASSERT_TRUE(items[0]); + EXPECT_EQ(std::get(items[0]->value), "plain text"); + EXPECT_EQ(nlohmann::json(richText), json); +} + +TEST(TypesJson, RichTextRoundTripsFormattedObjectAndParagraph) { + const auto bold = nlohmann::json::parse(R"({"type":"bold","text":"plain text"})"); + const auto richText = bold.get(); + EXPECT_TRUE(std::holds_alternative>(richText.value)); + EXPECT_EQ(nlohmann::json(richText), bold); + + const auto paragraph = nlohmann::json::parse(R"({"type":"paragraph","text":"plain text"})"); + EXPECT_EQ(nlohmann::json(paragraph.get()), paragraph); +} + +TEST(TypesJson, RichTextRejectsInvalidRepresentations) { + for (const auto& input : + { "null", "42", "true", R"({"type":"unknown"})", "[null]", "[42]", R"({"type":"bold","text":false})" }) { + SCOPED_TRACE(input); + EXPECT_THROW(nlohmann::json::parse(input).get(), std::invalid_argument); + } +} + TEST(TypesJson, GeneratedDiscriminatorHasLegacyConstantAndDefault) { TgBot::InlineQueryResultCachedAudio result;