TypeFetched/manual/chapter-13-api-abstractions.md
Casey Collier b85b9a63e2 Initial commit: TypedFetch - Zero-dependency, type-safe HTTP client
Features:
- Zero configuration, just works out of the box
- Runtime type inference and validation
- Built-in caching with W-TinyLFU algorithm
- Automatic retries with exponential backoff
- Circuit breaker for resilience
- Request deduplication
- Offline support with queue
- OpenAPI schema discovery
- Full TypeScript support with type descriptors
- Modular architecture
- Configurable for advanced use cases

Built with bun, ready for npm publishing
2025-07-20 12:35:43 -04:00

41 KiB

Chapter 13: Building API Abstractions

"The best code is the code you don't have to write twice."


The Scaling Problem

Weather Buddy was a massive success. Sarah's team had grown to 20 developers, and the codebase was expanding rapidly. But there was a problem.

"Why does every component make raw API calls?" the new senior engineer asked during code review. "We have the same weather-fetching logic copied in 15 different places!"

Sarah looked at the codebase with fresh eyes. He was right. Every developer was writing:

const { data } = await tf.get(`/api/weather/${city}`)
const temp = data.current_condition[0].temp_C
// ... transform data
// ... handle errors
// ... update cache

"Time to level up our architecture," Marcus said. "Let me show you how to build proper API abstractions that scale with your team."

The Repository Pattern: Your Data Layer

Instead of scattered API calls, centralize data access:

// repositories/WeatherRepository.ts
export class WeatherRepository {
  constructor(private tf: TypedFetch) {}
  
  async getByCity(city: string): Promise<Weather> {
    const { data } = await this.tf.get(`/api/weather/${city}`)
    return this.transformWeatherData(data)
  }
  
  async getByCities(cities: string[]): Promise<Weather[]> {
    // Batch request for efficiency
    const promises = cities.map(city => this.getByCity(city))
    return Promise.all(promises)
  }
  
  async search(query: string): Promise<WeatherSearchResult[]> {
    const { data } = await this.tf.get('/api/weather/search', {
      params: { q: query, limit: 10 }
    })
    
    return data.results.map(this.transformSearchResult)
  }
  
  async getForecast(city: string, days = 7): Promise<Forecast> {
    const { data } = await this.tf.get(`/api/weather/${city}/forecast`, {
      params: { days }
    })
    
    return this.transformForecast(data)
  }
  
  private transformWeatherData(raw: any): Weather {
    return {
      city: raw.location.name,
      country: raw.location.country,
      temperature: {
        current: parseInt(raw.current_condition[0].temp_C),
        feelsLike: parseInt(raw.current_condition[0].FeelsLikeC),
        unit: 'celsius'
      },
      condition: {
        text: raw.current_condition[0].weatherDesc[0].value,
        code: raw.current_condition[0].weatherCode,
        icon: this.getIconUrl(raw.current_condition[0].weatherCode)
      },
      wind: {
        speed: parseInt(raw.current_condition[0].windspeedKmph),
        direction: raw.current_condition[0].winddir16Point,
        degree: parseInt(raw.current_condition[0].winddirDegree)
      },
      humidity: parseInt(raw.current_condition[0].humidity),
      pressure: parseInt(raw.current_condition[0].pressure),
      visibility: parseInt(raw.current_condition[0].visibility),
      uv: parseInt(raw.current_condition[0].uvIndex),
      lastUpdated: new Date(raw.current_condition[0].localObsDateTime)
    }
  }
  
  private transformSearchResult(raw: any): WeatherSearchResult {
    return {
      city: raw.name,
      country: raw.country,
      region: raw.region,
      lat: raw.lat,
      lon: raw.lon,
      population: raw.population
    }
  }
  
  private transformForecast(raw: any): Forecast {
    return {
      city: raw.location.name,
      days: raw.forecast.forecastday.map((day: any) => ({
        date: new Date(day.date),
        maxTemp: parseInt(day.day.maxtemp_c),
        minTemp: parseInt(day.day.mintemp_c),
        avgTemp: parseInt(day.day.avgtemp_c),
        condition: {
          text: day.day.condition.text,
          code: day.day.condition.code,
          icon: this.getIconUrl(day.day.condition.code)
        },
        chanceOfRain: parseInt(day.day.daily_chance_of_rain),
        totalPrecipitation: parseFloat(day.day.totalprecip_mm),
        avgHumidity: parseInt(day.day.avghumidity),
        maxWind: parseInt(day.day.maxwind_kph),
        uv: parseInt(day.day.uv)
      }))
    }
  }
  
  private getIconUrl(code: string): string {
    return `https://cdn.weatherbuddy.com/icons/${code}.svg`
  }
}

// repositories/UserRepository.ts
export class UserRepository {
  constructor(private tf: TypedFetch) {}
  
  async getCurrentUser(): Promise<User> {
    const { data } = await this.tf.get('/api/users/me')
    return this.transformUser(data)
  }
  
  async updateProfile(updates: Partial<UserProfile>): Promise<User> {
    const { data } = await this.tf.patch('/api/users/me', {
      data: updates
    })
    
    return this.transformUser(data)
  }
  
