swagger-tools/src/lib/cache.ts
rimskij 70fada22d6 refactor: consolidate shared utilities and reduce code duplication
- Extract HTTP_METHODS constant to types.ts (eliminates duplication in 3 files)
- Add DEFAULT_CACHE_MAX_SIZE and DEFAULT_CACHE_TTL_MINUTES constants to cache.ts
- Create schema-utils.ts with getSchemas, findSchema, getSchemaNames, getSchemaCount
- Create spec-guards.ts with isOpenAPIV3, isSwaggerV2, getSpecVersion type guards
- Create tool-response.ts with successResponse, errorResponse helpers
- Update all tool handlers to use response helpers (~50 lines reduced)
- Update parser.ts to use type guards for version detection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:45:18 +01:00

118 lines
3.1 KiB
TypeScript

import * as fs from 'fs';
import * as path from 'path';
import type { OpenAPISpec, ParsedSpec } from './types.js';
/** Default maximum number of cached specs */
export const DEFAULT_CACHE_MAX_SIZE = 10;
/** Default cache TTL in minutes */
export const DEFAULT_CACHE_TTL_MINUTES = 15;
export interface CacheEntry {
spec: OpenAPISpec;
metadata: ParsedSpec;
dereferenced: boolean;
timestamp: number;
}
export class SpecCache {
private cache: Map<string, CacheEntry>;
private maxSize: number;
private ttlMs: number;
constructor(maxSize = DEFAULT_CACHE_MAX_SIZE, ttlMinutes = DEFAULT_CACHE_TTL_MINUTES) {
this.cache = new Map();
this.maxSize = maxSize;
this.ttlMs = ttlMinutes * 60 * 1000;
}
get(key: string): CacheEntry | undefined {
const entry = this.cache.get(key);
if (!entry) return undefined;
// Check TTL expiration
if (Date.now() - entry.timestamp > this.ttlMs) {
this.cache.delete(key);
return undefined;
}
// Move to end for LRU (delete and re-add)
this.cache.delete(key);
this.cache.set(key, entry);
return entry;
}
set(key: string, entry: Omit<CacheEntry, 'timestamp'>): void {
// Evict oldest entry if at capacity
if (this.cache.size >= this.maxSize) {
const firstKey = this.cache.keys().next().value;
if (firstKey) {
this.cache.delete(firstKey);
}
}
this.cache.set(key, { ...entry, timestamp: Date.now() });
}
invalidate(key?: string): void {
if (key) {
this.cache.delete(key);
} else {
this.cache.clear();
}
}
get size(): number {
return this.cache.size;
}
}
/**
* Generate cache key for a spec path.
* - Remote URLs: normalized URL
* - Local files: absolute path + mtime for change detection
*/
export function getCacheKey(specPath: string): string {
// Remote URL
if (specPath.startsWith('http://') || specPath.startsWith('https://')) {
return normalizeUrl(specPath);
}
// Local file: include mtime for change detection
try {
const resolved = path.resolve(specPath);
const stat = fs.statSync(resolved);
return `file:${resolved}:${stat.mtimeMs}`;
} catch {
// File doesn't exist or can't be read - use path only
return `file:${path.resolve(specPath)}`;
}
}
/**
* Normalize URL for consistent cache keys.
* - Removes trailing slashes
* - Lowercases protocol and host
*/
function normalizeUrl(url: string): string {
try {
const parsed = new URL(url);
// Lowercase protocol and host
let normalized = `${parsed.protocol}//${parsed.host.toLowerCase()}`;
// Add pathname (remove trailing slash unless it's the root)
const pathname = parsed.pathname.replace(/\/+$/, '') || '/';
normalized += pathname;
// Add search params if present (sorted for consistency)
if (parsed.search) {
const params = new URLSearchParams(parsed.search);
params.sort();
normalized += `?${params.toString()}`;
}
return normalized;
} catch {
// Invalid URL, use as-is
return url;
}
}
// Global cache instance
export const specCache = new SpecCache();