generated from bastian/boilerplate
meldungen
This commit is contained in:
@@ -9,6 +9,7 @@ import { routes } from './app.routes';
|
|||||||
import { csrfInterceptor } from './core/csrf.interceptor';
|
import { csrfInterceptor } from './core/csrf.interceptor';
|
||||||
import { sessionExpiryInterceptor } from './core/session-expiry.interceptor';
|
import { sessionExpiryInterceptor } from './core/session-expiry.interceptor';
|
||||||
import { titleStrategyProvider } from './core/title.strategy';
|
import { titleStrategyProvider } from './core/title.strategy';
|
||||||
|
import { backendErrorToastInterceptor } from './core/backend-error-toast.interceptor';
|
||||||
|
|
||||||
registerLocaleData(localeDe);
|
registerLocaleData(localeDe);
|
||||||
|
|
||||||
@@ -16,7 +17,9 @@ export const appConfig: ApplicationConfig = {
|
|||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
{ provide: LOCALE_ID, useValue: 'de-DE' },
|
{ provide: LOCALE_ID, useValue: 'de-DE' },
|
||||||
provideHttpClient(withInterceptors([csrfInterceptor, sessionExpiryInterceptor])),
|
provideHttpClient(
|
||||||
|
withInterceptors([csrfInterceptor, sessionExpiryInterceptor, backendErrorToastInterceptor]),
|
||||||
|
),
|
||||||
provideRouter(routes, withComponentInputBinding()),
|
provideRouter(routes, withComponentInputBinding()),
|
||||||
titleStrategyProvider,
|
titleStrategyProvider,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { ToastService } from '../shared/ui';
|
||||||
|
import { backendErrorToastInterceptor } from './backend-error-toast.interceptor';
|
||||||
|
|
||||||
|
describe('backendErrorToastInterceptor', () => {
|
||||||
|
it('shows the backend message, validation details and request ID', () => {
|
||||||
|
const show = vi.fn();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptors([backendErrorToastInterceptor])),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{ provide: ToastService, useValue: { show } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const http = TestBed.inject(HttpClient);
|
||||||
|
const controller = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
http.post('/api/projects/project-1/rooms', {}).subscribe({ error: () => undefined });
|
||||||
|
controller.expectOne('/api/projects/project-1/rooms').flush(
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
code: 'VALIDATION_FAILED',
|
||||||
|
message: 'Die Eingaben sind ungültig.',
|
||||||
|
requestId: 'request-123',
|
||||||
|
validation: [{ field: 'name', messages: ['Name fehlt.'] }],
|
||||||
|
},
|
||||||
|
{ status: 400, statusText: 'Bad Request' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(show).toHaveBeenCalledWith({
|
||||||
|
tone: 'danger',
|
||||||
|
title: 'Anfrage fehlgeschlagen',
|
||||||
|
message: 'Die Eingaben sind ungültig. name: Name fehlt.',
|
||||||
|
requestId: 'request-123',
|
||||||
|
});
|
||||||
|
controller.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show an error for the expected anonymous session check', () => {
|
||||||
|
const show = vi.fn();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptors([backendErrorToastInterceptor])),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{ provide: ToastService, useValue: { show } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const http = TestBed.inject(HttpClient);
|
||||||
|
const controller = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
http.get('/api/auth/me').subscribe({ error: () => undefined });
|
||||||
|
controller.expectOne('/api/auth/me').flush({}, { status: 401, statusText: 'Unauthorized' });
|
||||||
|
|
||||||
|
expect(show).not.toHaveBeenCalled();
|
||||||
|
controller.verify();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { HttpErrorResponse, type HttpInterceptorFn } from '@angular/common/http';
|
||||||
|
import { inject } from '@angular/core';
|
||||||
|
import type { ApiErrorBody } from '@boilerplate/api-client';
|
||||||
|
import { catchError, throwError } from 'rxjs';
|
||||||
|
import { ToastService } from '../shared/ui';
|
||||||
|
|
||||||
|
export const backendErrorToastInterceptor: HttpInterceptorFn = (request, next) => {
|
||||||
|
const toasts = inject(ToastService);
|
||||||
|
return next(request).pipe(
|
||||||
|
catchError((error: unknown) => {
|
||||||
|
if (error instanceof HttpErrorResponse && isBackendRequest(request.url)) {
|
||||||
|
const body = apiErrorBody(error.error);
|
||||||
|
if (!isExpectedAnonymousCheck(request.method, request.url, error.status)) {
|
||||||
|
toasts.show({
|
||||||
|
tone: 'danger',
|
||||||
|
title: error.status >= 500 ? 'Serverfehler' : 'Anfrage fehlgeschlagen',
|
||||||
|
message: messageFor(error, body),
|
||||||
|
...(body?.requestId ? { requestId: body.requestId } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return throwError(() => error);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function isBackendRequest(url: string): boolean {
|
||||||
|
return new URL(url, document.baseURI).pathname.startsWith('/api/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpectedAnonymousCheck(method: string, url: string, status: number): boolean {
|
||||||
|
return (
|
||||||
|
method === 'GET' && status === 401 && new URL(url, document.baseURI).pathname === '/api/auth/me'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiErrorBody(value: unknown): ApiErrorBody | undefined {
|
||||||
|
if (
|
||||||
|
typeof value !== 'object' ||
|
||||||
|
value === null ||
|
||||||
|
!('message' in value) ||
|
||||||
|
typeof value.message !== 'string' ||
|
||||||
|
!('requestId' in value) ||
|
||||||
|
typeof value.requestId !== 'string'
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return value as ApiErrorBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageFor(error: HttpErrorResponse, body: ApiErrorBody | undefined): string {
|
||||||
|
if (error.status === 0) return 'Das Backend ist derzeit nicht erreichbar.';
|
||||||
|
const details = body?.validation
|
||||||
|
?.flatMap((entry) => entry.messages.map((message) => `${entry.field}: ${message}`))
|
||||||
|
.join(' · ');
|
||||||
|
if (body && details) return `${body.message} ${details}`;
|
||||||
|
return body?.message ?? 'Die Anfrage konnte nicht verarbeitet werden.';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user