HTTP Service Wrapper

The Angular counterpart to HTTP Service Wrapper (Blazor): one generic ApiService every feature service calls through, so error handling and the ApiResponse<T> envelope (see ApiResponse & Controllers) are handled in exactly one place. The authInterceptor from the previous page already attaches the token, so this service never touches auth directly.

The envelope, mirrored in TypeScript

api-response.ts
export interface ApiResponse<T> {
  value: T | null;
  succeeded: boolean;
  message: string | null;
}

Generic wrapper over HttpClient

api.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, catchError, of } from 'rxjs';
import { ApiResponse } from './api-response';
import { environment } from '../environments/environment';

@Injectable({ providedIn: 'root' })
export class ApiService {
  private readonly baseUrl = environment.apiBaseUrl;

  constructor(private http: HttpClient) {}

  get<T>(url: string): Observable<ApiResponse<T>> {
    return this.http
      .get<ApiResponse<T>>(`${this.baseUrl}${url}`)
      .pipe(catchError((err) => this.handleError<T>(err)));
  }

  post<T>(url: string, body: unknown): Observable<ApiResponse<T>> {
    return this.http
      .post<ApiResponse<T>>(`${this.baseUrl}${url}`, body)
      .pipe(catchError((err) => this.handleError<T>(err)));
  }

  put<T>(url: string, body: unknown): Observable<ApiResponse<T>> {
    return this.http
      .put<ApiResponse<T>>(`${this.baseUrl}${url}`, body)
      .pipe(catchError((err) => this.handleError<T>(err)));
  }

  delete<T>(url: string): Observable<ApiResponse<T>> {
    return this.http
      .delete<ApiResponse<T>>(`${this.baseUrl}${url}`)
      .pipe(catchError((err) => this.handleError<T>(err)));
  }

  postFormData<T>(url: string, formData: FormData): Observable<ApiResponse<T>> {
    return this.http
      .post<ApiResponse<T>>(`${this.baseUrl}${url}`, formData)
      .pipe(catchError((err) => this.handleError<T>(err)));
  }

  private handleError<T>(error: unknown): Observable<ApiResponse<T>> {
    const message =
      error instanceof Object && 'error' in error
        ? (error as any).error?.message ?? 'An unexpected error occurred.'
        : 'An unexpected error occurred.';

    return of({ value: null, succeeded: false, message });
  }
}

File upload — the Angular equivalent of Blazor's reflection-based multipart

No reflection needed in TypeScript — FormData is naturally a bag of string/Blob entries, so building it is direct rather than needing the property-walking trick from the Blazor page.

usage — building FormData
uploadResource(name: string, image: File): Observable<ApiResponse<void>> {
  const formData = new FormData();
  formData.append('name', name);
  formData.append('image', image, image.name);

  return this.api.postFormData<void>('/resource', formData);
}

A feature service on top

Same idea as the Blazor page's ResourceService — thin, one line per endpoint, all the error handling already lives in ApiService.

resource.service.ts
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { ApiService } from './api.service';
import { ApiResponse } from './api-response';
import { ResourceDto } from './resource.dto';

@Injectable({ providedIn: 'root' })
export class ResourceService {
  constructor(private api: ApiService) {}

  getAll(): Observable<ApiResponse<ResourceDto[]>> {
    return this.api.get<ResourceDto[]>('/resource');
  }

  create(request: { name: string }): Observable<ApiResponse<object>> {
    return this.api.post<object>('/resource', request);
  }
}
Because every method returns an ApiResponse<T> — including the caught-error case — a component's subscribe callback never needs a separate error: handler; it checks response.succeeded the same way whether the request failed at the network layer or the API returned succeeded: false on purpose.