API Live Sync #3: Live Sync Service

Part 3: Implementing the Core Service Layer. You can find the repository here.


In our previous articles, we covered the architecture and data structures for live API synchronization. Now it's time to roll up our sleeves and build the actual engine that makes it all work. This is where we get our hands dirty and write some actual code that makes stuff happen!

The Challenge: Building a Bulletproof Service Layer

Having beautiful types is one thing, but making them work reliably in the wild is another thing entirely. Our service layer needs to handle:

  • Network failures and timeouts

  • Different API frameworks with their quirks

  • Storage persistence across browser sessions

  • Real-time event notifications

  • Graceful error recovery

Let's dive into how we handled each of these challenges.

The Validation System

Before we let any source into our system, we need to make sure it's legitimate.

URL Validation

export function validateURLSource(config: URLSourceConfig): SourceValidationResult {
  const errors: string[] = []
  const warnings: string[] = []

  try {
    const url = new URL(config.url)

    // Check protocol - we're picky about security
    if (!['http:', 'https:'].includes(url.protocol)) {
      errors.push('URL must use HTTP or HTTPS protocol')
    }

    // Warn about HTTP in production (we care about your security!)
    if (url.protocol === 'http:' && !isLocalhost(url.hostname)) {
      warnings.push('HTTP URLs are not secure. Consider using HTTPS.')
    }

    // Check for localhost/private IPs to prevent SSRF
    if (isPrivateIP(url.hostname)) {
      warnings.push('Private IP addresses may not be accessible from all environments')
    }
  } catch (error) {
    errors.push('Invalid URL format')
  }

  // More validation for intervals, timeouts, headers...

  return { isValid: errors.length === 0, errors, warnings }
}

The Reasoning: We're strict about errors (things that will break) but gentle with warnings (things that might cause issues).

File Validation

export function validateFileSource(config: FileSourceConfig): SourceValidationResult {
  const errors: string[] = []
  const warnings: string[] = []

  if (!config.filePath || !config.filePath.trim()) {
    errors.push('File path is required')
  } else {
    const filePath = config.filePath.trim()

    // Check file extension - we only want OpenAPI
    const validExtensions = ['.json', '.yaml', '.yml']
    const hasValidExtension = validExtensions.some(ext => 
      filePath.toLowerCase().endsWith(ext)
    )

    if (!hasValidExtension) {
      errors.push('File must have a .json, .yaml, or .yml extension')
    }

    // Security check - no directory traversal shenanigans
    if (filePath.includes('..') || filePath.includes('~')) {
      errors.push('File path cannot contain directory traversal sequences')
    }
  }

  return { isValid: errors.length === 0, errors, warnings }
}

Security First: We check for directory traversal attempts because we don't want someone trying to access ../../../etc/passwd. Safety first!

Framework Detection

This was one of the most fun parts to build. Different API frameworks have their own conventions for serving OpenAPI specs, so we built a system to figure out what we're dealing with.

The Pattern Matching System

const FRAMEWORK_PATTERNS = {
  fastapi: ['/openapi.json', '/docs/openapi.json'],
  express: ['/api-docs.json', '/swagger.json', '/api/docs'],
  nestjs: ['/api-json', '/api/docs-json'],
  spring: ['/v3/api-docs', '/v3/api-docs.json', '/swagger-ui/api-docs'],
  // ... more frameworks
} as const

Pretty basic, I know.

The Detection Algorithm

export function detectFrameworkFromURL(config: URLSourceConfig): FrameworkDetectionResult {
  const url = config.url.toLowerCase()
  const indicators: string[] = []
  let bestMatch: APIFramework = 'unknown'
  let highestConfidence = 0

  // Check each framework's patterns
  for (const [framework, patterns] of Object.entries(FRAMEWORK_PATTERNS)) {
    for (const pattern of patterns) {
      if (url.includes(pattern.toLowerCase())) {
        const confidence = calculateURLConfidence(url, pattern)
        indicators.push(`URL matches ${framework} pattern: ${pattern}`)

        if (confidence > highestConfidence) {
          highestConfidence = confidence
          bestMatch = framework as APIFramework
        }
      }
    }
  }

  // Additional heuristics - port-based detection
  if (url.includes(':8000')) {
    indicators.push('URL suggests FastAPI (port 8000)')
    if (highestConfidence < 0.6) {
      bestMatch = 'fastapi'
      highestConfidence = 0.6
    }
  }

  return { framework: bestMatch, confidence: highestConfidence, indicators }
}

The Magic: We use both explicit pattern matching (looking for known endpoints) and heuristics (common port numbers).

Framework-Specific Guidance

Once we know what framework we're dealing with, we can provide targeted help:

export function getFrameworkSetupInstructions(framework: APIFramework): string[] {
  switch (framework) {
    case 'fastapi':
      return [
        'FastAPI automatically generates OpenAPI specs',
        'Access your spec at: http://localhost:8000/openapi.json',
        'Make sure your FastAPI app is running',
        'Check that CORS is configured for cross-origin requests'
      ]

    case 'express':
      return [
        'Install swagger-jsdoc and swagger-ui-express',
        'Add JSDoc comments to your routes',
        'Set up swagger middleware to serve at /api-docs',
        'Ensure your development server is running'
      ]
    // ... more frameworks
  }
}

User Experience: Instead of generic "check your configuration" messages, we can say "Hey, you're using FastAPI! Here's exactly what you need to do."

The Service Layer

The LiveSpecSourceServiceImpl is the heart of our system. It orchestrates all the other components and provides a clean API for the UI to use.

The Event-Driven Architecture

class SimpleEventEmitter implements EventEmitter {
  private handlers = new Map<string, Array<(event: LiveSpecSourceServiceEvent) => void>>()

  emit(event: LiveSpecSourceServiceEvent): void {
    const handlers = this.handlers.get(event.type) || []
    handlers.forEach(handler => {
      try {
        handler(event)
      } catch (error) {
        console.error('Error in event handler:', error)
      }
    })
  }
}

Why Events?: The UI needs to know when things happen - when a source is registered, when a sync completes, when an error occurs. Events let us decouple the service from the UI, making both more maintainable.

Source Registration: The Onboarding Process

async registerSource(
  source: Omit<LiveSpecSource, 'id' | 'createdAt' | 'updatedAt'>
): Promise<LiveSpecSource> {
  // Step 1: Validate the name
  const nameValidation = validateSourceName(source.name)
  if (!nameValidation.isValid) {
    throw new Error(`Invalid source name: ${nameValidation.errors.join(', ')}`)
  }

  // Step 2: Validate the configuration
  const configValidation = await this.validateSource(source.config, source.type)
  if (!configValidation.isValid) {
    throw new Error(`Invalid source configuration: ${configValidation.errors.join(', ')}`)
  }

  // Step 3: Detect framework for better UX
  const framework = this.detectFramework(source.config, source.type)

  // Step 4: Create and store the source
  const newSource: LiveSpecSource = {
    id: generateSourceId(),
    name: sanitizeSourceName(source.name),
    // ... other properties
    createdAt: now,
    updatedAt: now
  }

  this.sources.set(newSource.id, newSource)
  await this.saveSourcesToStorage()

  // Step 5: Tell the world about it
  this.eventEmitter.emit({
    type: 'source-registered',
    source: newSource
  })

  return newSource
}

Synchronization

async syncSource(sourceId: string): Promise<SyncResult> {
  const source = this.sources.get(sourceId)
  if (!source) {
    throw new Error(`Source with ID ${sourceId} not found`)
  }

  // Update status to syncing
  await this.updateSourceStatus(sourceId, 'syncing')

  // Tell everyone we're starting
  this.eventEmitter.emit({ type: 'sync-started', sourceId })

  try {
    // Do the actual sync
    const result = await this.performSync(source)

    // Update source with results
    const updates: Partial<LiveSpecSource> = {
      lastSync: result.timestamp,
      status: result.success ? 'connected' : 'error',
      lastError: result.success ? undefined : result.errors.join('; ')
    }

    await this.updateSource(sourceId, updates)

    // Save to history
    const historyEntry = createSyncHistoryEntry(sourceId, result.success ? 'success' : 'error', {
      changesSummary: result.changesSummary,
      errorMessage: result.success ? undefined : result.errors.join('; '),
      specVersion: result.specVersion
    })
    await this.storage.saveSyncHistoryEntry(historyEntry)

    // Tell everyone we're done
    this.eventEmitter.emit({ type: 'sync-completed', sourceId, result })

    return result
  } catch (error) {
    // Handle errors gracefully
    // ... error handling code
  }
}

URL Synchronization