  async getFavorites(): Promise<FavoriteCity[]> {
    const { data } = await this.tf.get('/api/users/me/favorites')
    return data.map(this.transformFavorite)
  }
  
  async addFavorite(city: string): Promise<FavoriteCity> {
    const { data } = await this.tf.post('/api/users/me/favorites', {
      data: { city }
    })
    
    return this.transformFavorite(data)
  }
  
  async removeFavorite(cityId: string): Promise<void> {
    await this.tf.delete(`/api/users/me/favorites/${cityId}`)
  }
  
  async reorderFavorites(cityIds: string[]): Promise<FavoriteCity[]> {
    const { data } = await this.tf.put('/api/users/me/favorites/order', {
      data: { cityIds }
    })
    
    return data.map(this.transformFavorite)
  }
  
  private transformUser(raw: any): User {
    return {
      id: raw.id,
      email: raw.email,
      name: raw.name,
      avatar: raw.avatar_url,
      preferences: {
        temperatureUnit: raw.preferences.temp_unit,
        windSpeedUnit: raw.preferences.wind_unit,
        timeFormat: raw.preferences.time_format,
        theme: raw.preferences.theme
      },
      subscription: {
        plan: raw.subscription.plan,
        status: raw.subscription.status,
        expiresAt: raw.subscription.expires_at 
          ? new Date(raw.subscription.expires_at) 
          : null
      },
      createdAt: new Date(raw.created_at),
      updatedAt: new Date(raw.updated_at)
    }
  }
  
  private transformFavorite(raw: any): FavoriteCity {
    return {
      id: raw.id,
      city: raw.city_name,
      country: raw.country,
      position: raw.position,
      addedAt: new Date(raw.added_at),
      lastViewed: raw.last_viewed ? new Date(raw.last_viewed) : null
    }
  }
}

Domain-Driven Design: Speaking Business Language

Create a domain layer that speaks your business language:

// domain/Weather.ts
export class WeatherDomain {
  constructor(
    private weatherRepo: WeatherRepository,
    private userRepo: UserRepository,
    private alertService: AlertService
  ) {}
  
  async getDashboard(): Promise<WeatherDashboard> {
    const [user, favorites] = await Promise.all([
      this.userRepo.getCurrentUser(),
      this.userRepo.getFavorites()
    ])
    
    const weatherData = await this.weatherRepo.getByCities(
      favorites.map(f => f.city)
    )
    
    const alerts = await this.alertService.getActiveAlerts(
      favorites.map(f => f.city)
    )
    
    return {
      user,
      cities: favorites.map((fav, index) => ({
        ...fav,
        weather: weatherData[index],
        alerts: alerts.filter(a => a.city === fav.city)
      })),
      lastUpdated: new Date()
    }
  }
  
  async searchAndAdd(query: string): Promise<WeatherSearchResult[]> {
    const results = await this.weatherRepo.search(query)
    
    // Enhance with additional data
    const enhanced = await Promise.all(
      results.map(async (result) => {
        const weather = await this.weatherRepo.getByCity(result.city)
        
        return {
          ...result,
          currentTemp: weather.temperature.current,
          condition: weather.condition.text
        }
      })
    )
    
    return enhanced
  }
  
  async getDetailedForecast(city: string): Promise<DetailedForecast> {
    const [current, forecast, historical, alerts] = await Promise.all([
      this.weatherRepo.getByCity(city),
      this.weatherRepo.getForecast(city, 14),
      this.weatherRepo.getHistorical(city, 7),
      this.alertService.getAlertsForCity(city)
    ])
    
    return {
      current,
      forecast,
      historical: this.analyzeHistorical(historical),
      alerts,
      insights: this.generateInsights(current, forecast, historical)
    }
  }
  
  private analyzeHistorical(data: HistoricalWeather[]): HistoricalAnalysis {
    const temps = data.map(d => d.temperature)
    
    return {
      avgTemp: average(temps),
      maxTemp: Math.max(...temps),
      minTemp: Math.min(...temps),
      trend: this.calculateTrend(temps),
      anomalies: this.detectAnomalies(data)
    }
  }
  
  private generateInsights(
    current: Weather,
    forecast: Forecast,
    historical: HistoricalWeather[]
  ): WeatherInsight[] {
    const insights: WeatherInsight[] = []
    
    // Temperature insights
    const avgHistorical = average(historical.map(h => h.temperature))
    if (current.temperature.current > avgHistorical + 5) {
      insights.push({
        type: 'temperature',
        severity: 'info',
        message: `Today is ${Math.round(current.temperature.current - avgHistorical)}°C warmer than usual`
      })
    }
    
    // Rain insights
    const rainyDays = forecast.days.filter(d => d.chanceOfRain > 50)
    if (rainyDays.length >= 3) {
      insights.push({
        type: 'precipitation',
        severity: 'warning',
        message: `Rainy period ahead: ${rainyDays.length} days of rain expected`
      })
    }
    
    // UV insights
    const highUvDays = forecast.days.filter(d => d.uv >= 8)
    if (highUvDays.length > 0) {
      insights.push({
        type: 'uv',
        severity: 'warning',
        message: `High UV levels expected on ${highUvDays.length} days. Use sun protection!`
      })
    }
    
    return insights
  }
  
