- 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>
118 lines
3.1 KiB
TypeScript
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();
|