Files
NGRX-Playground/.agents/skills/angular-http/references/http-patterns.md
T

11 KiB

Angular HTTP Patterns

Table of Contents

Service Layer Pattern

Encapsulate HTTP logic in services:

import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { httpResource } from '@angular/common/http';

export interface User {
    id: string;
    name: string;
    email: string;
}

@Injectable({ providedIn: 'root' })
export class User {
    private http = inject(HttpClient);
    private baseUrl = '/api/users';

    // Current user ID for reactive fetching
    private currentUserId = signal<string | null>(null);

    // Reactive resource that updates when currentUserId changes
    currentUser = httpResource<User>(() => {
        const id = this.currentUserId();
        return id ? `${this.baseUrl}/${id}` : undefined;
    });

    // Set current user to fetch
    selectUser(id: string) {
        this.currentUserId.set(id);
    }

    // CRUD operations
    getAll() {
        return this.http.get<User[]>(this.baseUrl);
    }

    getById(id: string) {
        return this.http.get<User>(`${this.baseUrl}/${id}`);
    }

    create(user: Omit<User, 'id'>) {
        return this.http.post<User>(this.baseUrl, user);
    }

    update(id: string, user: Partial<User>) {
        return this.http.patch<User>(`${this.baseUrl}/${id}`, user);
    }

    delete(id: string) {
        return this.http.delete<void>(`${this.baseUrl}/${id}`);
    }
}

Caching Strategies

Simple In-Memory Cache

@Injectable({ providedIn: 'root' })
export class CachedUser {
    private http = inject(HttpClient);
    private cache = new Map<string, { data: User; timestamp: number }>();
    private cacheDuration = 5 * 60 * 1000; // 5 minutes

    getUser(id: string): Observable<User> {
        const cached = this.cache.get(id);

        if (cached && Date.now() - cached.timestamp < this.cacheDuration) {
            return of(cached.data);
        }

        return this.http.get<User>(`/api/users/${id}`).pipe(
            tap((user) => {
                this.cache.set(id, { data: user, timestamp: Date.now() });
            }),
        );
    }

    invalidateCache(id?: string) {
        if (id) {
            this.cache.delete(id);
        } else {
            this.cache.clear();
        }
    }
}

Signal-Based Cache

@Injectable({ providedIn: 'root' })
export class UserCache {
    private http = inject(HttpClient);

    // Cache as signal
    private usersCache = signal<Map<string, User>>(new Map());

    // Computed for easy access
    users = computed(() => Array.from(this.usersCache().values()));

    getUser(id: string): User | undefined {
        return this.usersCache().get(id);
    }

    async fetchUser(id: string): Promise<User> {
        const cached = this.getUser(id);
        if (cached) return cached;

        const user = await firstValueFrom(this.http.get<User>(`/api/users/${id}`));

        this.usersCache.update((cache) => {
            const newCache = new Map(cache);
            newCache.set(id, user);
            return newCache;
        });

        return user;
    }
}

Pagination

Paginated Resource

interface PaginatedResponse<T> {
    data: T[];
    total: number;
    page: number;
    pageSize: number;
    totalPages: number;
}

@Component({
    template: `
        @if (usersResource.isLoading()) {
            <app-spinner />
        } @else if (usersResource.hasValue()) {
            <ul>
                @for (user of usersResource.value().data; track user.id) {
                    <li>{{ user.name }}</li>
                }
            </ul>

            <div class="pagination">
                <button
                    [disabled]="page() === 1"
                    (click)="prevPage()">
                    Previous
                </button>

                <span>Page {{ page() }} of {{ usersResource.value().totalPages }}</span>

                <button
                    [disabled]="page() >= usersResource.value().totalPages"
                    (click)="nextPage()">
                    Next
                </button>
            </div>
        }
    `,
})
export class UsersList {
    page = signal(1);
    pageSize = signal(10);

    usersResource = httpResource<PaginatedResponse<User>>(() => ({
        url: '/api/users',
        params: {
            page: this.page().toString(),
            pageSize: this.pageSize().toString(),
        },
    }));

    nextPage() {
        this.page.update((p) => p + 1);
    }

    prevPage() {
        this.page.update((p) => Math.max(1, p - 1));
    }
}

Infinite Scroll

@Component({
    template: `
        <ul>
            @for (user of allUsers(); track user.id) {
                <li>{{ user.name }}</li>
            }
        </ul>

        @if (isLoading()) {
            <app-spinner />
        }

        @if (hasMore()) {
            <button (click)="loadMore()">Load More</button>
        }
    `,
})
export class InfiniteUsers {
    private http = inject(HttpClient);

    private page = signal(1);
    private users = signal<User[]>([]);
    private totalPages = signal(1);