  private calculateTrend(values: number[]): 'rising' | 'falling' | 'stable' {
    if (values.length < 2) return 'stable'
    
    const firstHalf = average(values.slice(0, Math.floor(values.length / 2)))
    const secondHalf = average(values.slice(Math.floor(values.length / 2)))
    
    const difference = secondHalf - firstHalf
    
    if (difference > 2) return 'rising'
    if (difference < -2) return 'falling'
    return 'stable'
  }
  
  private detectAnomalies(data: HistoricalWeather[]): Anomaly[] {
    const anomalies: Anomaly[] = []
    const temps = data.map(d => d.temperature)
    const mean = average(temps)
    const stdDev = standardDeviation(temps)
    
    data.forEach((day, index) => {
      const zScore = Math.abs((day.temperature - mean) / stdDev)
      
      if (zScore > 2) {
        anomalies.push({
          date: day.date,
          type: 'temperature',
          value: day.temperature,
          deviation: zScore,
          description: `Unusual ${day.temperature > mean ? 'high' : 'low'} of ${day.temperature}°C`
        })
      }
    })
    
    return anomalies
  }
}

API Client Factory: Configuration Made Easy

Create specialized API clients for different services:

// factories/ApiClientFactory.ts
export class ApiClientFactory {
  private clients = new Map<string, TypedFetch>()
  
  constructor(private baseConfig: ApiConfig) {}
  
  create(name: string, config: Partial<ApiConfig> = {}): TypedFetch {
    if (this.clients.has(name)) {
      return this.clients.get(name)!
    }
    
    const client = this.buildClient(name, config)
    this.clients.set(name, client)
    
    return client
  }
  
  private buildClient(name: string, config: Partial<ApiConfig>): TypedFetch {
    const clientConfig = {
      ...this.baseConfig,
      ...config,
      ...this.getServiceConfig(name)
    }
    
    const client = createTypedFetch(clientConfig)
    
    // Add service-specific interceptors
    this.addInterceptors(client, name)
    
    // Add telemetry
    this.addTelemetry(client, name)
    
    return client
  }
  
  private getServiceConfig(name: string): Partial<ApiConfig> {
    const configs: Record<string, Partial<ApiConfig>> = {
      weather: {
        baseURL: process.env.WEATHER_API_URL,
        timeout: 10000,
        retries: 3,
        cache: {
          maxAge: 300000,  // 5 minutes
          strategy: 'stale-while-revalidate'
        }
      },
      
      user: {
        baseURL: process.env.USER_API_URL,
        timeout: 5000,
        retries: 1,
        cache: {
          maxAge: 60000,  // 1 minute
          private: true
        }
      },
      
      analytics: {
        baseURL: process.env.ANALYTICS_API_URL,
        timeout: 30000,
        retries: 0,
        cache: false
      },
      
      maps: {
        baseURL: process.env.MAPS_API_URL,
        timeout: 20000,
        retries: 2,
        cache: {
          maxAge: 86400000,  // 24 hours
          strategy: 'cache-first'
        }
      }
    }
    
    return configs[name] || {}
  }
  
  private addInterceptors(client: TypedFetch, name: string) {
    // Common auth interceptor
    client.addRequestInterceptor(config => {
      const token = this.getAuthToken()
      if (token) {
        config.headers['Authorization'] = `Bearer ${token}`
      }
      return config
    })
    
    // Service-specific interceptors
    switch (name) {
      case 'weather':
        client.addRequestInterceptor(config => {
          config.headers['X-Weather-API-Key'] = process.env.WEATHER_API_KEY!
          return config
        })
        break
        
      case 'maps':
        client.addRequestInterceptor(config => {
          // Add signature for maps API
          const signature = this.signMapRequest(config)
          config.headers['X-Map-Signature'] = signature
          return config
        })
        break
        
      case 'analytics':
        client.addRequestInterceptor(config => {
          // Add tracking headers
          config.headers['X-Client-ID'] = this.getClientId()
          config.headers['X-Session-ID'] = this.getSessionId()
          return config
        })
        break
    }
  }
  
  private addTelemetry(client: TypedFetch, name: string) {
    client.addRequestInterceptor(config => {
      config.metadata.service = name
      config.metadata.startTime = Date.now()
      return config
    })
    
    client.addResponseInterceptor(response => {
      const duration = Date.now() - response.config.metadata.startTime
      
      this.recordMetric({
        service: name,
        endpoint: response.config.url,
        method: response.config.method,
        status: response.status,
        duration,
        cached: response.cached || false
      })
      
      return response
    })
    
    client.addErrorInterceptor(error => {
      this.recordError({
        service: name,
        endpoint: error.config.url,
        method: error.config.method,
        error: error.message,
        status: error.response?.status
      })
      
      throw error
    })
  }
  
  private getAuthToken(): string | null {
    return localStorage.getItem('authToken')
  }
  
  private signMapRequest(config: RequestConfig): string {
    // Implementation of request signing
    return 'signature'
  }
  
  private getClientId(): string {
    return 'client-id'
  }
  
  private getSessionId(): string {
    return 'session-id'
  }
  
  private recordMetric(metric: any) {
    // Send to telemetry service
  }
  
  private recordError(error: any) {
    // Send to error tracking
  }
}

// Usage
const factory = new ApiClientFactory({
  timeout: 10000,
  retries: 2
})

