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

452 lines
11 KiB
Markdown

# Angular HTTP Patterns
## Table of Contents
- [Service Layer Pattern](#service-layer-pattern)
- [Caching Strategies](#caching-strategies)
- [Pagination](#pagination)
- [File Upload](#file-upload)
- [Request Cancellation](#request-cancellation)
- [Testing HTTP](#testing-http)
## Service Layer Pattern
Encapsulate HTTP logic in services:
```typescript
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
```typescript
@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
```typescript
@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
```typescript
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
```typescript
@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
```typescript
@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
```typescript
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()
```typescript
// 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
```typescript
@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));
}
}
```
### Debounced Search
```typescript
@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
```typescript
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
```typescript
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 });
});
});
```