private async syncFromURL(config: URLSourceConfig): Promise<SyncResult> {
  const controller = new AbortController()
  const timeout = config.timeout || 10000

  // Set up timeout - we don't wait forever
  const timeoutId = setTimeout(() => controller.abort(), timeout)

  try {
    const response = await fetch(config.url, {
      method: 'GET',
      headers: {
        'Accept': 'application/json, application/x-yaml, text/yaml',
        ...config.headers
      },
      signal: controller.signal
    })

    clearTimeout(timeoutId)

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`)
    }

    const content = await response.text()
    const specVersion = generateContentHash(content)

    return {
      success: true,
      hasChanges: true, // For Phase 1, we assume changes
      changesSummary: ['Specification updated from URL'],
      errors: [],
      specVersion,
      timestamp: new Date()
    }
  } catch (error) {
    // Error handling with timeout detection
    if (error instanceof Error && error.name === 'AbortError') {
      throw new Error(`Request timeout after ${timeout}ms`)
    }
    throw error
  }
}

Timeout Handling: We use AbortController to handle timeouts gracefully. It's like setting an alarm clock - if the API doesn't respond in time, we give up and try again later.

The Testing Strategy

We wrote 129 tests to make sure everything works correctly. Our testing strategy had three levels:

1. Unit Tests: Testing the Building Blocks

describe('registerSource', () => {
  it('should register a new URL source', async () => {
    const sourceData = {
      name: 'Test API',
      type: 'url' as const,
      status: 'disconnected' as const,
      config: { url: 'https://api.example.com/spec.json' } as URLSourceConfig,
      syncStrategy: 'replace-all' as const
    }

    mockFetch.mockResolvedValueOnce({ ok: true, status: 200 })

    const source = await service.registerSource(sourceData)

    expect(source.id).toMatch(/^live-spec-[a-f0-9-]+$/)
    expect(source.name).toBe('Test API')
    expect(source.type).toBe('url')
  })
})

Reasoning: Test each method in isolation to make sure it does what it's supposed to do.

2. Integration Tests: Testing the Workflows

it('should complete a full FastAPI integration workflow', async () => {
  // Step 1: Register a FastAPI source
  const source = await service.registerSource({
    name: 'My FastAPI Service',
    type: 'url',
    config: { url: 'http://localhost:8000/openapi.json' },
    // ...
  })

  // Step 2: Validate the source
  const validation = await service.validateSource(config, 'url')
  expect(validation.isValid).toBe(true)

  // Step 3: Perform sync
  const syncResult = await service.syncSource(source.id)
  expect(syncResult.success).toBe(true)

  // Step 4: Verify status updated
  const updatedSource = service.getSource(source.id)
  expect(updatedSource?.status).toBe('connected')
})

Real-World Scenarios: These tests simulate actual user workflows to make sure the whole system works together.

3. Framework Detection Tests: Testing the Detective

describe('detectFrameworkFromURL', () => {
  it('should detect FastAPI from URL patterns', () => {
    const config: URLSourceConfig = {
      url: 'http://localhost:8000/openapi.json'
    }

    const result = detectFrameworkFromURL(config)

    expect(result.framework).toBe('fastapi')
    expect(result.confidence).toBeGreaterThan(0.5)
    expect(result.indicators).toContain('URL matches fastapi pattern: /openapi.json')
  })
})

Detective Work: We test that our framework detection actually works for all the patterns we claim to support.

The Challenges We Overcame

1. The Timing Challenge

One of our tests was failing because timestamps were identical:

// This failed because the timestamps were the same
expect(updated.updatedAt.getTime()).toBeGreaterThan(source.updatedAt.getTime())

// Solution: Add a small delay
await new Promise(resolve => setTimeout(resolve, 10))

Lesson Learned: Computers are fast! Sometimes too fast for our tests.

2. The Timeout Testing Challenge

Testing timeout behavior is tricky:

// Original approach - too slow and unreliable
mockFetch.mockImplementationOnce(() => 
  new Promise(resolve => setTimeout(resolve, 15000))
)

// Better approach - simulate the timeout error directly
mockFetch.mockImplementationOnce(() => 
  new Promise((resolve, reject) => {
    setTimeout(() => {
      const error = new Error('Request timeout')
      error.name = 'AbortError'
      reject(error)
    }, 100)
  })
)

Lesson Learned: Don't actually wait for timeouts in tests - simulate the error condition instead.

3. The Storage Mocking Challenge

Testing error conditions required careful mocking:

const errorStorage = {
  ...storage,
  saveSources: vi.fn().mockRejectedValue(new Error('Storage error'))
}

const errorService = new LiveSpecSourceServiceImpl(errorStorage as any)

Lesson Learned: Good error handling requires testing error conditions, which means making things fail on purpose.

What We Built

Core Functionality

  • Source Management: Register, update, and remove API sources

  • Validation: Comprehensive validation for URLs and file paths

  • Synchronization: Manual sync with proper error handling

  • Persistence: Local storage with proper serialization

  • Events: Real-time updates for UI integration

Framework Support

  • FastAPI: Automatic detection via /openapi.json and port 8000

  • Express.js: Detection via Swagger patterns and port 3000

  • NestJS: Detection via /api-json endpoint

  • Spring Boot: Detection via /v3/api-docs and port 8080

  • Flask, Django, ASP.NET, Go Gin, Rails: Pattern-based detection

  • Generic: Fallback support for any OpenAPI-compliant API

Perhaps it will be fun to play around with building the support for frameworks such as Actix-Web(Rust).


P.S. - If you made it this far, you deserve a cookie. Stay tuned for the next iteration!!

0
Subscribe to my newsletter

Read articles from Chijioke Ugwuanyi directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Chijioke Ugwuanyi
Chijioke Ugwuanyi