const weatherClient = factory.create('weather')
const userClient = factory.create('user')
const analyticsClient = factory.create('analytics')

Composable API Layers

Build APIs that compose like LEGO blocks:

// composables/useWeather.ts
export function useWeather() {
  const weatherRepo = new WeatherRepository(weatherClient)
  const cache = new WeatherCache()
  
  const getWeather = async (city: string): Promise<Weather> => {
    // Check cache first
    const cached = cache.get(city)
    if (cached && !cached.isStale()) {
      return cached.data
    }
    
    // Fetch fresh data
    const weather = await weatherRepo.getByCity(city)
    cache.set(city, weather)
    
    return weather
  }
  
  const prefetchWeather = async (cities: string[]) => {
    const uncached = cities.filter(city => !cache.has(city))
    
    if (uncached.length > 0) {
      const weather = await weatherRepo.getByCities(uncached)
      weather.forEach((w, i) => cache.set(uncached[i], w))
    }
  }
  
  const subscribeToWeather = (city: string, callback: (weather: Weather) => void) => {
    // Initial data
    getWeather(city).then(callback)
    
    // Subscribe to updates
    const unsubscribe = weatherEvents.on(`weather:${city}`, callback)
    
    // Polling fallback
    const interval = setInterval(() => {
      getWeather(city).then(callback)
    }, 60000)
    
    return () => {
      unsubscribe()
      clearInterval(interval)
    }
  }
  
  return {
    getWeather,
    prefetchWeather,
    subscribeToWeather,
    cache
  }
}

// composables/useWeatherDashboard.ts
export function useWeatherDashboard() {
  const { getWeather, prefetchWeather } = useWeather()
  const { getFavorites, addFavorite, removeFavorite } = useFavorites()
  const { showNotification } = useNotifications()
  
  const dashboard = reactive<WeatherDashboard>({
    cities: [],
    loading: false,
    error: null
  })
  
  const loadDashboard = async () => {
    dashboard.loading = true
    dashboard.error = null
    
    try {
      const favorites = await getFavorites()
      
      // Prefetch all weather data
      await prefetchWeather(favorites.map(f => f.city))
      
      // Load weather for each city
      const weatherPromises = favorites.map(async (fav) => {
        const weather = await getWeather(fav.city)
        return { ...fav, weather }
      })
      
      dashboard.cities = await Promise.all(weatherPromises)
    } catch (error) {
      dashboard.error = error.message
      showNotification({
        type: 'error',
        message: 'Failed to load weather dashboard'
      })
    } finally {
      dashboard.loading = false
    }
  }
  
  const addCity = async (city: string) => {
    try {
      const weather = await getWeather(city)
      const favorite = await addFavorite(city)
      
      dashboard.cities.push({ ...favorite, weather })
      
      showNotification({
        type: 'success',
        message: `Added ${city} to your dashboard`
      })
    } catch (error) {
      showNotification({
        type: 'error',
        message: `Failed to add ${city}`
      })
    }
  }
  
  const removeCity = async (cityId: string) => {
    try {
      await removeFavorite(cityId)
      dashboard.cities = dashboard.cities.filter(c => c.id !== cityId)
      
      showNotification({
        type: 'success',
        message: 'City removed from dashboard'
      })
    } catch (error) {
      showNotification({
        type: 'error',
        message: 'Failed to remove city'
      })
    }
  }
  
  const refreshAll = async () => {
    // Invalidate cache
    dashboard.cities.forEach(city => {
      cache.invalidate(city.city)
    })
    
    // Reload
    await loadDashboard()
  }
  
  // Auto-refresh every 5 minutes
  const autoRefresh = setInterval(refreshAll, 5 * 60 * 1000)
  
  onMounted(loadDashboard)
  onUnmounted(() => clearInterval(autoRefresh))
  
  return {
    dashboard: readonly(dashboard),
    addCity,
    removeCity,
    refreshAll
  }
}

Plugin Architecture: Extensible APIs

Make your API layer extensible with plugins:

// plugins/ApiPlugin.ts
export interface ApiPlugin {
  name: string
  version: string
  
  // Lifecycle hooks
  install?(api: TypedFetch): void
  uninstall?(api: TypedFetch): void
  
  // Request/Response hooks
  beforeRequest?(config: RequestConfig): RequestConfig | Promise<RequestConfig>
  afterResponse?(response: Response): Response | Promise<Response>
  onError?(error: Error): Error | Promise<Error>
  
  // Custom methods
  methods?: Record<string, Function>
}

// plugins/CachingPlugin.ts
export class CachingPlugin implements ApiPlugin {
  name = 'caching'
  version = '1.0.0'
  
  private cache = new Map<string, CacheEntry>()
  
  install(api: TypedFetch) {
    // Add cache methods to API
    api.cache = {
      get: (key: string) => this.cache.get(key),
      set: (key: string, value: any, ttl?: number) => {
        this.cache.set(key, {
          value,
          expires: ttl ? Date.now() + ttl : Infinity
        })
      },
      clear: () => this.cache.clear(),
      size: () => this.cache.size
    }
  }
  
