diff --git a/backend/pkg/api/connect/service/console/mapper.go b/backend/pkg/api/connect/service/console/mapper.go index 6e1f9757b9..ef5a06ce6e 100644 --- a/backend/pkg/api/connect/service/console/mapper.go +++ b/backend/pkg/api/connect/service/console/mapper.go @@ -62,8 +62,9 @@ func rpcPublishMessagePayloadOptionsToSerializeInput(po *v1alpha.PublishMessageP } input := &serde.RecordPayloadInput{ - Payload: po.GetData(), - Encoding: encoding, + Payload: po.GetData(), + Encoding: encoding, + SchemaContext: po.GetSchemaContext(), } if po.GetSchemaId() > 0 { diff --git a/backend/pkg/api/connect/service/console/service.go b/backend/pkg/api/connect/service/console/service.go index e2344a4629..f32ebc9f87 100644 --- a/backend/pkg/api/connect/service/console/service.go +++ b/backend/pkg/api/connect/service/console/service.go @@ -28,6 +28,7 @@ import ( "github.com/redpanda-data/console/backend/pkg/console" v1alpha "github.com/redpanda-data/console/backend/pkg/protogen/redpanda/api/console/v1alpha1" dataplane "github.com/redpanda-data/console/backend/pkg/protogen/redpanda/api/dataplane/v1alpha2" + schemacache "github.com/redpanda-data/console/backend/pkg/schema" ) // Service that implements the ConsoleServiceHandler interface. @@ -97,6 +98,7 @@ func (api *Service) ListMessages( IgnoreMaxSizeLimit: req.Msg.GetIgnoreMaxSizeLimit(), KeyDeserializer: fromProtoEncoding(req.Msg.GetKeyDeserializer()), ValueDeserializer: fromProtoEncoding(req.Msg.GetValueDeserializer()), + SchemaContext: req.Msg.GetSchemaContext(), PageToken: lmq.PageToken, PageSize: int(req.Msg.GetPageSize()), } @@ -229,6 +231,11 @@ func (api *Service) GenerateSchemaSample( indexPath = append(indexPath, int(v)) } + // Resolve the schema ID in the context the client named. + if schemaCtx := req.Msg.GetSchemaContext(); schemaCtx != "" { + ctx = schemacache.InContext(ctx, schemaCtx) + } + sample, err := api.consoleSvc.GenerateSchemaSampleJSON(ctx, int(req.Msg.GetSchemaId()), indexPath) if err != nil { return nil, apierrors.NewConnectError( diff --git a/backend/pkg/console/list_messages.go b/backend/pkg/console/list_messages.go index ef0fabb135..6ab7ce8b02 100644 --- a/backend/pkg/console/list_messages.go +++ b/backend/pkg/console/list_messages.go @@ -60,6 +60,7 @@ type ListMessageRequest struct { IgnoreMaxSizeLimit bool KeyDeserializer serde.PayloadEncoding ValueDeserializer serde.PayloadEncoding + SchemaContext string // Empty resolves the topic's context, "." forces the default. // Pagination fields (used when PageSize > 0) PageToken string @@ -133,6 +134,7 @@ type TopicConsumeRequest struct { KeyDeserializer serde.PayloadEncoding ValueDeserializer serde.PayloadEncoding Direction string // "desc" or "asc" - used for message ordering + SchemaContext string // Empty means default. } // ListMessages processes a list message request as sent from the Frontend. This function is responsible (mostly @@ -167,6 +169,12 @@ func (s *Service) ListMessages(ctx context.Context, listReq ListMessageRequest, return fmt.Errorf("failed to get metadata for topic %s: %w", listReq.TopicName, topicMetadata.Err) } + // Schema IDs are context-local; an explicit context wins over the topic's. + schemaCtx := listReq.SchemaContext + if schemaCtx == "" { + schemaCtx = s.resolveTopicSchemaContext(ctx, adminCl, listReq.TopicName) + } + partitionByID := make(map[int32]kadm.PartitionDetail) onlinePartitionIDs := make([]int32, 0) offlinePartitionIDs := make([]int32, 0) @@ -263,6 +271,7 @@ func (s *Service) ListMessages(ctx context.Context, listReq ListMessageRequest, KeyDeserializer: listReq.KeyDeserializer, ValueDeserializer: listReq.ValueDeserializer, Direction: direction, + SchemaContext: schemaCtx, } progress.OnPhase("Consuming messages") @@ -1015,6 +1024,7 @@ func (s *Service) startMessageWorker(ctx context.Context, wg *sync.WaitGroup, IgnoreMaxSizeLimit: consumeReq.IgnoreMaxSizeLimit, KeyEncoding: consumeReq.KeyDeserializer, ValueEncoding: consumeReq.ValueDeserializer, + SchemaContext: consumeReq.SchemaContext, }) headersByKey := make(map[string][]byte, len(deserializedRec.Headers)) diff --git a/backend/pkg/console/produce_records.go b/backend/pkg/console/produce_records.go index 086439ab30..dce0a9f418 100644 --- a/backend/pkg/console/produce_records.go +++ b/backend/pkg/console/produce_records.go @@ -32,10 +32,16 @@ func (s *Service) ProduceRecord( useTransactions bool, compressionOpts []kgo.CompressionCodec, ) (*ProduceRecordResponse, error) { + _, adminCl, err := s.kafkaClientFactory.GetKafkaClient(ctx) + if err != nil { + return nil, err + } + data, err := s.serdeSvc.SerializeRecord(ctx, serde.SerializeInput{ - Topic: topic, - Key: *key, - Value: *value, + Topic: topic, + SchemaContext: s.resolveTopicSchemaContext(ctx, adminCl, topic), + Key: *key, + Value: *value, }) if err != nil { return &ProduceRecordResponse{ diff --git a/backend/pkg/console/schema_context.go b/backend/pkg/console/schema_context.go new file mode 100644 index 0000000000..544a8c8672 --- /dev/null +++ b/backend/pkg/console/schema_context.go @@ -0,0 +1,67 @@ +// Copyright 2026 Redpanda Data, Inc. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.md +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0 + +package console + +import ( + "context" + "log/slog" + + "github.com/twmb/franz-go/pkg/kadm" + + schemacache "github.com/redpanda-data/console/backend/pkg/schema" +) + +// topicSchemaContext returns the topic's redpanda.schema.registry.context, or "". +func topicSchemaContext(configs []kadm.Config) string { + for _, c := range configs { + if c.Key == schemacache.TopicConfigSchemaRegistryContext { + return schemacache.NormalizeContextName(c.MaybeValue()) + } + } + return "" +} + +// schemaContextForTopic reads the topic's schema registry context via DescribeConfigs. +func schemaContextForTopic(ctx context.Context, adminCl *kadm.Client, topic string) (string, error) { + rcs, err := adminCl.DescribeTopicConfigs(ctx, topic) + if err != nil { + return "", err + } + rc, err := rcs.On(topic, nil) + if err != nil { + return "", err + } + if rc.Err != nil { + return "", rc.Err + } + return topicSchemaContext(rc.Configs), nil +} + +// resolveTopicSchemaContext returns the topic's schema registry context. Lookup +// failures are logged and fall back to the default context. +func (s *Service) resolveTopicSchemaContext(ctx context.Context, adminCl *kadm.Client, topic string) string { + if !s.cfg.SchemaRegistry.Enabled { + return "" + } + + schemaCtx, err := schemaContextForTopic(ctx, adminCl, topic) + if err != nil { + s.logger.WarnContext(ctx, "failed to determine the topic's schema registry context, using the default context", + slog.String("topic", topic), + slog.Any("error", err)) + return "" + } + if schemaCtx != "" { + s.logger.DebugContext(ctx, "resolving schemas in the topic's schema registry context", + slog.String("topic", topic), + slog.String("schema_context", schemaCtx)) + } + return schemaCtx +} diff --git a/backend/pkg/console/schema_context_test.go b/backend/pkg/console/schema_context_test.go new file mode 100644 index 0000000000..1bee7508b8 --- /dev/null +++ b/backend/pkg/console/schema_context_test.go @@ -0,0 +1,67 @@ +// Copyright 2026 Redpanda Data, Inc. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.md +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0 + +package console + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/twmb/franz-go/pkg/kadm" +) + +func TestTopicSchemaContext(t *testing.T) { + tests := []struct { + name string + configs []kadm.Config + expected string + }{ + {name: "nil configs", configs: nil, expected: ""}, + { + name: "context not set", + configs: []kadm.Config{{Key: "cleanup.policy", Value: new("delete")}}, + expected: "", + }, + { + name: "context set", + configs: []kadm.Config{{Key: "redpanda.schema.registry.context", Value: new(".prod")}}, + expected: ".prod", + }, + { + name: "context present without value", + configs: []kadm.Config{{Key: "redpanda.schema.registry.context", Value: nil}}, + expected: "", + }, + { + name: "explicit default context", + configs: []kadm.Config{{Key: "redpanda.schema.registry.context", Value: new(".")}}, + expected: "", + }, + { + name: "context without leading dot is normalized", + configs: []kadm.Config{{Key: "redpanda.schema.registry.context", Value: new("prod")}}, + expected: ".prod", + }, + { + name: "context among other configs", + configs: []kadm.Config{ + {Key: "cleanup.policy", Value: new("delete")}, + {Key: "redpanda.schema.registry.context", Value: new(".staging")}, + {Key: "redpanda.iceberg.mode", Value: new("value_schema_id_prefix")}, + }, + expected: ".staging", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, topicSchemaContext(tc.configs)) + }) + } +} diff --git a/backend/pkg/console/schema_registry.go b/backend/pkg/console/schema_registry.go index 70ed14ddaf..961b13c3c5 100644 --- a/backend/pkg/console/schema_registry.go +++ b/backend/pkg/console/schema_registry.go @@ -27,6 +27,7 @@ import ( "golang.org/x/sync/errgroup" "github.com/redpanda-data/console/backend/pkg/proto" + schemacache "github.com/redpanda-data/console/backend/pkg/schema" ) // SchemaRegistryMode returns the schema registry mode. @@ -441,7 +442,8 @@ func (s *Service) GetSchemaRegistrySubjectDetails(ctx context.Context, subjectNa return nil, err } - s.populateProtoMessageTypes(ctx, subjectName, schemas) + // Resolve the message types in the context of the qualified subject. + s.populateProtoMessageTypes(schemacache.InContext(ctx, schemacache.ContextFromSubject(subjectName)), subjectName, schemas) var schemaType sr.SchemaType if len(schemas) > 0 { @@ -473,8 +475,9 @@ func (s *Service) populateProtoMessageTypes(ctx context.Context, subjectName str if err != nil { s.logger.WarnContext(grpCtx, "failed to resolve protobuf message types", slog.String("subject", subjectName), - slog.Int("schemaId", schemas[i].ID), - slog.Any("err", err)) + slog.Int("schema_id", schemas[i].ID), + slog.String("schema_context", schemacache.ContextName(grpCtx)), + slog.Any("error", err)) return nil } schemas[i].MessageTypes = types @@ -960,15 +963,72 @@ func (s *Service) GetSchemaUsagesByID(ctx context.Context, schemaID int, subject return nil, err } - callCtx := ctx if subject != "" { - callCtx = sr.WithParams(ctx, sr.Subject(subject)) + // With a subject the registry searches across contexts itself. + res, err := srClient.SchemaUsagesByID(sr.WithParams(ctx, sr.Subject(subject)), schemaID) + if err != nil { + return nil, err + } + return mapSchemaVersions(res), nil } - res, err := srClient.SchemaUsagesByID(callCtx, schemaID) - if err != nil { - return nil, err + + return schemaUsagesAcrossContexts(ctx, srClient, schemaID) +} + +// schemaUsagesAcrossContexts looks up the subjects using schemaID in every +// context, since schema IDs are context-local. +func schemaUsagesAcrossContexts(ctx context.Context, srClient *rpsr.Client, schemaID int) ([]SchemaVersion, error) { + // Always search the default context; registries without contexts fail /contexts. + contextNames := []string{""} + if contexts, err := srClient.Contexts(ctx); err == nil { + for _, name := range contexts { + if normalized := schemacache.NormalizeContextName(name); normalized != "" { + contextNames = append(contextNames, normalized) + } + } + } + + results := make([][]sr.SubjectSchema, len(contextNames)) + errs := make([]error, len(contextNames)) + grp, grpCtx := errgroup.WithContext(ctx) + grp.SetLimit(10) + for i, name := range contextNames { + grp.Go(func() error { + callCtx := grpCtx + if name != "" { + callCtx = sr.InContext(grpCtx, name) + } + results[i], errs[i] = srClient.SchemaUsagesByID(callCtx, schemaID) + return nil + }) + } + _ = grp.Wait() + + // De-duplicate: a subject may be reported by more than one lookup. + seen := make(map[SchemaVersion]struct{}) + schemaVersions := make([]SchemaVersion, 0) + for _, res := range results { + for _, sv := range mapSchemaVersions(res) { + if _, ok := seen[sv]; ok { + continue + } + seen[sv] = struct{}{} + schemaVersions = append(schemaVersions, sv) + } + } + if len(schemaVersions) == 0 { + // Unknown everywhere: return the default context's error. + for _, err := range errs { + if err != nil { + return nil, err + } + } } + return schemaVersions, nil +} + +func mapSchemaVersions(res []sr.SubjectSchema) []SchemaVersion { schemaVersions := make([]SchemaVersion, len(res)) for i, r := range res { schemaVersions[i] = SchemaVersion{ @@ -976,8 +1036,7 @@ func (s *Service) GetSchemaUsagesByID(ctx context.Context, schemaID int, subject Version: r.Version, } } - - return schemaVersions, nil + return schemaVersions } // CheckSchemaRegistryACLSupport checks if the Schema Registry supports ACL diff --git a/backend/pkg/console/schema_registry_context_test.go b/backend/pkg/console/schema_registry_context_test.go new file mode 100644 index 0000000000..65fe136ff5 --- /dev/null +++ b/backend/pkg/console/schema_registry_context_test.go @@ -0,0 +1,58 @@ +// Copyright 2026 Redpanda Data, Inc. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.md +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0 + +package console + +import ( + "net/http" + "testing" + + "github.com/redpanda-data/common-go/rpsr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/twmb/franz-go/pkg/sr" + "github.com/twmb/franz-go/pkg/sr/srfake" +) + +func TestSchemaUsagesAcrossContexts(t *testing.T) { + registry := srfake.New() + defer registry.Close() + + registry.SeedSchema("orders-value", 1, 1, sr.Schema{ + Schema: `{"type":"record","name":"Order","fields":[{"name":"id","type":"string"}]}`, + }) + registry.SeedSchema(":.outgo:convoy-value", 1, 7, sr.Schema{ + Schema: `{"type":"record","name":"Convoy","fields":[{"name":"id","type":"string"}]}`, + }) + + srClient, err := sr.NewClient(sr.URLs(registry.URL())) + require.NoError(t, err) + client, err := rpsr.NewClient(srClient) + require.NoError(t, err) + + t.Run("id that only exists in a named context", func(t *testing.T) { + got, err := schemaUsagesAcrossContexts(t.Context(), client, 7) + require.NoError(t, err) + assert.Equal(t, []SchemaVersion{{Subject: ":.outgo:convoy-value", Version: 1}}, got) + }) + + t.Run("id in the default context", func(t *testing.T) { + got, err := schemaUsagesAcrossContexts(t.Context(), client, 1) + require.NoError(t, err) + assert.Equal(t, []SchemaVersion{{Subject: "orders-value", Version: 1}}, got) + }) + + t.Run("unknown id reports the registry's not-found error", func(t *testing.T) { + _, err := schemaUsagesAcrossContexts(t.Context(), client, 99) + require.Error(t, err) + var respErr *sr.ResponseError + require.ErrorAs(t, err, &respErr) + assert.Equal(t, http.StatusNotFound, respErr.StatusCode) + }) +} diff --git a/backend/pkg/protogen/redpanda/api/console/v1alpha1/list_messages.pb.go b/backend/pkg/protogen/redpanda/api/console/v1alpha1/list_messages.pb.go index cb1c63c375..9cf80f1bcd 100644 --- a/backend/pkg/protogen/redpanda/api/console/v1alpha1/list_messages.pb.go +++ b/backend/pkg/protogen/redpanda/api/console/v1alpha1/list_messages.pb.go @@ -38,6 +38,7 @@ type ListMessagesRequest struct { ValueDeserializer *PayloadEncoding `protobuf:"varint,11,opt,name=value_deserializer,json=valueDeserializer,proto3,enum=redpanda.api.console.v1alpha1.PayloadEncoding,oneof" json:"value_deserializer,omitempty"` // Optionally specify value payload deserialization strategy to use. IgnoreMaxSizeLimit bool `protobuf:"varint,12,opt,name=ignore_max_size_limit,json=ignoreMaxSizeLimit,proto3" json:"ignore_max_size_limit,omitempty"` // Optionally ignore configured maximum payload size limit. PageToken string `protobuf:"bytes,13,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` // Resume from cursor (only used when page_size is present). + SchemaContext string `protobuf:"bytes,15,opt,name=schema_context,json=schemaContext,proto3" json:"schema_context,omitempty"` // Optional Schema Registry context to resolve schema IDs in. Empty uses the topic's redpanda.schema.registry.context, then the default context; "." forces the default context. PageSize int32 `protobuf:"varint,14,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` // Number of messages to fetch per page. When set (> 0), pagination mode is enabled and max_results is ignored. When unset or 0, legacy mode is used. unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -164,6 +165,13 @@ func (x *ListMessagesRequest) GetPageToken() string { return "" } +func (x *ListMessagesRequest) GetSchemaContext() string { + if x != nil { + return x.SchemaContext + } + return "" +} + func (x *ListMessagesRequest) GetPageSize() int32 { if x != nil { return x.PageSize @@ -730,7 +738,7 @@ var file_redpanda_api_console_v1alpha1_list_messages_proto_rawDesc = []byte{ 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x2a, 0x72, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x2f, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2f, 0x63, - 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xa4, 0x06, 0x0a, 0x13, + 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xcb, 0x06, 0x0a, 0x13, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x34, 0x0a, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1e, 0xba, 0x48, 0x1b, 0x72, 0x19, 0x10, 0x01, 0x18, 0xf9, 0x01, 0x32, 0x12, @@ -775,147 +783,149 @@ var file_redpanda_api_console_v1alpha1_list_messages_proto_rawDesc = []byte{ 0x69, 0x6d, 0x69, 0x74, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x69, 0x67, 0x6e, 0x6f, 0x72, 0x65, 0x4d, 0x61, 0x78, 0x53, 0x69, 0x7a, 0x65, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x0d, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x09, 0x70, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x27, 0x0a, - 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x05, - 0x42, 0x0a, 0xba, 0x48, 0x07, 0x1a, 0x05, 0x18, 0xf4, 0x03, 0x28, 0x01, 0x52, 0x08, 0x70, 0x61, - 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x64, - 0x65, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x72, 0x42, 0x15, 0x0a, 0x13, 0x5f, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x5f, 0x64, 0x65, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x69, 0x7a, - 0x65, 0x72, 0x22, 0xc9, 0x0a, 0x0a, 0x14, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x55, 0x0a, 0x04, 0x64, - 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x3f, 0x2e, 0x72, 0x65, 0x64, 0x70, - 0x61, 0x6e, 0x64, 0x61, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, - 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x65, - 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x44, - 0x61, 0x74, 0x61, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x48, 0x00, 0x52, 0x04, 0x64, 0x61, - 0x74, 0x61, 0x12, 0x58, 0x0a, 0x05, 0x70, 0x68, 0x61, 0x73, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x40, 0x2e, 0x72, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2e, 0x61, 0x70, 0x69, - 0x2e, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, - 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x50, 0x68, 0x61, 0x73, 0x65, 0x4d, 0x65, 0x73, 0x73, - 0x61, 0x67, 0x65, 0x48, 0x00, 0x52, 0x05, 0x70, 0x68, 0x61, 0x73, 0x65, 0x12, 0x61, 0x0a, 0x08, - 0x70, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x43, - 0x2e, 0x72, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x6f, - 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x4c, - 0x69, 0x73, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x2e, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x4d, 0x65, 0x73, 0x73, - 0x61, 0x67, 0x65, 0x48, 0x00, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x12, - 0x60, 0x0a, 0x04, 0x64, 0x6f, 0x6e, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x4a, 0x2e, - 0x72, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x6f, 0x6e, - 0x73, 0x6f, 0x6c, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x4c, 0x69, + 0x28, 0x09, 0x52, 0x09, 0x70, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x25, 0x0a, + 0x0e, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, + 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x43, 0x6f, 0x6e, + 0x74, 0x65, 0x78, 0x74, 0x12, 0x27, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, + 0x65, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x05, 0x42, 0x0a, 0xba, 0x48, 0x07, 0x1a, 0x05, 0x18, 0xf4, + 0x03, 0x28, 0x01, 0x52, 0x08, 0x70, 0x61, 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65, 0x42, 0x13, 0x0a, + 0x11, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x64, 0x65, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x69, 0x7a, + 0x65, 0x72, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x5f, 0x64, 0x65, 0x73, + 0x65, 0x72, 0x69, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x72, 0x22, 0xc9, 0x0a, 0x0a, 0x14, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, - 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x48, 0x00, 0x52, 0x04, 0x64, 0x6f, 0x6e, - 0x65, 0x12, 0x58, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x40, 0x2e, 0x72, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2e, 0x61, 0x70, 0x69, 0x2e, + 0x73, 0x65, 0x12, 0x55, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x3f, 0x2e, 0x72, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x48, 0x00, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x1a, 0xbd, 0x03, 0x0a, 0x0b, - 0x44, 0x61, 0x74, 0x61, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x70, - 0x61, 0x72, 0x74, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x05, 0x52, 0x0b, 0x70, 0x61, 0x72, 0x74, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x16, - 0x0a, 0x06, 0x6f, 0x66, 0x66, 0x73, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, - 0x6f, 0x66, 0x66, 0x73, 0x65, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, - 0x61, 0x6d, 0x70, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, - 0x74, 0x61, 0x6d, 0x70, 0x12, 0x50, 0x0a, 0x0b, 0x63, 0x6f, 0x6d, 0x70, 0x72, 0x65, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2e, 0x2e, 0x72, 0x65, 0x64, 0x70, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x44, 0x61, 0x74, 0x61, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x48, 0x00, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x58, 0x0a, 0x05, 0x70, 0x68, 0x61, + 0x73, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x40, 0x2e, 0x72, 0x65, 0x64, 0x70, 0x61, + 0x6e, 0x64, 0x61, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x2e, + 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x50, 0x68, + 0x61, 0x73, 0x65, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x48, 0x00, 0x52, 0x05, 0x70, 0x68, + 0x61, 0x73, 0x65, 0x12, 0x61, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x43, 0x2e, 0x72, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, + 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x2e, 0x76, 0x31, 0x61, + 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x50, 0x72, 0x6f, 0x67, 0x72, + 0x65, 0x73, 0x73, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x48, 0x00, 0x52, 0x08, 0x70, 0x72, + 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x12, 0x60, 0x0a, 0x04, 0x64, 0x6f, 0x6e, 0x65, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x4a, 0x2e, 0x72, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2e, + 0x61, 0x70, 0x69, 0x2e, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, + 0x70, 0x68, 0x61, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, + 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x48, 0x00, 0x52, 0x04, 0x64, 0x6f, 0x6e, 0x65, 0x12, 0x58, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, + 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x40, 0x2e, 0x72, 0x65, 0x64, 0x70, 0x61, 0x6e, + 0x64, 0x61, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x2e, 0x76, + 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x45, 0x72, 0x72, + 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x48, 0x00, 0x52, 0x05, 0x65, 0x72, 0x72, + 0x6f, 0x72, 0x1a, 0xbd, 0x03, 0x0a, 0x0b, 0x44, 0x61, 0x74, 0x61, 0x4d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x70, 0x61, 0x72, 0x74, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0b, 0x70, 0x61, 0x72, 0x74, 0x69, 0x74, + 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x66, 0x66, 0x73, 0x65, 0x74, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x6f, 0x66, 0x66, 0x73, 0x65, 0x74, 0x12, 0x1c, 0x0a, + 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x50, 0x0a, 0x0b, 0x63, + 0x6f, 0x6d, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, + 0x32, 0x2e, 0x2e, 0x72, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2e, 0x61, 0x70, 0x69, 0x2e, + 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, + 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, + 0x52, 0x0b, 0x63, 0x6f, 0x6d, 0x70, 0x72, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x29, 0x0a, + 0x10, 0x69, 0x73, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x61, + 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0f, 0x69, 0x73, 0x54, 0x72, 0x61, 0x6e, 0x73, + 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x12, 0x4a, 0x0a, 0x07, 0x68, 0x65, 0x61, 0x64, + 0x65, 0x72, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x30, 0x2e, 0x72, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, - 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x72, 0x65, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x0b, 0x63, 0x6f, 0x6d, 0x70, 0x72, - 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x69, 0x73, 0x5f, 0x74, 0x72, 0x61, - 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x0f, 0x69, 0x73, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x61, - 0x6c, 0x12, 0x4a, 0x0a, 0x07, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x18, 0x06, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x30, 0x2e, 0x72, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2e, 0x61, 0x70, - 0x69, 0x2e, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, - 0x61, 0x31, 0x2e, 0x4b, 0x61, 0x66, 0x6b, 0x61, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x48, 0x65, - 0x61, 0x64, 0x65, 0x72, 0x52, 0x07, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x12, 0x43, 0x0a, - 0x03, 0x6b, 0x65, 0x79, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x31, 0x2e, 0x72, 0x65, 0x64, - 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, - 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x4b, 0x61, 0x66, 0x6b, 0x61, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x52, 0x03, 0x6b, - 0x65, 0x79, 0x12, 0x47, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, + 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x4b, 0x61, 0x66, 0x6b, 0x61, 0x52, + 0x65, 0x63, 0x6f, 0x72, 0x64, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x52, 0x07, 0x68, 0x65, 0x61, + 0x64, 0x65, 0x72, 0x73, 0x12, 0x43, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x31, 0x2e, 0x72, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x4b, 0x61, 0x66, 0x6b, 0x61, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x61, 0x79, - 0x6c, 0x6f, 0x61, 0x64, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x1a, 0x24, 0x0a, 0x0c, 0x50, - 0x68, 0x61, 0x73, 0x65, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x70, - 0x68, 0x61, 0x73, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x70, 0x68, 0x61, 0x73, - 0x65, 0x1a, 0x65, 0x0a, 0x0f, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x4d, 0x65, 0x73, - 0x73, 0x61, 0x67, 0x65, 0x12, 0x2b, 0x0a, 0x11, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, - 0x5f, 0x63, 0x6f, 0x6e, 0x73, 0x75, 0x6d, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, - 0x10, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x43, 0x6f, 0x6e, 0x73, 0x75, 0x6d, 0x65, - 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x63, 0x6f, 0x6e, 0x73, 0x75, - 0x6d, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x62, 0x79, 0x74, 0x65, 0x73, - 0x43, 0x6f, 0x6e, 0x73, 0x75, 0x6d, 0x65, 0x64, 0x1a, 0xd6, 0x01, 0x0a, 0x16, 0x53, 0x74, 0x72, - 0x65, 0x61, 0x6d, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, - 0x61, 0x67, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x65, 0x6c, 0x61, 0x70, 0x73, 0x65, 0x64, 0x5f, 0x6d, - 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x65, 0x6c, 0x61, 0x70, 0x73, 0x65, 0x64, - 0x4d, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x69, 0x73, 0x5f, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x6c, - 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x69, 0x73, 0x43, 0x61, 0x6e, 0x63, - 0x65, 0x6c, 0x6c, 0x65, 0x64, 0x12, 0x2b, 0x0a, 0x11, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x73, 0x5f, 0x63, 0x6f, 0x6e, 0x73, 0x75, 0x6d, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, - 0x52, 0x10, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x43, 0x6f, 0x6e, 0x73, 0x75, 0x6d, - 0x65, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x63, 0x6f, 0x6e, 0x73, - 0x75, 0x6d, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x62, 0x79, 0x74, 0x65, - 0x73, 0x43, 0x6f, 0x6e, 0x73, 0x75, 0x6d, 0x65, 0x64, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, - 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, - 0x6e, 0x1a, 0x28, 0x0a, 0x0c, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, 0x11, 0x0a, 0x0f, 0x63, - 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0xd8, - 0x03, 0x0a, 0x12, 0x4b, 0x61, 0x66, 0x6b, 0x61, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x61, - 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x2e, 0x0a, 0x10, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x61, - 0x6c, 0x5f, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x48, - 0x00, 0x52, 0x0f, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x61, 0x6c, 0x50, 0x61, 0x79, 0x6c, 0x6f, - 0x61, 0x64, 0x88, 0x01, 0x01, 0x12, 0x32, 0x0a, 0x12, 0x6e, 0x6f, 0x72, 0x6d, 0x61, 0x6c, 0x69, - 0x7a, 0x65, 0x64, 0x5f, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x0c, 0x48, 0x01, 0x52, 0x11, 0x6e, 0x6f, 0x72, 0x6d, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x64, 0x50, - 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x88, 0x01, 0x01, 0x12, 0x4a, 0x0a, 0x08, 0x65, 0x6e, 0x63, - 0x6f, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2e, 0x2e, 0x72, 0x65, - 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x6f, 0x6e, 0x73, 0x6f, - 0x6c, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x50, 0x61, 0x79, 0x6c, - 0x6f, 0x61, 0x64, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x08, 0x65, 0x6e, 0x63, - 0x6f, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x20, 0x0a, 0x09, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x5f, - 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x48, 0x02, 0x52, 0x08, 0x73, 0x63, 0x68, 0x65, - 0x6d, 0x61, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, 0x0c, 0x70, 0x61, 0x79, 0x6c, 0x6f, - 0x61, 0x64, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0b, 0x70, - 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x2f, 0x0a, 0x14, 0x69, 0x73, - 0x5f, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x74, 0x6f, 0x6f, 0x5f, 0x6c, 0x61, 0x72, - 0x67, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x69, 0x73, 0x50, 0x61, 0x79, 0x6c, - 0x6f, 0x61, 0x64, 0x54, 0x6f, 0x6f, 0x4c, 0x61, 0x72, 0x67, 0x65, 0x12, 0x62, 0x0a, 0x13, 0x74, - 0x72, 0x6f, 0x75, 0x62, 0x6c, 0x65, 0x73, 0x68, 0x6f, 0x6f, 0x74, 0x5f, 0x72, 0x65, 0x70, 0x6f, - 0x72, 0x74, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x31, 0x2e, 0x72, 0x65, 0x64, 0x70, 0x61, + 0x6c, 0x6f, 0x61, 0x64, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x47, 0x0a, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x31, 0x2e, 0x72, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x2e, - 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x54, 0x72, 0x6f, 0x75, 0x62, 0x6c, 0x65, - 0x73, 0x68, 0x6f, 0x6f, 0x74, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x12, 0x74, 0x72, 0x6f, - 0x75, 0x62, 0x6c, 0x65, 0x73, 0x68, 0x6f, 0x6f, 0x74, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x42, - 0x13, 0x0a, 0x11, 0x5f, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x61, 0x6c, 0x5f, 0x70, 0x61, 0x79, - 0x6c, 0x6f, 0x61, 0x64, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x6e, 0x6f, 0x72, 0x6d, 0x61, 0x6c, 0x69, - 0x7a, 0x65, 0x64, 0x5f, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, - 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x5f, 0x69, 0x64, 0x42, 0xb2, 0x02, 0x0a, 0x21, 0x63, 0x6f, - 0x6d, 0x2e, 0x72, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x63, - 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x42, - 0x11, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x50, 0x72, 0x6f, - 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x63, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, - 0x2f, 0x72, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2d, 0x64, 0x61, 0x74, 0x61, 0x2f, 0x63, - 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x2f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x2f, 0x70, - 0x6b, 0x67, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x67, 0x65, 0x6e, 0x2f, 0x72, 0x65, 0x64, 0x70, - 0x61, 0x6e, 0x64, 0x61, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, - 0x2f, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x3b, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, - 0x65, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0xa2, 0x02, 0x03, 0x52, 0x41, 0x43, 0xaa, - 0x02, 0x1d, 0x52, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2e, 0x41, 0x70, 0x69, 0x2e, 0x43, - 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x2e, 0x56, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0xca, - 0x02, 0x1d, 0x52, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x5c, 0x41, 0x70, 0x69, 0x5c, 0x43, - 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x5c, 0x56, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0xe2, - 0x02, 0x29, 0x52, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x5c, 0x41, 0x70, 0x69, 0x5c, 0x43, - 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x5c, 0x56, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x5c, - 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x20, 0x52, 0x65, - 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x3a, 0x3a, 0x41, 0x70, 0x69, 0x3a, 0x3a, 0x43, 0x6f, 0x6e, - 0x73, 0x6f, 0x6c, 0x65, 0x3a, 0x3a, 0x56, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x62, 0x06, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x4b, 0x61, 0x66, 0x6b, 0x61, 0x52, 0x65, + 0x63, 0x6f, 0x72, 0x64, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x52, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x1a, 0x24, 0x0a, 0x0c, 0x50, 0x68, 0x61, 0x73, 0x65, 0x4d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x70, 0x68, 0x61, 0x73, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x70, 0x68, 0x61, 0x73, 0x65, 0x1a, 0x65, 0x0a, 0x0f, 0x50, 0x72, 0x6f, 0x67, + 0x72, 0x65, 0x73, 0x73, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x2b, 0x0a, 0x11, 0x6d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x5f, 0x63, 0x6f, 0x6e, 0x73, 0x75, 0x6d, 0x65, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x10, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, + 0x43, 0x6f, 0x6e, 0x73, 0x75, 0x6d, 0x65, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x62, 0x79, 0x74, 0x65, + 0x73, 0x5f, 0x63, 0x6f, 0x6e, 0x73, 0x75, 0x6d, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x0d, 0x62, 0x79, 0x74, 0x65, 0x73, 0x43, 0x6f, 0x6e, 0x73, 0x75, 0x6d, 0x65, 0x64, 0x1a, + 0xd6, 0x01, 0x0a, 0x16, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, + 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x65, 0x6c, + 0x61, 0x70, 0x73, 0x65, 0x64, 0x5f, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, + 0x65, 0x6c, 0x61, 0x70, 0x73, 0x65, 0x64, 0x4d, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x69, 0x73, 0x5f, + 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x6c, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x0b, 0x69, 0x73, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x6c, 0x65, 0x64, 0x12, 0x2b, 0x0a, 0x11, + 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x5f, 0x63, 0x6f, 0x6e, 0x73, 0x75, 0x6d, 0x65, + 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x10, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x73, 0x43, 0x6f, 0x6e, 0x73, 0x75, 0x6d, 0x65, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x62, 0x79, 0x74, + 0x65, 0x73, 0x5f, 0x63, 0x6f, 0x6e, 0x73, 0x75, 0x6d, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x0d, 0x62, 0x79, 0x74, 0x65, 0x73, 0x43, 0x6f, 0x6e, 0x73, 0x75, 0x6d, 0x65, 0x64, + 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, + 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, + 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x1a, 0x28, 0x0a, 0x0c, 0x45, 0x72, 0x72, 0x6f, + 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x42, 0x11, 0x0a, 0x0f, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0xd8, 0x03, 0x0a, 0x12, 0x4b, 0x61, 0x66, 0x6b, 0x61, 0x52, + 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x2e, 0x0a, 0x10, + 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x61, 0x6c, 0x5f, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, 0x52, 0x0f, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, + 0x61, 0x6c, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x88, 0x01, 0x01, 0x12, 0x32, 0x0a, 0x12, + 0x6e, 0x6f, 0x72, 0x6d, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x64, 0x5f, 0x70, 0x61, 0x79, 0x6c, 0x6f, + 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x01, 0x52, 0x11, 0x6e, 0x6f, 0x72, 0x6d, + 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x64, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x88, 0x01, 0x01, + 0x12, 0x4a, 0x0a, 0x08, 0x65, 0x6e, 0x63, 0x6f, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x0e, 0x32, 0x2e, 0x2e, 0x72, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2e, 0x61, 0x70, + 0x69, 0x2e, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, + 0x61, 0x31, 0x2e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x69, + 0x6e, 0x67, 0x52, 0x08, 0x65, 0x6e, 0x63, 0x6f, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x20, 0x0a, 0x09, + 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x48, + 0x02, 0x52, 0x08, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x21, + 0x0a, 0x0c, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x05, 0x52, 0x0b, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x53, 0x69, 0x7a, + 0x65, 0x12, 0x2f, 0x0a, 0x14, 0x69, 0x73, 0x5f, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x5f, + 0x74, 0x6f, 0x6f, 0x5f, 0x6c, 0x61, 0x72, 0x67, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x11, 0x69, 0x73, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x54, 0x6f, 0x6f, 0x4c, 0x61, 0x72, + 0x67, 0x65, 0x12, 0x62, 0x0a, 0x13, 0x74, 0x72, 0x6f, 0x75, 0x62, 0x6c, 0x65, 0x73, 0x68, 0x6f, + 0x6f, 0x74, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x31, 0x2e, 0x72, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x63, + 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, + 0x54, 0x72, 0x6f, 0x75, 0x62, 0x6c, 0x65, 0x73, 0x68, 0x6f, 0x6f, 0x74, 0x52, 0x65, 0x70, 0x6f, + 0x72, 0x74, 0x52, 0x12, 0x74, 0x72, 0x6f, 0x75, 0x62, 0x6c, 0x65, 0x73, 0x68, 0x6f, 0x6f, 0x74, + 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x6f, 0x72, 0x69, 0x67, 0x69, + 0x6e, 0x61, 0x6c, 0x5f, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x42, 0x15, 0x0a, 0x13, 0x5f, + 0x6e, 0x6f, 0x72, 0x6d, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x64, 0x5f, 0x70, 0x61, 0x79, 0x6c, 0x6f, + 0x61, 0x64, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x5f, 0x69, 0x64, + 0x42, 0xb2, 0x02, 0x0a, 0x21, 0x63, 0x6f, 0x6d, 0x2e, 0x72, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, + 0x61, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x2e, 0x76, 0x31, + 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x42, 0x11, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x73, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x63, 0x67, 0x69, 0x74, + 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x72, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, + 0x2d, 0x64, 0x61, 0x74, 0x61, 0x2f, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x2f, 0x62, 0x61, + 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x67, + 0x65, 0x6e, 0x2f, 0x72, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2f, 0x61, 0x70, 0x69, 0x2f, + 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x2f, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, + 0x3b, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, + 0xa2, 0x02, 0x03, 0x52, 0x41, 0x43, 0xaa, 0x02, 0x1d, 0x52, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, + 0x61, 0x2e, 0x41, 0x70, 0x69, 0x2e, 0x43, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x2e, 0x56, 0x31, + 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0xca, 0x02, 0x1d, 0x52, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, + 0x61, 0x5c, 0x41, 0x70, 0x69, 0x5c, 0x43, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x5c, 0x56, 0x31, + 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0xe2, 0x02, 0x29, 0x52, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, + 0x61, 0x5c, 0x41, 0x70, 0x69, 0x5c, 0x43, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x5c, 0x56, 0x31, + 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, + 0x74, 0x61, 0xea, 0x02, 0x20, 0x52, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x3a, 0x3a, 0x41, + 0x70, 0x69, 0x3a, 0x3a, 0x43, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x3a, 0x3a, 0x56, 0x31, 0x61, + 0x6c, 0x70, 0x68, 0x61, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/backend/pkg/protogen/redpanda/api/console/v1alpha1/publish_messages.pb.go b/backend/pkg/protogen/redpanda/api/console/v1alpha1/publish_messages.pb.go index 85e9896b2d..b55769363c 100644 --- a/backend/pkg/protogen/redpanda/api/console/v1alpha1/publish_messages.pb.go +++ b/backend/pkg/protogen/redpanda/api/console/v1alpha1/publish_messages.pb.go @@ -122,6 +122,7 @@ type PublishMessagePayloadOptions struct { SchemaId *int32 `protobuf:"varint,9,opt,name=schema_id,json=schemaId,proto3,oneof" json:"schema_id,omitempty"` // Optional schema ID. Index *int32 `protobuf:"varint,10,opt,name=index,proto3,oneof" json:"index,omitempty"` // Deprecated single-index. Prefer index_path for Protobuf messages so nested types are addressable. IndexPath []int32 `protobuf:"varint,11,rep,packed,name=index_path,json=indexPath,proto3" json:"index_path,omitempty"` // Optional message-index path for Protobuf. Each element selects the Nth nested MessageDescriptor; e.g. [0] = first top-level, [1, 0] = first nested message of the second top-level. Empty = first top-level. + SchemaContext string `protobuf:"bytes,12,opt,name=schema_context,json=schemaContext,proto3" json:"schema_context,omitempty"` // Optional Schema Registry context of schema_id. Empty uses the topic's context, then the default context. unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -191,6 +192,13 @@ func (x *PublishMessagePayloadOptions) GetIndexPath() []int32 { return nil } +func (x *PublishMessagePayloadOptions) GetSchemaContext() string { + if x != nil { + return x.SchemaContext + } + return "" +} + // PublishMessageResponse is the response for PublishMessage call. type PublishMessageResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -259,6 +267,7 @@ type GenerateSchemaSampleRequest struct { state protoimpl.MessageState `protogen:"open.v1"` SchemaId int32 `protobuf:"varint,1,opt,name=schema_id,json=schemaId,proto3" json:"schema_id,omitempty"` IndexPath []int32 `protobuf:"varint,2,rep,packed,name=index_path,json=indexPath,proto3" json:"index_path,omitempty"` + SchemaContext string `protobuf:"bytes,3,opt,name=schema_context,json=schemaContext,proto3" json:"schema_context,omitempty"` // Optional Schema Registry context of schema_id. Empty means the default context. unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -307,6 +316,13 @@ func (x *GenerateSchemaSampleRequest) GetIndexPath() []int32 { return nil } +func (x *GenerateSchemaSampleRequest) GetSchemaContext() string { + if x != nil { + return x.SchemaContext + } + return "" +} + // GenerateSchemaSampleResponse returns the JSON skeleton. type GenerateSchemaSampleResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -395,8 +411,8 @@ var file_redpanda_api_console_v1alpha1_publish_messages_proto_rawDesc = []byte{ 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, - 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0xf2, - 0x01, 0x0a, 0x1c, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x99, + 0x02, 0x0a, 0x1c, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x4a, 0x0a, 0x08, 0x65, 0x6e, 0x63, 0x6f, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2e, 0x2e, 0x72, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2e, 0x61, 0x70, 0x69, @@ -409,46 +425,51 @@ var file_redpanda_api_console_v1alpha1_publish_messages_proto_rawDesc = []byte{ 0x01, 0x12, 0x19, 0x0a, 0x05, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x05, 0x48, 0x01, 0x52, 0x05, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x0a, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x05, - 0x52, 0x09, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x50, 0x61, 0x74, 0x68, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, - 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x5f, 0x69, 0x64, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x69, 0x6e, - 0x64, 0x65, 0x78, 0x22, 0x69, 0x0a, 0x16, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x4d, 0x65, - 0x73, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, - 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, - 0x70, 0x69, 0x63, 0x12, 0x21, 0x0a, 0x0c, 0x70, 0x61, 0x72, 0x74, 0x69, 0x74, 0x69, 0x6f, 0x6e, - 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0b, 0x70, 0x61, 0x72, 0x74, 0x69, - 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x66, 0x66, 0x73, 0x65, 0x74, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x6f, 0x66, 0x66, 0x73, 0x65, 0x74, 0x22, 0x62, - 0x0a, 0x1b, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, - 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x24, 0x0a, - 0x09, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, - 0x42, 0x07, 0xba, 0x48, 0x04, 0x1a, 0x02, 0x20, 0x00, 0x52, 0x08, 0x73, 0x63, 0x68, 0x65, 0x6d, - 0x61, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x5f, 0x70, 0x61, 0x74, - 0x68, 0x18, 0x02, 0x20, 0x03, 0x28, 0x05, 0x52, 0x09, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x50, 0x61, - 0x74, 0x68, 0x22, 0x3f, 0x0a, 0x1c, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x53, 0x63, - 0x68, 0x65, 0x6d, 0x61, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x5f, 0x6a, 0x73, 0x6f, - 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x4a, - 0x73, 0x6f, 0x6e, 0x42, 0xb5, 0x02, 0x0a, 0x21, 0x63, 0x6f, 0x6d, 0x2e, 0x72, 0x65, 0x64, 0x70, - 0x61, 0x6e, 0x64, 0x61, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, - 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x42, 0x14, 0x50, 0x75, 0x62, 0x6c, 0x69, - 0x73, 0x68, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, - 0x01, 0x5a, 0x63, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x72, 0x65, - 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2d, 0x64, 0x61, 0x74, 0x61, 0x2f, 0x63, 0x6f, 0x6e, 0x73, - 0x6f, 0x6c, 0x65, 0x2f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x2f, 0x70, 0x6b, 0x67, 0x2f, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x67, 0x65, 0x6e, 0x2f, 0x72, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, - 0x61, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x2f, 0x76, 0x31, - 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x3b, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x76, 0x31, - 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0xa2, 0x02, 0x03, 0x52, 0x41, 0x43, 0xaa, 0x02, 0x1d, 0x52, - 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2e, 0x41, 0x70, 0x69, 0x2e, 0x43, 0x6f, 0x6e, 0x73, - 0x6f, 0x6c, 0x65, 0x2e, 0x56, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0xca, 0x02, 0x1d, 0x52, - 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x5c, 0x41, 0x70, 0x69, 0x5c, 0x43, 0x6f, 0x6e, 0x73, - 0x6f, 0x6c, 0x65, 0x5c, 0x56, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0xe2, 0x02, 0x29, 0x52, - 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x5c, 0x41, 0x70, 0x69, 0x5c, 0x43, 0x6f, 0x6e, 0x73, - 0x6f, 0x6c, 0x65, 0x5c, 0x56, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x5c, 0x47, 0x50, 0x42, - 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x20, 0x52, 0x65, 0x64, 0x70, 0x61, - 0x6e, 0x64, 0x61, 0x3a, 0x3a, 0x41, 0x70, 0x69, 0x3a, 0x3a, 0x43, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, - 0x65, 0x3a, 0x3a, 0x56, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x33, + 0x52, 0x09, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x50, 0x61, 0x74, 0x68, 0x12, 0x25, 0x0a, 0x0e, 0x73, + 0x63, 0x68, 0x65, 0x6d, 0x61, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x0c, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0d, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x43, 0x6f, 0x6e, 0x74, 0x65, + 0x78, 0x74, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x5f, 0x69, 0x64, + 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x22, 0x69, 0x0a, 0x16, 0x50, 0x75, + 0x62, 0x6c, 0x69, 0x73, 0x68, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x70, 0x69, 0x63, 0x12, 0x21, 0x0a, 0x0c, 0x70, 0x61, + 0x72, 0x74, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, + 0x52, 0x0b, 0x70, 0x61, 0x72, 0x74, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x16, 0x0a, + 0x06, 0x6f, 0x66, 0x66, 0x73, 0x65, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x6f, + 0x66, 0x66, 0x73, 0x65, 0x74, 0x22, 0x89, 0x01, 0x0a, 0x1b, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, + 0x74, 0x65, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x24, 0x0a, 0x09, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x5f, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x42, 0x07, 0xba, 0x48, 0x04, 0x1a, 0x02, 0x20, + 0x00, 0x52, 0x08, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x69, + 0x6e, 0x64, 0x65, 0x78, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x03, 0x28, 0x05, 0x52, + 0x09, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x50, 0x61, 0x74, 0x68, 0x12, 0x25, 0x0a, 0x0e, 0x73, 0x63, + 0x68, 0x65, 0x6d, 0x61, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0d, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, + 0x74, 0x22, 0x3f, 0x0a, 0x1c, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x53, 0x63, 0x68, + 0x65, 0x6d, 0x61, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x4a, 0x73, + 0x6f, 0x6e, 0x42, 0xb5, 0x02, 0x0a, 0x21, 0x63, 0x6f, 0x6d, 0x2e, 0x72, 0x65, 0x64, 0x70, 0x61, + 0x6e, 0x64, 0x61, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x2e, + 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x42, 0x14, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x73, + 0x68, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, + 0x5a, 0x63, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x72, 0x65, 0x64, + 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2d, 0x64, 0x61, 0x74, 0x61, 0x2f, 0x63, 0x6f, 0x6e, 0x73, 0x6f, + 0x6c, 0x65, 0x2f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x67, 0x65, 0x6e, 0x2f, 0x72, 0x65, 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, + 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x2f, 0x76, 0x31, 0x61, + 0x6c, 0x70, 0x68, 0x61, 0x31, 0x3b, 0x63, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, 0x76, 0x31, 0x61, + 0x6c, 0x70, 0x68, 0x61, 0x31, 0xa2, 0x02, 0x03, 0x52, 0x41, 0x43, 0xaa, 0x02, 0x1d, 0x52, 0x65, + 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x2e, 0x41, 0x70, 0x69, 0x2e, 0x43, 0x6f, 0x6e, 0x73, 0x6f, + 0x6c, 0x65, 0x2e, 0x56, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0xca, 0x02, 0x1d, 0x52, 0x65, + 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x5c, 0x41, 0x70, 0x69, 0x5c, 0x43, 0x6f, 0x6e, 0x73, 0x6f, + 0x6c, 0x65, 0x5c, 0x56, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0xe2, 0x02, 0x29, 0x52, 0x65, + 0x64, 0x70, 0x61, 0x6e, 0x64, 0x61, 0x5c, 0x41, 0x70, 0x69, 0x5c, 0x43, 0x6f, 0x6e, 0x73, 0x6f, + 0x6c, 0x65, 0x5c, 0x56, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, + 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x20, 0x52, 0x65, 0x64, 0x70, 0x61, 0x6e, + 0x64, 0x61, 0x3a, 0x3a, 0x41, 0x70, 0x69, 0x3a, 0x3a, 0x43, 0x6f, 0x6e, 0x73, 0x6f, 0x6c, 0x65, + 0x3a, 0x3a, 0x56, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x33, } var ( diff --git a/backend/pkg/schema/client.go b/backend/pkg/schema/client.go index c563cb893c..87674256d2 100644 --- a/backend/pkg/schema/client.go +++ b/backend/pkg/schema/client.go @@ -43,6 +43,7 @@ const mainProtoFilename = "__console_tmp.proto" // CachedClient provides schema management with caching for efficient reuse. // It retrieves and parses Avro, Protobuf, and JSON schemas from a Schema Registry, // utilizing in-memory caches to minimize redundant fetches and compilations. +// Lookups and cache keys are scoped by the schema context set with InContext. type CachedClient struct { schemaClientFactory schema.ClientFactory // cacheNamespace returns a unique tenant identifier for resource cache isolation. @@ -75,6 +76,19 @@ type Client interface { // Ensure CachedClient implements the Client interface. var _ Client = (*CachedClient)(nil) +// scoped returns the cache key prefix: the tenant namespace plus the schema +// context, if any. Schema IDs are only unique within a context. +func (c *CachedClient) scoped(ctx context.Context) (string, error) { + namespace, err := c.cacheNamespace(ctx) + if err != nil { + return "", err + } + if schemaCtx := ContextName(ctx); schemaCtx != "" { + return namespace + "/contexts/" + schemaCtx, nil + } + return namespace, nil +} + // NewCachedClient initializes and returns a new CachedClient instance with the // provided schema client factory and cache namespace function. It sets up // caching with specific settings for compiled schema resources. @@ -182,12 +196,12 @@ func createCustomProtoResolver(ctx context.Context) (func(protocompile.Resolver) // value if available. If the schema isn't cached, it fetches the schema, parses // it with references, and stores the result in the cache. func (c *CachedClient) AvroSchemaByID(ctx context.Context, id int) (*avro.Schema, error) { - namespace, err := c.cacheNamespace(ctx) + prefix, err := c.scoped(ctx) if err != nil { return nil, err } - key := namespace + "/avro-parsed-schemas/ids/" + strconv.Itoa(id) + key := prefix + "/avro-parsed-schemas/ids/" + strconv.Itoa(id) avroSch, err, _ := c.avroSchemaCache.Get(key, func() (*avro.Schema, error) { sch, err := c.SchemaByID(ctx, id) @@ -212,12 +226,12 @@ func (c *CachedClient) AvroSchemaByID(ctx context.Context, id int) (*avro.Schema // It first checks if the compiled schema is cached; if not, it fetches the schema by ID, // compiles it along with any referenced schemas, and caches the result. func (c *CachedClient) ProtoFilesByID(ctx context.Context, id int) (linker.Files, string, error) { - namespace, err := c.cacheNamespace(ctx) + prefix, err := c.scoped(ctx) if err != nil { return nil, "", err } - key := namespace + "/proto-files/ids/" + strconv.Itoa(id) + key := prefix + "/proto-files/ids/" + strconv.Itoa(id) compiledProtoFiles, err, _ := c.protoSchemaCache.Get(key, func() (linker.Files, error) { sch, err := c.SchemaByID(ctx, id) @@ -240,12 +254,12 @@ func (c *CachedClient) ProtoFilesByID(ctx context.Context, id int) (linker.Files // value if available. If the schema isn't cached, it fetches the schema, compiles // it with references, and stores the result in the cache. func (c *CachedClient) JSONSchemaByID(ctx context.Context, id int) (*jsonschema.Schema, error) { - namespace, err := c.cacheNamespace(ctx) + prefix, err := c.scoped(ctx) if err != nil { return nil, err } - key := namespace + "/json-compiled-schemas/ids/" + strconv.Itoa(id) + key := prefix + "/json-compiled-schemas/ids/" + strconv.Itoa(id) jsonSch, err, _ := c.jsonSchemaCache.Get(key, func() (*jsonschema.Schema, error) { sch, err := c.SchemaByID(ctx, id) @@ -405,12 +419,12 @@ func (c *CachedClient) buildJSONSchemaWithReferences(ctx context.Context, compil // cached value if available. If the schema isn't cached, it is fetched from the // schema registry and stored in the cache. func (c *CachedClient) SchemaByID(ctx context.Context, id int) (sr.Schema, error) { - namespace, err := c.cacheNamespace(ctx) + prefix, err := c.scoped(ctx) if err != nil { return sr.Schema{}, err } - key := namespace + "/schemas/ids/" + strconv.Itoa(id) + key := prefix + "/schemas/ids/" + strconv.Itoa(id) sch, err, _ := c.schemaCache.Get(key, func() (sr.Schema, error) { srClient, err := c.schemaClientFactory.GetSchemaRegistryClient(ctx) @@ -427,12 +441,12 @@ func (c *CachedClient) SchemaByID(ctx context.Context, id int) (sr.Schema, error // registry, using a cached value if available. If not cached, it fetches the // schema from the registry and stores it in the cache. func (c *CachedClient) SchemaByVersion(ctx context.Context, subject string, id int) (sr.SubjectSchema, error) { - namespace, err := c.cacheNamespace(ctx) + prefix, err := c.scoped(ctx) if err != nil { return sr.SubjectSchema{}, err } - key := namespace + fmt.Sprintf("/subjects/%v/versions/%d", subject, id) + key := prefix + fmt.Sprintf("/subjects/%v/versions/%d", subject, id) sch, err, _ := c.subjectSchemaCache.Get(key, func() (sr.SubjectSchema, error) { srClient, err := c.schemaClientFactory.GetSchemaRegistryClient(ctx) diff --git a/backend/pkg/schema/client_context_test.go b/backend/pkg/schema/client_context_test.go new file mode 100644 index 0000000000..bcc903fb8f --- /dev/null +++ b/backend/pkg/schema/client_context_test.go @@ -0,0 +1,88 @@ +// Copyright 2026 Redpanda Data, Inc. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.md +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0 + +package schema + +import ( + "net/http" + "sync" + + "github.com/twmb/franz-go/pkg/sr" +) + +// TestSchemaContextScoping checks that InContext lookups hit the context route +// and are cached apart from the default context. +func (s *TestCachedClientSuite) TestSchemaContextScoping() { + avroSchema := `{"type": "record", "name": "Convoy", "fields": [{"name": "id", "type": "string"}]}` + s.mockRegistry.SeedSchema(":.outgo:convoy-value", 1, 7, sr.Schema{Schema: avroSchema}) + + var mu sync.Mutex + var paths []string + s.mockRegistry.Intercept(func(_ http.ResponseWriter, r *http.Request) bool { + mu.Lock() + defer mu.Unlock() + paths = append(paths, r.URL.Path) + return false + }) + + ctx := getTenantContext("tenant1") + scoped := InContext(ctx, ".outgo") + + sch, err := s.cachedClient.SchemaByID(scoped, 7) + s.Require().NoError(err) + s.JSONEq(avroSchema, sch.Schema) + + parsed, err := s.cachedClient.AvroSchemaByID(scoped, 7) + s.Require().NoError(err) + s.NotNil(parsed) + + // Same context, other spellings: served from the cache. + _, err = s.cachedClient.SchemaByID(InContext(ctx, "outgo"), 7) + s.Require().NoError(err) + _, err = s.cachedClient.SchemaByID(InContext(ctx, ":.outgo:"), 7) + s.Require().NoError(err) + + // Default context: own request and cache entry. + _, err = s.cachedClient.SchemaByID(ctx, 7) + s.Require().NoError(err) + _, err = s.cachedClient.SchemaByID(InContext(ctx, "."), 7) + s.Require().NoError(err) + + mu.Lock() + defer mu.Unlock() + s.Equal([]string{"/contexts/.outgo/schemas/ids/7", "/schemas/ids/7"}, paths) +} + +// TestSchemaContextIsolatesTenants checks that the context extends the tenant +// namespace instead of replacing it. +func (s *TestCachedClientSuite) TestSchemaContextIsolatesTenants() { + s.mockRegistry.SeedSchema(":.outgo:convoy-value", 1, 7, sr.Schema{ + Schema: `{"type": "record", "name": "Convoy", "fields": [{"name": "id", "type": "string"}]}`, + }) + + var mu sync.Mutex + requests := 0 + s.mockRegistry.Intercept(func(_ http.ResponseWriter, _ *http.Request) bool { + mu.Lock() + defer mu.Unlock() + requests++ + return false + }) + + _, err := s.cachedClient.SchemaByID(InContext(getTenantContext("tenant1"), ".outgo"), 7) + s.Require().NoError(err) + _, err = s.cachedClient.SchemaByID(InContext(getTenantContext("tenant2"), ".outgo"), 7) + s.Require().NoError(err) + _, err = s.cachedClient.SchemaByID(InContext(getTenantContext("tenant1"), ".outgo"), 7) + s.Require().NoError(err) + + mu.Lock() + defer mu.Unlock() + s.Equal(2, requests, "one request per tenant, the repeat is cached") +} diff --git a/backend/pkg/schema/context.go b/backend/pkg/schema/context.go new file mode 100644 index 0000000000..ab6349ad79 --- /dev/null +++ b/backend/pkg/schema/context.go @@ -0,0 +1,66 @@ +// Copyright 2026 Redpanda Data, Inc. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.md +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0 + +package schema + +import ( + "context" + "regexp" + "strings" + + "github.com/twmb/franz-go/pkg/sr" +) + +// TopicConfigSchemaRegistryContext is the topic property that binds a topic to +// a Schema Registry context. +const TopicConfigSchemaRegistryContext = "redpanda.schema.registry.context" + +type schemaContextKey struct{} + +// InContext scopes schema registry lookups to the given context. Empty or "." +// selects the default context. +func InContext(ctx context.Context, name string) context.Context { + name = NormalizeContextName(name) + ctx = context.WithValue(ctx, schemaContextKey{}, name) + return sr.InContext(ctx, name) +} + +// ContextName returns the context set by InContext, "" for the default. +func ContextName(ctx context.Context) string { + name, ok := ctx.Value(schemaContextKey{}).(string) + if !ok { + return "" + } + return name +} + +// NormalizeContextName returns the canonical ".name" form. "" and "." both +// mean the default context and become "", which franz-go leaves unprefixed. +func NormalizeContextName(name string) string { + name = strings.Trim(strings.TrimSpace(name), ":") + if name == "" || name == "." { + return "" + } + if !strings.HasPrefix(name, ".") { + return "." + name + } + return name +} + +var qualifiedSubjectRegexp = regexp.MustCompile(`^:(\.[^:]*):`) + +// ContextFromSubject returns the context of a qualified subject +// (":.ctx:subject"), "" for unqualified ones. +func ContextFromSubject(subject string) string { + m := qualifiedSubjectRegexp.FindStringSubmatch(subject) + if m == nil { + return "" + } + return NormalizeContextName(m[1]) +} diff --git a/backend/pkg/schema/context_test.go b/backend/pkg/schema/context_test.go new file mode 100644 index 0000000000..f42e1502fe --- /dev/null +++ b/backend/pkg/schema/context_test.go @@ -0,0 +1,62 @@ +// Copyright 2026 Redpanda Data, Inc. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.md +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0 + +package schema + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNormalizeContextName(t *testing.T) { + tests := map[string]string{ + "": "", + " ": "", + ".": "", + ":.:": "", + ".prod": ".prod", + "prod": ".prod", + ":.prod:": ".prod", + " .prod ": ".prod", + ".team.sub": ".team.sub", + } + for input, expected := range tests { + assert.Equal(t, expected, NormalizeContextName(input), "input %q", input) + } +} + +func TestContextFromSubject(t *testing.T) { + tests := map[string]string{ + "orders-value": "", + ":.outgo:orders-value": ".outgo", + ":.outgo:": ".outgo", + ":.team.sub:orders-value": ".team.sub", + ":.outgo:with:colons": ".outgo", + "weird:.outgo:not-a-prefix": "", + ":*:orders-value": "", + ":.default-looking.context:sub": ".default-looking.context", + } + for input, expected := range tests { + assert.Equal(t, expected, ContextFromSubject(input), "input %q", input) + } +} + +func TestInContext(t *testing.T) { + ctx := context.Background() + assert.Empty(t, ContextName(ctx), "a plain context selects the default context") + + scoped := InContext(ctx, "prod") + assert.Equal(t, ".prod", ContextName(scoped), "the name is normalized") + assert.Empty(t, ContextName(ctx), "the parent context is left untouched") + + assert.Empty(t, ContextName(InContext(scoped, ".")), "a nested default context wins over the parent") + assert.Equal(t, ".staging", ContextName(InContext(scoped, ".staging")), "a nested named context wins over the parent") +} diff --git a/backend/pkg/serde/record.go b/backend/pkg/serde/record.go index 3efd35281c..dbbf8e079f 100644 --- a/backend/pkg/serde/record.go +++ b/backend/pkg/serde/record.go @@ -109,9 +109,10 @@ type TroubleshootingReport struct { // SerializeInput represents the input to serialize methods. type SerializeInput struct { - Topic string - Key RecordPayloadInput - Value RecordPayloadInput + Topic string + SchemaContext string // Empty means default. + Key RecordPayloadInput + Value RecordPayloadInput } // RecordPayloadInput represents the actual input of payloads for serialization. @@ -119,6 +120,8 @@ type RecordPayloadInput struct { Payload any Encoding PayloadEncoding Options []SerdeOpt + // SchemaContext overrides SerializeInput.SchemaContext for this payload. + SchemaContext string } // SerializeOutput represents the result of serialization. diff --git a/backend/pkg/serde/service.go b/backend/pkg/serde/service.go index 0090dc7972..5838396eca 100644 --- a/backend/pkg/serde/service.go +++ b/backend/pkg/serde/service.go @@ -88,6 +88,11 @@ func (s *Service) DeserializeRecord(ctx context.Context, record *kgo.Record, opt opts.MaxPayloadSize = config.DefaultMaxDeserializationPayloadSize } + // Resolve schema IDs in the topic's schema registry context. + if opts.SchemaContext != "" { + ctx = schema.InContext(ctx, opts.SchemaContext) + } + // 1. Test if it's a known binary Format if record.Topic == "__consumer_offsets" { rec, err := s.deserializeConsumerOffset(record) @@ -201,6 +206,10 @@ type DeserializationOptions struct { // IgnoreMaxSizeLimit can be used to force returning deserialized payloads even if too large. IgnoreMaxSizeLimit bool + + // SchemaContext is the Schema Registry context to resolve schema IDs in. + // Empty means the default context. + SchemaContext string } // SerializeRecord will serialize the input. @@ -221,13 +230,14 @@ func (s *Service) SerializeRecord(ctx context.Context, input SerializeInput) (*S found := false var err error var bytes []byte + keyCtx := schemaContextFor(ctx, input.SchemaContext, input.Key.SchemaContext) for _, serde := range s.SerDes { if input.Key.Encoding != serde.Name() { continue } found = true - bytes, err = serde.SerializeObject(ctx, input.Key.Payload, PayloadTypeKey, input.Key.Options...) + bytes, err = serde.SerializeObject(keyCtx, input.Key.Payload, PayloadTypeKey, input.Key.Options...) if err != nil { keyTS = append(keyTS, TroubleshootingReport{ SerdeName: string(serde.Name()), @@ -259,13 +269,14 @@ func (s *Service) SerializeRecord(ctx context.Context, input SerializeInput) (*S valueTS := make([]TroubleshootingReport, 0) found = false err = nil + valueCtx := schemaContextFor(ctx, input.SchemaContext, input.Value.SchemaContext) for _, serde := range s.SerDes { if input.Value.Encoding != serde.Name() { continue } found = true - bytes, err = serde.SerializeObject(ctx, input.Value.Payload, PayloadTypeValue, input.Value.Options...) + bytes, err = serde.SerializeObject(valueCtx, input.Value.Payload, PayloadTypeValue, input.Value.Options...) if err != nil { valueTS = append(valueTS, TroubleshootingReport{ SerdeName: string(serde.Name()), @@ -288,6 +299,19 @@ func (s *Service) SerializeRecord(ctx context.Context, input SerializeInput) (*S return &sr, err } +// schemaContextFor scopes ctx to the payload's schema context, falling back to +// the topic's. +func schemaContextFor(ctx context.Context, topicContext, payloadContext string) context.Context { + name := topicContext + if payloadContext != "" { + name = payloadContext + } + if name == "" { + return ctx + } + return schema.InContext(ctx, name) +} + func payloadFromRecord(record *kgo.Record, payloadType PayloadType) []byte { if payloadType == PayloadTypeValue { return record.Value diff --git a/backend/pkg/serde/service_integration_test.go b/backend/pkg/serde/service_integration_test.go index 154b6606ef..47669ee25a 100644 --- a/backend/pkg/serde/service_integration_test.go +++ b/backend/pkg/serde/service_integration_test.go @@ -22,6 +22,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strconv" "strings" "testing" @@ -175,6 +176,129 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { serdeSvc, err := NewService(protoSvc, mspPackSvc, cachedSchemaClient, nil, cborConfig) require.NoError(err) + t.Run("schema registry protobuf in a named context", func(t *testing.T) { + // A topic bound to a context is decoded with that context's schemas. + const schemaContext = ".serde-ctx" + contextCfg := schemaContext + + rcl, err := sr.NewClient(sr.URLs(s.registryAddress)) + require.NoError(err) + + _, err = rcl.Contexts(t.Context()) + if err != nil { + t.Skipf("this Redpanda image has no schema registry contexts: %v", err) + } + + testTopicName := testutil.TopicNameForTest("serde_schema_protobuf_context") + _, err = s.kafkaAdminClient.CreateTopic(ctx, 1, 1, map[string]*string{ + schemacache.TopicConfigSchemaRegistryContext: &contextCfg, + }, testTopicName) + if err != nil { + t.Skipf("this Redpanda image does not support the %s topic config: %v", schemacache.TopicConfigSchemaRegistryContext, err) + } + + defer func() { + _, err := s.kafkaAdminClient.DeleteTopics(ctx, testTopicName) + assert.NoError(err) + }() + + protoFile, err := os.ReadFile("testdata/proto/shop/v1/order.proto") + require.NoError(err) + + ss, err := rcl.CreateSchema(sr.InContext(t.Context(), schemaContext), testTopicName+"-value", sr.Schema{ + Schema: string(protoFile), + Type: sr.TypeProtobuf, + }) + if err != nil { + t.Skipf("schema registry contexts are not enabled on this Redpanda image: %v", err) + } + require.NotNil(ss) + + contexts, err := rcl.Contexts(t.Context()) + if err != nil || !slices.Contains(contexts, schemaContext) { + t.Skipf("schema registry contexts are not enabled on this Redpanda image (contexts: %v, error: %v)", contexts, err) + } + + var serde sr.Serde + serde.Register( + ss.ID, + &shopv1.Order{}, + sr.EncodeFn(func(v any) ([]byte, error) { + return proto.Marshal(v.(*shopv1.Order)) + }), + sr.Index(0), + ) + + orderCreatedAt := time.Date(2026, time.September, 14, 13, 0, 0, 0, time.UTC) + msg := shopv1.Order{ + Id: "333", + CreatedAt: timestamppb.New(orderCreatedAt), + } + + msgData, err := serde.Encode(&msg) + require.NoError(err) + + r := &kgo.Record{ + Key: []byte(msg.Id), + Value: msgData, + Topic: testTopicName, + Timestamp: orderCreatedAt, + } + + produceCtx, produceCancel := context.WithTimeout(t.Context(), 10*time.Second) + defer produceCancel() + + results := s.kafkaClient.ProduceSync(produceCtx, r) + require.NoError(results.FirstErr()) + + consumeCtx, consumeCancel := context.WithTimeout(t.Context(), 1*time.Second) + defer consumeCancel() + + cl := s.consumerClientForTopic(testTopicName) + + var record *kgo.Record + + for { + fetches := cl.PollFetches(consumeCtx) + errs := fetches.Errors() + if fetches.IsClientClosed() || + (len(errs) == 1 && (errors.Is(errs[0].Err, context.DeadlineExceeded) || errors.Is(errs[0].Err, context.Canceled))) { + break + } + + require.Empty(errs) + + iter := fetches.RecordIter() + + for !iter.Done() && record == nil { + record = iter.Next() + break + } + } + + require.NotEmpty(record) + + dr := serdeSvc.DeserializeRecord(t.Context(), record, DeserializationOptions{ + Troubleshoot: true, + SchemaContext: schemaContext, + }) + require.NotNil(dr) + + assert.Equal(PayloadEncodingProtobuf, dr.Value.Encoding) + require.NotNil(dr.Value.SchemaID) + assert.Equal(uint32(ss.ID), *dr.Value.SchemaID) + + obj, ok := dr.Value.DeserializedPayload.(map[string]any) + require.Truef(ok, "parsed payload is not of type map[string]any") + assert.Equal("333", obj["id"]) + + rOrder := shopv1.Order{} + err = protojson.Unmarshal(dr.Value.NormalizedPayload, &rOrder) + require.NoError(err) + assert.Equal("333", rOrder.Id) + assert.Equal(timestamppb.New(orderCreatedAt).GetSeconds(), rOrder.GetCreatedAt().GetSeconds()) + }) + t.Run("plain JSON", func(t *testing.T) { testTopicName := testutil.TopicNameForTest("serde_plain_json") _, err := s.kafkaAdminClient.CreateTopic(ctx, 1, 1, nil, testTopicName) @@ -236,7 +360,7 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { // check value assert.IsType(map[string]any{}, dr.Value.DeserializedPayload) - obj, ok := (dr.Value.DeserializedPayload).(map[string]any) + obj, ok := dr.Value.DeserializedPayload.(map[string]any) require.Truef(ok, "parsed payload is not of type map[string]any") assert.Equal("123", obj["ID"]) @@ -256,7 +380,7 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { assert.Equal("payload is not null as expected for none encoding", dr.Value.Troubleshooting[0].Message) // check key - keyObj, ok := (dr.Key.DeserializedPayload).(string) + keyObj, ok := dr.Key.DeserializedPayload.(string) require.Truef(ok, "parsed payload is not of type string") assert.Equal("123", keyObj) assert.Empty(dr.Key.SchemaID) @@ -359,7 +483,7 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { // check value assert.IsType(map[string]any{}, dr.Value.DeserializedPayload) - obj, ok := (dr.Value.DeserializedPayload).(map[string]any) + obj, ok := dr.Value.DeserializedPayload.(map[string]any) require.Truef(ok, "parsed payload is not of type map[string]any") assert.Equal("123", obj["ID"]) @@ -377,7 +501,7 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { require.Len(dr.Value.Troubleshooting, 0) // check key - keyObj, ok := (dr.Key.DeserializedPayload).(string) + keyObj, ok := dr.Key.DeserializedPayload.(string) require.Truef(ok, "parsed payload is not of type string") assert.Equal("123", keyObj) assert.Empty(dr.Key.SchemaID) @@ -493,7 +617,7 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { // assert.Equal("incorrect magic byte for protobuf schema", dr.Value.Troubleshooting[1].Message) // check key - keyObj, ok := (dr.Key.DeserializedPayload).(string) + keyObj, ok := dr.Key.DeserializedPayload.(string) require.Truef(ok, "parsed payload is not of type string") assert.Equal("123", keyObj) assert.Empty(dr.Key.SchemaID) @@ -626,7 +750,7 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { expectedJSON := `{"id":"111","createdAt":"2023-06-10T13:00:00Z"}` assert.Equal(expectedJSON, actualJSON) - obj, ok := (dr.Value.DeserializedPayload).(map[string]any) + obj, ok := dr.Value.DeserializedPayload.(map[string]any) require.Truef(ok, "parsed payload is not of type map[string]any") assert.Equal(`111`, obj["id"]) @@ -653,7 +777,7 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { assert.Equal("incorrect magic byte for avro", dr.Value.Troubleshooting[4].Message) // check key - keyObj, ok := (dr.Key.DeserializedPayload).(string) + keyObj, ok := dr.Key.DeserializedPayload.(string) require.Truef(ok, "parsed payload is not of type string") assert.Equal("111", keyObj) assert.Empty(dr.Key.SchemaID) @@ -841,7 +965,7 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { require.NotNil(dr) // check value - obj, ok := (dr.Value.DeserializedPayload).(map[string]any) + obj, ok := dr.Value.DeserializedPayload.(map[string]any) require.Truef(ok, "parsed payload is not of type map[string]any") assert.Equal(`444`, obj["id"]) @@ -933,7 +1057,7 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { assert.Equal("incorrect magic byte for avro", dr.Value.Troubleshooting[4].Message) // check key - keyObj, ok := (dr.Key.DeserializedPayload).(string) + keyObj, ok := dr.Key.DeserializedPayload.(string) require.Truef(ok, "parsed payload is not of type string") assert.Equal("444", keyObj) assert.Empty(dr.Key.SchemaID) @@ -1070,7 +1194,7 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { assert.Equal("222", rOrder.Id) assert.Equal(timestamppb.New(orderCreatedAt).GetSeconds(), rOrder.GetCreatedAt().GetSeconds()) - obj, ok := (dr.Value.DeserializedPayload).(map[string]any) + obj, ok := dr.Value.DeserializedPayload.(map[string]any) require.Truef(ok, "parsed payload is not of type map[string]any") assert.Equal("222", obj["id"]) @@ -1105,7 +1229,7 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { assert.Equal("failed to get message descriptor for payload: no prototype found for the given topic 'test.redpanda.console.serde_schema_protobuf'. Check your configured protobuf mappings", dr.Value.Troubleshooting[5].Message) // check key - keyObj, ok := (dr.Key.DeserializedPayload).(string) + keyObj, ok := dr.Key.DeserializedPayload.(string) require.Truef(ok, "parsed payload is not of type string") assert.Equal("222", keyObj) assert.Empty(dr.Key.SchemaID) @@ -1257,7 +1381,7 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { require.NotNil(dr) // check value - obj, ok := (dr.Value.DeserializedPayload).(map[string]any) + obj, ok := dr.Value.DeserializedPayload.(map[string]any) require.Truef(ok, "parsed payload is not of type map[string]any") assert.Equal("345", obj["id"]) assert.Len(obj["decVal"], 1) @@ -1406,7 +1530,7 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { require.NotNil(dr) // check value - obj, ok := (dr.Value.DeserializedPayload).(map[string]any) + obj, ok := dr.Value.DeserializedPayload.(map[string]any) require.Truef(ok, "parsed payload is not of type map[string]any") assert.Equal("gadget_0", obj["identity"]) assert.NotEmpty(obj["gizmo"]) @@ -1463,7 +1587,7 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { assert.Equal("failed to get message descriptor for payload: no prototype found for the given topic 'test.redpanda.console.serde_schema_protobuf_multi'. Check your configured protobuf mappings", dr.Value.Troubleshooting[5].Message) // check key - keyObj, ok := (dr.Key.DeserializedPayload).(string) + keyObj, ok := dr.Key.DeserializedPayload.(string) require.Truef(ok, "parsed payload is not of type string") assert.Equal("gadget_0", keyObj) assert.Empty(dr.Key.SchemaID) @@ -1606,7 +1730,7 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { require.NotNil(dr) // check value - obj, ok := (dr.Value.DeserializedPayload).(map[string]any) + obj, ok := dr.Value.DeserializedPayload.(map[string]any) require.Truef(ok, "parsed payload is not of type map[string]any") assert.Equal(11.0, obj["size"]) assert.NotEmpty(obj["item"]) @@ -1853,7 +1977,7 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { require.NotNil(dr) // check value - obj, ok := (dr.Value.DeserializedPayload).(map[string]any) + obj, ok := dr.Value.DeserializedPayload.(map[string]any) require.Truef(ok, "parsed payload is not of type map[string]any") assert.Equal("123456789", obj["id"]) assert.Equal(1.0, obj["version"]) @@ -1947,7 +2071,7 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { assert.Equal("failed to get message descriptor for payload: no prototype found for the given topic 'test.redpanda.console.serde_schema_protobuf_ref'. Check your configured protobuf mappings", dr.Value.Troubleshooting[5].Message) // check key - keyObj, ok := (dr.Key.DeserializedPayload).(string) + keyObj, ok := dr.Key.DeserializedPayload.(string) require.Truef(ok, "parsed payload is not of type string") assert.Equal("123456789", keyObj) assert.Empty(dr.Key.SchemaID) @@ -2084,7 +2208,7 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { assert.Equal("222", rOrder.Id) assert.Equal(timestamppb.New(orderCreatedAt).GetSeconds(), rOrder.GetCreatedAt().GetSeconds()) - obj, ok := (dr.Value.DeserializedPayload).(map[string]any) + obj, ok := dr.Value.DeserializedPayload.(map[string]any) require.Truef(ok, "parsed payload is not of type map[string]any") assert.Equal("222", obj["id"]) @@ -2195,14 +2319,14 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { assert.Equal("222", rOrder.Id) assert.Equal(timestamppb.New(orderCreatedAt).GetSeconds(), rOrder.GetCreatedAt().GetSeconds()) - obj, ok := (dr.Value.DeserializedPayload).(map[string]any) + obj, ok := dr.Value.DeserializedPayload.(map[string]any) require.Truef(ok, "parsed payload is not of type map[string]any") assert.Equal("222", obj["id"]) } else if string(cr.Key) == msg2ID { dr := serdeSvc2.DeserializeRecord(t.Context(), cr, DeserializationOptions{Troubleshoot: true}) require.NotNil(dr) - obj, ok := (dr.Value.DeserializedPayload).(map[string]any) + obj, ok := dr.Value.DeserializedPayload.(map[string]any) require.Truef(ok, "parsed payload is not of type map[string]any") assert.Equal("333", obj["id"]) assert.Equal(float64(3456), obj["orderValue"]) @@ -2294,7 +2418,7 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { require.NotNil(dr) // check key - obj, ok := (dr.Key.DeserializedPayload).(uint32) + obj, ok := dr.Key.DeserializedPayload.(uint32) require.Truef(ok, "parsed payload is not of type uint32") assert.Equal(uint32(160), obj) @@ -2309,7 +2433,7 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { assert.Empty(dr.Key.SchemaID) // check value - valObj, ok := (dr.Value.DeserializedPayload).(string) + valObj, ok := dr.Value.DeserializedPayload.(string) require.Truef(ok, "parsed payload is not of type string") assert.Equal("my text value", valObj) assert.Empty(dr.Value.SchemaID) @@ -2378,7 +2502,7 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { require.NotNil(dr) // check key - obj, ok := (dr.Key.DeserializedPayload).(string) + obj, ok := dr.Key.DeserializedPayload.(string) require.Truef(ok, "parsed payload is not of type string") assert.Equal("text", obj) @@ -2393,7 +2517,7 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { assert.Empty(dr.Key.SchemaID) // check value - valObj, ok := (dr.Value.DeserializedPayload).(string) + valObj, ok := dr.Value.DeserializedPayload.(string) require.Truef(ok, "parsed payload is not of type string") assert.Equal("my text value", valObj) assert.Empty(dr.Value.SchemaID) @@ -2673,7 +2797,7 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { require.NotNil(dr) // check value - obj, ok := (dr.Value.DeserializedPayload).(map[string]any) + obj, ok := dr.Value.DeserializedPayload.(map[string]any) require.Truef(ok, "parsed payload is not of type map[string]any") assert.Equal("order_0", obj["id"]) assert.Equal(10.25, obj["price"]) @@ -2700,7 +2824,7 @@ func (s *SerdeIntegrationTestSuite) TestDeserializeRecord() { assert.Equal("first byte indicates this it not valid XML", dr.Value.Troubleshooting[3].Message) // check key - keyObj, ok := (dr.Key.DeserializedPayload).(string) + keyObj, ok := dr.Key.DeserializedPayload.(string) require.Truef(ok, "parsed payload is not of type string") assert.Equal("order_0", keyObj) assert.Empty(dr.Key.SchemaID) diff --git a/backend/pkg/serde/service_schema_context_test.go b/backend/pkg/serde/service_schema_context_test.go new file mode 100644 index 0000000000..39f0269747 --- /dev/null +++ b/backend/pkg/serde/service_schema_context_test.go @@ -0,0 +1,186 @@ +// Copyright 2026 Redpanda Data, Inc. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.md +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0 + +package serde + +import ( + "context" + "net/http" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/twmb/franz-go/pkg/kgo" + "github.com/twmb/franz-go/pkg/sr" + "github.com/twmb/franz-go/pkg/sr/srfake" + + "github.com/redpanda-data/console/backend/pkg/config" + schemafactory "github.com/redpanda-data/console/backend/pkg/factory/schema" + "github.com/redpanda-data/console/backend/pkg/schema" +) + +// requestRecorder records the paths requested from the fake schema registry. +type requestRecorder struct { + mu sync.Mutex + paths []string +} + +func (r *requestRecorder) intercept(_ http.ResponseWriter, req *http.Request) bool { + r.mu.Lock() + defer r.mu.Unlock() + r.paths = append(r.paths, req.URL.Path) + return false +} + +func (r *requestRecorder) requested(path string) bool { + r.mu.Lock() + defer r.mu.Unlock() + for _, p := range r.paths { + if p == path { + return true + } + } + return false +} + +func newSchemaContextTestService(t *testing.T, registryURL string) *Service { + t.Helper() + + provider, err := schemafactory.NewSingleClientProvider(&config.Config{ + SchemaRegistry: config.Schema{ + Enabled: true, + URLs: []string{registryURL}, + }, + }) + require.NoError(t, err) + + cachedClient, err := schema.NewCachedClient(provider, func(context.Context) (string, error) { + return "single", nil + }) + require.NoError(t, err) + + svc, err := NewService(nil, nil, cachedClient, nil, config.Cbor{}) + require.NoError(t, err) + return svc +} + +func TestService_SchemaContext(t *testing.T) { + const ( + schemaID = 7 + subject = ":.outgo:convoy-value" + schemaContext = ".outgo" + ) + avroSchema := sr.Schema{ + Type: sr.TypeAvro, + Schema: `{"type":"record","name":"Convoy","fields":[{"name":"id","type":"string"},{"name":"qty","type":"int"}]}`, + } + + // Convoy{id:"abc", qty:7} in Avro binary. + avroPayload := []byte{0x06, 'a', 'b', 'c', 0x0e} + var header sr.ConfluentHeader + wirePayload, err := header.AppendEncode(nil, schemaID, nil) + require.NoError(t, err) + wirePayload = append(wirePayload, avroPayload...) + + t.Run("deserialize in the topic's context", func(t *testing.T) { + registry := srfake.New() + defer registry.Close() + registry.SeedSchema(subject, 1, schemaID, avroSchema) + recorder := &requestRecorder{} + registry.Intercept(recorder.intercept) + + svc := newSchemaContextTestService(t, registry.URL()) + record := &kgo.Record{Topic: "convoy", Value: wirePayload} + + dr := svc.DeserializeRecord(t.Context(), record, DeserializationOptions{ + SchemaContext: schemaContext, + Troubleshoot: true, + }) + require.NotNil(t, dr) + assert.Equal(t, PayloadEncodingAvro, dr.Value.Encoding) + require.NotNil(t, dr.Value.SchemaID) + assert.Equal(t, uint32(schemaID), *dr.Value.SchemaID) + assert.JSONEq(t, `{"id":"abc","qty":7}`, string(dr.Value.NormalizedPayload)) + + assert.True(t, recorder.requested("/contexts/.outgo/schemas/ids/7"), "requested paths: %v", recorder.paths) + assert.False(t, recorder.requested("/schemas/ids/7"), "requested paths: %v", recorder.paths) + }) + + t.Run("cache keeps contexts apart", func(t *testing.T) { + registry := srfake.New() + defer registry.Close() + registry.SeedSchema(subject, 1, schemaID, avroSchema) + recorder := &requestRecorder{} + registry.Intercept(recorder.intercept) + + svc := newSchemaContextTestService(t, registry.URL()) + record := &kgo.Record{Topic: "convoy", Value: wirePayload} + + svc.DeserializeRecord(t.Context(), record, DeserializationOptions{SchemaContext: schemaContext}) + svc.DeserializeRecord(t.Context(), record, DeserializationOptions{}) + // Other spelling of the same context, served from the cache. + svc.DeserializeRecord(t.Context(), record, DeserializationOptions{SchemaContext: "outgo"}) + + assert.True(t, recorder.requested("/contexts/.outgo/schemas/ids/7"), "requested paths: %v", recorder.paths) + assert.True(t, recorder.requested("/schemas/ids/7"), "requested paths: %v", recorder.paths) + assert.Len(t, recorder.paths, 2, "one request per (context, id), got: %v", recorder.paths) + }) + + t.Run("serialize with the payload's context taking precedence over the topic's", func(t *testing.T) { + registry := srfake.New() + defer registry.Close() + registry.SeedSchema(subject, 1, schemaID, avroSchema) + recorder := &requestRecorder{} + registry.Intercept(recorder.intercept) + + svc := newSchemaContextTestService(t, registry.URL()) + + out, err := svc.SerializeRecord(t.Context(), SerializeInput{ + Topic: "convoy", + SchemaContext: ".unrelated", + Key: RecordPayloadInput{Encoding: PayloadEncodingNull}, + Value: RecordPayloadInput{ + Encoding: PayloadEncodingAvro, + Payload: `{"id":"abc","qty":7}`, + Options: []SerdeOpt{WithSchemaID(schemaID)}, + SchemaContext: schemaContext, + }, + }) + require.NoError(t, err) + assert.Equal(t, wirePayload, out.Value.Payload) + + assert.True(t, recorder.requested("/contexts/.outgo/schemas/ids/7"), "requested paths: %v", recorder.paths) + assert.False(t, recorder.requested("/contexts/.unrelated/schemas/ids/7"), "requested paths: %v", recorder.paths) + }) + + t.Run("serialize falls back to the topic's context", func(t *testing.T) { + registry := srfake.New() + defer registry.Close() + registry.SeedSchema(subject, 1, schemaID, avroSchema) + recorder := &requestRecorder{} + registry.Intercept(recorder.intercept) + + svc := newSchemaContextTestService(t, registry.URL()) + + out, err := svc.SerializeRecord(t.Context(), SerializeInput{ + Topic: "convoy", + SchemaContext: schemaContext, + Key: RecordPayloadInput{Encoding: PayloadEncodingNull}, + Value: RecordPayloadInput{ + Encoding: PayloadEncodingAvro, + Payload: `{"id":"abc","qty":7}`, + Options: []SerdeOpt{WithSchemaID(schemaID)}, + }, + }) + require.NoError(t, err) + assert.Equal(t, wirePayload, out.Value.Payload) + assert.True(t, recorder.requested("/contexts/.outgo/schemas/ids/7"), "requested paths: %v", recorder.paths) + }) +} diff --git a/frontend/src/components/pages/schemas/schema-context-utils.test.ts b/frontend/src/components/pages/schemas/schema-context-utils.test.ts index 272318ae1b..db8f62488d 100644 --- a/frontend/src/components/pages/schemas/schema-context-utils.test.ts +++ b/frontend/src/components/pages/schemas/schema-context-utils.test.ts @@ -20,7 +20,10 @@ import { deriveContexts, isNamedContext, parseSubjectContext, + pickSubjectForContext, pluralize, + subjectSchemaContext, + topicSchemaContext, } from './schema-context-utils'; describe('parseSubjectContext', () => { @@ -61,6 +64,49 @@ describe('parseSubjectContext', () => { }); }); +describe('subjectSchemaContext', () => { + test('named context keeps the leading dot', () => { + expect(subjectSchemaContext(':.staging:my-topic')).toBe('.staging'); + expect(subjectSchemaContext(':.team.sub:my-topic')).toBe('.team.sub'); + }); + + test('unprefixed subject is the default context', () => { + expect(subjectSchemaContext('my-topic')).toBe('.'); + }); +}); + +describe('topicSchemaContext', () => { + test('reads and normalizes the topic config', () => { + expect(topicSchemaContext([{ name: 'redpanda.schema.registry.context', value: '.outgo' }])).toBe('.outgo'); + expect(topicSchemaContext([{ name: 'redpanda.schema.registry.context', value: 'outgo' }])).toBe('.outgo'); + expect(topicSchemaContext([{ name: 'redpanda.schema.registry.context', value: ':.outgo:' }])).toBe('.outgo'); + }); + + test('missing or default config is the default context', () => { + expect(topicSchemaContext(undefined)).toBe('.'); + expect(topicSchemaContext([{ name: 'cleanup.policy', value: 'delete' }])).toBe('.'); + expect(topicSchemaContext([{ name: 'redpanda.schema.registry.context', value: '.' }])).toBe('.'); + expect(topicSchemaContext([{ name: 'redpanda.schema.registry.context', value: null }])).toBe('.'); + }); +}); + +describe('pickSubjectForContext', () => { + const subjects = [ + { subject: 'orders-value', version: 1 }, + { subject: ':.outgo:convoy-value', version: 3 }, + ]; + + test('prefers the subject of the given context', () => { + expect(pickSubjectForContext(subjects, '.outgo')).toEqual({ subject: ':.outgo:convoy-value', version: 3 }); + expect(pickSubjectForContext(subjects, '.')).toEqual({ subject: 'orders-value', version: 1 }); + }); + + test('falls back to the first subject when none matches', () => { + expect(pickSubjectForContext(subjects, '.other')).toEqual({ subject: 'orders-value', version: 1 }); + expect(pickSubjectForContext([], '.outgo')).toBeUndefined(); + }); +}); + describe('deriveContexts', () => { test('empty inputs returns single All entry', () => { const result = deriveContexts([], []); diff --git a/frontend/src/components/pages/schemas/schema-context-utils.ts b/frontend/src/components/pages/schemas/schema-context-utils.ts index 28aa82943c..a6875ebccd 100644 --- a/frontend/src/components/pages/schemas/schema-context-utils.ts +++ b/frontend/src/components/pages/schemas/schema-context-utils.ts @@ -10,7 +10,7 @@ */ import type { SchemaRegistryContextResponse } from '../../../react-query/api/schema-registry'; -import type { SchemaRegistrySubject } from '../../../state/rest-interfaces'; +import type { SchemaRegistrySubject, SchemaVersion } from '../../../state/rest-interfaces'; export const CONTEXT_PREFIX_RE = /^:\.([^:]+):(.+)$/; @@ -38,6 +38,29 @@ export function parseSubjectContext(name: string): ParsedSubject { }; } +// Schema Registry context of a subject as the backend expects it: +// ":.staging:my-topic" → ".staging", "my-topic" → "." (the default context). +export function subjectSchemaContext(name: string): string { + const match = CONTEXT_PREFIX_RE.exec(name); + return match ? `.${match[1]}` : '.'; +} + +// Schema Registry context a topic is bound to via redpanda.schema.registry.context, +// "." (default) if none. +export function topicSchemaContext(configEntries?: ReadonlyArray<{ name: string; value?: string | null }>): string { + const raw = configEntries?.find((e) => e.name === 'redpanda.schema.registry.context')?.value?.trim() ?? ''; + const name = raw.replace(/^:|:$/g, ''); + if (name === '' || name === '.') { + return '.'; + } + return name.startsWith('.') ? name : `.${name}`; +} + +// Schema IDs are context-local: prefer the subject that lives in the given context. +export function pickSubjectForContext(subjects: SchemaVersion[], schemaContext: string): SchemaVersion | undefined { + return subjects.find((s) => subjectSchemaContext(s.subject) === schemaContext) ?? subjects[0]; +} + export const ALL_CONTEXT_ID = '__all__'; export const DEFAULT_CONTEXT_ID = '__default__'; export const DEFAULT_CONTEXT_LABEL = 'Default'; diff --git a/frontend/src/components/pages/topics/Tab.Messages/index.tsx b/frontend/src/components/pages/topics/Tab.Messages/index.tsx index 9d4df249e0..3a775b9546 100644 --- a/frontend/src/components/pages/topics/Tab.Messages/index.tsx +++ b/frontend/src/components/pages/topics/Tab.Messages/index.tsx @@ -211,6 +211,7 @@ type LoadLargeMessageParams = { >; keyDeserializer: PayloadEncoding; valueDeserializer: PayloadEncoding; + schemaContext: string; }; async function loadLargeMessage({ @@ -220,6 +221,7 @@ async function loadLargeMessage({ setSearchState, keyDeserializer, valueDeserializer, + schemaContext, }: LoadLargeMessageParams) { // Create a new search that looks for only this message specifically const search = createMessageSearch(); @@ -234,6 +236,7 @@ async function loadLargeMessage({ ignoreSizeLimit: true, keyDeserializer, valueDeserializer, + schemaContext, }; const result = await search.startSearch(searchReq); @@ -426,6 +429,17 @@ export const TopicMessageView: FC = (props) => { parseAsInteger.withDefault(PayloadEncoding.UNSPECIFIED) ); + const [schemaContext, setSchemaContext] = useQueryStateWithCallback( + { + onUpdate: (val) => { + setSearchParams(props.topic.topicName, { schemaContext: val }); + }, + getDefaultValue: () => getSearchParams(props.topic.topicName)?.schemaContext ?? '', + }, + 'sc', + parseAsString.withDefault('') + ); + // Pagination state managed by nuqs const [pageIndex, setPageIndex] = useQueryStateWithCallback( { @@ -694,6 +708,7 @@ export const TopicMessageView: FC = (props) => { includeRawPayload: true, keyDeserializer, valueDeserializer, + schemaContext, } as MessageSearchRequest; try { @@ -750,6 +765,7 @@ export const TopicMessageView: FC = (props) => { pageSize, keyDeserializer, valueDeserializer, + schemaContext, filters, ] ); @@ -807,6 +823,7 @@ export const TopicMessageView: FC = (props) => { executeMessageSearch, keyDeserializer, valueDeserializer, + schemaContext, filters, ] ); @@ -1009,8 +1026,9 @@ export const TopicMessageView: FC = (props) => { setSearchState, keyDeserializer, valueDeserializer, + schemaContext, }), - [keyDeserializer, setSearchState, valueDeserializer] + [keyDeserializer, schemaContext, setSearchState, valueDeserializer] ); const onSetDownloadMessages = useCallback((nextMessages: TopicMessage[]) => { setDownloadMessages(nextMessages); @@ -1941,7 +1959,9 @@ export const TopicMessageView: FC = (props) => { showDeserializersModal} keyDeserializer={keyDeserializer} + schemaContext={schemaContext} setKeyDeserializer={setKeyDeserializer} + setSchemaContext={setSchemaContext} setShowDialog={setShowDeserializersModal} setValueDeserializer={setValueDeserializer} valueDeserializer={valueDeserializer} diff --git a/frontend/src/components/pages/topics/Tab.Messages/message-display/expanded-message.tsx b/frontend/src/components/pages/topics/Tab.Messages/message-display/expanded-message.tsx index 50793d651f..dbb2a2d242 100644 --- a/frontend/src/components/pages/topics/Tab.Messages/message-display/expanded-message.tsx +++ b/frontend/src/components/pages/topics/Tab.Messages/message-display/expanded-message.tsx @@ -79,7 +79,7 @@ export const ExpandedMessage: FC = React.memo( return (
- + diff --git a/frontend/src/components/pages/topics/Tab.Messages/message-display/message-meta-data.tsx b/frontend/src/components/pages/topics/Tab.Messages/message-display/message-meta-data.tsx index 2f92fcfdff..a0968f7f08 100644 --- a/frontend/src/components/pages/topics/Tab.Messages/message-display/message-meta-data.tsx +++ b/frontend/src/components/pages/topics/Tab.Messages/message-display/message-meta-data.tsx @@ -16,7 +16,7 @@ import type { TopicMessage } from '../../../../../state/rest-interfaces'; import { numberToThousandsString } from '../../../../../utils/tsx-utils'; import { prettyBytes, titleCase } from '../../../../../utils/utils'; -export const MessageMetaData = (props: { msg: TopicMessage }) => { +export const MessageMetaData = (props: { msg: TopicMessage; topicName?: string }) => { const msg = props.msg; const data: { [k: string]: React.ReactNode } = { Partition: msg.partitionID, @@ -31,7 +31,7 @@ export const MessageMetaData = (props: { msg: TopicMessage }) => { }; if (msg.value.schemaId) { - data.Schema = ; + data.Schema = ; } return ( diff --git a/frontend/src/components/pages/topics/Tab.Messages/message-display/message-schema.tsx b/frontend/src/components/pages/topics/Tab.Messages/message-display/message-schema.tsx index 18b39c890d..2c1da2ba0c 100644 --- a/frontend/src/components/pages/topics/Tab.Messages/message-display/message-schema.tsx +++ b/frontend/src/components/pages/topics/Tab.Messages/message-display/message-schema.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2025 Redpanda Data, Inc. + * Copyright 2022 Redpanda Data, Inc. * * Use of this software is governed by the Business Source License * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md @@ -12,15 +12,18 @@ import { Link } from '@tanstack/react-router'; import { api, useApiStoreHook } from '../../../../../state/backend-api'; +import { pickSubjectForContext, topicSchemaContext } from '../../../schemas/schema-context-utils'; -export const MessageSchema = (p: { schemaId: number }) => { - const subjects = useApiStoreHook((s) => s.schemaUsagesById.get(p.schemaId)); +export const MessageSchema = (p: { schemaId: number; topicName?: string }) => { + const subjects = useApiStoreHook((state) => state.schemaUsagesById.get(p.schemaId)); + const topicConfig = useApiStoreHook((state) => (p.topicName ? state.topicConfig.get(p.topicName) : undefined)); if (!subjects || subjects.length === 0) { api.refreshSchemaUsagesById(p.schemaId); return <>ID {p.schemaId} (unknown subject); } - const s = subjects[0]; + // The same schema ID can name different schemas in different contexts. + const s = pickSubjectForContext(subjects, topicSchemaContext(topicConfig?.configEntries)) ?? subjects[0]; return ( void; setValueDeserializer: (val: PayloadEncoding) => void; + schemaContext: string; + setSchemaContext: (val: string) => void; }> = ({ getShowDialog, setShowDialog, @@ -63,68 +66,83 @@ export const DeserializersModal: FC<{ valueDeserializer, setKeyDeserializer, setValueDeserializer, -}) => ( - - - - Deserialize - - -

Redpanda attempts to automatically detect a deserialization strategy. You can choose one manually here.

-
- - -
-
- - -
-
- - - -
-
-); + schemaContext, + setSchemaContext, +}) => { + const schemaContextsSupported = useSchemaContextsSupported(); + return ( + + + + Deserialize + + +

Redpanda attempts to automatically detect a deserialization strategy. You can choose one manually here.

+
+ + +
+
+ + +
+ {schemaContextsSupported && ( +
+ + +

+ Schema Registry context to resolve schema IDs in. Automatic follows the topic's + redpanda.schema.registry.context config. +

+
+ )} +
+ + + +
+
+ ); +}; diff --git a/frontend/src/components/pages/topics/messages/hooks/use-message-search.ts b/frontend/src/components/pages/topics/messages/hooks/use-message-search.ts index dd2da77bf8..cab2a0a20c 100644 --- a/frontend/src/components/pages/topics/messages/hooks/use-message-search.ts +++ b/frontend/src/components/pages/topics/messages/hooks/use-message-search.ts @@ -56,6 +56,8 @@ export type MessageSearchParams = { filterInterpreterCode: string; keyDeserializer?: PayloadEncoding; valueDeserializer?: PayloadEncoding; + /** '' resolves the topic's context, '.' forces the default. */ + schemaContext?: string; includeRawPayload?: boolean; ignoreSizeLimit?: boolean; }; @@ -95,6 +97,7 @@ const buildListMessagesRequest = (topicName: string, params: MessageSearchParams req.ignoreMaxSizeLimit = params.ignoreSizeLimit ?? false; req.keyDeserializer = params.keyDeserializer; req.valueDeserializer = params.valueDeserializer; + req.schemaContext = params.schemaContext ?? ''; return req; }; @@ -357,6 +360,7 @@ export function useMessageSearch(topicName: string): MessageSearchResult { ignoreSizeLimit: true, keyDeserializer: params?.keyDeserializer, valueDeserializer: params?.valueDeserializer, + schemaContext: params?.schemaContext, }); let loaded: TopicMessage | null = null; for await (const res of client.listMessages(req, { timeoutMs: DEFAULT_TIMEOUT_MS })) { diff --git a/frontend/src/components/pages/topics/messages/hooks/use-messages-url-state.ts b/frontend/src/components/pages/topics/messages/hooks/use-messages-url-state.ts index 0e0e04abbe..6325a7d377 100644 --- a/frontend/src/components/pages/topics/messages/hooks/use-messages-url-state.ts +++ b/frontend/src/components/pages/topics/messages/hooks/use-messages-url-state.ts @@ -144,6 +144,15 @@ export function useMessagesUrlState(topicName: string) { parseAsInteger.withDefault(PayloadEncoding.UNSPECIFIED) ); + const [schemaContext, setSchemaContext] = useQueryStateWithCallback( + { + onUpdate: (val) => setSearchParams(topicName, { schemaContext: val }), + getDefaultValue: () => getSearchParams(topicName)?.schemaContext ?? '', + }, + 'sc', + parseAsString.withDefault('') + ); + const [pageIndex, setPageIndex] = useQueryState('page', parseAsInteger.withDefault(0)); const [pageSize, setPageSize] = useQueryStateWithCallback( @@ -234,6 +243,8 @@ export function useMessagesUrlState(topicName: string) { setKeyDeserializer, valueDeserializer, setValueDeserializer, + schemaContext, + setSchemaContext, pageIndex, setPageIndex, pageSize, diff --git a/frontend/src/components/pages/topics/messages/topic-messages-view.tsx b/frontend/src/components/pages/topics/messages/topic-messages-view.tsx index dbe9d6904c..5815d3f4bc 100644 --- a/frontend/src/components/pages/topics/messages/topic-messages-view.tsx +++ b/frontend/src/components/pages/topics/messages/topic-messages-view.tsx @@ -163,6 +163,7 @@ export const TopicMessagesView = ({ topic }: TopicMessagesViewProps) => { filterInterpreterCode, keyDeserializer: urlState.keyDeserializer, valueDeserializer: urlState.valueDeserializer, + schemaContext: urlState.schemaContext, includeRawPayload: true, }), [ @@ -174,6 +175,7 @@ export const TopicMessagesView = ({ topic }: TopicMessagesViewProps) => { continuousActive, urlState.keyDeserializer, urlState.valueDeserializer, + urlState.schemaContext, filterInterpreterCode, ] ); @@ -594,8 +596,11 @@ export const TopicMessagesView = ({ topic }: TopicMessagesViewProps) => { onResetDeserializers={() => { urlState.setKeyDeserializer(PayloadEncoding.UNSPECIFIED); urlState.setValueDeserializer(PayloadEncoding.UNSPECIFIED); + urlState.setSchemaContext(''); }} + onSchemaContextChange={urlState.setSchemaContext} onValueDeserializerChange={urlState.setValueDeserializer} + schemaContext={urlState.schemaContext} topicName={topicName} valueDeserializer={urlState.valueDeserializer} valuePathHints={valuePathHints} diff --git a/frontend/src/components/pages/topics/messages/view-settings/view-settings-panel.test.tsx b/frontend/src/components/pages/topics/messages/view-settings/view-settings-panel.test.tsx index 595f48ca25..272098bbe1 100644 --- a/frontend/src/components/pages/topics/messages/view-settings/view-settings-panel.test.tsx +++ b/frontend/src/components/pages/topics/messages/view-settings/view-settings-panel.test.tsx @@ -28,6 +28,8 @@ const renderPanel = (overrides: Partial = {}) => { valueDeserializer: PayloadEncoding.UNSPECIFIED, onValueDeserializerChange: rs.fn(), onResetDeserializers: rs.fn(), + schemaContext: '', + onSchemaContextChange: rs.fn(), valuePathHints: ['address', 'address.city'], liveTail: false, ...overrides, @@ -88,6 +90,12 @@ describe('ViewSettingsPanel', () => { expect(screen.getByTestId('view-settings-key-deser')).toBeEnabled(); }); + test('schema context select is hidden when the registry has no contexts', async () => { + renderPanel(); + await userEvent.click(screen.getByTestId('column-config-value')); + expect(screen.queryByTestId('view-settings-schema-context')).toBeNull(); + }); + test('reset restores defaults and resets deserializers', async () => { const props = renderPanel(); useTopicSettingsStore.getState().setRowDensity(TOPIC, 'compact'); diff --git a/frontend/src/components/pages/topics/messages/view-settings/view-settings-panel.tsx b/frontend/src/components/pages/topics/messages/view-settings/view-settings-panel.tsx index 91af7b465c..8e4adf3f6c 100644 --- a/frontend/src/components/pages/topics/messages/view-settings/view-settings-panel.tsx +++ b/frontend/src/components/pages/topics/messages/view-settings/view-settings-panel.tsx @@ -26,6 +26,7 @@ import { PreviewFieldsEditor } from './preview-fields-editor'; import type { PayloadEncoding } from '../../../../../protogen/redpanda/api/console/v1alpha1/common_pb'; import type { TimestampDisplayFormat } from '../../../../../state/ui'; import { useTopicSettingsStore } from '../../../../../stores/topic-settings-store'; +import { TopicSchemaContextSelect, useSchemaContextsSupported } from '../../schema-context-select'; import { PAYLOAD_ENCODING_LABELS, PAYLOAD_ENCODING_PAIRS } from '../constants'; import type { MessageColumnConfig } from '../types'; @@ -73,6 +74,9 @@ export type ViewSettingsPanelProps = { valueDeserializer: PayloadEncoding; onValueDeserializerChange: (encoding: PayloadEncoding) => void; onResetDeserializers: () => void; + /** '' resolves the topic's context, '.' forces the default. */ + schemaContext: string; + onSchemaContextChange: (schemaContext: string) => void; /** Dotted paths seen in loaded values — autocomplete hints for preview patterns. */ valuePathHints: string[]; /** Deserializer changes only take effect on the next (re)start — disable them while streaming. */ @@ -91,9 +95,12 @@ export const ViewSettingsPanel = ({ valueDeserializer, onValueDeserializerChange, onResetDeserializers, + schemaContext, + onSchemaContextChange, valuePathHints, liveTail, }: ViewSettingsPanelProps) => { + const schemaContextsSupported = useSchemaContextsSupported(); const { getRowDensity, setRowDensity, @@ -171,6 +178,22 @@ export const ViewSettingsPanel = ({ value={valueDeserializer} />
+ {schemaContextsSupported && ( +
+ + +

+ Schema Registry context to resolve schema IDs in. Automatic follows + the topic's redpanda.schema.registry.context config. +

+
+ )} ); diff --git a/frontend/src/components/pages/topics/schema-context-select.test.tsx b/frontend/src/components/pages/topics/schema-context-select.test.tsx new file mode 100644 index 0000000000..c848cec3f7 --- /dev/null +++ b/frontend/src/components/pages/topics/schema-context-select.test.tsx @@ -0,0 +1,43 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { describe, expect, rs, test } from '@rstest/core'; +import { render, screen } from '@testing-library/react'; + +import { SchemaContextSelect, schemaContextOptions } from './schema-context-select'; + +describe('schemaContextOptions', () => { + test('lists automatic, default and the named contexts sorted', () => { + expect(schemaContextOptions(['.staging', '.', '.outgo'], '').map((o) => o.label)).toEqual([ + 'Automatic (topic config)', + 'Default', + '.outgo', + '.staging', + ]); + }); + + test('keeps a value that is not in the list selectable', () => { + const options = schemaContextOptions(['.'], '.gone'); + expect(options.at(-1)).toEqual({ value: '.gone', label: '.gone' }); + }); +}); + +describe('SchemaContextSelect', () => { + test('shows the automatic option for an empty value', () => { + render(); + expect(screen.getByTestId('schema-context')).toHaveTextContent('Automatic (topic config)'); + }); + + test('shows the selected context', () => { + render(); + expect(screen.getByTestId('schema-context')).toHaveTextContent('.outgo'); + }); +}); diff --git a/frontend/src/components/pages/topics/schema-context-select.tsx b/frontend/src/components/pages/topics/schema-context-select.tsx new file mode 100644 index 0000000000..9c377cd36f --- /dev/null +++ b/frontend/src/components/pages/topics/schema-context-select.tsx @@ -0,0 +1,86 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from 'components/redpanda-ui/components/select'; + +import { useSchemaRegistryContextsQuery } from '../../../react-query/api/schema-registry'; +import { useSupportedFeaturesStore } from '../../../state/supported-features'; + +/** Resolve the context from the topic's redpanda.schema.registry.context config. */ +export const AUTO_SCHEMA_CONTEXT = ''; +/** Force the default context, even when the topic is bound to another one. */ +export const DEFAULT_SCHEMA_CONTEXT = '.'; + +// Select items need a non-empty value. +const AUTO_OPTION = '__auto__'; + +export type SchemaContextSelectProps = { + id: string; + value: string; + onChange: (schemaContext: string) => void; + /** Context names as returned by the registry, e.g. [".", ".staging"]. */ + contexts: string[]; + disabled?: boolean; + title?: string; +}; + +export const schemaContextOptions = (contexts: string[], value: string) => { + const options = [ + { value: AUTO_OPTION, label: 'Automatic (topic config)' }, + { value: DEFAULT_SCHEMA_CONTEXT, label: 'Default' }, + ...contexts + .filter((c) => c !== DEFAULT_SCHEMA_CONTEXT) + .sort((a, b) => a.localeCompare(b)) + .map((c) => ({ value: c, label: c })), + ]; + // Keep a value that is not in the list (e.g. from a shared URL) selectable. + if (value !== AUTO_SCHEMA_CONTEXT && !options.some((o) => o.value === value)) { + options.push({ value, label: value }); + } + return options; +}; + +export const SchemaContextSelect = ({ id, value, onChange, contexts, disabled, title }: SchemaContextSelectProps) => { + const options = schemaContextOptions(contexts, value); + return ( + + ); +}; + +/** True when the connected Schema Registry has contexts enabled. */ +export const useSchemaContextsSupported = () => useSupportedFeaturesStore((s) => s.schemaRegistryContexts); + +/** SchemaContextSelect fed with the registry's contexts. Render only when useSchemaContextsSupported(). */ +export const TopicSchemaContextSelect = (props: Omit) => { + const { data } = useSchemaRegistryContextsQuery(); + return c.name)} />; +}; diff --git a/frontend/src/components/pages/topics/topic-produce.tsx b/frontend/src/components/pages/topics/topic-produce.tsx index f15c1ffaa9..62b5d6f316 100644 --- a/frontend/src/components/pages/topics/topic-produce.tsx +++ b/frontend/src/components/pages/topics/topic-produce.tsx @@ -47,6 +47,7 @@ import { import { Input } from '../../redpanda-ui/components/input'; import { KeyValueField } from '../../redpanda-ui/components/key-value-field'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../redpanda-ui/components/select'; +import { subjectSchemaContext } from '../schemas/schema-context-utils'; type EncodingOption = { value: PayloadEncoding | 'base64'; @@ -310,6 +311,7 @@ const PublishTopicForm: FC<{ topicName: string }> = ({ topicName }) => { ); if (selectedSchema) { req.key.schemaId = selectedSchema.id; + req.key.schemaContext = subjectSchemaContext(data.key.schemaName); } } } @@ -345,6 +347,7 @@ const PublishTopicForm: FC<{ topicName: string }> = ({ topicName }) => { ); if (selectedSchema) { req.value.schemaId = selectedSchema.id; + req.value.schemaContext = subjectSchemaContext(data.value.schemaName); } } } diff --git a/frontend/src/protogen/redpanda/api/console/v1alpha1/list_messages_pb.ts b/frontend/src/protogen/redpanda/api/console/v1alpha1/list_messages_pb.ts index 9f59d93528..a72a201578 100644 --- a/frontend/src/protogen/redpanda/api/console/v1alpha1/list_messages_pb.ts +++ b/frontend/src/protogen/redpanda/api/console/v1alpha1/list_messages_pb.ts @@ -13,7 +13,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file redpanda/api/console/v1alpha1/list_messages.proto. */ export const file_redpanda_api_console_v1alpha1_list_messages: GenFile = /*@__PURE__*/ - fileDesc("CjFyZWRwYW5kYS9hcGkvY29uc29sZS92MWFscGhhMS9saXN0X21lc3NhZ2VzLnByb3RvEh1yZWRwYW5kYS5hcGkuY29uc29sZS52MWFscGhhMSLOBAoTTGlzdE1lc3NhZ2VzUmVxdWVzdBItCgV0b3BpYxgBIAEoCUIeukgbchkQARj5ATISXlthLXpBLVowLTkuX1wtXSokEiMKDHN0YXJ0X29mZnNldBgCIAEoEkINukgKQggwATADMAUwBxIXCg9zdGFydF90aW1lc3RhbXAYAyABKAMSJgoMcGFydGl0aW9uX2lkGAQgASgFQhC6SA0aCyj///////////8BEhMKC21heF9yZXN1bHRzGAUgASgFEh8KF2ZpbHRlcl9pbnRlcnByZXRlcl9jb2RlGAYgASgJEhIKCmVudGVycHJpc2UYByABKAwSFAoMdHJvdWJsZXNob290GAggASgIEiQKHGluY2x1ZGVfb3JpZ2luYWxfcmF3X3BheWxvYWQYCSABKAgSTQoQa2V5X2Rlc2VyaWFsaXplchgKIAEoDjIuLnJlZHBhbmRhLmFwaS5jb25zb2xlLnYxYWxwaGExLlBheWxvYWRFbmNvZGluZ0gAiAEBEk8KEnZhbHVlX2Rlc2VyaWFsaXplchgLIAEoDjIuLnJlZHBhbmRhLmFwaS5jb25zb2xlLnYxYWxwaGExLlBheWxvYWRFbmNvZGluZ0gBiAEBEh0KFWlnbm9yZV9tYXhfc2l6ZV9saW1pdBgMIAEoCBISCgpwYWdlX3Rva2VuGA0gASgJEh0KCXBhZ2Vfc2l6ZRgOIAEoBUIKukgHGgUY9AMoAUITChFfa2V5X2Rlc2VyaWFsaXplckIVChNfdmFsdWVfZGVzZXJpYWxpemVyItkIChRMaXN0TWVzc2FnZXNSZXNwb25zZRJPCgRkYXRhGAEgASgLMj8ucmVkcGFuZGEuYXBpLmNvbnNvbGUudjFhbHBoYTEuTGlzdE1lc3NhZ2VzUmVzcG9uc2UuRGF0YU1lc3NhZ2VIABJRCgVwaGFzZRgCIAEoCzJALnJlZHBhbmRhLmFwaS5jb25zb2xlLnYxYWxwaGExLkxpc3RNZXNzYWdlc1Jlc3BvbnNlLlBoYXNlTWVzc2FnZUgAElcKCHByb2dyZXNzGAMgASgLMkMucmVkcGFuZGEuYXBpLmNvbnNvbGUudjFhbHBoYTEuTGlzdE1lc3NhZ2VzUmVzcG9uc2UuUHJvZ3Jlc3NNZXNzYWdlSAASWgoEZG9uZRgEIAEoCzJKLnJlZHBhbmRhLmFwaS5jb25zb2xlLnYxYWxwaGExLkxpc3RNZXNzYWdlc1Jlc3BvbnNlLlN0cmVhbUNvbXBsZXRlZE1lc3NhZ2VIABJRCgVlcnJvchgFIAEoCzJALnJlZHBhbmRhLmFwaS5jb25zb2xlLnYxYWxwaGExLkxpc3RNZXNzYWdlc1Jlc3BvbnNlLkVycm9yTWVzc2FnZUgAGuoCCgtEYXRhTWVzc2FnZRIUCgxwYXJ0aXRpb25faWQYASABKAUSDgoGb2Zmc2V0GAIgASgDEhEKCXRpbWVzdGFtcBgDIAEoAxJDCgtjb21wcmVzc2lvbhgEIAEoDjIuLnJlZHBhbmRhLmFwaS5jb25zb2xlLnYxYWxwaGExLkNvbXByZXNzaW9uVHlwZRIYChBpc190cmFuc2FjdGlvbmFsGAUgASgIEkEKB2hlYWRlcnMYBiADKAsyMC5yZWRwYW5kYS5hcGkuY29uc29sZS52MWFscGhhMS5LYWZrYVJlY29yZEhlYWRlchI+CgNrZXkYByABKAsyMS5yZWRwYW5kYS5hcGkuY29uc29sZS52MWFscGhhMS5LYWZrYVJlY29yZFBheWxvYWQSQAoFdmFsdWUYCCABKAsyMS5yZWRwYW5kYS5hcGkuY29uc29sZS52MWFscGhhMS5LYWZrYVJlY29yZFBheWxvYWQaHQoMUGhhc2VNZXNzYWdlEg0KBXBoYXNlGAEgASgJGkQKD1Byb2dyZXNzTWVzc2FnZRIZChFtZXNzYWdlc19jb25zdW1lZBgBIAEoAxIWCg5ieXRlc19jb25zdW1lZBgCIAEoAxqOAQoWU3RyZWFtQ29tcGxldGVkTWVzc2FnZRISCgplbGFwc2VkX21zGAEgASgDEhQKDGlzX2NhbmNlbGxlZBgCIAEoCBIZChFtZXNzYWdlc19jb25zdW1lZBgDIAEoAxIWCg5ieXRlc19jb25zdW1lZBgEIAEoAxIXCg9uZXh0X3BhZ2VfdG9rZW4YBSABKAkaHwoMRXJyb3JNZXNzYWdlEg8KB21lc3NhZ2UYASABKAlCEQoPY29udHJvbF9tZXNzYWdlIuwCChJLYWZrYVJlY29yZFBheWxvYWQSHQoQb3JpZ2luYWxfcGF5bG9hZBgBIAEoDEgAiAEBEh8KEm5vcm1hbGl6ZWRfcGF5bG9hZBgCIAEoDEgBiAEBEkAKCGVuY29kaW5nGAMgASgOMi4ucmVkcGFuZGEuYXBpLmNvbnNvbGUudjFhbHBoYTEuUGF5bG9hZEVuY29kaW5nEhYKCXNjaGVtYV9pZBgEIAEoBUgCiAEBEhQKDHBheWxvYWRfc2l6ZRgFIAEoBRIcChRpc19wYXlsb2FkX3Rvb19sYXJnZRgGIAEoCBJOChN0cm91Ymxlc2hvb3RfcmVwb3J0GAcgAygLMjEucmVkcGFuZGEuYXBpLmNvbnNvbGUudjFhbHBoYTEuVHJvdWJsZXNob290UmVwb3J0QhMKEV9vcmlnaW5hbF9wYXlsb2FkQhUKE19ub3JtYWxpemVkX3BheWxvYWRCDAoKX3NjaGVtYV9pZGIGcHJvdG8z", [file_buf_validate_validate, file_redpanda_api_console_v1alpha1_common]); + fileDesc("CjFyZWRwYW5kYS9hcGkvY29uc29sZS92MWFscGhhMS9saXN0X21lc3NhZ2VzLnByb3RvEh1yZWRwYW5kYS5hcGkuY29uc29sZS52MWFscGhhMSLmBAoTTGlzdE1lc3NhZ2VzUmVxdWVzdBItCgV0b3BpYxgBIAEoCUIeukgbchkQARj5ATISXlthLXpBLVowLTkuX1wtXSokEiMKDHN0YXJ0X29mZnNldBgCIAEoEkINukgKQggwATADMAUwBxIXCg9zdGFydF90aW1lc3RhbXAYAyABKAMSJgoMcGFydGl0aW9uX2lkGAQgASgFQhC6SA0aCyj///////////8BEhMKC21heF9yZXN1bHRzGAUgASgFEh8KF2ZpbHRlcl9pbnRlcnByZXRlcl9jb2RlGAYgASgJEhIKCmVudGVycHJpc2UYByABKAwSFAoMdHJvdWJsZXNob290GAggASgIEiQKHGluY2x1ZGVfb3JpZ2luYWxfcmF3X3BheWxvYWQYCSABKAgSTQoQa2V5X2Rlc2VyaWFsaXplchgKIAEoDjIuLnJlZHBhbmRhLmFwaS5jb25zb2xlLnYxYWxwaGExLlBheWxvYWRFbmNvZGluZ0gAiAEBEk8KEnZhbHVlX2Rlc2VyaWFsaXplchgLIAEoDjIuLnJlZHBhbmRhLmFwaS5jb25zb2xlLnYxYWxwaGExLlBheWxvYWRFbmNvZGluZ0gBiAEBEh0KFWlnbm9yZV9tYXhfc2l6ZV9saW1pdBgMIAEoCBISCgpwYWdlX3Rva2VuGA0gASgJEhYKDnNjaGVtYV9jb250ZXh0GA8gASgJEh0KCXBhZ2Vfc2l6ZRgOIAEoBUIKukgHGgUY9AMoAUITChFfa2V5X2Rlc2VyaWFsaXplckIVChNfdmFsdWVfZGVzZXJpYWxpemVyItkIChRMaXN0TWVzc2FnZXNSZXNwb25zZRJPCgRkYXRhGAEgASgLMj8ucmVkcGFuZGEuYXBpLmNvbnNvbGUudjFhbHBoYTEuTGlzdE1lc3NhZ2VzUmVzcG9uc2UuRGF0YU1lc3NhZ2VIABJRCgVwaGFzZRgCIAEoCzJALnJlZHBhbmRhLmFwaS5jb25zb2xlLnYxYWxwaGExLkxpc3RNZXNzYWdlc1Jlc3BvbnNlLlBoYXNlTWVzc2FnZUgAElcKCHByb2dyZXNzGAMgASgLMkMucmVkcGFuZGEuYXBpLmNvbnNvbGUudjFhbHBoYTEuTGlzdE1lc3NhZ2VzUmVzcG9uc2UuUHJvZ3Jlc3NNZXNzYWdlSAASWgoEZG9uZRgEIAEoCzJKLnJlZHBhbmRhLmFwaS5jb25zb2xlLnYxYWxwaGExLkxpc3RNZXNzYWdlc1Jlc3BvbnNlLlN0cmVhbUNvbXBsZXRlZE1lc3NhZ2VIABJRCgVlcnJvchgFIAEoCzJALnJlZHBhbmRhLmFwaS5jb25zb2xlLnYxYWxwaGExLkxpc3RNZXNzYWdlc1Jlc3BvbnNlLkVycm9yTWVzc2FnZUgAGuoCCgtEYXRhTWVzc2FnZRIUCgxwYXJ0aXRpb25faWQYASABKAUSDgoGb2Zmc2V0GAIgASgDEhEKCXRpbWVzdGFtcBgDIAEoAxJDCgtjb21wcmVzc2lvbhgEIAEoDjIuLnJlZHBhbmRhLmFwaS5jb25zb2xlLnYxYWxwaGExLkNvbXByZXNzaW9uVHlwZRIYChBpc190cmFuc2FjdGlvbmFsGAUgASgIEkEKB2hlYWRlcnMYBiADKAsyMC5yZWRwYW5kYS5hcGkuY29uc29sZS52MWFscGhhMS5LYWZrYVJlY29yZEhlYWRlchI+CgNrZXkYByABKAsyMS5yZWRwYW5kYS5hcGkuY29uc29sZS52MWFscGhhMS5LYWZrYVJlY29yZFBheWxvYWQSQAoFdmFsdWUYCCABKAsyMS5yZWRwYW5kYS5hcGkuY29uc29sZS52MWFscGhhMS5LYWZrYVJlY29yZFBheWxvYWQaHQoMUGhhc2VNZXNzYWdlEg0KBXBoYXNlGAEgASgJGkQKD1Byb2dyZXNzTWVzc2FnZRIZChFtZXNzYWdlc19jb25zdW1lZBgBIAEoAxIWCg5ieXRlc19jb25zdW1lZBgCIAEoAxqOAQoWU3RyZWFtQ29tcGxldGVkTWVzc2FnZRISCgplbGFwc2VkX21zGAEgASgDEhQKDGlzX2NhbmNlbGxlZBgCIAEoCBIZChFtZXNzYWdlc19jb25zdW1lZBgDIAEoAxIWCg5ieXRlc19jb25zdW1lZBgEIAEoAxIXCg9uZXh0X3BhZ2VfdG9rZW4YBSABKAkaHwoMRXJyb3JNZXNzYWdlEg8KB21lc3NhZ2UYASABKAlCEQoPY29udHJvbF9tZXNzYWdlIuwCChJLYWZrYVJlY29yZFBheWxvYWQSHQoQb3JpZ2luYWxfcGF5bG9hZBgBIAEoDEgAiAEBEh8KEm5vcm1hbGl6ZWRfcGF5bG9hZBgCIAEoDEgBiAEBEkAKCGVuY29kaW5nGAMgASgOMi4ucmVkcGFuZGEuYXBpLmNvbnNvbGUudjFhbHBoYTEuUGF5bG9hZEVuY29kaW5nEhYKCXNjaGVtYV9pZBgEIAEoBUgCiAEBEhQKDHBheWxvYWRfc2l6ZRgFIAEoBRIcChRpc19wYXlsb2FkX3Rvb19sYXJnZRgGIAEoCBJOChN0cm91Ymxlc2hvb3RfcmVwb3J0GAcgAygLMjEucmVkcGFuZGEuYXBpLmNvbnNvbGUudjFhbHBoYTEuVHJvdWJsZXNob290UmVwb3J0QhMKEV9vcmlnaW5hbF9wYXlsb2FkQhUKE19ub3JtYWxpemVkX3BheWxvYWRCDAoKX3NjaGVtYV9pZGIGcHJvdG8z", [file_buf_validate_validate, file_redpanda_api_console_v1alpha1_common]); /** * ListMessagesRequest is the request for ListMessages call. @@ -112,6 +112,13 @@ export type ListMessagesRequest = Message<"redpanda.api.console.v1alpha1.ListMes */ pageToken: string; + /** + * Optional Schema Registry context to resolve schema IDs in. Empty uses the topic's redpanda.schema.registry.context, then the default context; "." forces the default context. + * + * @generated from field: string schema_context = 15; + */ + schemaContext: string; + /** * Number of messages to fetch per page. When set (> 0), pagination mode is enabled and max_results is ignored. When unset or 0, legacy mode is used. * diff --git a/frontend/src/protogen/redpanda/api/console/v1alpha1/publish_messages_pb.ts b/frontend/src/protogen/redpanda/api/console/v1alpha1/publish_messages_pb.ts index 99aa2f97ca..8e162ead65 100644 --- a/frontend/src/protogen/redpanda/api/console/v1alpha1/publish_messages_pb.ts +++ b/frontend/src/protogen/redpanda/api/console/v1alpha1/publish_messages_pb.ts @@ -13,7 +13,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file redpanda/api/console/v1alpha1/publish_messages.proto. */ export const file_redpanda_api_console_v1alpha1_publish_messages: GenFile = /*@__PURE__*/ - fileDesc("CjRyZWRwYW5kYS9hcGkvY29uc29sZS92MWFscGhhMS9wdWJsaXNoX21lc3NhZ2VzLnByb3RvEh1yZWRwYW5kYS5hcGkuY29uc29sZS52MWFscGhhMSKmAwoVUHVibGlzaE1lc3NhZ2VSZXF1ZXN0Ei0KBXRvcGljGAEgASgJQh66SBtyGRABGPkBMhJeW2EtekEtWjAtOS5fXC1dKiQSJgoMcGFydGl0aW9uX2lkGAIgASgFQhC6SA0aCyj///////////8BEkMKC2NvbXByZXNzaW9uGAMgASgOMi4ucmVkcGFuZGEuYXBpLmNvbnNvbGUudjFhbHBoYTEuQ29tcHJlc3Npb25UeXBlEhgKEHVzZV90cmFuc2FjdGlvbnMYBCABKAgSQQoHaGVhZGVycxgFIAMoCzIwLnJlZHBhbmRhLmFwaS5jb25zb2xlLnYxYWxwaGExLkthZmthUmVjb3JkSGVhZGVyEkgKA2tleRgGIAEoCzI7LnJlZHBhbmRhLmFwaS5jb25zb2xlLnYxYWxwaGExLlB1Ymxpc2hNZXNzYWdlUGF5bG9hZE9wdGlvbnMSSgoFdmFsdWUYByABKAsyOy5yZWRwYW5kYS5hcGkuY29uc29sZS52MWFscGhhMS5QdWJsaXNoTWVzc2FnZVBheWxvYWRPcHRpb25zIsYBChxQdWJsaXNoTWVzc2FnZVBheWxvYWRPcHRpb25zEkAKCGVuY29kaW5nGAEgASgOMi4ucmVkcGFuZGEuYXBpLmNvbnNvbGUudjFhbHBoYTEuUGF5bG9hZEVuY29kaW5nEgwKBGRhdGEYAiABKAwSFgoJc2NoZW1hX2lkGAkgASgFSACIAQESEgoFaW5kZXgYCiABKAVIAYgBARISCgppbmRleF9wYXRoGAsgAygFQgwKCl9zY2hlbWFfaWRCCAoGX2luZGV4Ik0KFlB1Ymxpc2hNZXNzYWdlUmVzcG9uc2USDQoFdG9waWMYASABKAkSFAoMcGFydGl0aW9uX2lkGAIgASgFEg4KBm9mZnNldBgDIAEoAyJNChtHZW5lcmF0ZVNjaGVtYVNhbXBsZVJlcXVlc3QSGgoJc2NoZW1hX2lkGAEgASgFQge6SAQaAiAAEhIKCmluZGV4X3BhdGgYAiADKAUiMwocR2VuZXJhdGVTY2hlbWFTYW1wbGVSZXNwb25zZRITCgtzYW1wbGVfanNvbhgBIAEoCWIGcHJvdG8z", [file_buf_validate_validate, file_redpanda_api_console_v1alpha1_common]); + fileDesc("CjRyZWRwYW5kYS9hcGkvY29uc29sZS92MWFscGhhMS9wdWJsaXNoX21lc3NhZ2VzLnByb3RvEh1yZWRwYW5kYS5hcGkuY29uc29sZS52MWFscGhhMSKmAwoVUHVibGlzaE1lc3NhZ2VSZXF1ZXN0Ei0KBXRvcGljGAEgASgJQh66SBtyGRABGPkBMhJeW2EtekEtWjAtOS5fXC1dKiQSJgoMcGFydGl0aW9uX2lkGAIgASgFQhC6SA0aCyj///////////8BEkMKC2NvbXByZXNzaW9uGAMgASgOMi4ucmVkcGFuZGEuYXBpLmNvbnNvbGUudjFhbHBoYTEuQ29tcHJlc3Npb25UeXBlEhgKEHVzZV90cmFuc2FjdGlvbnMYBCABKAgSQQoHaGVhZGVycxgFIAMoCzIwLnJlZHBhbmRhLmFwaS5jb25zb2xlLnYxYWxwaGExLkthZmthUmVjb3JkSGVhZGVyEkgKA2tleRgGIAEoCzI7LnJlZHBhbmRhLmFwaS5jb25zb2xlLnYxYWxwaGExLlB1Ymxpc2hNZXNzYWdlUGF5bG9hZE9wdGlvbnMSSgoFdmFsdWUYByABKAsyOy5yZWRwYW5kYS5hcGkuY29uc29sZS52MWFscGhhMS5QdWJsaXNoTWVzc2FnZVBheWxvYWRPcHRpb25zIt4BChxQdWJsaXNoTWVzc2FnZVBheWxvYWRPcHRpb25zEkAKCGVuY29kaW5nGAEgASgOMi4ucmVkcGFuZGEuYXBpLmNvbnNvbGUudjFhbHBoYTEuUGF5bG9hZEVuY29kaW5nEgwKBGRhdGEYAiABKAwSFgoJc2NoZW1hX2lkGAkgASgFSACIAQESEgoFaW5kZXgYCiABKAVIAYgBARISCgppbmRleF9wYXRoGAsgAygFEhYKDnNjaGVtYV9jb250ZXh0GAwgASgJQgwKCl9zY2hlbWFfaWRCCAoGX2luZGV4Ik0KFlB1Ymxpc2hNZXNzYWdlUmVzcG9uc2USDQoFdG9waWMYASABKAkSFAoMcGFydGl0aW9uX2lkGAIgASgFEg4KBm9mZnNldBgDIAEoAyJlChtHZW5lcmF0ZVNjaGVtYVNhbXBsZVJlcXVlc3QSGgoJc2NoZW1hX2lkGAEgASgFQge6SAQaAiAAEhIKCmluZGV4X3BhdGgYAiADKAUSFgoOc2NoZW1hX2NvbnRleHQYAyABKAkiMwocR2VuZXJhdGVTY2hlbWFTYW1wbGVSZXNwb25zZRITCgtzYW1wbGVfanNvbhgBIAEoCWIGcHJvdG8z", [file_buf_validate_validate, file_redpanda_api_console_v1alpha1_common]); /** * PublishMessageRequest is the request for PublishMessage call. @@ -112,6 +112,13 @@ export type PublishMessagePayloadOptions = Message<"redpanda.api.console.v1alpha * @generated from field: repeated int32 index_path = 11; */ indexPath: number[]; + + /** + * Optional Schema Registry context of schema_id. Empty uses the topic's context, then the default context. + * + * @generated from field: string schema_context = 12; + */ + schemaContext: string; }; /** @@ -167,6 +174,13 @@ export type GenerateSchemaSampleRequest = Message<"redpanda.api.console.v1alpha1 * @generated from field: repeated int32 index_path = 2; */ indexPath: number[]; + + /** + * Optional Schema Registry context of schema_id. Empty means the default context. + * + * @generated from field: string schema_context = 3; + */ + schemaContext: string; }; /** diff --git a/frontend/src/state/backend-api.ts b/frontend/src/state/backend-api.ts index 9cf6ce40c9..8e19bb6b16 100644 --- a/frontend/src/state/backend-api.ts +++ b/frontend/src/state/backend-api.ts @@ -2588,6 +2588,7 @@ export function createMessageSearch() { req.ignoreMaxSizeLimit = searchRequest.ignoreSizeLimit ?? false; req.keyDeserializer = searchRequest.keyDeserializer; req.valueDeserializer = searchRequest.valueDeserializer; + req.schemaContext = searchRequest.schemaContext ?? ''; // For StartOffset = Newest and any set push-down filter we need to bump the default timeout // from 30s to 30 minutes before ending the request gracefully. @@ -2864,6 +2865,7 @@ export type MessageSearchRequest = { keyDeserializer?: PayloadEncoding; valueDeserializer?: PayloadEncoding; + schemaContext?: string; // '' resolves the topic's context, '.' forces the default. }; async function parseOrUnwrap(response: Response, text: string | null): Promise { diff --git a/frontend/src/state/ui.ts b/frontend/src/state/ui.ts index 17b2040645..68eb7c6107 100644 --- a/frontend/src/state/ui.ts +++ b/frontend/src/state/ui.ts @@ -129,6 +129,7 @@ export const DEFAULT_SEARCH_PARAMS = { keyDeserializer: PayloadEncoding.UNSPECIFIED as PayloadEncoding, valueDeserializer: PayloadEncoding.UNSPECIFIED as PayloadEncoding, + schemaContext: '' as string, }; export type TopicMessageSearchSettings = TopicDetailsSettings['searchParams']; diff --git a/frontend/src/stores/topic-settings-store.ts b/frontend/src/stores/topic-settings-store.ts index 5cff4b3809..8fb66ca766 100644 --- a/frontend/src/stores/topic-settings-store.ts +++ b/frontend/src/stores/topic-settings-store.ts @@ -66,6 +66,7 @@ export type TopicSearchParams = { filters: FilterEntry[]; keyDeserializer: PayloadEncoding; valueDeserializer: PayloadEncoding; + schemaContext: string; // '' resolves the topic's context, '.' forces the default. }; /** @@ -170,6 +171,7 @@ const DEFAULT_SEARCH_PARAMS: TopicSearchParams = { filters: [], keyDeserializer: PayloadEncoding.UNSPECIFIED, valueDeserializer: PayloadEncoding.UNSPECIFIED, + schemaContext: '', }; // Helper function to create default topic settings diff --git a/proto/redpanda/api/console/v1alpha1/list_messages.proto b/proto/redpanda/api/console/v1alpha1/list_messages.proto index 656624dd25..0c0a363d0f 100644 --- a/proto/redpanda/api/console/v1alpha1/list_messages.proto +++ b/proto/redpanda/api/console/v1alpha1/list_messages.proto @@ -38,6 +38,7 @@ message ListMessagesRequest { // Used to force returning deserialized payloads. string page_token = 13; // Resume from cursor (only used when page_size is present). + string schema_context = 15; // Optional Schema Registry context to resolve schema IDs in. Empty uses the topic's redpanda.schema.registry.context, then the default context; "." forces the default context. int32 page_size = 14 [(buf.validate.field).int32 = { gte: 1 lte: 500 diff --git a/proto/redpanda/api/console/v1alpha1/publish_messages.proto b/proto/redpanda/api/console/v1alpha1/publish_messages.proto index 197575e01a..bc11f5206f 100644 --- a/proto/redpanda/api/console/v1alpha1/publish_messages.proto +++ b/proto/redpanda/api/console/v1alpha1/publish_messages.proto @@ -26,6 +26,7 @@ message PublishMessagePayloadOptions { optional int32 schema_id = 9; // Optional schema ID. optional int32 index = 10; // Deprecated single-index. Prefer index_path for Protobuf messages so nested types are addressable. repeated int32 index_path = 11; // Optional message-index path for Protobuf. Each element selects the Nth nested MessageDescriptor; e.g. [0] = first top-level, [1, 0] = first nested message of the second top-level. Empty = first top-level. + string schema_context = 12; // Optional Schema Registry context of schema_id. Empty uses the topic's context, then the default context. } // PublishMessageResponse is the response for PublishMessage call. @@ -41,6 +42,7 @@ message PublishMessageResponse { message GenerateSchemaSampleRequest { int32 schema_id = 1 [(buf.validate.field).int32.gt = 0]; repeated int32 index_path = 2; + string schema_context = 3; // Optional Schema Registry context of schema_id. Empty means the default context. } // GenerateSchemaSampleResponse returns the JSON skeleton.