File Upload and Download — FormData and Blob Responses

File uploads and downloads require special handling compared to JSON requests. Uploads use FormData (multipart) rather than JSON, need progress tracking for large files, and require the Angular interceptor to avoid setting Content-Type (the browser sets it with the correct boundary). Downloads receive binary Blob data that must be converted to a URL and programmatically triggered as a browser download. Both patterns build on HttpClient with additional configuration.

File Upload with Progress

import { HttpEventType, HttpClient } from '@angular/common/http';

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

  // ── Upload with progress tracking ────────────────────────────────────
  uploadCoverImage(postId: number, file: File): Observable<UploadProgress | PostDto> {
    const formData = new FormData();
    formData.append('file', file, file.name);  // field name must match ASP.NET Core [FromForm]
    formData.append('altText', file.name);

    // IMPORTANT: DO NOT set Content-Type header manually —
    // the browser sets it with the multipart boundary automatically
    return this.http.post<PostDto>(
      `${this.baseUrl}/api/posts/${postId}/cover-image`,
      formData,
      {
        reportProgress: true,     // emit progress events
        observe:        'events', // emit all HTTP events, not just the response
      }
    ).pipe(
      map(event => {
        switch (event.type) {
          case HttpEventType.UploadProgress:
            const percent = Math.round(100 * (event.loaded / (event.total ?? event.loaded)));
            return { type: 'progress', percent } as UploadProgress;
          case HttpEventType.Response:
            return event.body as PostDto;
          default:
            return { type: 'pending', percent: 0 } as UploadProgress;
        }
      }),
    );
  }
}

// ── Upload component with drag-and-drop and progress bar ─────────────────
@Component({
  selector:   'app-cover-upload',
  standalone:  true,
  template: `
    <div class="upload-zone"
         [class.drag-over]="isDragging()"
         (dragover)="$event.preventDefault(); isDragging.set(true)"
         (dragleave)="isDragging.set(false)"
         (drop)="onDrop($event)"
         (click)="fileInput.click()">

      @if (progress() > 0 && progress() < 100) {
        <div class="progress-bar" [style.width.%]="progress()"></div>
        <p>Uploading {{ progress() }}%</p>
      } @else if (previewUrl()) {
        <img [src]="previewUrl()" alt="Cover preview" class="preview">
      } @else {
        <p>Drop image here or click to upload</p>
        <p class="hint">JPEG, PNG, WebP · max 5MB</p>
      }

      <input #fileInput type="file" accept="image/*" hidden
             (change)="onFileSelected($event)">
    </div>
  `,
})
export class CoverUploadComponent {
  @Input({ required: true }) postId!: number;

  private uploadService = inject(FileUploadService);
  isDragging  = signal(false);
  progress    = signal(0);
  previewUrl  = signal<string | null>(null);
  error       = signal<string | null>(null);

  onFileSelected(event: Event): void {
    const file = (event.target as HTMLInputElement).files?.[0];
    if (file) this.uploadFile(file);
  }

  onDrop(event: DragEvent): void {
    event.preventDefault();
    this.isDragging.set(false);
    const file = event.dataTransfer?.files[0];
    if (file) this.uploadFile(file);
  }

  private uploadFile(file: File): void {
    // Show preview immediately (before upload completes)
    this.previewUrl.set(URL.createObjectURL(file));
    this.progress.set(0);
    this.error.set(null);

    this.uploadService.uploadCoverImage(this.postId, file).subscribe({
      next: result => {
        if ('percent' in result) {
          this.progress.set(result.percent);
        }
      },
      error: err => {
        this.progress.set(0);
        this.previewUrl.set(null);
        this.error.set(err.status === 413 ? 'File too large (max 5MB).' : 'Upload failed.');
      },
    });
  }
}

// ── File download — Blob response ─────────────────────────────────────────
downloadPost(id: number, format: 'pdf' | 'docx'): Observable<void> {
  return this.http.get(`${this.baseUrl}/api/posts/${id}/export?format=${format}`, {
    responseType: 'blob',   // tells HttpClient the response is binary
    observe:      'response',
  }).pipe(
    tap(response => {
      const blob     = response.body!;
      const filename = response.headers.get('Content-Disposition')
        ?.split('filename=')[1]?.replace(/"/g, '') ?? `post-${id}.${format}`;
      const url = URL.createObjectURL(blob);
      const a   = document.createElement('a');
      a.href     = url;
      a.download = filename;
      a.click();
      URL.revokeObjectURL(url);   // free memory after click
    }),
    map(() => void 0),
  );
}
Note: When using FormData for file uploads, do not set the Content-Type header manually. The browser automatically generates a Content-Type: multipart/form-data; boundary=----WebKitFormBoundary... header including the boundary string that separates form fields. If you set Content-Type: multipart/form-data without the boundary, the server cannot parse the body. Angular’s interceptors that add headers must skip file upload requests or use req.clone({ setHeaders: {...} }) only for JSON requests.
Tip: Use URL.createObjectURL(file) to show an image preview immediately when the user selects a file — before the upload completes. This provides instant feedback that the correct file was selected. The Object URL is a temporary in-memory URL pointing to the file data. Always call URL.revokeObjectURL(url) when the preview is no longer needed to free the memory. For the download case, revoke immediately after triggering the click (as shown above).
Warning: File upload progress (reportProgress: true) requires the upload to be a true streaming upload — some backend configurations (like ASP.NET Core with request buffering enabled) receive the entire request body before sending any HTTP events, making progress events inaccurate. Verify progress events work end-to-end with your specific ASP.NET Core configuration. Kestrel supports streaming by default; IIS/IIS Express may buffer the request.

Common Mistakes

Mistake 1 — Setting Content-Type manually for FormData uploads (corrupts the request)

❌ Wrong — interceptor sets Content-Type: multipart/form-data; no boundary; server cannot parse.

✅ Correct — never set Content-Type for FormData; browser sets it with the correct boundary automatically.

Mistake 2 — Not revoking Object URLs after download (memory leak)

❌ Wrong — URL.createObjectURL(blob) without calling revokeObjectURL(url); browser retains Blob in memory.

✅ Correct — always call URL.revokeObjectURL(url) after the download is initiated.

🧠 Test Yourself

A file upload interceptor adds Authorization: Bearer token to all requests. Will this cause issues with file uploads that use FormData?