Skip to content

Commit 3c734f5

Browse files
authored
feat(produce): schema-aware Produce Record backend (UX-1292) (#2461)
* feat(produce): schema-aware Produce Record backend (UX-1292) Adds schema-introspection + sample-generator infrastructure for the schema-aware Produce Record flow: - proto: new GenerateSchemaSample RPC; extended PublishMessagePayloadOptions with optional schema_id / index_path so the wire format can address nested Protobuf messages. - backend/pkg/schemasample: schema-type-aware sample JSON generator (Avro / Protobuf / JSON Schema), unit-tested. - backend/pkg/console: GetSchemaRegistrySubjectDetails now embeds per-version MessageTypes for Protobuf entries (descriptor walk via cachedSchemaClient), so the produce UI can render a typed message-type picker without a second RPC. /schema-registry/schemas endpoint + new GetAllSchemas service method. Per-version message-type resolution extracted to populateProtoMessageTypes helper to keep the parent function under the cyclop ceiling. - backend/pkg/serde: Protobuf+schemaId payloads now dispatch through the schema-registry serde path so registered subjects resolve correctly. * fix(produce): backend review fixes (UX-1292) Addresses adversarial code review findings on PR #2461: - JSON tags on MessageTypeInfo so the REST subject-details response uses fullyQualifiedName/indexPath camelCase. Without tags Go encoded them as FullyQualifiedName/IndexPath, breaking the frontend message-type picker. - avroSamplePrimitiveOrRef no longer double-marks the visited flag. The duplicate-flag bug caused the first reference to a non-recursive named record to render as null instead of expanding the record's fields. - walkMessageTypes skips synthetic map-entry descriptors so map<K,V> field types don't appear as selectable message types in the produce UI. - handleGetAllSchemas clamps Limit to 1000 (and defaults to 1000 when unset) so an unbounded request can't pull MB-sized payloads. - Regression test TestAvro_NonRecursiveRecordReference locks the visited-flag fix. * fix(produce): address PR review nits (UX-1292) - drop redundant `i := i` loop alias (Go 1.22+ scopes per-iteration) - use `slices.Clone` for prefix copy in `walkMessageTypes` - shorten Avro/JSON-Schema constants block comment
1 parent 8d17cb6 commit 3c734f5

23 files changed

Lines changed: 1836 additions & 105 deletions

backend/pkg/api/connect/service/console/mapper.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ func rpcPublishMessagePayloadOptionsToSerializeInput(po *v1alpha.PublishMessageP
5454
encoding = serde.PayloadEncodingProtobufBSR
5555
}
5656

57+
// When the client picks Protobuf together with a schema ID, route through the schema-registry
58+
// path (ProtobufSchemaSerde) instead of the static-config ProtobufSerde — the latter requires
59+
// a configured proto.Service and panics when only schema-registry-backed schemas are in use.
60+
if encoding == serde.PayloadEncodingProtobuf && po.GetSchemaId() > 0 {
61+
encoding = serde.PayloadEncodingProtobufSchema
62+
}
63+
5764
input := &serde.RecordPayloadInput{
5865
Payload: po.GetData(),
5966
Encoding: encoding,
@@ -63,7 +70,13 @@ func rpcPublishMessagePayloadOptionsToSerializeInput(po *v1alpha.PublishMessageP
6370
input.Options = []serde.SerdeOpt{serde.WithSchemaID(uint32(po.GetSchemaId()))}
6471
}
6572

66-
if po.GetIndex() > 0 {
73+
if path := po.GetIndexPath(); len(path) > 0 {
74+
ints := make([]int, len(path))
75+
for i, v := range path {
76+
ints[i] = int(v)
77+
}
78+
input.Options = append(input.Options, serde.WithIndex(ints...))
79+
} else if po.GetIndex() > 0 {
6780
input.Options = append(input.Options, serde.WithIndex(int(po.GetIndex())))
6881
}
6982

backend/pkg/api/connect/service/console/service.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,3 +215,30 @@ func (api *Service) PublishMessage(
215215
},
216216
), nil
217217
}
218+
219+
// GenerateSchemaSample renders a zero-valued JSON skeleton for any Schema
220+
// Registry-backed schema (Avro / Protobuf / JSON Schema). The backend
221+
// dispatches on the registered schema type so the frontend can call a single
222+
// RPC regardless of which encoding the user picked.
223+
func (api *Service) GenerateSchemaSample(
224+
ctx context.Context,
225+
req *connect.Request[v1alpha.GenerateSchemaSampleRequest],
226+
) (*connect.Response[v1alpha.GenerateSchemaSampleResponse], error) {
227+
indexPath := make([]int, 0, len(req.Msg.GetIndexPath()))
228+
for _, v := range req.Msg.GetIndexPath() {
229+
indexPath = append(indexPath, int(v))
230+
}
231+
232+
sample, err := api.consoleSvc.GenerateSchemaSampleJSON(ctx, int(req.Msg.GetSchemaId()), indexPath)
233+
if err != nil {
234+
return nil, apierrors.NewConnectError(
235+
connect.CodeInvalidArgument,
236+
fmt.Errorf("failed to generate schema sample JSON: %w", err),
237+
apierrors.NewErrorInfo(commonv1alpha1.Reason_REASON_INVALID_INPUT.String()),
238+
)
239+
}
240+
241+
return connect.NewResponse(&v1alpha.GenerateSchemaSampleResponse{
242+
SampleJson: string(sample),
243+
}), nil
244+
}

backend/pkg/api/handle_schema_registry.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,83 @@ func (api *API) handleGetSchemaSubjects() http.HandlerFunc {
464464
}
465465
}
466466

