Compare commits
4 commits
1feae7a91d
...
4984afbff3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4984afbff3 | ||
|
|
70fada22d6 | ||
|
|
cae5f7fce1 | ||
|
|
a4fc2df4ea |
18 changed files with 1489 additions and 204 deletions
46
CLAUDE.md
46
CLAUDE.md
|
|
@ -35,7 +35,7 @@ Restart Claude Code after configuration.
|
|||
| `validate-spec` | Validate against schema | `path` |
|
||||
| `query-endpoints` | Search/filter endpoints | `path`, `method?`, `pathPattern?`, `tag?` |
|
||||
| `get-schema` | Get schema details | `path`, `schemaName` |
|
||||
| `generate-types` | Generate TypeScript | `path`, `schemas?` |
|
||||
| `generate-types` | Generate TypeScript | `path`, `schemas?`, `options?` |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
|
|
@ -70,6 +70,50 @@ 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`:
|
||||
|
||||
| Option | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `enumAsUnion` | boolean | Generate enums as union types (default) |
|
||||
| `enumAsEnum` | boolean | Generate enums as TypeScript enums |
|
||||
| `interfacePrefix` | string | Prefix for interface names (e.g., "I") |
|
||||
| `interfaceSuffix` | string | Suffix for interface names (e.g., "Type") |
|
||||
| `indentation` | '2'\|'4'\|'tab' | Indentation style (default: '2') |
|
||||
|
||||
Example:
|
||||
```json
|
||||
{
|
||||
"name": "generate-types",
|
||||
"arguments": {
|
||||
"path": "api.yaml",
|
||||
"options": {
|
||||
"enumAsEnum": true,
|
||||
"interfacePrefix": "I",
|
||||
"indentation": "4"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
|
|
|
|||
470
docs/analysis/feature-enhancements-analysis.md
Normal file
470
docs/analysis/feature-enhancements-analysis.md
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
# Analysis: Feature Enhancements (Caching, OpenAPI 3.1, Batch, TypeScript Options)
|
||||
|
||||
## Scope Affected
|
||||
- [x] Backend
|
||||
- [ ] Frontend
|
||||
- [ ] Database
|
||||
- [ ] Infrastructure
|
||||
|
||||
## Summary
|
||||
|
||||
Analysis of four proposed feature enhancements for the swagger-tools MCP server: remote spec caching, OpenAPI 3.1 support improvements, batch operations, and TypeScript generation options. The current codebase is well-structured with clear separation of concerns, making these enhancements straightforward to implement.
|
||||
|
||||
## Files Involved
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `/Users/rimskij/projects/swagger-tools/src/index.ts` | MCP server entry point - tool registration |
|
||||
| `/Users/rimskij/projects/swagger-tools/src/lib/parser.ts` | Spec parsing with dereference fallback |
|
||||
| `/Users/rimskij/projects/swagger-tools/src/lib/validator.ts` | Spec validation wrapper |
|
||||
| `/Users/rimskij/projects/swagger-tools/src/lib/types.ts` | TypeScript interfaces for parsed data |
|
||||
| `/Users/rimskij/projects/swagger-tools/src/tools/parse.ts` | parse-spec tool handler |
|
||||
| `/Users/rimskij/projects/swagger-tools/src/tools/validate.ts` | validate-spec tool handler |
|
||||
| `/Users/rimskij/projects/swagger-tools/src/tools/query.ts` | query-endpoints tool handler |
|
||||
| `/Users/rimskij/projects/swagger-tools/src/tools/schema.ts` | get-schema tool handler |
|
||||
| `/Users/rimskij/projects/swagger-tools/src/tools/generate.ts` | generate-types tool with sanitizeName() |
|
||||
| `/Users/rimskij/projects/swagger-tools/src/utils/format.ts` | Human-readable output formatters |
|
||||
|
||||
## Current Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts # Entry: registers 5 tools, starts stdio server
|
||||
├── lib/
|
||||
│ ├── parser.ts # parseSpec() - dereference with fallback
|
||||
│ ├── validator.ts # validateSpec(), validateWithWarnings()
|
||||
│ └── types.ts # TypeScript interfaces
|
||||
├── tools/
|
||||
│ ├── parse.ts # Calls parseSpec(), returns metadata
|
||||
│ ├── validate.ts # Calls validateWithWarnings()
|
||||
│ ├── query.ts # Filters endpoints from parsed spec
|
||||
│ ├── schema.ts # Looks up schema by name
|
||||
│ └── generate.ts # Converts schemas to TypeScript
|
||||
└── utils/
|
||||
└── format.ts # Markdown formatters
|
||||
```
|
||||
|
||||
### Execution Flow
|
||||
|
||||
1. **Entry**: MCP client sends JSON-RPC request via stdio
|
||||
2. **Routing**: McpServer routes to registered tool handler
|
||||
3. **Parsing**: Tool handler calls `parseSpec()` with spec path
|
||||
4. **Processing**: Tool-specific logic (filter, validate, generate)
|
||||
5. **Response**: Returns `{content: [...], structuredContent: {...}}`
|
||||
|
||||
### Key Dependencies
|
||||
|
||||
- `@apidevtools/swagger-parser` v10.1.1 - Supports OpenAPI 3.1 (with workarounds)
|
||||
- `@modelcontextprotocol/sdk` v1.25.2 - MCP protocol
|
||||
- `openapi-types` v12.1.3 - TypeScript types (OpenAPIV3_1 namespace)
|
||||
- `zod` v3.23.0 - Schema validation for tool parameters
|
||||
|
||||
---
|
||||
|
||||
## Feature 1: Caching for Remote Specs
|
||||
|
||||
### Current Behavior
|
||||
|
||||
Every tool call to `parseSpec()` fetches and parses the spec fresh:
|
||||
|
||||
```typescript
|
||||
// src/lib/parser.ts:7-11
|
||||
export async function parseSpec(specPath: string, options?: { dereference?: boolean }): Promise<{...}>
|
||||
// Always fetches/reads from scratch
|
||||
spec = await SwaggerParser.dereference(specPath);
|
||||
```
|
||||
|
||||
### Proposed Solution
|
||||
|
||||
Add an in-memory LRU cache in `src/lib/parser.ts`:
|
||||
|
||||
```typescript
|
||||
// New file: src/lib/cache.ts
|
||||
interface CacheEntry {
|
||||
spec: OpenAPISpec;
|
||||
metadata: ParsedSpec;
|
||||
dereferenced: boolean;
|
||||
timestamp: number;
|
||||
etag?: string;
|
||||
}
|
||||
|
||||
class SpecCache {
|
||||
private cache: Map<string, CacheEntry>;
|
||||
private maxSize: number;
|
||||
private ttlMs: number;
|
||||
|
||||
constructor(maxSize = 10, ttlMinutes = 15) {...}
|
||||
get(key: string): CacheEntry | undefined {...}
|
||||
set(key: string, entry: CacheEntry): void {...}
|
||||
invalidate(key?: string): void {...}
|
||||
}
|
||||
```
|
||||
|
||||
### Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/lib/cache.ts` | NEW: LRU cache with TTL |
|
||||
| `src/lib/parser.ts` | Import cache, wrap parseSpec |
|
||||
| `src/tools/parse.ts` | Add optional `noCache` parameter |
|
||||
| `src/tools/validate.ts` | Add optional `noCache` parameter |
|
||||
| `src/tools/query.ts` | Add optional `noCache` parameter |
|
||||
| `src/tools/schema.ts` | Add optional `noCache` parameter |
|
||||
| `src/tools/generate.ts` | Add optional `noCache` parameter |
|
||||
|
||||
### Cache Key Strategy
|
||||
|
||||
```typescript
|
||||
// For remote URLs: normalize URL (remove trailing slash, sort query params)
|
||||
// For local files: use absolute path + file mtime
|
||||
function getCacheKey(specPath: string): string {
|
||||
if (specPath.startsWith('http://') || specPath.startsWith('https://')) {
|
||||
return normalizeUrl(specPath);
|
||||
}
|
||||
const resolved = path.resolve(specPath);
|
||||
const stat = fs.statSync(resolved);
|
||||
return `${resolved}:${stat.mtimeMs}`;
|
||||
}
|
||||
```
|
||||
|
||||
### Risks
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| Stale cache for rapidly changing specs | MEDIUM | Add `noCache` param, short TTL (15 min) |
|
||||
| Memory pressure with large specs | MEDIUM | LRU eviction, max 10 entries |
|
||||
| Local file changes not detected | LOW | Include mtime in cache key |
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: OpenAPI 3.1 Support
|
||||
|
||||
### Current State
|
||||
|
||||
**Good news**: The underlying `@apidevtools/swagger-parser` v10.1.1 already supports OpenAPI 3.1:
|
||||
|
||||
```javascript
|
||||
// node_modules/@apidevtools/swagger-parser/lib/index.js
|
||||
const supported31Versions = ["3.1.0", "3.1.1"];
|
||||
```
|
||||
|
||||
**Gap**: The TypeScript generation in `generate.ts` doesn't handle 3.1-specific JSON Schema features.
|
||||
|
||||
### OpenAPI 3.1 Differences from 3.0
|
||||
|
||||
| Feature | 3.0 | 3.1 | Code Impact |
|
||||
|---------|-----|-----|-------------|
|
||||
| Schema dialect | OpenAPI Schema | JSON Schema 2020-12 | Type generation |
|
||||
| `type` field | Single string | Array allowed | schemaToType() |
|
||||
| `nullable` | `nullable: true` | `type: ['string', 'null']` | schemaToType() |
|
||||
| `const` | Not supported | Supported | schemaToType() |
|
||||
| `exclusiveMinimum` | boolean | number | Not TS-relevant |
|
||||
| `webhooks` | Not supported | Top-level field | metadata extraction |
|
||||
| `$ref` siblings | Not allowed | Allowed with summary/desc | Reference handling |
|
||||
| `pathItems` | Not in components | In components | Schema lookup |
|
||||
|
||||
### Proposed Changes
|
||||
|
||||
1. **Detect 3.1 version** in parser.ts metadata extraction
|
||||
2. **Handle array types** in generate.ts:
|
||||
```typescript
|
||||
// type: ['string', 'null'] -> string | null
|
||||
if (Array.isArray(s.type)) {
|
||||
return s.type.map(t => t === 'null' ? 'null' : mapPrimitiveType(t)).join(' | ');
|
||||
}
|
||||
```
|
||||
3. **Handle `const`** in generate.ts:
|
||||
```typescript
|
||||
if (s.const !== undefined) {
|
||||
return typeof s.const === 'string' ? `'${s.const}'` : String(s.const);
|
||||
}
|
||||
```
|
||||
4. **Extract webhooks** in parser.ts metadata
|
||||
|
||||
### Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/lib/parser.ts` | Add webhook count to metadata |
|
||||
| `src/lib/types.ts` | Add `webhookCount?: number` to ParsedSpec |
|
||||
| `src/tools/generate.ts` | Handle 3.1 type arrays, const |
|
||||
| `src/utils/format.ts` | Display webhook count |
|
||||
|
||||
### Risks
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| Array type combinations complex | LOW | Map each type, join with `\|` |
|
||||
| `const` type inference tricky | LOW | Use literal types |
|
||||
| Existing 3.0 specs break | LOW | Changes are additive |
|
||||
|
||||
---
|
||||
|
||||
## Feature 3: Batch Operations
|
||||
|
||||
### Proposed Design
|
||||
|
||||
Add a new tool `batch-process` that accepts multiple specs:
|
||||
|
||||
```typescript
|
||||
// src/tools/batch.ts
|
||||
export const batchToolSchema = {
|
||||
paths: z.array(z.string()).describe('Paths to OpenAPI specs'),
|
||||
operation: z.enum(['parse', 'validate']).describe('Operation to perform'),
|
||||
};
|
||||
```
|
||||
|
||||
### Implementation Options
|
||||
|
||||
**Option A: Sequential Processing** (Simpler)
|
||||
```typescript
|
||||
const results = [];
|
||||
for (const path of paths) {
|
||||
results.push(await parseSpec(path));
|
||||
}
|
||||
```
|
||||
|
||||
**Option B: Parallel Processing** (Faster)
|
||||
```typescript
|
||||
const results = await Promise.allSettled(
|
||||
paths.map(path => parseSpec(path))
|
||||
);
|
||||
```
|
||||
|
||||
### Recommended Approach
|
||||
|
||||
Use **Option B** with concurrency limit:
|
||||
|
||||
```typescript
|
||||
// src/lib/batch.ts
|
||||
async function batchProcess<T>(
|
||||
items: string[],
|
||||
processor: (item: string) => Promise<T>,
|
||||
concurrency = 5
|
||||
): Promise<Array<{ path: string; result?: T; error?: string }>> {
|
||||
const results: Array<...> = [];
|
||||
const executing: Promise<void>[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const p = processor(item)
|
||||
.then(result => results.push({ path: item, result }))
|
||||
.catch(error => results.push({ path: item, error: error.message }));
|
||||
|
||||
executing.push(p);
|
||||
if (executing.length >= concurrency) {
|
||||
await Promise.race(executing);
|
||||
}
|
||||
}
|
||||
await Promise.all(executing);
|
||||
return results;
|
||||
}
|
||||
```
|
||||
|
||||
### Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/lib/batch.ts` | NEW: Parallel processing utility |
|
||||
| `src/tools/batch.ts` | NEW: batch-process tool |
|
||||
| `src/index.ts` | Register batch-process tool |
|
||||
| `src/utils/format.ts` | Add formatBatchResults() |
|
||||
|
||||
### Output Format
|
||||
|
||||
```typescript
|
||||
interface BatchResult {
|
||||
success: boolean;
|
||||
total: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
results: Array<{
|
||||
path: string;
|
||||
success: boolean;
|
||||
data?: ParsedSpec | ValidationResult;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
### Risks
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| Resource exhaustion with many specs | HIGH | Concurrency limit (5) |
|
||||
| One failure stops all | LOW | Promise.allSettled continues |
|
||||
| Output too large | MEDIUM | Summarize in text, full in structured |
|
||||
|
||||
---
|
||||
|
||||
## Feature 4: TypeScript Generation Options
|
||||
|
||||
### Current Behavior
|
||||
|
||||
`generate-types` produces a fixed output format:
|
||||
- `export interface` for objects
|
||||
- `export type` for enums, arrays, primitives
|
||||
- JSDoc comments for descriptions
|
||||
- Properties marked optional with `?`
|
||||
|
||||
### Proposed Options
|
||||
|
||||
```typescript
|
||||
export const generateToolSchema = {
|
||||
path: z.string().describe('Path to the OpenAPI/Swagger spec file'),
|
||||
schemas: z.array(z.string()).optional(),
|
||||
options: z.object({
|
||||
// Output style
|
||||
interfacePrefix: z.string().optional().describe('Prefix for interfaces (e.g., "I")'),
|
||||
interfaceSuffix: z.string().optional().describe('Suffix for interfaces (e.g., "Type")'),
|
||||
enumAsUnion: z.boolean().optional().describe('Generate enums as union types (default: true)'),
|
||||
enumAsEnum: z.boolean().optional().describe('Generate enums as TypeScript enums'),
|
||||
|
||||
// Formatting
|
||||
indentation: z.enum(['2', '4', 'tab']).optional().describe('Indentation style'),
|
||||
semicolons: z.boolean().optional().describe('Add trailing semicolons'),
|
||||
|
||||
// Optionality
|
||||
allOptional: z.boolean().optional().describe('Make all properties optional'),
|
||||
allRequired: z.boolean().optional().describe('Make all properties required'),
|
||||
|
||||
// Advanced
|
||||
readonlyProperties: z.boolean().optional().describe('Mark properties as readonly'),
|
||||
exportDefault: z.boolean().optional().describe('Use default exports'),
|
||||
includeRefs: z.boolean().optional().describe('Include $ref comments'),
|
||||
}).optional(),
|
||||
};
|
||||
```
|
||||
|
||||
### Implementation Priority
|
||||
|
||||
**Phase 1** (High value, low effort):
|
||||
- `enumAsUnion` vs `enumAsEnum`
|
||||
- `interfacePrefix` / `interfaceSuffix`
|
||||
|
||||
**Phase 2** (Medium value):
|
||||
- `indentation` options
|
||||
- `allOptional` / `allRequired`
|
||||
|
||||
**Phase 3** (Lower priority):
|
||||
- `readonlyProperties`
|
||||
- `includeRefs`
|
||||
|
||||
### Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/tools/generate.ts` | Add options parameter, conditional generation |
|
||||
| `src/lib/types.ts` | Add TypeScriptOptions interface |
|
||||
|
||||
### Example Output Variations
|
||||
|
||||
**Default (current)**:
|
||||
```typescript
|
||||
export type PetStatus = 'available' | 'pending' | 'sold';
|
||||
export interface Pet {
|
||||
id: number;
|
||||
name: string;
|
||||
status?: PetStatus;
|
||||
}
|
||||
```
|
||||
|
||||
**With `enumAsEnum: true, interfacePrefix: 'I'`**:
|
||||
```typescript
|
||||
export enum PetStatus {
|
||||
Available = 'available',
|
||||
Pending = 'pending',
|
||||
Sold = 'sold'
|
||||
}
|
||||
export interface IPet {
|
||||
id: number;
|
||||
name: string;
|
||||
status?: PetStatus;
|
||||
}
|
||||
```
|
||||
|
||||
### Risks
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| Option combinations create invalid TS | LOW | Validate combinations |
|
||||
| Too many options confuse users | MEDIUM | Good defaults, clear docs |
|
||||
| Breaking existing behavior | LOW | All options optional with current defaults |
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Upstream (What calls this)
|
||||
- MCP Clients (Claude Code, etc.) call tools via JSON-RPC
|
||||
- All tools use `parseSpec()` from lib/parser.ts
|
||||
|
||||
### Downstream (What this calls)
|
||||
- `@apidevtools/swagger-parser` for spec parsing/validation
|
||||
- Node.js `fs` module for local file access
|
||||
- Node.js `https`/`http` for remote fetch (via swagger-parser)
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### Caching
|
||||
- Spec URL returns 304 Not Modified - should reuse cache
|
||||
- Local file deleted between cache set and get - invalidate on error
|
||||
- Concurrent requests for same URL - deduplicate in-flight requests
|
||||
|
||||
### OpenAPI 3.1
|
||||
- Mixed array types: `type: ['string', 'integer', 'null']`
|
||||
- `const` with complex objects: `const: {foo: 'bar'}`
|
||||
- `$ref` with sibling `description` - merge into output
|
||||
|
||||
### Batch Operations
|
||||
- Empty paths array - return immediately with success
|
||||
- Mix of local and remote paths - process in parallel
|
||||
- Single path failure mid-batch - continue, report in results
|
||||
|
||||
### TypeScript Options
|
||||
- Invalid combination: `enumAsUnion` + `enumAsEnum` both true
|
||||
- Schema name collision after prefix/suffix added
|
||||
- Circular references with option changes
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Caching**
|
||||
- Unit test cache hit/miss
|
||||
- Test TTL expiration
|
||||
- Test LRU eviction
|
||||
- Test local file mtime detection
|
||||
|
||||
2. **OpenAPI 3.1**
|
||||
- Add 3.1 test fixture with webhooks, array types, const
|
||||
- Test type generation for `type: ['string', 'null']`
|
||||
- Test backward compatibility with 3.0 spec
|
||||
|
||||
3. **Batch Operations**
|
||||
- Test with 0, 1, many specs
|
||||
- Test mix of success and failure
|
||||
- Test concurrency limit respected
|
||||
|
||||
4. **TypeScript Options**
|
||||
- Snapshot tests for each option combination
|
||||
- Test prefix/suffix application
|
||||
- Test enum generation modes
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order (Recommended)
|
||||
|
||||
1. **TypeScript Options** - Most user-visible, low risk
|
||||
2. **Caching** - Performance win, moderate complexity
|
||||
3. **OpenAPI 3.1** - Already mostly working, polish needed
|
||||
4. **Batch Operations** - New tool, can be done independently
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- `/implement` - Proceed with implementation (specify which feature)
|
||||
- `/create work` - Create detailed task documentation for each feature
|
||||
|
||||
159
docs/tasks/caching-task.md
Normal file
159
docs/tasks/caching-task.md
Normal file
|
|
@ -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<string, CacheEntry>;
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
175
docs/tasks/code-quality-refactoring-task.md
Normal file
175
docs/tasks/code-quality-refactoring-task.md
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
---
|
||||
openproject: 359
|
||||
---
|
||||
# Task: Code Quality Refactoring
|
||||
|
||||
## Related Documents
|
||||
- Analysis: `/streamline` command output (2026-01-12)
|
||||
- OpenProject: [#359](https://openproject.rimskij.net/work_packages/359)
|
||||
- Branch: `feature/359-code-quality-refactoring` (from `feature/caching`)
|
||||
|
||||
## Priority
|
||||
NORMAL
|
||||
|
||||
## Objective
|
||||
Reduce code duplication and improve type safety across the swagger-tools codebase by extracting shared utilities, consolidating schema access patterns, and adding proper type guards. This will reduce maintenance burden by ~30% and make adding new tools faster.
|
||||
|
||||
## Definition of Done
|
||||
- [ ] Code implemented per specification
|
||||
- [ ] TypeScript compilation CLEAN
|
||||
- [ ] ALL manual tests passing (parse-spec, validate-spec, query-endpoints, get-schema, generate-types)
|
||||
- [ ] No regression in existing functionality
|
||||
- [ ] PROOF PROVIDED (before/after comparison)
|
||||
|
||||
## Scope
|
||||
|
||||
### IN SCOPE
|
||||
- Extract HTTP_METHODS constant to shared location
|
||||
- Consolidate schema access functions (getSchemas, findSchema, getSchemaNames, getSchemaCount)
|
||||
- Create tool response helpers (successResponse, errorResponse)
|
||||
- Add type guards for OpenAPI spec versions
|
||||
- Extract cache configuration constants
|
||||
|
||||
### OUT OF SCOPE
|
||||
- Adding new features
|
||||
- Changing tool behavior or output format
|
||||
- Restructuring directory layout
|
||||
- Adding test framework
|
||||
|
||||
## Sub-Tasks
|
||||
|
||||
### Phase 1: Foundation - Extract Shared Constants
|
||||
#### 1.1 Extract HTTP_METHODS to types.ts
|
||||
- **Details**: Move the duplicated HTTP_METHODS constant from parser.ts, query.ts, and validator.ts to lib/types.ts. Export as const array and derive HttpMethod type.
|
||||
- **Files**:
|
||||
- `src/lib/types.ts` (add export)
|
||||
- `src/lib/parser.ts` (import, remove local)
|
||||
- `src/tools/query.ts` (import, remove local)
|
||||
- `src/lib/validator.ts` (import, remove inline)
|
||||
- **Testing**: `npm run build` must pass, all tools work unchanged
|
||||
|
||||
#### 1.2 Extract cache configuration constants
|
||||
- **Details**: Extract DEFAULT_CACHE_MAX_SIZE=10 and DEFAULT_CACHE_TTL_MINUTES=15 as named constants in cache.ts
|
||||
- **Files**: `src/lib/cache.ts`
|
||||
- **Testing**: `npm run build`, caching behavior unchanged
|
||||
|
||||
### Phase 2: Schema Utilities Consolidation
|
||||
#### 2.1 Create schema-utils.ts module
|
||||
- **Details**: Create new module with consolidated schema access functions extracted from schema.ts, generate.ts, and parser.ts
|
||||
- **Files**:
|
||||
- `src/lib/schema-utils.ts` (new file)
|
||||
- **Testing**: `npm run build`
|
||||
|
||||
#### 2.2 Implement getSchemas function
|
||||
- **Details**: Single function handling both OpenAPI 3.x (components.schemas) and Swagger 2.0 (definitions)
|
||||
- **Files**: `src/lib/schema-utils.ts`
|
||||
- **Testing**: Parse petstore.yaml, verify schemas returned
|
||||
|
||||
#### 2.3 Implement findSchema, getSchemaNames, getSchemaCount
|
||||
- **Details**: Build on getSchemas for consistent behavior
|
||||
- **Files**: `src/lib/schema-utils.ts`
|
||||
- **Testing**: get-schema tool works, generate-types works
|
||||
|
||||
#### 2.4 Update consumers to use schema-utils
|
||||
- **Details**: Replace local implementations with imports from schema-utils.ts
|
||||
- **Files**:
|
||||
- `src/tools/schema.ts` (replace findSchema, getAvailableSchemas)
|
||||
- `src/tools/generate.ts` (replace getSchemas)
|
||||
- `src/lib/parser.ts` (replace getSchemaCount)
|
||||
- **Testing**: All tools produce identical output to before
|
||||
|
||||
### Phase 3: Tool Response Helpers
|
||||
#### 3.1 Create tool-response.ts module
|
||||
- **Details**: Create response builder utilities for consistent MCP tool responses
|
||||
- **Files**:
|
||||
- `src/lib/tool-response.ts` (new file)
|
||||
- **Testing**: `npm run build`
|
||||
|
||||
#### 3.2 Implement successResponse and errorResponse
|
||||
- **Details**: Helper functions that construct the MCP response format with content and structuredContent
|
||||
- **Files**: `src/lib/tool-response.ts`
|
||||
- **Testing**: Type-check passes
|
||||
|
||||
#### 3.3 Update tool handlers to use response helpers
|
||||
- **Details**: Replace try/catch boilerplate in each tool handler
|
||||
- **Files**:
|
||||
- `src/tools/parse.ts`
|
||||
- `src/tools/validate.ts`
|
||||
- `src/tools/query.ts`
|
||||
- `src/tools/schema.ts`
|
||||
- `src/tools/generate.ts`
|
||||
- **Testing**: All tools produce identical output, error handling works
|
||||
|
||||
### Phase 4: Type Guards
|
||||
#### 4.1 Create spec-guards.ts module
|
||||
- **Details**: Type guard functions for OpenAPI spec versions
|
||||
- **Files**:
|
||||
- `src/lib/spec-guards.ts` (new file)
|
||||
- **Testing**: `npm run build`
|
||||
|
||||
#### 4.2 Implement isOpenAPIV3 and isSwagger2 guards
|
||||
- **Details**: Proper type guards that narrow types, replace `as` casts
|
||||
- **Files**: `src/lib/spec-guards.ts`
|
||||
- **Testing**: Type-check passes
|
||||
|
||||
#### 4.3 Update consumers to use type guards
|
||||
- **Details**: Replace type assertions with proper guards where beneficial
|
||||
- **Files**:
|
||||
- `src/lib/parser.ts`
|
||||
- `src/lib/schema-utils.ts`
|
||||
- **Testing**: All tools work unchanged
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/lib/types.ts` | Add HTTP_METHODS, HttpMethod exports |
|
||||
| `src/lib/cache.ts` | Extract cache config constants |
|
||||
| `src/lib/parser.ts` | Import HTTP_METHODS, use schema-utils, use type guards |
|
||||
| `src/lib/validator.ts` | Import HTTP_METHODS |
|
||||
| `src/lib/schema-utils.ts` | **NEW** - consolidated schema access |
|
||||
| `src/lib/tool-response.ts` | **NEW** - response helpers |
|
||||
| `src/lib/spec-guards.ts` | **NEW** - type guards |
|
||||
| `src/tools/parse.ts` | Use tool-response helpers |
|
||||
| `src/tools/validate.ts` | Use tool-response helpers |
|
||||
| `src/tools/query.ts` | Import HTTP_METHODS, use tool-response helpers |
|
||||
| `src/tools/schema.ts` | Use schema-utils, tool-response helpers |
|
||||
| `src/tools/generate.ts` | Use schema-utils, tool-response helpers |
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| Breaking existing tool behavior | HIGH | Test each tool after each phase |
|
||||
| Type errors from refactoring | MEDIUM | Run `npm run build` after each sub-task |
|
||||
| Missing edge cases in consolidation | MEDIUM | Compare output before/after for test fixtures |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
**After each phase:**
|
||||
1. Build: `npm run build` - must pass
|
||||
2. Type check: `npm run typecheck` - must pass
|
||||
3. Manual tests:
|
||||
```bash
|
||||
# Test parse-spec
|
||||
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"parse-spec","arguments":{"path":"test/fixtures/petstore.yaml"}}}' | node dist/index.js
|
||||
|
||||
# Test validate-spec
|
||||
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"validate-spec","arguments":{"path":"test/fixtures/petstore.yaml"}}}' | node dist/index.js
|
||||
|
||||
# Test query-endpoints
|
||||
echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"query-endpoints","arguments":{"path":"test/fixtures/petstore.yaml","method":"GET"}}}' | node dist/index.js
|
||||
|
||||
# Test get-schema
|
||||
echo '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"get-schema","arguments":{"path":"test/fixtures/petstore.yaml","schemaName":"Pet"}}}' | node dist/index.js
|
||||
|
||||
# Test generate-types
|
||||
echo '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"generate-types","arguments":{"path":"test/fixtures/petstore.yaml","schemas":["Pet"]}}}' | node dist/index.js
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Preserve exact output format - consumers may depend on it
|
||||
- Each phase should be independently committable
|
||||
- Run full test suite between phases
|
||||
- New files follow existing patterns (ESM imports, zod schemas, etc.)
|
||||
133
docs/tasks/typescript-options-task.md
Normal file
133
docs/tasks/typescript-options-task.md
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# Task: TypeScript Generation Options
|
||||
|
||||
## Related Documents
|
||||
- Analysis: `docs/analysis/feature-enhancements-analysis.md`
|
||||
- Branch: `feature/typescript-options` (from `dev`)
|
||||
|
||||
## Priority
|
||||
NORMAL
|
||||
|
||||
## Objective
|
||||
Add configurable options to the `generate-types` tool for customizing TypeScript output format, including enum style, interface naming conventions, and indentation preferences.
|
||||
|
||||
## Definition of Done
|
||||
- [x] Code implemented per specification
|
||||
- [x] TypeScript compilation CLEAN
|
||||
- [x] Lint checks PASSED (no lint script in project)
|
||||
- [x] ALL tests passing
|
||||
- [x] Manual verification with petstore spec
|
||||
- [x] PROOF PROVIDED (before/after output samples)
|
||||
|
||||
## Scope
|
||||
|
||||
### IN SCOPE
|
||||
- `enumAsUnion` option (default true) - generate enums as union types
|
||||
- `enumAsEnum` option - generate enums as TypeScript enums
|
||||
- `interfacePrefix` option - prefix for interface names (e.g., "I")
|
||||
- `interfaceSuffix` option - suffix for interface names (e.g., "Type")
|
||||
- `indentation` option - 2 spaces, 4 spaces, or tab
|
||||
- Validation of mutually exclusive options
|
||||
|
||||
### OUT OF SCOPE
|
||||
- `readonlyProperties` option (future enhancement)
|
||||
- `exportDefault` option (future enhancement)
|
||||
- `includeRefs` option (future enhancement)
|
||||
- `allOptional` / `allRequired` options (future enhancement)
|
||||
|
||||
## Sub-Tasks
|
||||
|
||||
### Phase 1: Options Interface and Schema
|
||||
#### 1.1 Add TypeScriptOptions interface to types.ts
|
||||
- **Details**: Define interface with all option properties as optional
|
||||
- **Files**: `src/lib/types.ts`
|
||||
- **Testing**: TypeScript compilation
|
||||
|
||||
#### 1.2 Update generate tool schema with options parameter
|
||||
- **Details**: Add nested options object to Zod schema
|
||||
- **Files**: `src/tools/generate.ts`
|
||||
- **Testing**: Parse spec with invalid options should fail
|
||||
|
||||
### Phase 2: Enum Generation Modes
|
||||
#### 2.1 Implement enumAsEnum generation
|
||||
- **Details**: Convert `type X = 'a' | 'b'` to `enum X { A = 'a', B = 'b' }`
|
||||
- **Files**: `src/tools/generate.ts`
|
||||
- **Testing**: Generate with enumAsEnum=true
|
||||
|
||||
#### 2.2 Add mutual exclusion validation
|
||||
- **Details**: Error if both enumAsUnion and enumAsEnum are true
|
||||
- **Files**: `src/tools/generate.ts`
|
||||
- **Testing**: Should reject invalid combination
|
||||
|
||||
### Phase 3: Interface Naming
|
||||
#### 3.1 Implement prefix/suffix application
|
||||
- **Details**: Apply prefix/suffix to interface names during generation
|
||||
- **Files**: `src/tools/generate.ts`
|
||||
- **Testing**: interfacePrefix='I' produces `interface IPet`
|
||||
|
||||
#### 3.2 Handle sanitized names with prefix/suffix
|
||||
- **Details**: Ensure prefix/suffix works with .NET sanitized names
|
||||
- **Files**: `src/tools/generate.ts`
|
||||
- **Testing**: `Namespace.Type` with prefix='I' produces `IType`
|
||||
|
||||
### Phase 4: Indentation Options
|
||||
#### 4.1 Implement indentation configuration
|
||||
- **Details**: Support '2', '4', 'tab' options for generated code
|
||||
- **Files**: `src/tools/generate.ts`
|
||||
- **Testing**: Generate with each option, verify output
|
||||
|
||||
## Files to Modify
|
||||
- `src/lib/types.ts`: Add `TypeScriptOptions` interface
|
||||
- `src/tools/generate.ts`: Update schema, implement option handling in schemaToType() and generateTypes()
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| Invalid option combinations | LOW | Validate at handler entry, return clear error |
|
||||
| Breaking existing behavior | LOW | All options optional with current defaults |
|
||||
| Enum PascalCase conversion edge cases | MEDIUM | Use robust capitalizeFirst function |
|
||||
|
||||
## Testing Strategy
|
||||
- Build: `npm run build` - must pass
|
||||
- Manual: Test with petstore spec using various option combinations
|
||||
- Verification commands:
|
||||
```bash
|
||||
# Default (current behavior)
|
||||
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"generate-types","arguments":{"path":"https://petstore.swagger.io/v2/swagger.json","schemas":["Pet"]}}}' | node dist/index.js
|
||||
|
||||
# With enumAsEnum
|
||||
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"generate-types","arguments":{"path":"https://petstore.swagger.io/v2/swagger.json","schemas":["Pet"],"options":{"enumAsEnum":true}}}}' | node dist/index.js
|
||||
|
||||
# With prefix
|
||||
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"generate-types","arguments":{"path":"https://petstore.swagger.io/v2/swagger.json","schemas":["Pet"],"options":{"interfacePrefix":"I"}}}}' | node dist/index.js
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Enum to TypeScript Enum Conversion
|
||||
```typescript
|
||||
// Input: enum values ['available', 'pending', 'sold']
|
||||
// Output:
|
||||
export enum PetStatus {
|
||||
Available = 'available',
|
||||
Pending = 'pending',
|
||||
Sold = 'sold'
|
||||
}
|
||||
```
|
||||
|
||||
Use PascalCase for enum member names:
|
||||
```typescript
|
||||
function toPascalCase(value: string): string {
|
||||
return value.replace(/(?:^|[_-])(\w)/g, (_, c) => c.toUpperCase());
|
||||
}
|
||||
```
|
||||
|
||||
### Indentation Implementation
|
||||
```typescript
|
||||
const INDENT = {
|
||||
'2': ' ',
|
||||
'4': ' ',
|
||||
'tab': '\t'
|
||||
};
|
||||
const indent = INDENT[options.indentation ?? '2'];
|
||||
```
|
||||
118
src/lib/cache.ts
Normal file
118
src/lib/cache.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
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();
|
||||
|
|
@ -1,15 +1,40 @@
|
|||
import SwaggerParser from '@apidevtools/swagger-parser';
|
||||
import type { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
|
||||
import type { OpenAPIV3 } from 'openapi-types';
|
||||
import { HTTP_METHODS } from './types.js';
|
||||
import type { ParsedSpec, OpenAPISpec } from './types.js';
|
||||
import { specCache, getCacheKey } from './cache.js';
|
||||
import { getSchemaCount } from './schema-utils.js';
|
||||
import { isOpenAPIV3, isSwaggerV2, getSpecVersion as getVersion } from './spec-guards.js';
|
||||
|
||||
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'] as const;
|
||||
export interface ParseOptions {
|
||||
dereference?: boolean;
|
||||
noCache?: boolean;
|
||||
}
|
||||
|
||||
export async function parseSpec(specPath: string, options?: { dereference?: boolean }): Promise<{
|
||||
export interface ParseResult {
|
||||
spec: OpenAPISpec;
|
||||
metadata: ParsedSpec;
|
||||
dereferenced: boolean;
|
||||
}> {
|
||||
cached?: boolean;
|
||||
}
|
||||
|
||||
export async function parseSpec(specPath: string, options?: ParseOptions): Promise<ParseResult> {
|
||||
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 +52,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<OpenAPISpec> {
|
||||
|
|
@ -35,7 +67,7 @@ export async function bundleSpec(specPath: string): Promise<OpenAPISpec> {
|
|||
}
|
||||
|
||||
function extractMetadata(spec: OpenAPISpec): ParsedSpec {
|
||||
const version = getSpecVersion(spec);
|
||||
const version = getSpecVersionString(spec);
|
||||
const info = spec.info;
|
||||
|
||||
const paths = spec.paths || {};
|
||||
|
|
@ -72,35 +104,25 @@ function extractMetadata(spec: OpenAPISpec): ParsedSpec {
|
|||
};
|
||||
}
|
||||
|
||||
function getSpecVersion(spec: OpenAPISpec): string {
|
||||
if ('openapi' in spec) {
|
||||
return `OpenAPI ${spec.openapi}`;
|
||||
function getSpecVersionString(spec: OpenAPISpec): string {
|
||||
const version = getVersion(spec);
|
||||
if (isOpenAPIV3(spec)) {
|
||||
return `OpenAPI ${version}`;
|
||||
}
|
||||
if ('swagger' in spec) {
|
||||
return `Swagger ${spec.swagger}`;
|
||||
if (isSwaggerV2(spec)) {
|
||||
return `Swagger ${version}`;
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
function getSchemaCount(spec: OpenAPISpec): number {
|
||||
if ('components' in spec && spec.components?.schemas) {
|
||||
return Object.keys(spec.components.schemas).length;
|
||||
}
|
||||
if ('definitions' in spec && spec.definitions) {
|
||||
return Object.keys(spec.definitions).length;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getServers(spec: OpenAPISpec): string[] {
|
||||
if ('servers' in spec && spec.servers) {
|
||||
if (isOpenAPIV3(spec) && spec.servers) {
|
||||
return spec.servers.map(s => s.url);
|
||||
}
|
||||
if ('host' in spec) {
|
||||
const swagger2 = spec as { host?: string; basePath?: string; schemes?: string[] };
|
||||
const scheme = swagger2.schemes?.[0] || 'https';
|
||||
const host = swagger2.host || 'localhost';
|
||||
const basePath = swagger2.basePath || '';
|
||||
if (isSwaggerV2(spec)) {
|
||||
const scheme = spec.schemes?.[0] || 'https';
|
||||
const host = spec.host || 'localhost';
|
||||
const basePath = spec.basePath || '';
|
||||
return [`${scheme}://${host}${basePath}`];
|
||||
}
|
||||
return [];
|
||||
|
|
|
|||
45
src/lib/schema-utils.ts
Normal file
45
src/lib/schema-utils.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Consolidated schema access utilities for OpenAPI/Swagger specs.
|
||||
* Handles both OpenAPI 3.x (components.schemas) and Swagger 2.0 (definitions).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get all schemas from a spec, handling both OpenAPI 3.x and Swagger 2.0 formats.
|
||||
*/
|
||||
export function getSchemas(spec: object): Record<string, object> {
|
||||
// OpenAPI 3.x: components.schemas
|
||||
const spec3 = spec as { components?: { schemas?: Record<string, object> } };
|
||||
if (spec3.components?.schemas) {
|
||||
return spec3.components.schemas;
|
||||
}
|
||||
|
||||
// Swagger 2.0: definitions
|
||||
const spec2 = spec as { definitions?: Record<string, object> };
|
||||
if (spec2.definitions) {
|
||||
return spec2.definitions;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a specific schema by name.
|
||||
*/
|
||||
export function findSchema(spec: object, schemaName: string): object | null {
|
||||
const schemas = getSchemas(spec);
|
||||
return schemas[schemaName] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all schema names.
|
||||
*/
|
||||
export function getSchemaNames(spec: object): string[] {
|
||||
return Object.keys(getSchemas(spec));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of schemas in the spec.
|
||||
*/
|
||||
export function getSchemaCount(spec: object): number {
|
||||
return Object.keys(getSchemas(spec)).length;
|
||||
}
|
||||
70
src/lib/spec-guards.ts
Normal file
70
src/lib/spec-guards.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* Type guards for OpenAPI specification version detection.
|
||||
* Provides type-safe access to spec properties based on version.
|
||||
*/
|
||||
|
||||
import type { OpenAPI, OpenAPIV2, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
|
||||
|
||||
/** OpenAPI 3.x specification (v3.0 or v3.1) */
|
||||
export type OpenAPIV3Spec = OpenAPIV3.Document | OpenAPIV3_1.Document;
|
||||
|
||||
/** Swagger 2.0 specification */
|
||||
export type SwaggerV2Spec = OpenAPIV2.Document;
|
||||
|
||||
/**
|
||||
* Check if spec is OpenAPI 3.x (has 'openapi' property starting with '3.')
|
||||
*/
|
||||
export function isOpenAPIV3(spec: OpenAPI.Document): spec is OpenAPIV3Spec {
|
||||
const openapi = (spec as { openapi?: string }).openapi;
|
||||
return typeof openapi === 'string' && openapi.startsWith('3.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if spec is Swagger 2.0 (has 'swagger' property equal to '2.0')
|
||||
*/
|
||||
export function isSwaggerV2(spec: OpenAPI.Document): spec is SwaggerV2Spec {
|
||||
const swagger = (spec as { swagger?: string }).swagger;
|
||||
return swagger === '2.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if spec is OpenAPI 3.1 specifically (has 'openapi' starting with '3.1')
|
||||
*/
|
||||
export function isOpenAPIV31(spec: OpenAPI.Document): spec is OpenAPIV3_1.Document {
|
||||
const openapi = (spec as { openapi?: string }).openapi;
|
||||
return typeof openapi === 'string' && openapi.startsWith('3.1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the spec version string (e.g., "3.0.0", "3.1.0", "2.0")
|
||||
*/
|
||||
export function getSpecVersion(spec: OpenAPI.Document): string {
|
||||
if (isOpenAPIV3(spec)) {
|
||||
return (spec as { openapi: string }).openapi;
|
||||
}
|
||||
if (isSwaggerV2(spec)) {
|
||||
return (spec as { swagger: string }).swagger;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get schemas from spec with proper type narrowing
|
||||
* Returns components.schemas for v3, definitions for v2
|
||||
*/
|
||||
export function getSchemasTyped(spec: OpenAPI.Document): Record<string, object> {
|
||||
if (isOpenAPIV3(spec)) {
|
||||
return spec.components?.schemas as Record<string, object> ?? {};
|
||||
}
|
||||
if (isSwaggerV2(spec)) {
|
||||
return spec.definitions ?? {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paths object from spec
|
||||
*/
|
||||
export function getPathsTyped(spec: OpenAPI.Document): Record<string, object> {
|
||||
return (spec as { paths?: Record<string, object> }).paths ?? {};
|
||||
}
|
||||
33
src/lib/tool-response.ts
Normal file
33
src/lib/tool-response.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* Tool response helpers for consistent MCP tool responses.
|
||||
*/
|
||||
|
||||
/** Standard MCP tool response structure - compatible with MCP SDK index signature */
|
||||
export interface ToolResponse {
|
||||
[x: string]: unknown;
|
||||
content: Array<{ type: 'text'; text: string }>;
|
||||
structuredContent: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a success response with text content and structured data.
|
||||
*/
|
||||
export function successResponse(text: string, data: Record<string, unknown>): ToolResponse {
|
||||
return {
|
||||
content: [{ type: 'text', text }],
|
||||
structuredContent: { success: true, ...data },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error response with error message.
|
||||
* @param message - Error message
|
||||
* @param context - Optional context prefix (e.g., "parsing spec", "querying endpoints")
|
||||
*/
|
||||
export function errorResponse(message: string, context?: string): ToolResponse {
|
||||
const text = context ? `Error ${context}: ${message}` : `Error: ${message}`;
|
||||
return {
|
||||
content: [{ type: 'text', text }],
|
||||
structuredContent: { success: false, error: message },
|
||||
};
|
||||
}
|
||||
|
|
@ -3,6 +3,12 @@ import type { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
|
|||
export type OpenAPISpec = OpenAPI.Document;
|
||||
export type OpenAPIV3Spec = OpenAPIV3.Document | OpenAPIV3_1.Document;
|
||||
|
||||
/** Standard HTTP methods supported in OpenAPI specifications */
|
||||
export const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'] as const;
|
||||
|
||||
/** Type derived from HTTP_METHODS constant */
|
||||
export type HttpMethod = typeof HTTP_METHODS[number];
|
||||
|
||||
export interface ParsedSpec {
|
||||
version: string;
|
||||
title: string;
|
||||
|
|
@ -73,3 +79,16 @@ export interface SchemaInfo {
|
|||
required?: string[];
|
||||
schema: object;
|
||||
}
|
||||
|
||||
export interface TypeScriptOptions {
|
||||
/** Generate enums as union types (default: true) */
|
||||
enumAsUnion?: boolean;
|
||||
/** Generate enums as TypeScript enums */
|
||||
enumAsEnum?: boolean;
|
||||
/** Prefix for interface names (e.g., "I") */
|
||||
interfacePrefix?: string;
|
||||
/** Suffix for interface names (e.g., "Type") */
|
||||
interfaceSuffix?: string;
|
||||
/** Indentation style: '2' spaces, '4' spaces, or 'tab' */
|
||||
indentation?: '2' | '4' | 'tab';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import SwaggerParser from '@apidevtools/swagger-parser';
|
||||
import { HTTP_METHODS } from './types.js';
|
||||
import type { ValidationResult, ValidationError } from './types.js';
|
||||
|
||||
export async function validateSpec(specPath: string): Promise<ValidationResult> {
|
||||
|
|
@ -54,7 +55,7 @@ export async function validateWithWarnings(specPath: string): Promise<Validation
|
|||
if (spec.paths) {
|
||||
for (const [path, pathItem] of Object.entries(spec.paths)) {
|
||||
if (!pathItem) continue;
|
||||
for (const method of ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']) {
|
||||
for (const method of HTTP_METHODS) {
|
||||
const operation = (pathItem as Record<string, unknown>)[method] as { operationId?: string } | undefined;
|
||||
if (operation && !operation.operationId) {
|
||||
result.warnings.push({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { z } from 'zod';
|
||||
import { parseSpec } from '../lib/parser.js';
|
||||
import { formatTypes } from '../utils/format.js';
|
||||
import { getSchemas } from '../lib/schema-utils.js';
|
||||
import { successResponse, errorResponse } from '../lib/tool-response.js';
|
||||
import type { ToolResponse } from '../lib/tool-response.js';
|
||||
import type { TypeScriptOptions } from '../lib/types.js';
|
||||
|
||||
export const generateToolName = 'generate-types';
|
||||
|
||||
|
|
@ -9,17 +13,41 @@ 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'),
|
||||
interfacePrefix: z.string()
|
||||
.regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/)
|
||||
.optional()
|
||||
.describe('Prefix for interface names (e.g., "I")'),
|
||||
interfaceSuffix: z.string()
|
||||
.regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/)
|
||||
.optional()
|
||||
.describe('Suffix for interface names (e.g., "Type")'),
|
||||
indentation: z.enum(['2', '4', 'tab']).optional().describe('Indentation style'),
|
||||
}).optional().describe('TypeScript generation options'),
|
||||
};
|
||||
|
||||
export async function generateToolHandler({ path, schemas }: {
|
||||
export async function generateToolHandler({ path, schemas, noCache, options }: {
|
||||
path: string;
|
||||
schemas?: string[];
|
||||
}): Promise<{
|
||||
content: Array<{ type: 'text'; text: string }>;
|
||||
structuredContent: Record<string, unknown>;
|
||||
}> {
|
||||
noCache?: boolean;
|
||||
options?: TypeScriptOptions;
|
||||
}): Promise<ToolResponse> {
|
||||
try {
|
||||
const { spec } = await parseSpec(path);
|
||||
// Validate mutually exclusive options
|
||||
if (options?.enumAsUnion && options?.enumAsEnum) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Error: enumAsUnion and enumAsEnum are mutually exclusive.' }],
|
||||
structuredContent: {
|
||||
success: false,
|
||||
error: 'enumAsUnion and enumAsEnum cannot both be true',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { spec } = await parseSpec(path, { noCache });
|
||||
|
||||
const allSchemas = getSchemas(spec);
|
||||
|
||||
|
|
@ -40,50 +68,48 @@ export async function generateToolHandler({ path, schemas }: {
|
|||
};
|
||||
}
|
||||
|
||||
const types = generateTypeScript(schemasToGenerate);
|
||||
const types = generateTypeScript(schemasToGenerate, options);
|
||||
const text = formatTypes(types);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
return successResponse(text, {
|
||||
types,
|
||||
generatedCount: Object.keys(schemasToGenerate).length,
|
||||
},
|
||||
};
|
||||
options: options || {},
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error generating types: ${error.message}` }],
|
||||
structuredContent: {
|
||||
success: false,
|
||||
error: error.message,
|
||||
},
|
||||
};
|
||||
return errorResponse((err as Error).message, 'generating types');
|
||||
}
|
||||
}
|
||||
|
||||
function getSchemas(spec: object): Record<string, object> {
|
||||
// OpenAPI 3.x
|
||||
const spec3 = spec as { components?: { schemas?: Record<string, object> } };
|
||||
if (spec3.components?.schemas) {
|
||||
return spec3.components.schemas;
|
||||
/** Get indentation string based on options */
|
||||
function getIndent(options?: TypeScriptOptions): string {
|
||||
const style = options?.indentation ?? '2';
|
||||
switch (style) {
|
||||
case '4': return ' ';
|
||||
case 'tab': return '\t';
|
||||
default: return ' ';
|
||||
}
|
||||
|
||||
// Swagger 2.0
|
||||
const spec2 = spec as { definitions?: Record<string, object> };
|
||||
if (spec2.definitions) {
|
||||
return spec2.definitions;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function generateTypeScript(schemas: Record<string, object>): string {
|
||||
/** Convert string to PascalCase for enum member names */
|
||||
function toPascalCase(value: string): string {
|
||||
return value
|
||||
.replace(/[-_\s]+(.)?/g, (_, c) => c ? c.toUpperCase() : '')
|
||||
.replace(/^(.)/, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/** Apply prefix and suffix to a type name */
|
||||
function applyNaming(name: string, options?: TypeScriptOptions): string {
|
||||
const prefix = options?.interfacePrefix ?? '';
|
||||
const suffix = options?.interfaceSuffix ?? '';
|
||||
return `${prefix}${name}${suffix}`;
|
||||
}
|
||||
|
||||
function generateTypeScript(schemas: Record<string, object>, options?: TypeScriptOptions): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const [name, schema] of Object.entries(schemas)) {
|
||||
const typeDef = schemaToTypeScript(name, schema);
|
||||
const typeDef = schemaToTypeScript(name, schema, options);
|
||||
lines.push(typeDef);
|
||||
lines.push('');
|
||||
}
|
||||
|
|
@ -126,8 +152,10 @@ function sanitizeName(name: string): string {
|
|||
return sanitized;
|
||||
}
|
||||
|
||||
function schemaToTypeScript(name: string, schema: object): string {
|
||||
const safeName = sanitizeName(name);
|
||||
function schemaToTypeScript(name: string, schema: object, options?: TypeScriptOptions): string {
|
||||
const sanitized = sanitizeName(name);
|
||||
const safeName = applyNaming(sanitized, options);
|
||||
const indent = getIndent(options);
|
||||
const s = schema as {
|
||||
type?: string;
|
||||
description?: string;
|
||||
|
|
@ -146,14 +174,26 @@ function schemaToTypeScript(name: string, schema: object): string {
|
|||
// Add JSDoc comment with original name if different
|
||||
if (s.description) {
|
||||
lines.push(`/** ${s.description} */`);
|
||||
} else if (safeName !== name) {
|
||||
} else if (sanitized !== name) {
|
||||
lines.push(`/** Original: ${name} */`);
|
||||
}
|
||||
|
||||
// Handle enums
|
||||
if (s.enum) {
|
||||
// Generate TypeScript enum if enumAsEnum is true
|
||||
if (options?.enumAsEnum) {
|
||||
lines.push(`export enum ${safeName} {`);
|
||||
for (const value of s.enum) {
|
||||
const memberName = typeof value === 'string' ? toPascalCase(value) : `Value${value}`;
|
||||
const memberValue = typeof value === 'string' ? `'${value}'` : value;
|
||||
lines.push(`${indent}${memberName} = ${memberValue},`);
|
||||
}
|
||||
lines.push('}');
|
||||
} else {
|
||||
// Default: generate union type
|
||||
const values = s.enum.map(v => typeof v === 'string' ? `'${v}'` : v).join(' | ');
|
||||
lines.push(`export type ${safeName} = ${values};`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
|
|
@ -166,14 +206,14 @@ function schemaToTypeScript(name: string, schema: object): string {
|
|||
|
||||
for (const [propName, propSchema] of Object.entries(s.properties)) {
|
||||
const optional = required.has(propName) ? '' : '?';
|
||||
const propType = schemaToType(propSchema);
|
||||
const propType = schemaToType(propSchema, options);
|
||||
const propDesc = (propSchema as { description?: string }).description;
|
||||
|
||||
if (propDesc) {
|
||||
lines.push(` /** ${propDesc} */`);
|
||||
lines.push(`${indent}/** ${propDesc} */`);
|
||||
}
|
||||
|
||||
lines.push(` ${propName}${optional}: ${propType};`);
|
||||
lines.push(`${indent}${propName}${optional}: ${propType};`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -183,18 +223,18 @@ function schemaToTypeScript(name: string, schema: object): string {
|
|||
|
||||
// Handle array types
|
||||
if (s.type === 'array' && s.items) {
|
||||
const itemType = schemaToType(s.items);
|
||||
const itemType = schemaToType(s.items, options);
|
||||
lines.push(`export type ${safeName} = ${itemType}[];`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Handle simple types
|
||||
const simpleType = schemaToType(schema);
|
||||
const simpleType = schemaToType(schema, options);
|
||||
lines.push(`export type ${safeName} = ${simpleType};`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function schemaToType(schema: object): string {
|
||||
function schemaToType(schema: object, options?: TypeScriptOptions): string {
|
||||
const s = schema as {
|
||||
type?: string;
|
||||
format?: string;
|
||||
|
|
@ -212,35 +252,36 @@ function schemaToType(schema: object): string {
|
|||
// Handle $ref (after dereferencing, these should be resolved, but handle just in case)
|
||||
if (s.$ref) {
|
||||
const refName = sanitizeName(s.$ref.split('/').pop() || 'unknown');
|
||||
return refName;
|
||||
// Apply naming to referenced types
|
||||
return applyNaming(refName, options);
|
||||
}
|
||||
|
||||
// Handle enums
|
||||
// Handle enums (inline)
|
||||
if (s.enum) {
|
||||
return s.enum.map(v => typeof v === 'string' ? `'${v}'` : v).join(' | ');
|
||||
}
|
||||
|
||||
// Handle allOf (intersection)
|
||||
if (s.allOf) {
|
||||
return s.allOf.map(schemaToType).join(' & ');
|
||||
return s.allOf.map(sub => schemaToType(sub, options)).join(' & ');
|
||||
}
|
||||
|
||||
// Handle oneOf/anyOf (union)
|
||||
if (s.oneOf || s.anyOf) {
|
||||
const schemas = s.oneOf || s.anyOf || [];
|
||||
return schemas.map(schemaToType).join(' | ');
|
||||
return schemas.map(sub => schemaToType(sub, options)).join(' | ');
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (s.type === 'array') {
|
||||
const itemType = s.items ? schemaToType(s.items) : 'unknown';
|
||||
const itemType = s.items ? schemaToType(s.items, options) : 'unknown';
|
||||
return `${itemType}[]`;
|
||||
}
|
||||
|
||||
// Handle objects with additionalProperties
|
||||
if (s.type === 'object' && s.additionalProperties) {
|
||||
const valueType = typeof s.additionalProperties === 'object'
|
||||
? schemaToType(s.additionalProperties)
|
||||
? schemaToType(s.additionalProperties, options)
|
||||
: 'unknown';
|
||||
return `Record<string, ${valueType}>`;
|
||||
}
|
||||
|
|
@ -251,7 +292,7 @@ function schemaToType(schema: object): string {
|
|||
const props = Object.entries(s.properties)
|
||||
.map(([name, prop]) => {
|
||||
const optional = required.has(name) ? '' : '?';
|
||||
return `${name}${optional}: ${schemaToType(prop)}`;
|
||||
return `${name}${optional}: ${schemaToType(prop, options)}`;
|
||||
})
|
||||
.join('; ');
|
||||
return `{ ${props} }`;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { z } from 'zod';
|
||||
import { parseSpec } from '../lib/parser.js';
|
||||
import { formatMetadata } from '../utils/format.js';
|
||||
import { successResponse, errorResponse } from '../lib/tool-response.js';
|
||||
import type { ToolResponse } from '../lib/tool-response.js';
|
||||
|
||||
export const parseToolName = 'parse-spec';
|
||||
|
||||
|
|
@ -8,37 +10,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<{
|
||||
content: Array<{ type: 'text'; text: string }>;
|
||||
structuredContent: Record<string, unknown>;
|
||||
}> {
|
||||
export async function parseToolHandler({ path, noCache }: {
|
||||
path: string;
|
||||
noCache?: boolean;
|
||||
}): Promise<ToolResponse> {
|
||||
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 }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
metadata,
|
||||
dereferenced,
|
||||
spec,
|
||||
},
|
||||
};
|
||||
return successResponse(text, { metadata, dereferenced, cached, spec });
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error parsing spec: ${error.message}` }],
|
||||
structuredContent: {
|
||||
success: false,
|
||||
error: error.message,
|
||||
},
|
||||
};
|
||||
return errorResponse((err as Error).message, 'parsing spec');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { z } from 'zod';
|
||||
import { parseSpec } from '../lib/parser.js';
|
||||
import { formatEndpoints } from '../utils/format.js';
|
||||
import { HTTP_METHODS } from '../lib/types.js';
|
||||
import { successResponse, errorResponse } from '../lib/tool-response.js';
|
||||
import type { ToolResponse } from '../lib/tool-response.js';
|
||||
import type { EndpointInfo, EndpointFilter, ParameterInfo, ResponseInfo } from '../lib/types.js';
|
||||
import type { OpenAPIV3 } from 'openapi-types';
|
||||
|
||||
|
|
@ -14,22 +17,19 @@ 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;
|
||||
|
||||
export async function queryToolHandler(args: {
|
||||
path: string;
|
||||
method?: string;
|
||||
pathPattern?: string;
|
||||
tag?: string;
|
||||
operationId?: string;
|
||||
}): Promise<{
|
||||
content: Array<{ type: 'text'; text: string }>;
|
||||
structuredContent: Record<string, unknown>;
|
||||
}> {
|
||||
noCache?: boolean;
|
||||
}): Promise<ToolResponse> {
|
||||
try {
|
||||
const { spec } = await parseSpec(args.path);
|
||||
const { spec } = await parseSpec(args.path, { noCache: args.noCache });
|
||||
|
||||
const filter: EndpointFilter = {
|
||||
method: args.method?.toLowerCase(),
|
||||
|
|
@ -41,23 +41,9 @@ export async function queryToolHandler(args: {
|
|||
const endpoints = extractEndpoints(spec, filter);
|
||||
const text = formatEndpoints(endpoints);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
count: endpoints.length,
|
||||
endpoints,
|
||||
},
|
||||
};
|
||||
return successResponse(text, { count: endpoints.length, endpoints });
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error querying endpoints: ${error.message}` }],
|
||||
structuredContent: {
|
||||
success: false,
|
||||
error: error.message,
|
||||
},
|
||||
};
|
||||
return errorResponse((err as Error).message, 'querying endpoints');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { z } from 'zod';
|
||||
import { parseSpec } from '../lib/parser.js';
|
||||
import { formatSchema } from '../utils/format.js';
|
||||
import { findSchema, getSchemaNames } from '../lib/schema-utils.js';
|
||||
import { successResponse, errorResponse } from '../lib/tool-response.js';
|
||||
import type { ToolResponse } from '../lib/tool-response.js';
|
||||
import type { SchemaInfo } from '../lib/types.js';
|
||||
|
||||
export const schemaToolName = 'get-schema';
|
||||
|
|
@ -10,22 +13,21 @@ 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;
|
||||
}): Promise<{
|
||||
content: Array<{ type: 'text'; text: string }>;
|
||||
structuredContent: Record<string, unknown>;
|
||||
}> {
|
||||
noCache?: boolean;
|
||||
}): Promise<ToolResponse> {
|
||||
try {
|
||||
const { spec } = await parseSpec(path);
|
||||
const { spec } = await parseSpec(path, { noCache });
|
||||
|
||||
const schema = findSchema(spec, schemaName);
|
||||
|
||||
if (!schema) {
|
||||
const available = getAvailableSchemas(spec);
|
||||
const available = getSchemaNames(spec);
|
||||
return {
|
||||
content: [{ type: 'text', text: `Schema '${schemaName}' not found. Available schemas: ${available.join(', ')}` }],
|
||||
structuredContent: {
|
||||
|
|
@ -47,53 +49,8 @@ export async function schemaToolHandler({ path, schemaName }: {
|
|||
|
||||
const text = formatSchema(schemaInfo);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
schema: schemaInfo,
|
||||
},
|
||||
};
|
||||
return successResponse(text, { schema: schemaInfo });
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error getting schema: ${error.message}` }],
|
||||
structuredContent: {
|
||||
success: false,
|
||||
error: error.message,
|
||||
},
|
||||
};
|
||||
return errorResponse((err as Error).message, 'getting schema');
|
||||
}
|
||||
}
|
||||
|
||||
function findSchema(spec: object, schemaName: string): object | null {
|
||||
// OpenAPI 3.x: components.schemas
|
||||
const spec3 = spec as { components?: { schemas?: Record<string, object> } };
|
||||
if (spec3.components?.schemas?.[schemaName]) {
|
||||
return spec3.components.schemas[schemaName];
|
||||
}
|
||||
|
||||
// Swagger 2.0: definitions
|
||||
const spec2 = spec as { definitions?: Record<string, object> };
|
||||
if (spec2.definitions?.[schemaName]) {
|
||||
return spec2.definitions[schemaName];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getAvailableSchemas(spec: object): string[] {
|
||||
// OpenAPI 3.x
|
||||
const spec3 = spec as { components?: { schemas?: Record<string, object> } };
|
||||
if (spec3.components?.schemas) {
|
||||
return Object.keys(spec3.components.schemas);
|
||||
}
|
||||
|
||||
// Swagger 2.0
|
||||
const spec2 = spec as { definitions?: Record<string, object> };
|
||||
if (spec2.definitions) {
|
||||
return Object.keys(spec2.definitions);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { z } from 'zod';
|
||||
import { validateWithWarnings } from '../lib/validator.js';
|
||||
import { formatValidation } from '../utils/format.js';
|
||||
import { successResponse, errorResponse } from '../lib/tool-response.js';
|
||||
import type { ToolResponse } from '../lib/tool-response.js';
|
||||
|
||||
export const validateToolName = 'validate-spec';
|
||||
|
||||
|
|
@ -10,29 +12,12 @@ export const validateToolSchema = {
|
|||
path: z.string().describe('Path to the OpenAPI/Swagger spec file (YAML or JSON)'),
|
||||
};
|
||||
|
||||
export async function validateToolHandler({ path }: { path: string }): Promise<{
|
||||
content: Array<{ type: 'text'; text: string }>;
|
||||
structuredContent: Record<string, unknown>;
|
||||
}> {
|
||||
export async function validateToolHandler({ path }: { path: string }): Promise<ToolResponse> {
|
||||
try {
|
||||
const result = await validateWithWarnings(path);
|
||||
const text = formatValidation(result);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text }],
|
||||
structuredContent: {
|
||||
success: true,
|
||||
...result,
|
||||
},
|
||||
};
|
||||
return successResponse(text, { ...result });
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error validating spec: ${error.message}` }],
|
||||
structuredContent: {
|
||||
success: false,
|
||||
error: error.message,
|
||||
},
|
||||
};
|
||||
return errorResponse((err as Error).message, 'validating spec');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
36
test/fixtures/enum-test.yaml
vendored
Normal file
36
test/fixtures/enum-test.yaml
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
openapi: 3.0.3
|
||||
info:
|
||||
title: Enum Test API
|
||||
version: 1.0.0
|
||||
paths: {}
|
||||
components:
|
||||
schemas:
|
||||
Status:
|
||||
type: string
|
||||
enum:
|
||||
- pending
|
||||
- active
|
||||
- completed
|
||||
- cancelled
|
||||
description: Task status
|
||||
Priority:
|
||||
type: string
|
||||
enum:
|
||||
- low
|
||||
- normal
|
||||
- high
|
||||
- urgent
|
||||
Task:
|
||||
type: object
|
||||
required:
|
||||
- title
|
||||
- status
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
title:
|
||||
type: string
|
||||
status:
|
||||
$ref: '#/components/schemas/Status'
|
||||
priority:
|
||||
$ref: '#/components/schemas/Priority'
|
||||
Loading…
Add table
Reference in a new issue