  async beforeRequest(config: RequestConfig): Promise<RequestConfig> {
    if (config.method !== 'GET') return config
    
    const cacheKey = this.getCacheKey(config)
    const cached = this.cache.get(cacheKey)
    
    if (cached && !this.isExpired(cached)) {
      // Return cached response
      throw {
        cached: true,
        data: cached.value,
        config
      }
    }
    
    return config
  }
  
  async afterResponse(response: Response): Promise<Response> {
    if (response.config.method === 'GET' && response.ok) {
      const cacheKey = this.getCacheKey(response.config)
      const ttl = this.getTTL(response)
      
      this.cache.set(cacheKey, {
        value: response.data,
        expires: Date.now() + ttl
      })
    }
    
    return response
  }
  
  private getCacheKey(config: RequestConfig): string {
    return `${config.method}:${config.url}:${JSON.stringify(config.params)}`
  }
  
  private isExpired(entry: CacheEntry): boolean {
    return Date.now() > entry.expires
  }
  
  private getTTL(response: Response): number {
    const cacheControl = response.headers.get('cache-control')
    
    if (cacheControl) {
      const maxAge = cacheControl.match(/max-age=(\d+)/)
      if (maxAge) {
        return parseInt(maxAge[1]) * 1000
      }
    }
    
    return 5 * 60 * 1000  // 5 minutes default
  }
}

// plugins/MetricsPlugin.ts
export class MetricsPlugin implements ApiPlugin {
  name = 'metrics'
  version = '1.0.0'
  
  private metrics = {
    requests: 0,
    successes: 0,
    failures: 0,
    totalDuration: 0,
    endpoints: new Map<string, EndpointMetrics>()
  }
  
  install(api: TypedFetch) {
    api.metrics = {
      get: () => ({ ...this.metrics }),
      reset: () => this.resetMetrics(),
      getEndpoint: (url: string) => this.metrics.endpoints.get(url)
    }
  }
  
  beforeRequest(config: RequestConfig): RequestConfig {
    config.metadata.metricsStart = Date.now()
    this.metrics.requests++
    
    return config
  }
  
  afterResponse(response: Response): Response {
    const duration = Date.now() - response.config.metadata.metricsStart
    
    this.metrics.successes++
    this.metrics.totalDuration += duration
    
    this.updateEndpointMetrics(response.config.url, {
      success: true,
      duration
    })
    
    return response
  }
  
  onError(error: Error): Error {
    const duration = Date.now() - error.config.metadata.metricsStart
    
    this.metrics.failures++
    this.metrics.totalDuration += duration
    
    this.updateEndpointMetrics(error.config.url, {
      success: false,
      duration,
      error: error.message
    })
    
    return error
  }
  
  private updateEndpointMetrics(url: string, data: any) {
    if (!this.metrics.endpoints.has(url)) {
      this.metrics.endpoints.set(url, {
        requests: 0,
        successes: 0,
        failures: 0,
        avgDuration: 0,
        errors: new Map()
      })
    }
    
    const endpoint = this.metrics.endpoints.get(url)!
    endpoint.requests++
    
    if (data.success) {
      endpoint.successes++
    } else {
      endpoint.failures++
      
      const errorCount = endpoint.errors.get(data.error) || 0
      endpoint.errors.set(data.error, errorCount + 1)
    }
    
    // Update average duration
    endpoint.avgDuration = 
      (endpoint.avgDuration * (endpoint.requests - 1) + data.duration) / 
      endpoint.requests
  }
  
  private resetMetrics() {
    this.metrics = {
      requests: 0,
      successes: 0,
      failures: 0,
      totalDuration: 0,
      endpoints: new Map()
    }
  }
}

// core/PluginManager.ts
export class PluginManager {
  private plugins = new Map<string, ApiPlugin>()
  
  register(plugin: ApiPlugin, api: TypedFetch) {
    if (this.plugins.has(plugin.name)) {
      throw new Error(`Plugin ${plugin.name} already registered`)
    }
    
    this.plugins.set(plugin.name, plugin)
    
    // Install plugin
    if (plugin.install) {
      plugin.install(api)
    }
    
    // Register interceptors
    if (plugin.beforeRequest) {
      api.addRequestInterceptor(config => plugin.beforeRequest!(config))
    }
    
    if (plugin.afterResponse) {
      api.addResponseInterceptor(response => plugin.afterResponse!(response))
    }
    
    if (plugin.onError) {
      api.addErrorInterceptor(error => plugin.onError!(error))
    }
    
    console.log(`Plugin ${plugin.name} v${plugin.version} registered`)
  }
  
  unregister(pluginName: string, api: TypedFetch) {
    const plugin = this.plugins.get(pluginName)
    
    if (plugin?.uninstall) {
      plugin.uninstall(api)
    }
    
    this.plugins.delete(pluginName)
    console.log(`Plugin ${pluginName} unregistered`)
  }
  
  get(pluginName: string): ApiPlugin | undefined {
    return this.plugins.get(pluginName)
  }
  
  list(): PluginInfo[] {
    return Array.from(this.plugins.values()).map(plugin => ({
      name: plugin.name,
      version: plugin.version
    }))
  }
}

// Usage
const api = createTypedFetch()
const pluginManager = new PluginManager()

