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 8000Express.js: Detection via Swagger patterns and port 3000
NestJS: Detection via
/api-json
endpointSpring Boot: Detection via
/v3/api-docs
and port 8080Flask, 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!!
Subscribe to my newsletter
Read articles from Chijioke Ugwuanyi directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by