Skip to content

Commit 4486331

Browse files
authored
feat(schema-registry): Schema metadata UI improvements (#2160)
* feat(schema-registry): improve schema metadata UI - Add Properties subheading under Metadata section - Display metadata properties in a table format - Load existing metadata when adding new schema version - Fix version selection to show latest after creating new version - Invalidate React Query cache after schema creation * feat(schema-registry): add metadata support to schema responses - Add SchemaMetadata struct with tags, properties, and sensitive fields - Map metadata from franz-go schema responses - Add integration tests for metadata handling * fix: resolve golangci-lint errors for schema registry - Fix gci import ordering by grouping redpanda-data imports separately - Remove redundant embedded field .Schema from selectors (staticcheck QF1008) - Fix comment formatting for gofumpt compliance * chore: remove console.log statements from schema-create Remove debug console.log calls per code standards which only allow console.error and console.warn for actionable errors. * fix: graceful handling of parsing error if schema metadata is used against old version of redpanda that does not support metadata. * fix: graceful handling of parsing error if schema metadata is used against old version of redpanda that does not support metadata.
1 parent c5f454e commit 4486331

File tree

5 files changed

+287
-19
lines changed

5 files changed

+287
-19
lines changed

backend/pkg/api/handle_schema_registry_integration_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,3 +337,93 @@ enum MyEnumA {
337337
assert.Equal(thirdSchemaID, fourthSchemaID, "with normalize=true, schemas with different enum value order should produce the same schema ID")
338338
})
339339
}
340+
341+
func (s *APIIntegrationTestSuite) TestSchemaMetadata() {
342+
t := s.T()
343+
t.Skip() // todo remove skip once redpanda v26.1 is GA
344+
require := require.New(t)
345+
assert := assert.New(t)
346+
347+
t.Run("create schema with metadata properties", func(t *testing.T) {
348+
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
349+
defer cancel()
350+
351+
schemaStr := `{"type":"record","name":"User","fields":[{"name":"id","type":"string"}]}`
352+
req := struct {
353+
Schema string `json:"schema"`
354+
Type string `json:"schemaType"`
355+
Metadata struct {
356+
Properties map[string]string `json:"properties"`
357+
} `json:"metadata"`
358+
}{
359+
Schema: schemaStr,
360+
Type: sr.TypeAvro.String(),
361+
}
362+
req.Metadata.Properties = map[string]string{
363+
"owner": "team-platform",
364+
"version": "1.0.0",
365+
}
366+
367+
res, body := s.apiRequest(ctx, http.MethodPost, "/api/schema-registry/subjects/test-metadata/versions", req)
368+
require.Equal(200, res.StatusCode)
369+
370+
createResponse := struct {
371+
ID int `json:"id"`
372+
}{}
373+
err := json.Unmarshal(body, &createResponse)
374+
require.NoError(err)
375+
assert.Greater(createResponse.ID, 0, "schema ID should be returned")
376+
})
377+
378+
t.Run("retrieve schema with metadata", func(t *testing.T) {
379+
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
380+
defer cancel()
381+
382+
res, body := s.apiRequest(ctx, http.MethodGet, "/api/schema-registry/subjects/test-metadata/versions/latest", nil)
383+
require.Equal(200, res.StatusCode)
384+
385+
var details console.SchemaRegistrySubjectDetails
386+
err := json.Unmarshal(body, &details)
387+
require.NoError(err)
388+
389+
// Verify metadata is present in response
390+
require.Len(details.Schemas, 1, "should have one schema")
391+
require.NotNil(details.Schemas[0].Metadata, "metadata should not be nil")
392+
assert.Equal("team-platform", details.Schemas[0].Metadata.Properties["owner"], "owner property should match")
393+
assert.Equal("1.0.0", details.Schemas[0].Metadata.Properties["version"], "version property should match")
394+
})
395+
396+
t.Run("create schema without metadata (backward compatibility)", func(t *testing.T) {
397+
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
398+
defer cancel()
399+
400+
schemaStr := `{"type":"record","name":"Event","fields":[{"name":"id","type":"string"}]}`
401+
req := struct {
402+
Schema string `json:"schema"`
403+
Type string `json:"schemaType"`
404+
}{
405+
Schema: schemaStr,
406+
Type: sr.TypeAvro.String(),
407+
}
408+
409+
res, body := s.apiRequest(ctx, http.MethodPost, "/api/schema-registry/subjects/test-no-metadata/versions", req)
410+
require.Equal(200, res.StatusCode)
411+
412+
createResponse := struct {
413+
ID int `json:"id"`
414+
}{}
415+
err := json.Unmarshal(body, &createResponse)
416+
require.NoError(err)
417+
assert.Greater(createResponse.ID, 0, "schema ID should be returned")
418+
419+
// Verify schema without metadata retrieves correctly
420+
res, body = s.apiRequest(ctx, http.MethodGet, "/api/schema-registry/subjects/test-no-metadata/versions/latest", nil)
421+
require.Equal(200, res.StatusCode)
422+
423+
var details console.SchemaRegistrySubjectDetails
424+
err = json.Unmarshal(body, &details)
425+
require.NoError(err)
426+
require.Len(details.Schemas, 1, "should have one schema")
427+
assert.Nil(details.Schemas[0].Metadata, "metadata should be nil for schema without metadata")
428+
})
429+
}

