Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions backend/pkg/console/schema_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -682,24 +682,27 @@ func (s *Service) CreateSchemaRegistrySchema(ctx context.Context, subjectName st
ctx = sr.WithParams(ctx, sr.Normalize)
}

subjectSchema, err := srClient.CreateSchema(ctx, subjectName, schema)
// Use RegisterSchema instead of CreateSchema to avoid a follow-up
// SchemaUsagesByID call that fails for named contexts (schema IDs
// are context-scoped, but the lookup doesn't include context).
schemaID, err := srClient.RegisterSchema(ctx, subjectName, schema, -1, -1)
if err != nil {
// If metadata was included and we got a parse error, retry without metadata.
// Older Redpanda versions don't support the metadata field.
if schema.SchemaMetadata != nil {
s.logger.WarnContext(ctx, "retrying schema creation without metadata (unsupported by this Redpanda version)",
slog.String("subject", subjectName))
schema.SchemaMetadata = nil
subjectSchema, err = srClient.CreateSchema(ctx, subjectName, schema)
schemaID, err = srClient.RegisterSchema(ctx, subjectName, schema, -1, -1)
if err != nil {
return nil, err
}
return &CreateSchemaResponse{ID: subjectSchema.ID}, nil
return &CreateSchemaResponse{ID: schemaID}, nil
}
return nil, err
}

return &CreateSchemaResponse{ID: subjectSchema.ID}, nil
return &CreateSchemaResponse{ID: schemaID}, nil
}

