1515
1616#include < seastar/core/coroutine.hh>
1717
18+ #include < algorithm>
19+ #include < array>
1820#include < cstdint>
1921#include < limits>
2022#include < optional>
23+ #include < string_view>
2124#include < utility>
2225
2326namespace pandaproxy ::schema_registry::rest_client {
@@ -43,6 +46,59 @@ std::optional<int32_t> checked_nonnegative_i32(int64_t v) {
4346 return static_cast <int32_t >(v);
4447}
4548
49+ // Server-assigned response fields Redpanda does not model but which carry no
50+ // user content. The client opts into them via the
51+ // `Confluent-Accept-Unknown-Properties` header (see client.cc), so they arrive
52+ // on schema responses; this list drops them rather than surfacing them to the
53+ // unsupported-feature policy, so a source that returns them does not spuriously
54+ // trip it.
55+ //
56+ // A constexpr array scanned with ranges::contains is a deliberate choice at
57+ // this size: it keeps the list trivially extensible (just add a literal) rather
58+ // than a switch/case, and for N=2 a linear scan beats a hash set (no static
59+ // init, stays constexpr). Promote to a flat_hash_set only if this list grows
60+ // large or gains a bulk-lookup site (cf. cluster_link's
61+ // disallowed_topic_properties, materialized into a set in its validator).
62+ constexpr auto ignorable_fields = std::to_array<std::string_view>(
63+ {" guid" , " ts" });
64+
65+ bool is_ignorable_field (std::string_view key) {
66+ return std::ranges::contains (ignorable_fields, key);
67+ }
68+
69+ // The field's JSON type name, for unsupported-feature diagnostics; takes the
70+ // parser's current value token.
71+ const char * json_type_name (serde::json::token t) {
72+ using token = serde::json::token;
73+ switch (t) {
74+ case token::start_object:
75+ return " object" ;
76+ case token::start_array:
77+ return " array" ;
78+ case token::value_string:
79+ return " string" ;
80+ case token::value_int:
81+ case token::value_double:
82+ return " number" ;
83+ case token::value_true:
84+ case token::value_false:
85+ return " boolean" ;
86+ case token::value_null:
87+ return " null" ;
88+ // Non-value tokens never reach here (this is called only on the parser's
89+ // current value token). They are enumerated rather than folded into a
90+ // default so the switch stays exhaustive: -Wswitch (via -Werror) then flags
91+ // a newly-added token at compile time instead of silently returning
92+ // "unknown".
93+ case token::error:
94+ case token::key:
95+ case token::end_object:
96+ case token::end_array:
97+ case token::eof:
98+ return " unknown" ;
99+ }
100+ }
101+
46102} // namespace
47103
48104ss::future<std::expected<chunked_vector<context_subject>, parse_error>>
@@ -517,21 +573,19 @@ parse_references(serde::json::parser& p, qualified_subjects_enabled qualified) {
517573 parse_error{.reason = " truncated or malformed references array" });
518574}
519575
520- // The result of parsing a metadata object: the modeled portion plus the names
521- // of any sub-keys we don't model .
576+ // The result of parsing a metadata object: the modeled portion plus any
577+ // unsupported sub-fields, each already reported as a `/metadata/<key>` pointer .
522578struct parsed_metadata {
523579 schema_metadata metadata;
524- // Unmodeled keys found directly inside `metadata` (e.g. `tags`,
525- // `sensitive`), unqualified; the caller qualifies and propagates them.
526- chunked_vector<ss::sstring> unknown_fields;
580+ chunked_vector<unsupported_feature> unsupported;
527581};
528582
529583// Parse a metadata object of the form {"properties": {<str>: <str>}, ...}.
530584// Entered with the current token at the object start; leaves the parser at the
531585// end_object token. Only `properties` is modeled; its values are stored as
532586// strings, with numbers and booleans coerced to strings to match the write
533- // path. Any other key (e.g. `tags`, `sensitive`) is returned, unqualified, in
534- // parsed_metadata::unknown_fields for the caller to record .
587+ // path. Any other non-null key (e.g. `tags`, `sensitive`) is reported in
588+ // parsed_metadata::unsupported as a `/metadata/<key>` pointer .
535589ss::future<std::expected<parsed_metadata, parse_error>>
536590parse_metadata (serde::json::parser& p) {
537591 using token = serde::json::token;
@@ -546,7 +600,14 @@ parse_metadata(serde::json::parser& p) {
546600 parse_error{.reason = " truncated JSON in schema metadata" });
547601 }
548602 if (key != " properties" ) {
549- result.unknown_fields .push_back (std::move (key));
603+ // A null value means the sub-field is absent; anything else is an
604+ // unsupported feature, surfaced as a `/metadata/<key>` pointer.
605+ if (p.token () != token::value_null) {
606+ result.unsupported .push_back (
607+ unsupported_feature{
608+ .json_pointer = ssx::sformat (" /metadata/{}" , key),
609+ .json_type = json_type_name (p.token ())});
610+ }
550611 co_await p.skip_value ();
551612 continue ;
552613 }
@@ -602,7 +663,7 @@ parse_metadata(serde::json::parser& p) {
602663
603664} // namespace
604665
605- ss::future<std::expected<parsed_schema , parse_error>>
666+ ss::future<std::expected<source_schema_read , parse_error>>
606667parse_subject_version (iobuf body, qualified_subjects_enabled qualified) {
607668 using token = serde::json::token;
608669 // Firewall exceptions from the parser: malformed input is reported via the
@@ -623,7 +684,7 @@ parse_subject_version(iobuf body, qualified_subjects_enabled qualified) {
623684 schema_definition::references refs;
624685 is_deleted deleted{false };
625686 std::optional<schema_metadata> metadata;
626- chunked_vector<ss::sstring> unknown_fields ;
687+ chunked_vector<unsupported_feature> unsupported ;
627688
628689 while (co_await p.next ()) {
629690 if (p.token () == token::end_object) {
@@ -637,8 +698,8 @@ parse_subject_version(iobuf body, qualified_subjects_enabled qualified) {
637698 }
638699 // Absent fields fall back to defaults/sentinels; completeness
639700 // is a higher-layer concern. Unmodeled fields were recorded in
640- // unknown_fields above for the caller to act on.
641- co_return parsed_schema {
701+ // `unsupported` above for the caller to act on.
702+ co_return source_schema_read {
642703 .schema = stored_schema{
643704 .schema = subject_schema{
644705 subject.value_or (invalid_subject),
@@ -651,7 +712,7 @@ parse_subject_version(iobuf body, qualified_subjects_enabled qualified) {
651712 .version = version.value_or (invalid_schema_version),
652713 .id = id.value_or (invalid_schema_id),
653714 .deleted = deleted},
654- .unknown_fields = std::move (unknown_fields )};
715+ .unsupported = std::move (unsupported )};
655716 }
656717 if (p.token () != token::key) {
657718 co_return std::unexpected (
@@ -730,30 +791,35 @@ parse_subject_version(iobuf body, qualified_subjects_enabled qualified) {
730791 refs = std::move (*r);
731792 } else if (key == " metadata" ) {
732793 // Partially modeled: parse_metadata captures `properties` and
733- // returns any other sub-key (e.g. `tags`), which we qualify
734- // with a `metadata.` prefix into unknown_fields. A null
735- // metadata is treated as absent; any other non-object is
736- // unrepresentable.
794+ // returns any other sub-key (e.g. `tags`) as a
795+ // `/metadata/<key>` unsupported feature. A null metadata is
796+ // treated as absent; any other non-object is unrepresentable.
737797 if (p.token () == token::start_object) {
738798 auto m = co_await parse_metadata (p);
739799 if (!m) {
740800 co_return std::unexpected (std::move (m.error ()));
741801 }
742802 metadata = std::move (m->metadata );
743- for (const auto & sub : m->unknown_fields ) {
744- unknown_fields.push_back (
745- ssx::sformat (" metadata.{}" , sub));
803+ for (auto & f : m->unsupported ) {
804+ unsupported.push_back (std::move (f));
746805 }
747806 } else if (p.token () != token::value_null) {
748807 co_return std::unexpected (
749808 parse_error{.reason = " metadata must be an object" });
750809 }
751810 } else {
752- // Unknown / not-yet-modeled field (guid, ts, ruleSet,
753- // schemaTags, ...): skip its value, but record the top-level
754- // key so the caller can decide whether dropping it is
755- // acceptable.
756- unknown_fields.push_back (std::move (key));
811+ // Unmodeled field. Server-assigned fields that carry no user
812+ // content (`guid`, `ts`) are dropped silently; a null value
813+ // means the field is absent; anything else is an unsupported
814+ // feature, surfaced as a `/<key>` pointer for the migration
815+ // policy to act on.
816+ if (
817+ !is_ignorable_field (key) && p.token () != token::value_null) {
818+ unsupported.push_back (
819+ unsupported_feature{
820+ .json_pointer = ssx::sformat (" /{}" , key),
821+ .json_type = json_type_name (p.token ())});
822+ }
757823 co_await p.skip_value ();
758824 }
759825 }
0 commit comments