Skip to content

Commit 02be074

Browse files
committed
fix(errors): surface field-level validation messages instead of generic 400 problem detail
1 parent 986326c commit 02be074

2 files changed

Lines changed: 86 additions & 8 deletions

File tree

src/app/core/http/interceptors/problem-details.interceptor.spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,32 @@ describe('problemDetailsInterceptor', () => {
7979
})
8080
);
8181
});
82+
83+
it('prefers field validation errors over generic detail message', () => {
84+
spyOn(messageService, 'add');
85+
86+
http.post('/api/projects', {}).subscribe({
87+
error: (_error: HttpErrorResponse) => undefined
88+
});
89+
90+
const request = httpTestingController.expectOne('/api/projects');
91+
request.flush(
92+
{
93+
title: 'Validation Error',
94+
status: 400,
95+
detail: 'One or more validation errors occurred.',
96+
errors: {
97+
description: ['Description must be 500 characters or fewer.']
98+
}
99+
},
100+
{ status: 400, statusText: 'Bad Request' }
101+
);
102+
103+
expect(messageService.add).toHaveBeenCalledWith(
104+
jasmine.objectContaining({
105+
summary: 'Validation error',
106+
detail: 'Description must be 500 characters or fewer.'
107+
})
108+
);
109+
});
82110
});

src/app/core/http/interceptors/problem-details.interceptor.ts

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,17 @@ function resolveErrorSummary(status: number): string {
6464
}
6565

6666
function resolveErrorDetail(problem: ProblemDetails, status: number): string {
67+
const validationSummary = extractValidationSummary(problem.errors);
68+
if (status === 400 && validationSummary) {
69+
return validationSummary;
70+
}
71+
6772
if (problem.detail && problem.detail.trim().length > 0) {
6873
return problem.detail;
6974
}
7075

71-
const firstValidationError = extractFirstValidationError(problem.errors);
72-
if (firstValidationError) {
73-
return firstValidationError;
76+
if (validationSummary) {
77+
return validationSummary;
7478
}
7579

7680
switch (status) {
@@ -89,16 +93,62 @@ function resolveErrorDetail(problem: ProblemDetails, status: number): string {
8993
}
9094
}
9195

92-
function extractFirstValidationError(errors?: Record<string, string[]>): string | null {
96+
function extractValidationSummary(errors?: Record<string, string[]>): string | null {
9397
if (!errors) {
9498
return null;
9599
}
96100

97-
for (const messages of Object.values(errors)) {
98-
if (messages.length > 0) {
99-
return messages[0];
101+
const messages: string[] = [];
102+
103+
for (const [fieldName, fieldErrors] of Object.entries(errors)) {
104+
for (const message of fieldErrors) {
105+
if (!message || message.trim().length === 0) {
106+
continue;
107+
}
108+
109+
const fieldLabel = toFieldLabel(fieldName);
110+
const hasFieldNameInMessage = message.toLowerCase().includes(fieldLabel.toLowerCase());
111+
messages.push(hasFieldNameInMessage ? message : `${fieldLabel}: ${message}`);
100112
}
101113
}
102114

103-
return null;
115+
if (messages.length === 0) {
116+
return null;
117+
}
118+
119+
const preview = messages.slice(0, 2).join(' ');
120+
const remainingCount = messages.length - 2;
121+
if (remainingCount > 0) {
122+
return `${preview} (+${remainingCount} more)`;
123+
}
124+
125+
return preview;
126+
}
127+
128+
function toFieldLabel(fieldName: string): string {
129+
const normalized = fieldName
130+
.replace(/^\$\./, '')
131+
.replace(/\./g, ' ')
132+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
133+
.replace(/\s+/g, ' ')
134+
.trim();
135+
136+
if (!normalized) {
137+
return 'Field';
138+
}
139+
140+
return normalized
141+
.split(' ')
142+
.map((segment) => {
143+
if (segment.length === 0) {
144+
return segment;
145+
}
146+
147+
if (segment.length === 1) {
148+
return segment.toUpperCase();
149+
}
150+
151+
return `${segment[0].toUpperCase()}${segment.slice(1)}`;
152+
})
153+
.join(' ');
104154
}

0 commit comments

Comments
 (0)