    allUsers = this.users.asReadonly();
    isLoading = signal(false);
    hasMore = computed(() => this.page() < this.totalPages());

    constructor() {
        this.loadPage(1);
    }

    loadMore() {
        this.loadPage(this.page() + 1);
    }

    private async loadPage(page: number) {
        this.isLoading.set(true);

        try {
            const response = await firstValueFrom(
                this.http.get<PaginatedResponse<User>>('/api/users', {
                    params: { page: page.toString(), pageSize: '20' },
                }),
            );

            this.users.update((users) => [...users, ...response.data]);
            this.page.set(page);
            this.totalPages.set(response.totalPages);
        } finally {
            this.isLoading.set(false);
        }
    }
}

File Upload

Single File Upload

@Component({
    template: `
        <input
            type="file"
            (change)="onFileSelected($event)" />

        @if (uploadProgress() !== null) {
            <progress
                max="100"
                [value]="uploadProgress()"></progress>
        }
    `,
})
export class FileUpload {
    private http = inject(HttpClient);

    uploadProgress = signal<number | null>(null);

    onFileSelected(event: Event) {
        const file = (event.target as HTMLInputElement).files?.[0];
        if (!file) return;

        const formData = new FormData();
        formData.append('file', file);

        this.http
            .post('/api/upload', formData, {
                reportProgress: true,
                observe: 'events',
            })
            .subscribe((event) => {
                if (event.type === HttpEventType.UploadProgress && event.total) {
                    this.uploadProgress.set(Math.round((100 * event.loaded) / event.total));
                } else if (event.type === HttpEventType.Response) {
                    this.uploadProgress.set(null);
                    console.log('Upload complete:', event.body);
                }
            });
    }
}

Multiple Files

uploadFiles(files: FileList) {
  const formData = new FormData();

  for (let i = 0; i < files.length; i++) {
    formData.append('files', files[i]);
  }

  return this.http.post<{ urls: string[] }>('/api/upload-multiple', formData);
}

Request Cancellation

With resource()

// resource() automatically handles cancellation via abortSignal
searchResource = resource({
    params: () => ({ q: this.query() }),
    loader: async ({ params, abortSignal }) => {
        const response = await fetch(`/api/search?q=${params.q}`, {
            signal: abortSignal, // Cancels if params change
        });
        return response.json();
    },
});

With HttpClient

@Component({...})
export class Search implements OnDestroy {
  private http = inject(HttpClient);
  private destroyRef = inject(DestroyRef);

  query = signal('');
  results = signal<Result[]>([]);

  private searchSubscription?: Subscription;

  search() {
    // Cancel previous request
    this.searchSubscription?.unsubscribe();

    this.searchSubscription = this.http
      .get<Result[]>(`/api/search?q=${this.query()}`)
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(results => this.results.set(results));
  }
}
@Component({...})
export class SearchDebounced {
  query = signal('');

  private http = inject(HttpClient);

  results = toSignal(
    toObservable(this.query).pipe(
      debounceTime(300),
      distinctUntilChanged(),
      filter(q => q.length >= 2),
      switchMap(q => this.http.get<Result[]>(`/api/search?q=${q}`)),
      catchError(() => of([]))
    ),
    { initialValue: [] }
  );
}

Testing HTTP

Testing httpResource

describe('UserCmpt', () => {
    let component: UserCmpt;
    let httpMock: HttpTestingController;

    beforeEach(() => {
        TestBed.configureTestingModule({
            imports: [UserCmpt],
            providers: [provideHttpClientTesting()],
        });

        component = TestBed.createComponent(UserCmpt).componentInstance;
        httpMock = TestBed.inject(HttpTestingController);
    });

    it('should load user', () => {
        component.userId.set('123');

        const req = httpMock.expectOne('/api/users/123');
        req.flush({ id: '123', name: 'Test User' });

        expect(component.userResource.value()?.name).toBe('Test User');
    });

    afterEach(() => {
        httpMock.verify();
    });
});

Testing Services

describe('User', () => {
    let service: User;
    let httpMock: HttpTestingController;

    beforeEach(() => {
        TestBed.configureTestingModule({
            providers: [User, provideHttpClient(), provideHttpClientTesting()],
        });

        service = TestBed.inject(User);
        httpMock = TestBed.inject(HttpTestingController);
    });

    it('should create user', () => {
        const newUser = { name: 'Test', email: 'test@example.com' };

        service.create(newUser).subscribe((user) => {
            expect(user.id).toBeDefined();
            expect(user.name).toBe('Test');
        });

        const req = httpMock.expectOne('/api/users');
        expect(req.request.method).toBe('POST');
        expect(req.request.body).toEqual(newUser);

        req.flush({ id: '1', ...newUser });
    });
});