Skip to content

Commit ee3afa7

Browse files
authored
feat[UX-664]: add detailed schema compatibility error messages (#2031)
* feat: add detailed schema compatibility error messages Parse and display structured error details from schema registry when protobuf schema validation fails. Previously returned only boolean compatibility status without explanation of why schemas are incompatible. Backend changes: - Add schemaRegValidationError struct with errorType and description - Parse verbose compatibility messages from schema registry API - Handle invalid JSON format returned by schema registry Frontend changes: - Update validation response types to include structured error object - Display formatted compatibility errors with human-readable labels - Hide error details section when no error information available * test: add comprehensive schema registry compatibility error parser tests - Document parser behavior with real Confluent/Redpanda response format - Test handling of unquoted JSON keys in compatibility messages - Verify regex transformation for invalid JSON structure - Add edge case coverage for malformed messages * refactor: improve schema registry compatibility error parser - Replace string-based JSON fixing with regex approach for better accuracy - Use regex pattern (\w+): to quote unquoted keys consistently - Add structured logging for parse failures with original/fixed messages - Make parseCompatibilityError a Service method for logger access * test: update integration tests for schema registry compatibility - Upgrade Redpanda test container from v23.3.18 to v25.2.10 - Add integration test for incompatible protobuf schema changes - Verify error type and description extraction from compatibility response - Test field type change detection (string to int32) * feat(ui): add persistent error banner for schema validation failures - Add dismissible alert banner that persists validation errors after modal close - Update openValidationErrorsModal to support onClose callback - Display error type and description in banner above action buttons - Auto-clear banner on successful validation or schema creation - Improve UX by keeping error visible while user fixes schema
1 parent 1f5ff7d commit ee3afa7

File tree

7 files changed

+329
-107
lines changed

7 files changed

+329
-107
lines changed

backend/pkg/api/api_integration_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ func (s *APIIntegrationTestSuite) SetupSuite() {
6262
require := require.New(t)
6363

6464
ctx := context.Background()
65-
container, err := redpanda.Run(ctx, "redpandadata/redpanda:v23.3.18")
65+
container, err := redpanda.Run(ctx, "redpandadata/redpanda:v25.2.10")
6666
require.NoError(err)
6767
s.redpandaContainer = container
6868

backend/pkg/api/handle_schema_registry_integration_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,4 +123,45 @@ func (s *APIIntegrationTestSuite) TestValidateSchema() {
123123
assert.False(validationResponse.IsValid)
124124
assert.NotEmpty(validationResponse.ParsingError, "parsing error should be set")
125125
})
126+
127+
t.Run("incompatible schema change (protobuf)", func(t *testing.T) {
128+
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
129+
defer cancel()
130+
131+
// First, register a schema with string year field
132+
originalSchema := "syntax = \"proto3\";\n\npackage test.v1;\n\nmessage Car {\n string make = 1;\n string model = 2;\n string year = 3;\n}\n"
133+
registerReq := struct {
134+
Schema string `json:"schema"`
135+
Type string `json:"schemaType"`
136+
}{
137+
Schema: originalSchema,
138+
Type: sr.TypeProtobuf.String(),
139+
}
140+
registerRes, _ := s.apiRequest(ctx, http.MethodPost, "/api/schema-registry/subjects/test-car-compat/versions", registerReq)
141+
require.Equal(200, registerRes.StatusCode)
142+
143+
// Now try to validate an incompatible change: year field changed from string to int32
144+
incompatibleSchema := "syntax = \"proto3\";\n\npackage test.v1;\n\nmessage Car {\n string make = 1;\n string model = 2;\n int32 year = 3;\n}\n"
145+
validateReq := struct {
146+
Schema string `json:"schema"`
147+
Type string `json:"schemaType"`
148+
}{
149+
Schema: incompatibleSchema,
150+
Type: sr.TypeProtobuf.String(),
151+
}
152+
res, body := s.apiRequest(ctx, http.MethodPost, "/api/schema-registry/subjects/test-car-compat/versions/latest/validate", validateReq)
153+
require.Equal(200, res.StatusCode)
154+
155+
validationResponse := console.SchemaRegistrySchemaValidation{}
156+
err := json.Unmarshal(body, &validationResponse)
157+
require.NoError(err)
158+
159+
// Schema should parse correctly but fail compatibility check
160+
assert.False(validationResponse.IsValid, "schema should be invalid due to compatibility")
161+
assert.Empty(validationResponse.ParsingError, "parsing error should not be set for valid protobuf")
162+
assert.False(validationResponse.Compatibility.IsCompatible, "schema should not be compatible")
163+
assert.NotEmpty(validationResponse.Compatibility.Error.ErrorType, "error type should be set")
164+
assert.NotEmpty(validationResponse.Compatibility.Error.Description, "error description should be set")
165+
assert.Contains(validationResponse.Compatibility.Error.ErrorType, "FIELD_SCALAR_KIND_CHANGED", "error type should indicate field type change")
166+
})
126167
}