backend/pkg/console/schema_registry.go

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -186,13 +186,24 @@ func mapSubjectSchema(in sr.SubjectSchema, isSoftDeleted bool) SchemaRegistryVer
186186
Version: ref.Version,
187187
}
188188
}
189+
190+
var metadata *SchemaMetadata
191+
if in.SchemaMetadata != nil {
192+
metadata = &SchemaMetadata{
193+
Tags: in.SchemaMetadata.Tags,
194+
Properties: in.SchemaMetadata.Properties,
195+
Sensitive: in.SchemaMetadata.Sensitive,
196+
}
197+
}
198+
189199
return SchemaRegistryVersionedSchema{
190200
ID: in.ID,
191201
Version: in.Version,
192202
IsSoftDeleted: isSoftDeleted,
193203
Type: in.Type,
194204
Schema: in.Schema.Schema,
195205
References: references,
206+
Metadata: metadata,
196207
}
197208
}
198209

@@ -395,12 +406,13 @@ func (s *Service) getSubjectCompatibilityLevel(ctx context.Context, srClient *rp
395406

396407
// SchemaRegistryVersionedSchema describes a retrieved schema.
397408
type SchemaRegistryVersionedSchema struct {
398-
ID int `json:"id"`
399-
Version int `json:"version"`
400-
IsSoftDeleted bool `json:"isSoftDeleted"`
401-
Type sr.SchemaType `json:"type"`
402-
Schema string `json:"schema"`
403-
References []Reference `json:"references"`
409+
ID int `json:"id"`
410+
Version int `json:"version"`
411+
IsSoftDeleted bool `json:"isSoftDeleted"`
412+
Type sr.SchemaType `json:"type"`
413+
Schema string `json:"schema"`
414+
References []Reference `json:"references"`
415+
Metadata *SchemaMetadata `json:"metadata,omitempty"`
404416
}
405417

406418
// Reference describes a reference to a different schema stored in the schema registry.
@@ -410,6 +422,13 @@ type Reference struct {
410422
Version int `json:"version"`
411423
}
412424

425+
// SchemaMetadata contains metadata associated with a schema version.
426+
type SchemaMetadata struct {
427+
Tags map[string][]string `json:"tags,omitempty"`
428+
Properties map[string]string `json:"properties,omitempty"`
429+
Sensitive []string `json:"sensitive,omitempty"`
430+
}
431+
413432
// GetSchemaRegistrySchema retrieves a schema for a given subject, version tuple from the
414433
// schema registry. You can use -1 as the version to return the latest schema,
415434
func (s *Service) GetSchemaRegistrySchema(ctx context.Context, subjectName string, version int, showSoftDeleted bool) (*SchemaRegistryVersionedSchema, error) {
@@ -585,6 +604,18 @@ func (s *Service) CreateSchemaRegistrySchema(ctx context.Context, subjectName st
585604

586605
subjectSchema, err := srClient.CreateSchema(ctx, subjectName, schema)
587606
if err != nil {
607+
// If metadata was included and we got a parse error, retry without metadata.
608+
// Older Redpanda versions don't support the metadata field.
609+
if schema.SchemaMetadata != nil {
610+
s.logger.WarnContext(ctx, "retrying schema creation without metadata (unsupported by this Redpanda version)",
611+
slog.String("subject", subjectName))
612+
schema.SchemaMetadata = nil
613+
subjectSchema, err = srClient.CreateSchema(ctx, subjectName, schema)
614+
if err != nil {
615+
return nil, err
616+
}
617+
return &CreateSchemaResponse{ID: subjectSchema.ID}, nil
618+
}
588619
return nil, err
589620
}
590621

frontend/src/components/pages/schemas/schema-create.tsx

Lines changed: 96 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ import {
2323
IconButton,
2424
Input,
2525
RadioGroup,
26+
Text,
2627
useToast,
2728
} from '@redpanda-data/ui';
29+
import { useQueryClient } from '@tanstack/react-query';
2830
import { TrashIcon } from 'components/icons';
2931
import { InfoIcon } from 'lucide-react';
3032
import { observable } from 'mobx';
@@ -150,6 +152,16 @@ export class SchemaAddVersionPage extends PageComponent<{ subjectName: string }>
150152
this.editorState.references = schema.references;
151153
this.editorState.strategy = 'CUSTOM';
152154
this.editorState.userInput = subject.name;
155+
156+
// Load existing metadata properties for editing
157+
if (schema.metadata?.properties) {
158+
this.editorState.metadataProperties = Object.entries(schema.metadata.properties).map(([key, value]) => ({
159+
key,
160+
value,
161+
}));
162+
// Add an empty row for adding new properties
163+
this.editorState.metadataProperties.push({ key: '', value: '' });
164+
}
153165
}
154166

155167
return (
@@ -176,6 +188,7 @@ const SchemaPageButtons = observer(
176188
editorState: SchemaEditorStateHelper;
177189
}) => {
178190
const toast = useToast();
191+
const queryClient = useQueryClient();
179192
const [isValidating, setValidating] = useState(false);
180193
const [isCreating, setCreating] = useState(false);
181194
const [persistentValidationError, setPersistentValidationError] = useState<{
@@ -243,11 +256,12 @@ const SchemaPageButtons = observer(
243256
setCreating(true);
244257
try {
245258
const subjectName = editorState.computedSubjectName;
246-
const r = await api
259+
await api
247260
.createSchema(editorState.computedSubjectName, {
248261
schemaType: editorState.format as SchemaTypeType,
249262
schema: editorState.schemaText,
250263
references: editorState.references.filter((x) => x.name && x.subject),
264+
metadata: editorState.computedMetadata,
251265
params: {
252266
normalize: editorState.normalize,
253267
},
@@ -256,19 +270,14 @@ const SchemaPageButtons = observer(
256270

257271
await api.refreshSchemaDetails(subjectName, true);
258272

259-
// success: navigate to details
260-
const latestVersion = api.schemaDetails.get(subjectName)?.latestActiveVersion;
261-
// biome-ignore lint/suspicious/noConsole: intentional console usage
262-
console.log('schema created', { response: r });
263-
// biome-ignore lint/suspicious/noConsole: intentional console usage
264-
console.log('navigating to details', { subjectName, latestVersion });
265-
appGlobal.historyReplace(
266-
`/schema-registry/subjects/${encodeURIComponent(subjectName)}?version=${latestVersion}`
267-
);
273+
// Invalidate React Query cache so details page shows latest data
274+
await queryClient.invalidateQueries({
275+
queryKey: ['schemaRegistry', 'subjects', subjectName, 'details'],
276+
});
277+
278+
// success: navigate to details with "latest" so it picks up the new version
279+
appGlobal.historyReplace(`/schema-registry/subjects/${encodeURIComponent(subjectName)}?version=latest`);
268280
} catch (err) {
269-
// error: open modal
270-
// biome-ignore lint/suspicious/noConsole: intentional console usage
271-
console.log('failed to create schema', { err });
272281
toast({
273282
status: 'error',
274283
duration: undefined,
@@ -547,6 +556,16 @@ const SchemaEditor = observer((p: { state: SchemaEditorStateHelper; mode: 'CREAT
547556
{/* <Text>This is an example help text about the references list, to be updated later</Text> */}
548557

549558
<ReferencesEditor state={state} />
559+
560+
<Heading mt="8" variant="lg">
561+
Schema metadata
562+
</Heading>
563+
<Text>
564+
Optional key-value properties to associate with this schema. Metadata will be ignored if not supported by
565+
schema registry.
566+
</Text>
567+
568+
<MetadataPropertiesEditor state={state} />
550569
</Flex>
551570
</>
552571
);
@@ -636,6 +655,59 @@ const ReferencesEditor = observer((p: { state: SchemaEditorStateHelper }) => {
636655
);
637656
});
638657

658+
const MetadataPropertiesEditor = observer((p: { state: SchemaEditorStateHelper }) => {
659+
const { state } = p;
660+
const props = state.metadataProperties;
661+
662+
const renderRow = (prop: { key: string; value: string }, index: number) => (
663+
<Flex alignItems="flex-end" gap="4" key={index}>
664+
<FormField label="Key">
665+
<Input
666+
data-testid={`schema-create-metadata-key-input-${index}`}
667+
onChange={(e) => {
668+
prop.key = e.target.value;
669+
}}
670+
placeholder="e.g. owner"
671+
value={prop.key}
672+
/>
673+
</FormField>
674+
<FormField label="Value">
675+
<Input
676+
data-testid={`schema-create-metadata-value-input-${index}`}
677+
onChange={(e) => {
678+
prop.value = e.target.value;
679+
}}
680+
placeholder="e.g. team-platform"
681+
value={prop.value}
682+
/>
683+
</FormField>
684+
<IconButton
685+
aria-label="delete"
686+
data-testid={`schema-create-metadata-delete-btn-${index}`}
687+
icon={<TrashIcon fontSize="19px" />}
688+
onClick={() => props.remove(prop)}
689+
variant="ghost"
690+
/>
691+
</Flex>
692+
);
693+
694+
return (
695+
<Flex direction="column" gap="4">
696+
{props.map((x, index) => renderRow(x, index))}
697+
698+
<Button
699+
data-testid="schema-create-add-metadata-btn"
700+
onClick={() => props.push({ key: '', value: '' })}
701+
size="sm"
702+
variant="outline"
703+
width="fit-content"
704+
>
705+
Add property
706+
</Button>
707+
</Flex>
708+
);
709+
});
710+
639711
function createSchemaState() {
640712
return observable({
641713
strategy: 'TOPIC' as
@@ -654,6 +726,17 @@ function createSchemaState() {
654726
version: number;
655727
}[],
656728
normalize: false,
729+
metadataProperties: [{ key: '', value: '' }] as { key: string; value: string }[],
730+
731+
get computedMetadata(): { properties: Record<string, string> } | undefined {
732+
const properties: Record<string, string> = {};
733+
for (const prop of this.metadataProperties) {
734+
if (prop.key && prop.value) {
735+
properties[prop.key] = prop.value;
736+
}
737+
}
738+
return Object.keys(properties).length > 0 ? { properties } : undefined;
739+
},
657740

658741
get isInvalidKeyOrValue() {
659742
return this.strategy === 'TOPIC' && this.userInput.length > 0 && !this.keyOrValue;

0 commit comments

Comments
 (0)