467+
func (api *API) handleGetAllSchemas() http.HandlerFunc {
468+
if !api.Cfg.SchemaRegistry.Enabled {
469+
return api.handleSchemaRegistryNotConfigured()
470+
}
471+
472+
parseBool := func(r *http.Request, name string) (bool, error) {
473+
raw := rest.GetQueryParam(r, name)
474+
if raw == "" {
475+
return false, nil
476+
}
477+
v, err := strconv.ParseBool(raw)
478+
if err != nil {
479+
return false, fmt.Errorf("invalid %q query param: %w", name, err)
480+
}
481+
return v, nil
482+
}
483+
parseInt := func(r *http.Request, name string) (int, error) {
484+
raw := rest.GetQueryParam(r, name)
485+
if raw == "" {
486+
return 0, nil
487+
}
488+
v, err := strconv.Atoi(raw)
489+
if err != nil {
490+
return 0, fmt.Errorf("invalid %q query param: %w", name, err)
491+
}
492+
if v < 0 {
493+
return 0, fmt.Errorf("invalid %q query param: must be non-negative", name)
494+
}
495+
return v, nil
496+
}
497+
498+
return func(w http.ResponseWriter, r *http.Request) {
499+
opts := console.GetAllSchemasOptions{
500+
SubjectPrefix: rest.GetQueryParam(r, "subjectPrefix"),
501+
}
502+
var err error
503+
if opts.LatestOnly, err = parseBool(r, "latestOnly"); err != nil {
504+
rest.SendRESTError(w, r, api.Logger, &rest.Error{Err: err, Status: http.StatusBadRequest, Message: err.Error()})
505+
return
506+
}
507+
if opts.Deleted, err = parseBool(r, "deleted"); err != nil {
508+
rest.SendRESTError(w, r, api.Logger, &rest.Error{Err: err, Status: http.StatusBadRequest, Message: err.Error()})
509+
return
510+
}
511+
if opts.DeletedOnly, err = parseBool(r, "deletedOnly"); err != nil {
512+
rest.SendRESTError(w, r, api.Logger, &rest.Error{Err: err, Status: http.StatusBadRequest, Message: err.Error()})
513+
return
514+
}
515+
if opts.Offset, err = parseInt(r, "offset"); err != nil {
516+
rest.SendRESTError(w, r, api.Logger, &rest.Error{Err: err, Status: http.StatusBadRequest, Message: err.Error()})
517+
return
518+
}
519+
if opts.Limit, err = parseInt(r, "limit"); err != nil {
520+
rest.SendRESTError(w, r, api.Logger, &rest.Error{Err: err, Status: http.StatusBadRequest, Message: err.Error()})
521+
return
522+
}
523+
// Defense in depth: cap unbounded fetches. A registry with thousands of schemas would
524+
// otherwise stream MBs of schema text per request.
525+
const maxSchemasLimit = 1000
526+
if opts.Limit == 0 || opts.Limit > maxSchemasLimit {
527+
opts.Limit = maxSchemasLimit
528+
}
529+
530+
res, err := api.ConsoleSvc.GetAllSchemas(r.Context(), opts)
531+
if err != nil {
532+
rest.SendRESTError(w, r, api.Logger, &rest.Error{
533+
Err: err,
534+
Status: http.StatusBadGateway,
535+
Message: fmt.Sprintf("Failed to retrieve schemas from the schema registry: %v", err.Error()),
536+
IsSilent: false,
537+
})
538+
return
539+
}
540+
rest.SendResponse(w, r, api.Logger, http.StatusOK, res)
541+
}
542+
}
543+
467544
func (api *API) handleGetSchemaSubjectDetails() http.HandlerFunc {
468545
if !api.Cfg.SchemaRegistry.Enabled {
469546
return api.handleSchemaRegistryNotConfigured()

backend/pkg/api/routes.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,7 @@ func (api *API) routes() *chi.Mux {
635635
r.Delete("/schema-registry/config/{subject}", api.handleDeleteSchemaRegistrySubjectConfig())
636636
r.Get("/schema-registry/contexts", api.handleGetSchemaRegistryContexts())
637637
r.Get("/schema-registry/subjects", api.handleGetSchemaSubjects())
638+
r.Get("/schema-registry/schemas", api.handleGetAllSchemas())
638639
r.Get("/schema-registry/schemas/types", api.handleGetSchemaRegistrySchemaTypes())
639640
r.Get("/schema-registry/schemas/ids/{id}/versions", api.handleGetSchemaUsagesByID())
640641
r.Delete("/schema-registry/subjects/{subject}", api.handleDeleteSubject())
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright 2026 Redpanda Data, Inc.
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
5+
//
6+
// As of the Change Date specified in that file, in accordance with
7+
// the Business Source License, use of this software will be governed
8+
// by the Apache License, Version 2.0
9+
10+
package console
11+
12+
import (
13+
"context"
14+
"errors"
15+
"fmt"
16+
"slices"
17+
18+
"google.golang.org/protobuf/reflect/protoreflect"
19+
20+
"github.com/redpanda-data/console/backend/pkg/proto"
21+
)
22+
23+
// protoMessageTypesByID walks the descriptor tree of the schema, returning each message paired with
24+
// its Confluent wire-format index path. Goes through cachedSchemaClient so it works without the
25+
// optional static Protobuf service.
26+
func (s *Service) protoMessageTypesByID(ctx context.Context, schemaID int) ([]proto.MessageTypeInfo, error) {
27+
if s.cachedSchemaClient == nil {
28+
return nil, errors.New("schema registry is not configured")
29+
}
30+
31+
files, rootFilename, err := s.cachedSchemaClient.ProtoFilesByID(ctx, schemaID)
32+
if err != nil {
33+
return nil, fmt.Errorf("failed to load proto files for schema %d: %w", schemaID, err)
34+
}
35+
36+
rootFile := files.FindFileByPath(rootFilename)
37+
if rootFile == nil {
38+
return nil, fmt.Errorf("root proto file %q not found for schema %d", rootFilename, schemaID)
39+
}
40+
41+
var out []proto.MessageTypeInfo
42+
walkMessageTypes(rootFile.Messages(), nil, &out)
43+
return out, nil
44+
}
45+
46+
func walkMessageTypes(msgs protoreflect.MessageDescriptors, prefix []int32, out *[]proto.MessageTypeInfo) {
47+
for i := 0; i < msgs.Len(); i++ {
48+
md := msgs.Get(i)
49+
path := append(slices.Clone(prefix), int32(i))
50+
// Synthetic map-entry descriptors (e.g. Foo.LabelsEntry for a map<string,string> field)
51+
// are not user-selectable message types — skip them but keep walking so real siblings
52+
// keep their indices.
53+
if md.IsMapEntry() {
54+
continue
55+
}
56+
*out = append(*out, proto.MessageTypeInfo{
57+
FullyQualifiedName: string(md.FullName()),
58+
IndexPath: path,
59+
})
60+
walkMessageTypes(md.Messages(), path, out)
61+
}
62+
}

backend/pkg/console/schema_registry.go

Lines changed: 132 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import (
2525
"github.com/twmb/franz-go/pkg/sr"
2626
"golang.org/x/exp/slices"
2727
"golang.org/x/sync/errgroup"
28+
29+
"github.com/redpanda-data/console/backend/pkg/proto"
2830
)
2931

3032
// SchemaRegistryMode returns the schema registry mode.
@@ -214,6 +216,101 @@ func (s *Service) GetSchemaRegistrySubjects(ctx context.Context, subjectPrefix s
214216
return result, nil
215217
}
216218

219+
// SchemaRegistrySchema is a single (subject, version) entry returned by the
220+
// registry's GET /schemas endpoint. The shape mirrors sr.SubjectSchema so the
221+
// payload is suitable for a future dataplane API surface.
222+
type SchemaRegistrySchema struct {
223+
Subject string `json:"subject"`
224+
Version int `json:"version"`
225+
ID int `json:"id"`
226+
Type sr.SchemaType `json:"type"`
227+
Schema string `json:"schema,omitempty"`
228+
References []Reference `json:"references,omitempty"`
229+
Metadata *SchemaMetadata `json:"metadata,omitempty"`
230+
}
231+
232+
// GetAllSchemasOptions controls the query parameters forwarded to the schema
233+
// registry's GET /schemas endpoint.
234+
type GetAllSchemasOptions struct {
235+
SubjectPrefix string
236+
LatestOnly bool
237+
Deleted bool // include soft-deleted entries
238+
DeletedOnly bool
239+
Offset int
240+
Limit int
241+
}
242+
243+
// GetAllSchemas lists schemas from the registry's GET /schemas endpoint and
244+
// returns the full (subject, version, id, type, schema, references, metadata)
245+
// for each entry. Callers control filtering and pagination via opts.
246+
func (s *Service) GetAllSchemas(ctx context.Context, opts GetAllSchemasOptions) ([]SchemaRegistrySchema, error) {
247+
srClient, err := s.schemaClientFactory.GetSchemaRegistryClient(ctx)
248+
if err != nil {
249+
return nil, err
250+
}
251+
252+
params := make([]sr.Param, 0, 6)
253+
if opts.SubjectPrefix != "" {
254+
params = append(params, sr.SubjectPrefix(opts.SubjectPrefix))
255+
}
256+
if opts.LatestOnly {
257+
params = append(params, sr.LatestOnly)
258+
}
259+
if opts.Deleted {
260+
params = append(params, sr.ShowDeleted)
261+
}
262+
if opts.DeletedOnly {
263+
params = append(params, sr.DeletedOnly)
264+
}
265+
if opts.Offset > 0 {
266+
params = append(params, sr.Offset(opts.Offset))
267+
}
268+
if opts.Limit > 0 {
269+
params = append(params, sr.Limit(opts.Limit))
270+
}
271+
272+
schemas, err := srClient.AllSchemas(sr.WithParams(ctx, params...))
273+
if err != nil {
274+
return nil, err
275+
}
276+
277+
result := make([]SchemaRegistrySchema, 0, len(schemas))
278+
for _, schema := range schemas {
279+
references := make([]Reference, len(schema.References))
280+
for i, ref := range schema.References {
281+
references[i] = Reference{
282+
Name: ref.Name,
283+
Subject: ref.Subject,
284+
Version: ref.Version,
285+
}
286+
}
287+
var metadata *SchemaMetadata
288+
if schema.SchemaMetadata != nil {
289+
metadata = &SchemaMetadata{
290+
Tags: schema.SchemaMetadata.Tags,
291+
Properties: schema.SchemaMetadata.Properties,
292+
Sensitive: schema.SchemaMetadata.Sensitive,
293+
}
294+
}
295+
result = append(result, SchemaRegistrySchema{
296+
Subject: schema.Subject,
297+
Version: schema.Version,
298+
ID: schema.ID,
299+
Type: schema.Type,
300+
Schema: schema.Schema.Schema,
301+
References: references,
302+
Metadata: metadata,
303+
})
304+
}
305+
slices.SortFunc(result, func(a, b SchemaRegistrySchema) int {
306+
if c := strings.Compare(a.Subject, b.Subject); c != 0 {
307+
return c
308+
}
309+
return a.Version - b.Version
310+
})
311+
return result, nil
312+
}
313+
217314
// SchemaRegistrySubjectDetails represents a schema registry subject along
218315
// with other information such as the registered versions that belong to it,
219316
// or the full schema information that's part of the subject.
@@ -344,6 +441,8 @@ func (s *Service) GetSchemaRegistrySubjectDetails(ctx context.Context, subjectNa
344441
return nil, err
345442
}
346443

444+
s.populateProtoMessageTypes(ctx, subjectName, schemas)
445+
347446
var schemaType sr.SchemaType
348447
if len(schemas) > 0 {
349448
schemaType = schemas[len(schemas)-1].Type
@@ -360,6 +459,31 @@ func (s *Service) GetSchemaRegistrySubjectDetails(ctx context.Context, subjectNa
360459
}, nil
361460
}
362461

462+
// populateProtoMessageTypes resolves message types for every Protobuf version in schemas. Failures
463+
// are non-fatal: a parse error leaves MessageTypes nil rather than failing the parent call.
464+
func (s *Service) populateProtoMessageTypes(ctx context.Context, subjectName string, schemas []SchemaRegistryVersionedSchema) {
465+
grp, grpCtx := errgroup.WithContext(ctx)
466+
grp.SetLimit(10)
467+
for i := range schemas {
468+
if schemas[i].Type != sr.TypeProtobuf {
469+
continue
470+
}
471+
grp.Go(func() error {
472+
types, err := s.protoMessageTypesByID(grpCtx, schemas[i].ID)
473+
if err != nil {
474+
s.logger.WarnContext(grpCtx, "failed to resolve protobuf message types",
475+
slog.String("subject", subjectName),
476+
slog.Int("schemaId", schemas[i].ID),
477+
slog.Any("err", err))
478+
return nil
479+
}
480+
schemas[i].MessageTypes = types
481+
return nil
482+
})
483+
}
484+
_ = grp.Wait()
485+
}
486+
363487
// SchemaRegistrySubjectDetailsVersion represents a schema version and if it's
364488
// soft-deleted or not.
365489
type SchemaRegistrySubjectDetailsVersion struct {
@@ -486,13 +610,14 @@ func (s *Service) getSubjectMode(ctx context.Context, srClient *rpsr.Client, sub
486610

487611
// SchemaRegistryVersionedSchema describes a retrieved schema.
488612
type SchemaRegistryVersionedSchema struct {
489-
ID int `json:"id"`
490-
Version int `json:"version"`
491-
IsSoftDeleted bool `json:"isSoftDeleted"`
492-
Type sr.SchemaType `json:"type"`
493-
Schema string `json:"schema"`
494-
References []Reference `json:"references"`
495-
Metadata *SchemaMetadata `json:"metadata,omitempty"`
613+
ID int `json:"id"`
614+
Version int `json:"version"`
615+
IsSoftDeleted bool `json:"isSoftDeleted"`
616+
Type sr.SchemaType `json:"type"`
617+
Schema string `json:"schema"`
618+
References []Reference `json:"references"`
619+
Metadata *SchemaMetadata `json:"metadata,omitempty"`
620+
MessageTypes []proto.MessageTypeInfo `json:"messageTypes,omitempty"` // Protobuf only.
496621
}
497622

498623
// Reference describes a reference to a different schema stored in the schema registry.

0 commit comments

Comments
 (0)