diff --git a/CLAUDE.md b/CLAUDE.md index 12393fb..37dc157 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,24 @@ Converts .NET-style schema names to valid TypeScript: "Company.Api.V5.ViewModel" → "ViewModel" ``` +### Spec Caching +Parsed specs are cached in-memory for improved performance: +- LRU cache with max 10 entries +- 15-minute TTL +- Local files: cache key includes mtime for change detection +- Remote URLs: normalized URL as cache key + +All tools support `noCache: true` to bypass the cache: +```json +{ + "name": "parse-spec", + "arguments": { + "path": "https://api.example.com/spec.json", + "noCache": true + } +} +``` + ### TypeScript Generation Options The `generate-types` tool supports customization via `options`: diff --git a/docs/tasks/caching-task.md b/docs/tasks/caching-task.md new file mode 100644 index 0000000..a5e7c1e --- /dev/null +++ b/docs/tasks/caching-task.md @@ -0,0 +1,159 @@ +# Task: Remote Spec Caching + +## Related Documents +- Analysis: `docs/analysis/feature-enhancements-analysis.md` +- Branch: `feature/caching` (from `feature/typescript-options`) + +## Priority +NORMAL + +## Objective +Add an in-memory LRU cache for parsed OpenAPI specs to improve performance when repeatedly accessing the same remote URLs or local files. + +## Definition of Done +- [x] Code implemented per specification +- [x] TypeScript compilation CLEAN +- [x] ALL tests passing +- [x] Manual verification with remote spec +- [x] PROOF PROVIDED (cache hit/miss demonstration) + +## Scope + +### IN SCOPE +- LRU cache with configurable max entries (default 10) +- TTL-based expiration (default 15 minutes) +- Cache key includes mtime for local files +- `noCache` parameter to bypass cache +- Cache invalidation on parse errors + +### OUT OF SCOPE +- Persistent (disk) caching +- ETag/304 support for remote URLs +- Cache warming/preloading +- Distributed caching + +## Sub-Tasks + +### Phase 1: Cache Implementation +#### 1.1 Create cache module +- **Details**: Implement LRU cache class with TTL support +- **Files**: `src/lib/cache.ts` (new) +- **Testing**: Unit test cache hit/miss/expiration + +#### 1.2 Define cache entry interface +- **Details**: Store spec, metadata, timestamp, dereferenced flag +- **Files**: `src/lib/cache.ts`, `src/lib/types.ts` +- **Testing**: TypeScript compilation + +### Phase 2: Parser Integration +#### 2.1 Add cache to parseSpec +- **Details**: Check cache before parsing, store result after +- **Files**: `src/lib/parser.ts` +- **Testing**: Verify cache hit on repeated calls + +#### 2.2 Implement cache key strategy +- **Details**: URL normalization for remote, path+mtime for local +- **Files**: `src/lib/cache.ts` +- **Testing**: Local file change triggers re-parse + +### Phase 3: Tool Integration +#### 3.1 Add noCache parameter to tools +- **Details**: Optional parameter to bypass cache +- **Files**: `src/tools/parse.ts`, `src/tools/validate.ts`, `src/tools/query.ts`, `src/tools/schema.ts`, `src/tools/generate.ts` +- **Testing**: noCache=true forces fresh parse + +## Files to Modify +- `src/lib/cache.ts`: NEW - LRU cache implementation +- `src/lib/types.ts`: Add CacheEntry interface +- `src/lib/parser.ts`: Integrate cache into parseSpec +- `src/tools/*.ts`: Add noCache parameter to all tools + +## Risks & Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Stale cache for rapidly changing specs | MEDIUM | Short TTL (15 min), noCache param | +| Memory pressure with large specs | MEDIUM | LRU eviction, max 10 entries | +| Local file changes not detected | LOW | Include mtime in cache key | + +## Testing Strategy +- Build: `npm run build` - must pass +- Manual verification: + ```bash + # First call - should parse fresh + echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"parse-spec","arguments":{"path":"https://petstore.swagger.io/v2/swagger.json"}}}' | node dist/index.js + + # Second call - should hit cache (faster) + echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"parse-spec","arguments":{"path":"https://petstore.swagger.io/v2/swagger.json"}}}' | node dist/index.js + + # With noCache - should bypass + echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"parse-spec","arguments":{"path":"https://petstore.swagger.io/v2/swagger.json","noCache":true}}}' | node dist/index.js + ``` + +## Implementation Notes + +### Cache Key Strategy +```typescript +function getCacheKey(specPath: string): string { + if (specPath.startsWith('http://') || specPath.startsWith('https://')) { + // Normalize URL: remove trailing slash, sort query params + return normalizeUrl(specPath); + } + // Local file: include mtime for change detection + const resolved = path.resolve(specPath); + const stat = fs.statSync(resolved); + return `${resolved}:${stat.mtimeMs}`; +} +``` + +### LRU Cache Structure +```typescript +interface CacheEntry { + spec: OpenAPISpec; + metadata: ParsedSpec; + dereferenced: boolean; + timestamp: number; +} + +class SpecCache { + private cache: Map; + private maxSize: number; + private ttlMs: number; + + constructor(maxSize = 10, ttlMinutes = 15) { + 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; + if (Date.now() - entry.timestamp > this.ttlMs) { + this.cache.delete(key); + return undefined; + } + // Move to end for LRU + this.cache.delete(key); + this.cache.set(key, entry); + return entry; + } + + set(key: string, entry: CacheEntry): void { + if (this.cache.size >= this.maxSize) { + // Remove oldest (first) entry + const firstKey = this.cache.keys().next().value; + 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(); + } + } +} +``` diff --git a/src/lib/cache.ts b/src/lib/cache.ts new file mode 100644 index 0000000..67a163b --- /dev/null +++ b/src/lib/cache.ts @@ -0,0 +1,112 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { OpenAPISpec, ParsedSpec } from './types.js'; + +export interface CacheEntry { + spec: OpenAPISpec; + metadata: ParsedSpec; + dereferenced: boolean; + timestamp: number; +} + +export class SpecCache { + private cache: Map; + private maxSize: number; + private ttlMs: number; + + constructor(maxSize = 10, ttlMinutes = 15) { + 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): 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(); diff --git a/src/lib/parser.ts b/src/lib/parser.ts index e156b53..f75cd88 100644 --- a/src/lib/parser.ts +++ b/src/lib/parser.ts @@ -1,15 +1,39 @@ import SwaggerParser from '@apidevtools/swagger-parser'; import type { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'; import type { ParsedSpec, OpenAPISpec } from './types.js'; +import { specCache, getCacheKey } from './cache.js'; const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'] as const; -export async function parseSpec(specPath: string, options?: { dereference?: boolean }): Promise<{ +export interface ParseOptions { + dereference?: boolean; + noCache?: boolean; +} + +export interface ParseResult { spec: OpenAPISpec; metadata: ParsedSpec; dereferenced: boolean; -}> { + cached?: boolean; +} + +export async function parseSpec(specPath: string, options?: ParseOptions): Promise { const shouldDereference = options?.dereference !== false; + const useCache = options?.noCache !== true; + + // Check cache first (unless noCache is set) + if (useCache) { + const cacheKey = getCacheKey(specPath); + const cached = specCache.get(cacheKey); + if (cached) { + return { + spec: cached.spec, + metadata: cached.metadata, + dereferenced: cached.dereferenced, + cached: true, + }; + } + } let spec: OpenAPISpec; let dereferenced = false; @@ -27,7 +51,14 @@ export async function parseSpec(specPath: string, options?: { dereference?: bool } const metadata = extractMetadata(spec); - return { spec, metadata, dereferenced }; + + // Store in cache + if (useCache) { + const cacheKey = getCacheKey(specPath); + specCache.set(cacheKey, { spec, metadata, dereferenced }); + } + + return { spec, metadata, dereferenced, cached: false }; } export async function bundleSpec(specPath: string): Promise { diff --git a/src/tools/generate.ts b/src/tools/generate.ts index adf9a0a..0e5e65b 100644 --- a/src/tools/generate.ts +++ b/src/tools/generate.ts @@ -10,6 +10,7 @@ export const generateToolDescription = 'Generate TypeScript interfaces from Open export const generateToolSchema = { path: z.string().describe('Path to the OpenAPI/Swagger spec file'), schemas: z.array(z.string()).optional().describe('Specific schema names to generate (all if omitted)'), + noCache: z.boolean().optional().describe('Bypass cache and parse fresh'), options: z.object({ enumAsUnion: z.boolean().optional().describe('Generate enums as union types (default: true)'), enumAsEnum: z.boolean().optional().describe('Generate enums as TypeScript enums'), @@ -25,9 +26,10 @@ export const generateToolSchema = { }).optional().describe('TypeScript generation options'), }; -export async function generateToolHandler({ path, schemas, options }: { +export async function generateToolHandler({ path, schemas, noCache, options }: { path: string; schemas?: string[]; + noCache?: boolean; options?: TypeScriptOptions; }): Promise<{ content: Array<{ type: 'text'; text: string }>; @@ -45,7 +47,7 @@ export async function generateToolHandler({ path, schemas, options }: { }; } - const { spec } = await parseSpec(path); + const { spec } = await parseSpec(path, { noCache }); const allSchemas = getSchemas(spec); diff --git a/src/tools/parse.ts b/src/tools/parse.ts index fc0264c..2c59b11 100644 --- a/src/tools/parse.ts +++ b/src/tools/parse.ts @@ -8,19 +8,26 @@ export const parseToolDescription = 'Parse and analyze an OpenAPI/Swagger specif export const parseToolSchema = { path: z.string().describe('Path to the OpenAPI/Swagger spec file (YAML or JSON)'), + noCache: z.boolean().optional().describe('Bypass cache and parse fresh'), }; -export async function parseToolHandler({ path }: { path: string }): Promise<{ +export async function parseToolHandler({ path, noCache }: { + path: string; + noCache?: boolean; +}): Promise<{ content: Array<{ type: 'text'; text: string }>; structuredContent: Record; }> { try { - const { spec, metadata, dereferenced } = await parseSpec(path); + const { spec, metadata, dereferenced, cached } = await parseSpec(path, { noCache }); let text = formatMetadata(metadata); if (!dereferenced) { text += '\n\n**Warning**: Spec has broken $refs. Parsed without dereferencing.'; } + if (cached) { + text += '\n\n*Served from cache*'; + } return { content: [{ type: 'text', text }], @@ -28,6 +35,7 @@ export async function parseToolHandler({ path }: { path: string }): Promise<{ success: true, metadata, dereferenced, + cached, spec, }, }; diff --git a/src/tools/query.ts b/src/tools/query.ts index 36f03ec..914ce86 100644 --- a/src/tools/query.ts +++ b/src/tools/query.ts @@ -14,6 +14,7 @@ export const queryToolSchema = { pathPattern: z.string().optional().describe('Regex pattern to match path'), tag: z.string().optional().describe('Filter by tag name'), operationId: z.string().optional().describe('Filter by exact operation ID'), + noCache: z.boolean().optional().describe('Bypass cache and parse fresh'), }; const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'] as const; @@ -24,12 +25,13 @@ export async function queryToolHandler(args: { pathPattern?: string; tag?: string; operationId?: string; + noCache?: boolean; }): Promise<{ content: Array<{ type: 'text'; text: string }>; structuredContent: Record; }> { try { - const { spec } = await parseSpec(args.path); + const { spec } = await parseSpec(args.path, { noCache: args.noCache }); const filter: EndpointFilter = { method: args.method?.toLowerCase(), diff --git a/src/tools/schema.ts b/src/tools/schema.ts index f27b854..a5f64da 100644 --- a/src/tools/schema.ts +++ b/src/tools/schema.ts @@ -10,17 +10,19 @@ export const schemaToolDescription = 'Get details of a component schema from an export const schemaToolSchema = { path: z.string().describe('Path to the OpenAPI/Swagger spec file'), schemaName: z.string().describe('Name of the schema in components/schemas (or definitions for Swagger 2.0)'), + noCache: z.boolean().optional().describe('Bypass cache and parse fresh'), }; -export async function schemaToolHandler({ path, schemaName }: { +export async function schemaToolHandler({ path, schemaName, noCache }: { path: string; schemaName: string; + noCache?: boolean; }): Promise<{ content: Array<{ type: 'text'; text: string }>; structuredContent: Record; }> { try { - const { spec } = await parseSpec(path); + const { spec } = await parseSpec(path, { noCache }); const schema = findSchema(spec, schemaName);