// Register plugins
pluginManager.register(new CachingPlugin(), api)
pluginManager.register(new MetricsPlugin(), api)
pluginManager.register(new LoggingPlugin(), api)
pluginManager.register(new RetryPlugin(), api)

// Use enhanced API
const { data } = await api.get('/users')
console.log(api.metrics.get())

Code Generation: Let Machines Write Code

Generate TypeScript clients from OpenAPI specs:

// generators/ApiGenerator.ts
export class ApiGenerator {
  async generateFromOpenAPI(specUrl: string): Promise<GeneratedCode> {
    const spec = await this.fetchSpec(specUrl)
    
    const code = {
      types: this.generateTypes(spec),
      client: this.generateClient(spec),
      mocks: this.generateMocks(spec),
      tests: this.generateTests(spec)
    }
    
    return code
  }
  
  private generateTypes(spec: OpenAPISpec): string {
    const types: string[] = []
    
    // Generate interfaces from schemas
    Object.entries(spec.components.schemas).forEach(([name, schema]) => {
      types.push(this.schemaToInterface(name, schema))
    })
    
    // Generate request/response types
    Object.entries(spec.paths).forEach(([path, methods]) => {
      Object.entries(methods).forEach(([method, operation]) => {
        if (operation.requestBody) {
          types.push(this.generateRequestType(path, method, operation))
        }
        
        if (operation.responses) {
          types.push(this.generateResponseTypes(path, method, operation))
        }
      })
    })
    
    return types.join('\n\n')
  }
  
  private generateClient(spec: OpenAPISpec): string {
    const methods: string[] = []
    
    Object.entries(spec.paths).forEach(([path, pathItem]) => {
      Object.entries(pathItem).forEach(([method, operation]) => {
        if (['get', 'post', 'put', 'patch', 'delete'].includes(method)) {
          methods.push(this.generateMethod(path, method, operation))
        }
      })
    })
    
    return `
export class ${this.getClientName(spec)}Client {
  constructor(private tf: TypedFetch) {}
  
  ${methods.join('\n\n  ')}
}
    `.trim()
  }
  
  private generateMethod(path: string, method: string, operation: Operation): string {
    const name = this.getMethodName(operation, path, method)
    const params = this.getMethodParams(operation)
    const returnType = this.getReturnType(operation)
    
    return `
  async ${name}(${params}): Promise<${returnType}> {
    const { data } = await this.tf.${method}<${returnType}>(\`${path}\`, {
      ${this.getRequestConfig(operation)}
    })
    
    return data
  }
    `.trim()
  }
  
  private schemaToInterface(name: string, schema: Schema): string {
    const properties = Object.entries(schema.properties || {})
      .map(([prop, propSchema]) => {
        const type = this.schemaToType(propSchema)
        const optional = !schema.required?.includes(prop) ? '?' : ''
        return `  ${prop}${optional}: ${type}`
      })
      .join('\n')
    
    return `
export interface ${name} {
${properties}
}
    `.trim()
  }
  
  private schemaToType(schema: Schema): string {
    if (schema.$ref) {
      return schema.$ref.split('/').pop()!
    }
    
    switch (schema.type) {
      case 'string':
        return schema.enum ? schema.enum.map(e => `'${e}'`).join(' | ') : 'string'
      case 'number':
      case 'integer':
        return 'number'
      case 'boolean':
        return 'boolean'
      case 'array':
        return `${this.schemaToType(schema.items)}[]`
      case 'object':
        return 'any'  // Could be more specific
      default:
        return 'any'
    }
  }
  
  private getMethodName(operation: Operation, path: string, method: string): string {
    if (operation.operationId) {
      return camelCase(operation.operationId)
    }
    
    // Generate from path and method
    const parts = path.split('/').filter(p => p && !p.startsWith('{'))
    return camelCase(`${method}_${parts.join('_')}`)
  }
}

// Usage
const generator = new ApiGenerator()
const code = await generator.generateFromOpenAPI('https://api.example.com/openapi.json')

// Write generated code
await fs.writeFile('generated/api-types.ts', code.types)
await fs.writeFile('generated/api-client.ts', code.client)
await fs.writeFile('generated/api-mocks.ts', code.mocks)
await fs.writeFile('generated/api-tests.ts', code.tests)

Weather Buddy 13.0: Architecture at Scale

Let's rebuild Weather Buddy with proper architecture:

// Weather Buddy 13.0 - Enterprise Architecture

// 1. Core Domain Models
// domain/models/Weather.ts
export interface Weather {
  city: string
  country: string
  coordinates: Coordinates
  current: CurrentConditions
  forecast?: Forecast
  alerts?: Alert[]
  lastUpdated: Date
}

export interface CurrentConditions {
  temperature: Temperature
  feelsLike: Temperature
  humidity: Percentage
  pressure: Pressure
  visibility: Distance
  wind: Wind
  uv: UVIndex
  condition: WeatherCondition
}

export interface Temperature {
  value: number
  unit: 'celsius' | 'fahrenheit' | 'kelvin'
}

export interface Wind {
  speed: Speed
  direction: Direction
  gust?: Speed
}

// 2. Repository Layer
// repositories/base/BaseRepository.ts
export abstract class BaseRepository<T> {
  constructor(
    protected tf: TypedFetch,
    protected cache?: CacheManager
  ) {}
  
