API Integration
Air Tracker integrates with a backend API to fetch real-time flight data and aircraft photos. The integration layer is designed for reliability, performance, and maintainability.
API Service Structure
The FlightsApiService provides a clean abstraction over HTTP communication:
// features/flights/services/flights-api.service.ts:11
@Injectable({ providedIn: 'root' })
export class FlightsApiService {
private readonly http = inject(HttpClient);
private readonly apiConfig = inject(ApiConfigService);
private readonly baseUrl = this.apiConfig.apiBaseUrl;
getLiveFlights(): Observable<FlightResponseDto> {
return this.http.get<FlightResponseDto>(`${this.baseUrl}/flights/live`);
}
getPhotosByIcao24(icao24: string): Observable<AircraftPhotoDto> {
return this.http.get<AircraftPhotoDto>(`${this.baseUrl}/aircraft-photos/${icao24}`);
}
}
Design Principles:
- Single Responsibility: API service only handles HTTP communication
- Dependency Injection: Uses Angular’s
inject() function
- Type Safety: All responses are strongly typed
- Configuration: Base URL comes from environment config
- Observables: Returns RxJS Observables for async handling
API Endpoints
The service exposes two primary endpoints:
GET /flights/live
Fetches current flight data with metadata:
Response Type:
// shared/models/flight.dto.ts:1
export interface FlightResponseDto {
time: number; // Unix timestamp of response
flights: FlightDto[]; // Array of flight data
cacheAgeMs?: number; // Age of cached data (milliseconds)
pollingIntervalMs?: number; // Suggested polling interval
nextUpdateInMs?: number; // Time until next data refresh
}
Usage:
this.api.getLiveFlights().subscribe(response => {
console.log(`Received ${response.flights.length} flights`);
console.log(`Cache age: ${response.cacheAgeMs}ms`);
console.log(`Poll again in: ${response.nextUpdateInMs}ms`);
});
The backend controls polling frequency via nextUpdateInMs and pollingIntervalMs. This allows the server to throttle requests during high load or adjust intervals based on data freshness.
GET /aircraft-photos/
Fetches photo metadata for a specific aircraft:
Response Type:
// shared/models/aircraft-photo.dto.ts:1
export interface AircraftPhotoDto {
thumbnailUrl: string; // Small preview image URL
largeUrl: string; // Full-size image URL
link: string; // Link to photo source
photographer: string; // Photographer name
attribution: string; // Attribution text
}
Usage:
this.api.getPhotosByIcao24('a1b2c3')
.pipe(
take(1),
catchError(() => of(null))
)
.subscribe(photo => {
if (photo) {
this.displayPhoto(photo);
} else {
this.showPlaceholder();
}
});
Photo requests are not cached by the frontend. Each flight detail view triggers a new request. Consider implementing a photo cache if users frequently view the same aircraft.
Data Transfer Objects (DTOs)
DTOs represent the API contract exactly as the backend provides it:
FlightDto
// shared/models/flight.dto.ts:9
export interface FlightDto {
icao24: string; // Unique aircraft identifier
callsign: string | null; // Flight callsign (e.g., "UAL123")
originCountry: string; // Country of origin
timePosition: number | null; // Last position update timestamp
lastContact: number; // Last contact timestamp
latitude: number | null; // Current latitude
longitude: number | null; // Current longitude
altitudeBaro: number | null; // Barometric altitude (meters)
altitudeGeo: number | null; // Geometric altitude (meters)
heading: number | null; // True track (degrees)
velocity: number | null; // Velocity (m/s)
onGround: boolean; // True if on ground
category: string | null; // Aircraft category
model: string | null; // Aircraft model
operator: string | null; // Airline operator
operatorIcao: string | null; // ICAO operator code
owner: string | null; // Aircraft owner
typecode: string | null; // Aircraft type code
registration: string | null; // Aircraft registration
verticalRate: number | null; // Vertical rate (m/s)
squawk: string | null; // Transponder code
spi: boolean; // Special position indicator
}
Nullable Fields: Many fields are nullable (| null) because data may be unavailable from the source (OpenSky Network). The UI must handle these gracefully.
DTO to Model Mapping
DTOs are mapped to internal domain models:
// features/flights/models/flight.model.ts:28
export const mapFlightDtoToFlight = (dto: FlightDto): Flight => ({
icao24: dto.icao24,
callsign: dto.callsign,
originCountry: dto.originCountry,
timePosition: dto.timePosition,
lastContact: dto.lastContact,
latitude: dto.latitude,
longitude: dto.longitude,
altitudeBaro: dto.altitudeBaro,
altitudeGeo: dto.altitudeGeo,
heading: dto.heading,
onGround: dto.onGround,
velocity: dto.velocity,
category: dto.category,
model: dto.model,
operator: dto.operator,
operatorIcao: dto.operatorIcao,
owner: dto.owner,
typecode: dto.typecode,
registration: dto.registration,
verticalRate: dto.verticalRate,
squawk: dto.squawk,
spi: dto.spi,
});
Currently, the Flight model matches the DTO exactly, but separating them allows:
- Future transformations (e.g., converting velocities to different units)
- Adding computed properties
- Decoupling internal state from API contracts
Why Separate DTOs and Models?
- API Contract Stability: DTOs represent the backend contract and shouldn’t change
- Domain Logic: Models can include business logic and computed properties
- Type Safety: Explicit mapping catches breaking API changes at compile time
- Testing: Easier to mock and test with domain models
HTTP Client Configuration
The HTTP client is provided at the application level:
// app.config.ts:17
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
// ... other providers
],
};
This enables:
- Singleton HttpClient instance
- Automatic JSON parsing
- Interceptor support (for future auth, logging, etc.)
API Configuration Service
Environment-specific configuration is centralized:
// core/services/api-config.service.ts:16
@Injectable({ providedIn: 'root' })
export class ApiConfigService {
private readonly config: ApiConfig = {
apiBaseUrl: environment.apiBaseUrl,
features: environment.features,
production: environment.production,
};
get apiBaseUrl(): string {
return this.config.apiBaseUrl;
}
}
Configuration Interface:
export interface ApiConfig {
apiBaseUrl: string;
features: {
enableFavorites: boolean;
enableStats: boolean;
};
production: boolean;
}
This allows:
- Different API URLs for dev/staging/production
- Feature flags for gradual rollouts
- Environment-specific behavior
Environment Files: API configuration comes from src/environments/environment.ts (dev) and src/environments/environment.prod.ts (production), managed by Angular’s build system.
Error Handling
The API service returns raw Observables; error handling is the caller’s responsibility:
In FlightsStoreService
// features/flights/services/flights-store.service.ts:181
this.activePollSub = this.api.getLiveFlights()
.pipe(
catchError((err: unknown) => {
console.error('❌ Polling failed:', err);
this._error.set('Error fetching flights');
this._loading.set(false);
this._warning.set(null);
// Retry after 15 seconds
this.pollingTimeoutId = setTimeout(() => this.poll(), 15000);
return of(null);
})
)
.subscribe(response => {
if (!response) return;
// Handle success...
});
Error Handling Strategy:
- Log error to console
- Set user-visible error message
- Clear loading state
- Schedule retry with exponential backoff (15s)
- Return
of(null) to prevent subscription termination
Error Recovery: Returning of(null) from catchError keeps the subscription alive. Without this, a single failed request would stop all future polling.
In FlightsShellComponent (Photo Fetch)
// features/flights/flights-shell/flights-shell.component.ts:109
this.api.getPhotosByIcao24(icao24)
.pipe(
take(1),
catchError(() => of(null))
)
.subscribe(photoDto => {
let photo: AircraftPhoto | null = null;
if (photoDto) {
try {
photo = fromAircraftPhotoDto(photoDto);
} catch (e) {
console.warn('Invalid photo:', e);
}
}
// Display with or without photo
if (this.viewport.isDesktop()) {
this.openOverlayPanel(icao24, photo);
} else {
this.openBottomSheet(icao24, photo);
}
});
Photo Error Handling:
- Fail silently with
catchError(() => of(null))
- Display detail view without photo
- No user-visible error (photos are optional)
Photo failures are non-critical. Flight details display even if the photo request fails, ensuring a resilient user experience.
Smart Polling Implementation
Smart polling is the most sophisticated aspect of API integration.
Polling Lifecycle
┌─────────────────────────────────────────────────────┐
│ Component Mounts │
│ ngOnInit() │
└──────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────┐
│ startSmartPolling() │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ poll() │◄─────────┐
└──────────┬──────────┘ │
│ │
▼ │
┌─────────────────────┐ │
│ api.getLiveFlights()│ │
└──────────┬──────────┘ │
│ │
┌──────────┴──────────┐ │
│ │ │
▼ ▼ │
┌─────────┐ ┌─────────┐ │
│ Success │ │ Error │ │
└────┬────┘ └────┬────┘ │
│ │ │
▼ ▼ │
┌──────────────┐ ┌──────────────┐ │
│Update Store │ │ Set Error │ │
│Calculate │ │ Retry: 15s │─┘
│Next Interval │ └──────────────┘
└──────┬───────┘
│
▼
┌──────────────────┐
│ setTimeout() │
│ (adaptive delay) │──────────────────┘
└──────────────────┘
Adaptive Interval Calculation
// features/flights/services/flights-store.service.ts:231
private calculateNextPollInterval(response: FlightResponseDto): number {
const nextPollMs = response.nextUpdateInMs
?? response.pollingIntervalMs
?? 8000; // Default 8 seconds
this._nextUpdateInMs.set(nextPollMs);
this._nextUpdateAtMs.set(Date.now() + nextPollMs);
if (response.pollingIntervalMs) {
this._currentPollingIntervalMs.set(response.pollingIntervalMs);
}
return nextPollMs;
}
Fallback Priority:
response.nextUpdateInMs - Specific time until next update
response.pollingIntervalMs - General polling interval
8000 - Hardcoded default (8 seconds)
Backend-Driven Intervals: This design allows the backend to dynamically adjust polling frequency based on:
- Data freshness (e.g., longer intervals for stale data)
- Server load (e.g., increase intervals during peak traffic)
- Client priority (e.g., premium users get faster updates)
Polling State Tracking
The store maintains comprehensive polling state:
private readonly _lastUpdated = signal<Date | null>(null);
private readonly _cacheAgeMs = signal<number>(0);
private readonly _nextUpdateInMs = signal<number>(0);
private readonly _nextUpdateAtMs = signal<number | null>(null);
private readonly _currentPollingIntervalMs = signal<number>(8000);
These signals power the UI’s polling status indicator:
// Example: Polling status component
<div class="polling-status">
Last updated: {{ store.lastUpdated() | date:'short' }}
Next update in: {{ store.nextUpdateInMs() / 1000 }}s
Current interval: {{ store.currentPollingIntervalMs() / 1000 }}s
</div>
Cache Age Monitoring
// features/flights/services/flights-store.service.ts:217
if (cacheAgeMs > 30000) {
const cacheAgeSec = Math.round(cacheAgeMs / 1000);
this._warning.set(`⚠️ Data from ${cacheAgeSec}s ago (may be outdated)`);
} else {
this._warning.set(null);
}
When backend data is older than 30 seconds, a warning displays to inform users.
Stale Data Threshold: The 30-second threshold is arbitrary. Adjust based on your use case:
- Real-time trading: 5 seconds
- Flight tracking: 30-60 seconds
- Weather data: 5-10 minutes
Subscription Cleanup
// features/flights/services/flights-store.service.ts:152
stopPolling(): void {
if (this.pollingTimeoutId) {
clearTimeout(this.pollingTimeoutId);
this.pollingTimeoutId = undefined;
}
this.activePollSub?.unsubscribe();
this.activePollSub = null;
this._loading.set(false);
}
Proper cleanup prevents:
- Memory leaks from dangling subscriptions
- Multiple concurrent requests
- Zombie timers continuing after component destruction
stopPolling() is called before starting new polls to ensure clean state transitions.
Request Lifecycle
Detailed flow of a single polling request:
// 1. Start polling
private poll(): void {
this._loading.set(true); // UI shows loading indicator
// 2. Cancel any in-flight request
this.activePollSub?.unsubscribe();
this.activePollSub = null;
// 3. Make HTTP request
this.activePollSub = this.api.getLiveFlights()
.pipe(
catchError((err: unknown) => {
// 4a. Handle error
console.error('❌ Polling failed:', err);
this._error.set('Error fetching flights');
this._loading.set(false);
// 5a. Schedule retry
this.pollingTimeoutId = setTimeout(() => this.poll(), 15000);
return of(null);
})
)
.subscribe(response => {
if (!response) return;
// 4b. Handle success
this._error.set(null);
this.updateStoreFromResponse(response);
// 5b. Schedule next poll
const nextPollMs = this.calculateNextPollInterval(response);
this.pollingTimeoutId = setTimeout(() => this.poll(), nextPollMs);
});
}
Key Points:
- Set loading state immediately
- Cancel previous request to prevent overlaps
- Handle both success and error paths
- Always schedule next poll (success or failure)
- Use adaptive intervals on success, fixed retry on error
Request Cancellation
this.activePollSub?.unsubscribe();
Unsubscribing cancels the HTTP request if still in-flight, preventing:
- Wasted bandwidth
- Race conditions (old response arriving after new response)
- Multiple overlapping requests
Debouncing User Actions
While not implemented for polling, user-triggered API calls should be debounced:
// Example: Debounce search input
searchControl.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(query => this.api.search(query))
).subscribe(results => {
// Update UI
});
Caching Strategies
Consider implementing client-side caching for:
Aircraft Photos
private photoCache = new Map<string, AircraftPhotoDto>();
getPhotosByIcao24(icao24: string): Observable<AircraftPhotoDto> {
if (this.photoCache.has(icao24)) {
return of(this.photoCache.get(icao24)!);
}
return this.http.get<AircraftPhotoDto>(`${this.baseUrl}/aircraft-photos/${icao24}`)
.pipe(
tap(photo => this.photoCache.set(icao24, photo))
);
}
Cache Invalidation: Implement TTL (time-to-live) for cached data to prevent serving stale information indefinitely.
Future Enhancements
1. WebSocket Support
Replace polling with WebSockets for true real-time updates:
connectWebSocket(): void {
const ws = new WebSocket('wss://api.airtracker.com/flights');
ws.onmessage = (event) => {
const response: FlightResponseDto = JSON.parse(event.data);
this.updateStoreFromResponse(response);
};
}
Benefits:
- Lower latency
- Reduced bandwidth
- Server-initiated updates
2. Request Interceptors
Add HTTP interceptors for:
- Authentication tokens
- Request logging
- Error tracking
- Retry logic
provideHttpClient(
withInterceptors([authInterceptor, loggingInterceptor])
)
3. Optimistic Updates
Update UI before API confirmation:
favoriteFlight(icao24: string): void {
// Immediately update UI
this._favorites.update(favs => [...favs, icao24]);
// Sync with backend
this.api.addFavorite(icao24).pipe(
catchError(() => {
// Rollback on error
this._favorites.update(favs => favs.filter(id => id !== icao24));
return EMPTY;
})
).subscribe();
}
4. Offline Support
Cache responses for offline viewing:
getLiveFlights(): Observable<FlightResponseDto> {
return this.http.get<FlightResponseDto>(`${this.baseUrl}/flights/live`)
.pipe(
tap(response => localStorage.setItem('last-flights', JSON.stringify(response))),
catchError(() => {
const cached = localStorage.getItem('last-flights');
return cached ? of(JSON.parse(cached)) : throwError(() => new Error('No cached data'));
})
);
}
5. GraphQL Integration
Migrate from REST to GraphQL for more efficient data fetching:
query GetFlights($filters: FlightFilters) {
flights(filters: $filters) {
icao24
callsign
position { latitude longitude }
operator
onGround
}
}
Benefits:
- Request only needed fields
- Single endpoint for all queries
- Real-time subscriptions built-in