// SchemaRegistrySchemaValidation is the response to a schema validation.
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/layout/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ function AppPageHeader() {
className={cn('mr-2', lastBreadcrumb.options?.canBeTruncated ? 'break-spaces break-all' : 'nowrap')}
level={1}
>
{lastBreadcrumb.title}
{lastBreadcrumb.titleNode ?? lastBreadcrumb.title}
</Heading>
) : null}
{lastBreadcrumb ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* 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 { Text } from 'components/redpanda-ui/components/typography';

import PageContent from '../../misc/page-content';

export function ContextsNotSupportedPage() {
return (
<PageContent>
<div className="flex flex-col items-center gap-4" data-testid="contexts-not-supported">
<Text className="font-bold text-lg">Not Supported</Text>
<Text className="text-center">Schema Registry contexts are not supported in this cluster.</Text>
</div>
</PageContent>
);
}
12 changes: 1 addition & 11 deletions frontend/src/components/pages/schemas/edit-compatibility.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { useNavigate } from '@tanstack/react-router';
import { InfoIcon } from 'components/icons';
import { type FC, useCallback, useEffect, useMemo, useState } from 'react';

import { ContextsNotSupportedPage } from './contexts-not-supported-page';
import { getFormattedSchemaText, schemaTypeToCodeBlockLanguage } from './schema-details';
import { SchemaNotConfiguredPage } from './schema-not-configured';
import {
Expand Down Expand Up @@ -371,14 +372,3 @@ function EditSchemaCompatibility(p: {
</>
);
}

function ContextsNotSupportedPage() {
return (
<PageContent>
<div className="flex flex-col items-center gap-4" data-testid="contexts-not-supported">
<Text className="font-bold text-lg">Not Supported</Text>
<Text className="text-center">Schema Registry contexts are not supported in this cluster.</Text>
</div>
</PageContent>
);
}
12 changes: 1 addition & 11 deletions frontend/src/components/pages/schemas/edit-mode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { Text } from 'components/redpanda-ui/components/typography';
import { type FC, useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';

import { ContextsNotSupportedPage } from './contexts-not-supported-page';
import { getFormattedSchemaText, schemaTypeToCodeBlockLanguage } from './schema-details';
import { SchemaNotConfiguredPage } from './schema-not-configured';
import {
Expand Down Expand Up @@ -295,14 +296,3 @@ function EditSchemaMode({
</div>
);
}

function ContextsNotSupportedPage() {
return (
<PageContent>
<div className="flex flex-col items-center gap-4" data-testid="contexts-not-supported">
<Text className="font-bold text-lg">Not Supported</Text>
<Text className="text-center">Schema Registry contexts are not supported in this cluster.</Text>
</div>
</PageContent>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import { describe, expect, test } from 'vitest';

import {
ALL_CONTEXT_ID,
buildQualifiedReferences,
buildQualifiedSubjectName,
contextNameToId,
DEFAULT_CONTEXT_ID,
deriveContexts,
isNamedContext,
Expand Down Expand Up @@ -173,7 +176,43 @@ describe('isNamedContext', () => {
});

test('empty string is a named context', () => {
expect(isNamedContext('')).toBe(true);
expect(isNamedContext('')).toBe(false);
});
});

describe('buildQualifiedSubjectName', () => {
test('returns plain subject name for default context', () => {
expect(buildQualifiedSubjectName(DEFAULT_CONTEXT_ID, 'my-topic')).toBe('my-topic');
});

test('returns qualified name for named context', () => {
expect(buildQualifiedSubjectName('.staging', 'my-topic')).toBe(':.staging:my-topic');
});

test('returns empty string when subject is empty', () => {
expect(buildQualifiedSubjectName('.staging', '')).toBe('');
});

test('returns empty string for default context with empty subject', () => {
expect(buildQualifiedSubjectName(DEFAULT_CONTEXT_ID, '')).toBe('');
});

test('returns plain name for ALL_CONTEXT_ID', () => {
expect(buildQualifiedSubjectName(ALL_CONTEXT_ID, 'my-topic')).toBe('my-topic');
});
});

describe('contextNameToId', () => {
test('maps "default" to DEFAULT_CONTEXT_ID', () => {
expect(contextNameToId('default')).toBe(DEFAULT_CONTEXT_ID);
});

test('passes through dot-prefixed names unchanged', () => {
expect(contextNameToId('.staging')).toBe('.staging');
});

test('prepends dot to bare context names', () => {
expect(contextNameToId('prod')).toBe('.prod');
});
});

Expand All @@ -190,3 +229,40 @@ describe('pluralize', () => {
expect(pluralize(3, 'subject')).toBe('3 subjects');
});
});

describe('buildQualifiedReferences', () => {
const ref = (context: string, subject: string) => ({
name: 'ref1',
subject,
version: 1,
context,
});

test('default parent + default ref → unqualified subject', () => {
const result = buildQualifiedReferences([ref(DEFAULT_CONTEXT_ID, 'aaaa')], DEFAULT_CONTEXT_ID);
expect(result).toEqual([{ name: 'ref1', subject: 'aaaa', version: 1 }]);
});

test('named parent + same-context ref → qualified with context', () => {
const result = buildQualifiedReferences([ref('.supertest', 'aaaa')], '.supertest');
expect(result).toEqual([{ name: 'ref1', subject: ':.supertest:aaaa', version: 1 }]);
});

test('named parent + default ref → explicitly qualified as `:.:subject`', () => {
const result = buildQualifiedReferences([ref(DEFAULT_CONTEXT_ID, 'aaaa')], '.supertest');
expect(result).toEqual([{ name: 'ref1', subject: ':.:aaaa', version: 1 }]);
});

test('named parent + empty-string context ref → explicitly qualified as `:.:subject`', () => {
const result = buildQualifiedReferences([ref('', 'aaaa')], '.supertest');
expect(result).toEqual([{ name: 'ref1', subject: ':.:aaaa', version: 1 }]);
});

test('filters out refs with missing name or subject', () => {
const result = buildQualifiedReferences(
[ref(DEFAULT_CONTEXT_ID, ''), { name: '', subject: 'aaaa', version: 1, context: DEFAULT_CONTEXT_ID }],
DEFAULT_CONTEXT_ID
);
expect(result).toEqual([]);
});
});
53 changes: 51 additions & 2 deletions frontend/src/components/pages/schemas/schema-context-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export function parseSubjectContext(name: string): ParsedSubject {

export const ALL_CONTEXT_ID = '__all__';
export const DEFAULT_CONTEXT_ID = '__default__';
export const DEFAULT_CONTEXT_LABEL = 'Default';

export type DerivedContext = {
id: string;
Expand Down Expand Up @@ -74,7 +75,7 @@ export function deriveContexts(
defaultContext = ctx;
contexts.push({
id: DEFAULT_CONTEXT_ID,
label: 'Default',
label: DEFAULT_CONTEXT_LABEL,
subjectCount: countByContext.get(DEFAULT_CONTEXT_ID) ?? 0,
mode: ctx.mode,
compatibility: ctx.compatibility,
Expand Down Expand Up @@ -111,7 +112,55 @@ export function deriveContexts(
// True for actual SR contexts (not the synthetic
// "All" or "Default" entries).
export function isNamedContext(contextId: string): boolean {
return contextId !== ALL_CONTEXT_ID && contextId !== DEFAULT_CONTEXT_ID;
return contextId != '' && contextId !== ALL_CONTEXT_ID && contextId !== DEFAULT_CONTEXT_ID;
}

// Convert a raw context name (e.g. from a URL param or parseSubjectContext)
// into the internal context ID used by the editor state.
// ".staging" → ".staging", "default" → DEFAULT_CONTEXT_ID, "prod" → ".prod"
export function contextNameToId(name: string): string {
if (name === 'default') return DEFAULT_CONTEXT_ID;
if (name.startsWith('.')) return name;
return `.${name}`;
}

// Build a qualified subject name from context + subject.
// Named contexts (e.g. ".staging") → ":.staging:subject"
// Default context → plain "subject"
export function buildQualifiedSubjectName(contextId: string, subjectName: string): string {
if (!subjectName) return '';
if (isNamedContext(contextId)) return `:${contextId}:${subjectName}`;
return subjectName;
}

// Map between internal context IDs and display labels.
// DEFAULT_CONTEXT_ID ('__default__') ↔ 'Default'; all others pass through.
export function contextIdToLabel(contextId: string): string {
return contextId === DEFAULT_CONTEXT_ID ? DEFAULT_CONTEXT_LABEL : contextId;
}

export function contextLabelToId(label: string): string {
return label === DEFAULT_CONTEXT_LABEL ? DEFAULT_CONTEXT_ID : label;
}

// Build qualified references for API calls (create/validate).
// When the parent subject is in a named context and a reference targets the
// default context, explicitly qualify it as `:.:subject` so the SR doesn't
// auto-prefix with the parent's context.
export function buildQualifiedReferences(
refs: { name: string; subject: string; version: number; context: string }[],
parentContext: string
): { name: string; subject: string; version: number }[] {
return refs
.filter((x) => x.name && x.subject)
.map((r) => ({
name: r.name,
subject:
isNamedContext(parentContext) && !isNamedContext(r.context)
? buildQualifiedSubjectName('.', r.subject)
: buildQualifiedSubjectName(r.context, r.subject),
version: r.version,
}));
}

// Simple English pluralization: 1 subject / 3 subjects.
Expand Down
Loading
Loading