backend/pkg/console/schema_registry.go

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ package console
1111

1212
import (
1313
"context"
14+
"encoding/json"
1415
"errors"
1516
"fmt"
1617
"log/slog"
1718
"net/http"
19+
"regexp"
1820
"strconv"
1921
"strings"
2022

@@ -589,8 +591,47 @@ type SchemaRegistrySchemaValidation struct {
589591
// SchemaRegistrySchemaValidationCompatibility is the response to the compatibility check
590592
// performed by the schema registry.
591593
type SchemaRegistrySchemaValidationCompatibility struct {
592-
IsCompatible bool `json:"isCompatible"`
593-
Error string `json:"error,omitempty"`
594+
IsCompatible bool `json:"isCompatible"`
595+
Error schemaRegValidationError `json:"error,omitempty"`
596+
}
597+
598+
// schemaRegValidationError represents the structure of compatibility messages from schema registry
599+
type schemaRegValidationError struct {
600+
ErrorType string `json:"errorType"`
601+
Description string `json:"description"`
602+
}
603+
604+
// parseCompatibilityError parses schema registry messages and returns formatted user-friendly messages.
605+
// Schema registry may return JSON with unquoted keys that we need to fix before parsing.
606+
func (s *Service) parseCompatibilityError(messages []string) schemaRegValidationError {
607+
if len(messages) == 0 {
608+
return schemaRegValidationError{}
609+
}
610+
611+
// Iterate through all messages to extract errorType and description
612+
data := schemaRegValidationError{}
613+
for _, msg := range messages {
614+
// Schema registry may return invalid JSON with unquoted keys like: {errorType:"...", description:"..."}
615+
// Use regex to quote unquoted keys: word: becomes "word":
616+
fixedMsg := regexp.MustCompile(`(\w+):`).ReplaceAllString(msg, `"$1":`)
617+
618+
err := json.Unmarshal([]byte(fixedMsg), &data)
619+
if err != nil {
620+
s.logger.Warn("failed to parse schema registry compatibility error message",
621+
slog.String("original_message", msg),
622+
slog.String("fixed_message", fixedMsg),
623+
slog.String("error", err.Error()))
624+
continue
625+
}
626+
627+
// Stop once we have either error type or Description we can exit
628+
if data.ErrorType != "" || data.Description != "" {
629+
break
630+
}
631+
}
632+
633+
// Return empty if we couldn't parse anything useful
634+
return data
594635
}
595636

596637
// ValidateSchemaRegistrySchema validates a given schema by checking:
@@ -608,23 +649,27 @@ func (s *Service) ValidateSchemaRegistrySchema(
608649
}
609650

610651
// Compatibility check from schema registry
611-
var compatErr string
652+
var compatErr schemaRegValidationError
612653
var isCompatible bool
613-
compatRes, err := srClient.CheckCompatibility(ctx, subjectName, version, sch)
654+
// Use Verbose parameter to get detailed error messages from schema registry
655+
compatRes, err := srClient.CheckCompatibility(sr.WithParams(ctx, sr.Verbose), subjectName, version, sch)
614656
if err != nil {
615-
compatErr = err.Error()
657+
compatErr.ErrorType = "Client Error"
658+
compatErr.Description = err.Error()
616659

617660
// If subject doesn't exist, we will reset the error, because new subject schemas
618661
// don't have any existing schema and therefore can't be incompatible.
619662
var schemaErr *sr.ResponseError
620663
if errors.As(err, &schemaErr) {
621664
if schemaErr.ErrorCode == 40401 { // Subject not found error code
622-
compatErr = ""
665+
compatErr = schemaRegValidationError{}
623666
isCompatible = true
624667
}
625668
}
626669
} else {
627670
isCompatible = compatRes.Is
671+
// Parse the messages from schema registry to extract only errorType and description
672+
compatErr = s.parseCompatibilityError(compatRes.Messages)
628673
}
629674

630675
var parsingErr string
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright 2025 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+
"log/slog"
14+
"os"
15+
"testing"
16+
17+
"github.com/stretchr/testify/assert"
18+
)
19+
20+
// TestParseCompatibilityError demonstrates why the parser exists and how it handles
21+
// the unusual response format from Confluent Schema Registry.
22+
//
23+
// Schema Registry returns compatibility errors with messages that contain invalid JSON:
24+
// - Keys are unquoted (e.g., {errorType:"..."} instead of {"errorType":"..."})
25+
// - Multiple messages contain different pieces of information
26+
// - Some messages contain schema details, versions, and config that need to be skipped
27+
//
28+
// Example real response from Confluent and Redpanda Schema Registry:
29+
//
30+
// {
31+
// "is_compatible": false,
32+
// "messages": [
33+
// "{errorType:\"FIELD_SCALAR_KIND_CHANGED\", description:\"The kind of a SCALAR field at path '#/Car/2' in the new schema does not match its kind in the old schema\"}",
34+
// "{oldSchemaVersion: 1}",
35+
// "{oldSchema: 'syntax = \"proto3\";\n\nmessage Car {\n string make = 1;\n string model = 2;\n int32 year = 3;\n}\n'}",
36+
// "{compatibility: 'BACKWARD'}"
37+
// ]
38+
// }
39+
func TestParseCompatibilityError_RealResponse(t *testing.T) {
40+
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
41+
s := &Service{logger: logger}
42+
43+
// This is the actual response format from Confluent and Redpanda Schema Registry
44+
// Note: Keys are unquoted, which is invalid JSON
45+
messages := []string{
46+
`{errorType:"FIELD_SCALAR_KIND_CHANGED", description:"The kind of a SCALAR field at path '#/Car/2' in the new schema does not match its kind in the old schema"}`,
47+
`{oldSchemaVersion: 1}`,
48+
`{oldSchema: 'syntax = "proto3";\n\nmessage Car {\n string make = 1;\n string model = 2;\n int32 year = 3;\n}\n'}`,
49+
`{compatibility: 'BACKWARD'}`,
50+
}
51+
52+
result := s.parseCompatibilityError(messages)
53+
54+
// The parser should extract the error type and description from the first message
55+
assert.Equal(t, "FIELD_SCALAR_KIND_CHANGED", result.ErrorType, "Should extract error type from unquoted JSON")
56+
assert.Equal(t, "The kind of a SCALAR field at path '#/Car/2' in the new schema does not match its kind in the old schema", result.Description, "Should extract description")
57+
}

frontend/src/components/pages/schemas/modals.tsx

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,16 @@ export function openInfoModal(p: {
150150
});
151151
}
152152

