Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «HttpClient та перехоплювачі в Angular». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**`HttpClient`** - типізований HTTP-сервіс Angular, що повертає RxJS Observables для кожного запиту. Перехоплювачі (interceptors) - middleware, що трансформує кожен запит і відповідь глобально. ```typescript this.http.get<User[]>('/api/users'); // HttpClient: один конкретний запит export const authInterceptor: HttpInterceptorFn = (req, next) => next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })); // Перехоплювач: виконується автоматично для кожного запиту ``` **Ключове:** `HttpClient` для конкретних API-викликів; перехоплювачі для auth-токенів, логування і глобальної обробки помилок.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**`HttpClient`** - типізований HTTP-сервіс Angular, що обгортає кожен запит в RxJS Observable. **Перехоплювачі (interceptors)** - middleware-функції між твоїм кодом і мережею, які трансформують кожен запит і відповідь глобально без змін у конкретних сервісах. ## Теорія ### TL;DR - `HttpClient` схожий на пошту: ти даєш запит, вона бере на себе серіалізацію і доставку - Перехоплювач схожий на контрольний пункт: кожен пакет проходить через нього і може бути перевірений, змінений або зупинений - Головна різниця: `HttpClient` робить запити, перехоплювачі змінюють ці запити глобально - Перехоплювачі для спільних задач: auth-токени, логування, глобальна обробка помилок - Перехоплювачі виконуються по черзі; кожен може зупинити ланцюг, не викликавши `next()` ### Швидкий приклад ```typescript // HttpClient: типізований GET-запит const users$ = this.http.get<User[]>('/api/users'); // Функціональний перехоплювач: автоматично додає токен до кожного запиту export const authInterceptor: HttpInterceptorFn = (req, next) => { const token = inject(AuthService).getToken(); if (!token) return next(req); const cloned = req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }); return next(cloned); // передаємо змінений запит далі }; // Результат: кожен HTTP-запит отримає токен без змін у сервісах ``` `HttpRequest` в Angular іммутабельний. Завжди використовуй `clone()`, щоб створити змінену копію. ### Ключова різниця `HttpClient` - це відправник запитів. Ти викликаєш `.get()`, `.post()`, `.put()` і отримуєш Observable назад. Перехоплювачі - це модифікатори: вони обробляють Observable до того, як запит пішов у мережу, і знову коли прийшла відповідь. Можна зареєструвати кілька перехоплювачів, і вони виконуються по черзі. Відповідь проходить через той самий ланцюг у зворотному напрямку. ### Коли що використовувати - **`HttpClient` напряму**: будь-який конкретний HTTP-запит у сервісі чи компоненті - **Auth-перехоплювач**: додавати JWT-токен один раз, а не в кожному методі сервісу - **Error-перехоплювач**: глобально ловити 401/403 і перенаправляти на сторінку логіну - **Logging-перехоплювач**: відстежувати час запитів і помилки по всьому застосунку - **Cache-перехоплювач**: повертати збережені GET-відповіді без звернення до мережі - **Retry-перехоплювач**: автоматично повторювати невдалі запити з паузою ### Таблиця порівняння | Аспект | `HttpClient` | Перехоплювач | |---|---|---| | Призначення | Виконує HTTP-запити | Трансформує запити/відповіді глобально | | Область дії | Один запит за раз | Усі запити, що відповідають умовам | | Виконується коли | Ти викликаєш `.get()`, `.post()` тощо | До кожного запиту і після кожної відповіді | | Може змінити запит | Так, через параметри виклику | Так, для всіх запитів одразу | | Може зупинити ланцюг | Ні | Так - пропуск `next()` скасовує запит | | Типовий варіант | API-виклики в сервісах | Auth, логування, помилки, кешування | ### Як працює ланцюг перехоплювачів Коли ти викликаєш `this.http.get()`, Angular створює об'єкт `HttpRequest` і передає його через кожен зареєстрований перехоплювач по черзі. Кожен може клонувати запит, змінити його і передати далі через `next(cloned)`. Останнім у ланцюгу стоїть справжній HTTP-обробник, що надсилає запит у мережу. Відповідь іде через той самий ланцюг у зворотному напрямку. Якщо якийсь перехоплювач не викликає `next()`, ланцюг обривається і жодного запиту не надсилається. Саме так cache-перехоплювачі повертають збережені дані без звернення до мережі. Сучасний Angular (v15+) використовує функціональні перехоплювачі, зареєстровані через `withInterceptors()`. Класові перехоплювачі з `implements HttpInterceptor` ще працюють, але більше не є рекомендованим підходом. ```typescript // main.ts - реєстрація перехоплювачів bootstrapApplication(AppComponent, { providers: [ provideHttpClient( withInterceptors([ authInterceptor, // виконується першим loggingInterceptor, // виконується другим errorInterceptor, // виконується третім ]) ), ], }); ``` ### Типові помилки **Забули повернути `next()` - запит не надсилається** ```typescript // НЕПРАВИЛЬНО: клонуємо запит, але ніколи не передаємо його далі export const authInterceptor: HttpInterceptorFn = (req, next) => { const cloned = req.clone({ setHeaders: { 'X-Custom': 'value' } }); // Відсутнє: return next(cloned); }; // ПРАВИЛЬНО export const authInterceptor: HttpInterceptorFn = (req, next) => { const cloned = req.clone({ setHeaders: { 'X-Custom': 'value' } }); return next(cloned); }; ``` **Мутація запиту замість клонування** ```typescript // НЕПРАВИЛЬНО: HttpRequest іммутабельний, ця операція нічого не змінює export const authInterceptor: HttpInterceptorFn = (req, next) => { req.headers.set('Authorization', 'Bearer token'); // жодного ефекту return next(req); // заголовки не змінились }; // ПРАВИЛЬНО: clone() створює новий запит із застосованими змінами export const authInterceptor: HttpInterceptorFn = (req, next) => { const cloned = req.clone({ setHeaders: { Authorization: 'Bearer token' } }); return next(cloned); }; ``` **Проковтнути помилку замість передачі її далі** ```typescript // НЕПРАВИЛЬНО: компонент ніколи не дізнається, що запит провалився export const errorInterceptor: HttpInterceptorFn = (req, next) => { return next(req).pipe( catchError(() => of(null)) // помилка зникає ); }; // ПРАВИЛЬНО: обробляємо що потрібно, потім передаємо помилку далі export const errorInterceptor: HttpInterceptorFn = (req, next) => { return next(req).pipe( catchError((error: HttpErrorResponse) => { if (error.status === 401) inject(Router).navigate(['/login']); return throwError(() => error); }) ); }; ``` **Реєстрація класового перехоплювача в кількох модулях** ```typescript // НЕПРАВИЛЬНО: якщо SharedModule імпортується в кількох місцях, // перехоплювач виконується кілька разів на один запит @NgModule({ providers: [ { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true } ] }) export class SharedModule {} // ПРАВИЛЬНО: використовуй функціональні перехоплювачі, // зареєстровані один раз через provideHttpClient(withInterceptors([...])) ``` **Нескінченний цикл повторних запитів** ```typescript // НЕПРАВИЛЬНО: повтор 401 без рефрешу токена зациклиться export const retryInterceptor: HttpInterceptorFn = (req, next) => { return next(req).pipe( retry(3) // повторює наосліп, включаючи 401 що ніколи не пройдуть ); }; // ПРАВИЛЬНО: обмежуй кількість спроб і конкретизуй умову export const retryInterceptor: HttpInterceptorFn = (req, next) => { return next(req).pipe( retry({ count: 3, delay: 1000 }) ); }; ``` ### Де зустрічається в реальних проектах - **NgRx-застосунки**: перехоплювачі відправляють `loading`/`success`/`error` actions, щоб стор відображав стан HTTP-операцій - **Firebase**: перехоплювачі прикріплюють ID-токени і автоматично оновлюють їх коли закінчується термін дії - **Багатосередовищні API**: перехоплювачі додають базовий URL або версію (`/v2/`) без змін у коді сервісів - **Sentry або Datadog**: перехоплювачі ловлять HTTP-помилки і пересилають їх у моніторинг з повним контекстом запиту - **Нормалізація відповідей API**: перехоплювачі конвертують `snake_case` з бекенду в `camelCase` для фронтенду На практиці найчастіший варіант - два окремих перехоплювачі: один для auth, інший для помилок. Тримати їх роздільно робить тестування і дебаг набагато простішими. ### Питання для поглиблення **Q:** Чому `HttpClient` повертає Observables, а не Promises? **A:** Observable можна скасувати - відписка зупиняє запит у польоті. Promise не можна скасувати, він завжди дорезолвиться. До того ж з Observables ти отримуєш оператори `retry()`, `debounceTime()`, `shareReplay()`, які роблять HTTP-логіку значно гнучкішою. **Q:** Що відбувається, якщо один перехоплювач у ланцюгу кидає помилку? **A:** Помилка поширюється через `catchError` наступних перехоплювачів. Якщо жоден не перехопить - вона досягає підписки компонента. Порядок реєстрації має значення: якщо хочеш ловити помилки від усіх попередніх перехоплювачів, ставь error-interceptor останнім. **Q:** Як пропустити перехоплювач для конкретних запитів? **A:** Додай кастомний заголовок при запиті і перевіряй його в перехоплювачі. Наприклад: `req.clone({ setHeaders: { 'X-Skip-Auth': 'true' } })`. У перехоплювачі: `if (req.headers.has('X-Skip-Auth')) return next(req);` **Q:** Чи можуть перехоплювачі змінювати тіло відповіді? **A:** Так. Використовуй `map()` після `next()` і перевіряй тип події: `map(event => event instanceof HttpResponse ? event.clone({ body: transform(event.body) }) : event)`. Для потокових відповідей - наприклад файлів - тіло недоступне одразу, тому такий сценарій потребує окремої обробки. **Q:** (Senior) Як реалізувати дедуплікацію запитів - запобігти повторним викликам до одного ендпоінта протягом 100 мс? **A:** Зберігай активні запити в `Map<string, Observable>` з ключем по URL. Коли той самий URL приходить повторно в межах часового вікна - повертай закешований Observable через `shareReplay(1)` замість нового виклику. Видаляй запис після завершення або помилки. ## Приклади ### Базовий: HttpClient із типізованими запитами та обробкою помилок ```typescript @Injectable({ providedIn: 'root' }) export class UserService { private http = inject(HttpClient); getUsers(): Observable<User[]> { return this.http.get<User[]>('/api/users').pipe( retry(2), catchError((error: HttpErrorResponse) => { if (error.status === 404) return of([]); return throwError(() => error); }) ); } createUser(user: CreateUserDto): Observable<User> { return this.http.post<User>('/api/users', user); } searchUsers(query: string): Observable<User[]> { return this.http.get<User[]>('/api/users', { params: { q: query, limit: '10' } }); } } // HttpClient автоматично серіалізує і десеріалізує JSON. // Дженерик <User[]> дає типову безпеку на рівні відповіді. ``` `retry(2)` повторює запит двічі перш ніж здатись. Для 404 сервіс повертає порожній масив замість помилки - поширений патерн, коли відсутній ресурс не є помилковим станом для UI. ### Середній: Auth-перехоплювач з рефрешем токена ```typescript export const authInterceptor: HttpInterceptorFn = (req, next) => { const auth = inject(AuthService); const router = inject(Router); // Пропускаємо логін без токена if (req.url.includes('/auth/login')) return next(req); const token = auth.getToken(); if (!token) { router.navigate(['/login']); return throwError(() => new Error('No token')); } const cloned = req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }); return next(cloned).pipe( catchError((error: HttpErrorResponse) => { if (error.status === 401) { // Токен протух - намагаємось оновити один раз return auth.refreshToken().pipe( switchMap((newToken) => { const retried = req.clone({ setHeaders: { Authorization: `Bearer ${newToken}` } }); return next(retried); }), catchError(() => { router.navigate(['/login']); return throwError(() => error); }) ); } return throwError(() => error); }) ); }; // Компонент, що зробив запит, ніколи не дізнається про рефреш. // Ротація токена повністю прозора для решти застосунку. ``` ### Просунутий: Cache-перехоплювач із TTL ```typescript @Injectable({ providedIn: 'root' }) export class HttpCacheService { private cache = new Map<string, { body: any; timestamp: number }>(); private readonly TTL = 5 * 60 * 1000; // 5 хвилин get(url: string): any | null { const entry = this.cache.get(url); if (!entry || Date.now() - entry.timestamp > this.TTL) return null; return entry.body; } set(url: string, body: any): void { this.cache.set(url, { body, timestamp: Date.now() }); } } export const cacheInterceptor: HttpInterceptorFn = (req, next) => { const cache = inject(HttpCacheService); if (req.method !== 'GET') return next(req); const cached = cache.get(req.url); if (cached) { return of(new HttpResponse({ body: cached, status: 200 })); } return next(req).pipe( tap((event) => { if (event instanceof HttpResponse) { cache.set(req.url, event.body); } }) ); }; // Для закешованих URL ланцюг обривається тут. // next() не викликається - жодного мережевого запиту. ``` Компонент отримує об'єкт `HttpResponse` ідентичний за структурою реальній відповіді, тому жодної спеціальної обробки на стороні виклику не потрібно.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.