  protected async fetchWithCache<R>(
    key: string,
    fetcher: () => Promise<R>,
    ttl?: number
  ): Promise<R> {
    if (this.cache) {
      const cached = await this.cache.get<R>(key)
      if (cached) return cached
    }
    
    const data = await fetcher()
    
    if (this.cache) {
      await this.cache.set(key, data, ttl)
    }
    
    return data
  }
  
  protected handleError(error: any): never {
    if (error.response?.status === 404) {
      throw new NotFoundError(error.message)
    }
    
    if (error.response?.status === 401) {
      throw new UnauthorizedError(error.message)
    }
    
    if (error.code === 'NETWORK_ERROR') {
      throw new NetworkError(error.message)
    }
    
    throw new ApiError(error.message, error)
  }
}

// 3. Service Layer
// services/WeatherService.ts
export class WeatherService {
  constructor(
    private weatherRepo: WeatherRepository,
    private userRepo: UserRepository,
    private alertService: AlertService,
    private analyticsService: AnalyticsService
  ) {}
  
  async getWeatherForUser(userId: string): Promise<UserWeatherData> {
    const user = await this.userRepo.getById(userId)
    const favorites = await this.userRepo.getFavorites(userId)
    
    // Track analytics
    this.analyticsService.track('weather_dashboard_viewed', {
      userId,
      favoriteCount: favorites.length
    })
    
    // Fetch weather in parallel
    const weatherPromises = favorites.map(fav => 
      this.getEnhancedWeather(fav.city)
    )
    
    const weatherData = await Promise.all(weatherPromises)
    
    return {
      user,
      preferences: user.preferences,
      weather: weatherData,
      generated: new Date()
    }
  }
  
  private async getEnhancedWeather(city: string): Promise<EnhancedWeather> {
    const [weather, alerts, insights] = await Promise.all([
      this.weatherRepo.getByCity(city),
      this.alertService.getForCity(city),
      this.generateInsights(city)
    ])
    
    return {
      ...weather,
      alerts,
      insights,
      enhanced: true
    }
  }
  
  private async generateInsights(city: string): Promise<Insight[]> {
    const insights: Insight[] = []
    
    // Get historical data
    const historical = await this.weatherRepo.getHistorical(city, 30)
    
    // Temperature trends
    const tempTrend = this.analyzeTrend(
      historical.map(h => h.temperature.value)
    )
    
    if (tempTrend.significant) {
      insights.push({
        type: 'temperature_trend',
        title: `Temperature ${tempTrend.direction}`,
        description: `Average temperature has ${tempTrend.direction} by ${tempTrend.change}° over the past month`,
        severity: 'info'
      })
    }
    
    return insights
  }
  
  private analyzeTrend(values: number[]): TrendAnalysis {
    // Linear regression to find trend
    const n = values.length
    const sumX = values.reduce((sum, _, i) => sum + i, 0)
    const sumY = values.reduce((sum, val) => sum + val, 0)
    const sumXY = values.reduce((sum, val, i) => sum + i * val, 0)
    const sumX2 = values.reduce((sum, _, i) => sum + i * i, 0)
    
    const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX)
    const change = slope * n
    
    return {
      direction: slope > 0 ? 'increasing' : 'decreasing',
      change: Math.abs(change),
      significant: Math.abs(change) > 2
    }
  }
}

// 4. Application Layer
// app/WeatherBuddyApp.ts
export class WeatherBuddyApp {
  private container: DIContainer
  
  constructor() {
    this.container = new DIContainer()
    this.registerServices()
  }
  
  private registerServices() {
    // Register API clients
    this.container.register('apiFactory', () => 
      new ApiClientFactory(config)
    )
    
    this.container.register('weatherClient', (c) => 
      c.get('apiFactory').create('weather')
    )
    
    this.container.register('userClient', (c) => 
      c.get('apiFactory').create('user')
    )
    
    // Register repositories
    this.container.register('weatherRepo', (c) => 
      new WeatherRepository(c.get('weatherClient'))
    )
    
    this.container.register('userRepo', (c) => 
      new UserRepository(c.get('userClient'))
    )
    
    // Register services
    this.container.register('weatherService', (c) => 
      new WeatherService(
        c.get('weatherRepo'),
        c.get('userRepo'),
        c.get('alertService'),
        c.get('analyticsService')
      )
    )
    
    // Register plugins
    this.registerPlugins()
  }
  
  private registerPlugins() {
    const pluginManager = new PluginManager()
    
    // Core plugins
    pluginManager.register(new CachingPlugin())
    pluginManager.register(new MetricsPlugin())
    pluginManager.register(new LoggingPlugin())
    
    // Feature plugins
    pluginManager.register(new OfflinePlugin())
    pluginManager.register(new CompressionPlugin())
    pluginManager.register(new SecurityPlugin())
    
    this.container.register('plugins', () => pluginManager)
  }
  
  async initialize() {
    // Load configuration
    await this.loadConfig()
    
    // Initialize services
    await this.container.get('weatherService').initialize()
    
    // Start background tasks
    this.startBackgroundTasks()
    
    // Setup error handling
    this.setupErrorHandling()
  }
  