153-
export function openValidationErrorsModal(result: {
154-
isValid: boolean;
155-
errorDetails?: string | undefined;
156-
isCompatible?: boolean | undefined;
157-
}) {
158-
const { isValid, errorDetails, isCompatible } = result;
153+
export function openValidationErrorsModal(
154+
result: {
155+
isValid: boolean;
156+
errorDetails?: string | undefined;
157+
isCompatible?: boolean | undefined;
158+
compatibilityError?: { errorType: string; description: string } | undefined;
159+
},
160+
onClose?: () => void
161+
) {
162+
const { isValid, errorDetails, isCompatible, compatibilityError } = result;
159163

160164
const compatBox = (() => {
161165
if (isCompatible === undefined || isValid === false) {
@@ -177,8 +181,28 @@ export function openValidationErrorsModal(result: {
177181
);
178182
})();
179183

184+
const compatErrorBox =
185+
compatibilityError && (compatibilityError.errorType || compatibilityError.description) ? (
186+
<Box>
187+
<Text fontWeight="semibold" mb="2">
188+
Compatibility Error Details:
189+
</Text>
190+
<Box background="gray.100" maxHeight="400px" overflowY="auto" p="6">
191+
{compatibilityError.errorType && (
192+
<Text color="red.600" fontWeight="bold" mb="2">
193+
Error: {compatibilityError.errorType.replace(/_/g, ' ')}
194+
</Text>
195+
)}
196+
{compatibilityError.description && <Text lineHeight="1.6">{compatibilityError.description}</Text>}
197+
</Box>
198+
</Box>
199+
) : null;
200+
180201
const errDetailsBox = errorDetails ? (
181202
<Box>
203+
<Text fontWeight="semibold" mb="2">
204+
Parsing Error:
205+
</Text>
182206
<Box background="gray.100" fontFamily="monospace" letterSpacing="-0.5px" maxHeight="400px" overflowY="auto" p="6">
183207
{errorDetails?.trim()}
184208
</Box>
@@ -199,10 +223,12 @@ export function openValidationErrorsModal(result: {
199223
<Text mb="3">Schema validation failed due to the following error.</Text>
200224
<Flex direction="column" gap="4">
201225
{compatBox}
226+
{compatErrorBox}
202227
{errDetailsBox}
203228
</Flex>
204229
</>
205230
),
231+
onClose,
206232
});
207233
}
208234

0 commit comments

Comments
 (0)