  private startBackgroundTasks() {
    // Sync favorites
    setInterval(() => {
      this.container.get('syncService').syncFavorites()
    }, 5 * 60 * 1000)
    
    // Update cache
    setInterval(() => {
      this.container.get('cacheService').cleanup()
    }, 60 * 60 * 1000)
    
    // Send analytics
    setInterval(() => {
      this.container.get('analyticsService').flush()
    }, 30 * 1000)
  }
  
  private setupErrorHandling() {
    window.addEventListener('unhandledrejection', (event) => {
      this.container.get('errorService').handle(event.reason)
    })
  }
  
  getService<T>(name: string): T {
    return this.container.get(name)
  }
}

// 5. Dependency Injection Container
// core/DIContainer.ts
export class DIContainer {
  private services = new Map<string, any>()
  private factories = new Map<string, Factory>()
  
  register(name: string, factory: Factory) {
    this.factories.set(name, factory)
  }
  
  get<T>(name: string): T {
    if (this.services.has(name)) {
      return this.services.get(name)
    }
    
    const factory = this.factories.get(name)
    if (!factory) {
      throw new Error(`Service ${name} not registered`)
    }
    
    const service = factory(this)
    this.services.set(name, service)
    
    return service
  }
  
  has(name: string): boolean {
    return this.factories.has(name)
  }
  
  reset() {
    this.services.clear()
  }
}

// Initialize the app
const app = new WeatherBuddyApp()
await app.initialize()

export default app

Best Practices for API Abstractions 🎯

1. Separation of Concerns

// ✅ Good: Clear separation
class WeatherRepository {  // Data access
  async getByCity(city: string): Promise<RawWeather> 
}

class WeatherService {     // Business logic
  async getWeatherWithInsights(city: string): Promise<EnhancedWeather>
}

class WeatherController {  // HTTP handling
  async handleGetWeather(req: Request): Promise<Response>
}

// ❌ Bad: Mixed concerns
class WeatherManager {
  async getWeather(req: Request) {
    // Validation, data access, business logic, response formatting
    // all in one place!
  }
}

2. Dependency Injection

// ✅ Good: Dependencies injected
class WeatherService {
  constructor(
    private weatherRepo: WeatherRepository,
    private cache: CacheService
  ) {}
}

// ❌ Bad: Hard dependencies
class WeatherService {
  private weatherRepo = new WeatherRepository()
  private cache = new CacheService()
}

3. Error Handling

// ✅ Good: Domain-specific errors
class CityNotFoundError extends Error {
  constructor(city: string) {
    super(`City ${city} not found`)
    this.name = 'CityNotFoundError'
  }
}

// ❌ Bad: Generic errors
throw new Error('City not found')

4. Testability

// ✅ Good: Easily testable
const mockRepo = createMock<WeatherRepository>()
const service = new WeatherService(mockRepo)

// ❌ Bad: Hard to test
const service = new WeatherService()  // Creates own dependencies

Practice Time! 🏋️

Exercise 1: Build a Repository

Create a complete repository:

class ProductRepository {
  // Your code here:
  // - CRUD operations
  // - Search functionality
  // - Batch operations
  // - Error handling
}

Exercise 2: Create a Plugin

Build a custom plugin:

class RateLimitPlugin implements ApiPlugin {
  // Your code here:
  // - Track requests per endpoint
  // - Implement backoff
  // - Queue when limited
  // - Provide status
}

Exercise 3: Design an API Factory

Create a flexible factory:

class ApiFactory {
  // Your code here:
  // - Service registration
  // - Configuration management
  // - Interceptor setup
  // - Plugin system
}

Key Takeaways 🎯

  1. Repository pattern centralizes data access - One place for API calls
  2. Domain layer speaks business language - Not API language
  3. Factories create configured clients - Consistent setup
  4. Plugins make APIs extensible - Add features without modifying core
  5. Code generation saves time - Let machines write boilerplate
  6. Dependency injection enables testing - Mock everything
  7. Composable APIs scale with teams - Build once, use everywhere
  8. Clear separation of concerns - Each layer has one job

Common Pitfalls 🚨

  1. Leaking API details into UI - Keep transformations in repository
  2. Tight coupling to API structure - Use domain models
  3. Scattered API configuration - Centralize in factories
  4. No error abstraction - Throw domain errors
  5. Missing dependency injection - Hard to test
  6. Over-engineering - Start simple, evolve as needed

What's Next?

You've mastered API abstractions! But how do you integrate with frameworks? In Chapter 14, we'll explore framework integration:

  • React hooks and providers
  • Vue composables
  • Angular services
  • Svelte stores
  • Next.js integration
  • Framework-agnostic patterns

Ready to integrate with any framework? See you in Chapter 14! ⚛️


Chapter Summary

  • Repository pattern centralizes all API calls in one place per domain
  • Domain-driven design creates a business language layer above APIs
  • API client factories provide consistent configuration across services
  • Composable APIs enable building complex features from simple pieces
  • Plugin architecture makes APIs extensible without modifying core code
  • Code generation from OpenAPI specs eliminates boilerplate
  • Dependency injection enables testing and flexibility
  • Weather Buddy 13.0 demonstrates enterprise-scale architecture patterns

Next Chapter Preview: Framework Integration - React hooks, Vue composables, Angular services, and framework-agnostic patterns for using TypedFetch.