diff --git a/CLAUDE.md b/CLAUDE.md index a2bf304..12393fb 100644 --- a/CLAUDE.md +++ b/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,32 @@ Converts .NET-style schema names to valid TypeScript: "Company.Api.V5.ViewModel" → "ViewModel" ``` +### 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 ``` diff --git a/docs/analysis/feature-enhancements-analysis.md b/docs/analysis/feature-enhancements-analysis.md new file mode 100644 index 0000000..a0e9b9c --- /dev/null +++ b/docs/analysis/feature-enhancements-analysis.md @@ -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; + 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( + items: string[], + processor: (item: string) => Promise, + concurrency = 5 +): Promise> { + const results: Array<...> = []; + const executing: Promise[] = []; + + 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 + diff --git a/docs/tasks/typescript-options-task.md b/docs/tasks/typescript-options-task.md new file mode 100644 index 0000000..c52f4c8 --- /dev/null +++ b/docs/tasks/typescript-options-task.md @@ -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']; +``` diff --git a/src/lib/types.ts b/src/lib/types.ts index bd6f749..ac8f306 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -73,3 +73,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'; +} diff --git a/src/tools/generate.ts b/src/tools/generate.ts index 12afb99..adf9a0a 100644 --- a/src/tools/generate.ts +++ b/src/tools/generate.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { parseSpec } from '../lib/parser.js'; import { formatTypes } from '../utils/format.js'; +import type { TypeScriptOptions } from '../lib/types.js'; export const generateToolName = 'generate-types'; @@ -9,16 +10,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)'), + 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, options }: { path: string; schemas?: string[]; + options?: TypeScriptOptions; }): Promise<{ content: Array<{ type: 'text'; text: string }>; structuredContent: Record; }> { try { + // 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); const allSchemas = getSchemas(spec); @@ -40,7 +66,7 @@ export async function generateToolHandler({ path, schemas }: { }; } - const types = generateTypeScript(schemasToGenerate); + const types = generateTypeScript(schemasToGenerate, options); const text = formatTypes(types); return { @@ -49,6 +75,7 @@ export async function generateToolHandler({ path, schemas }: { success: true, types, generatedCount: Object.keys(schemasToGenerate).length, + options: options || {}, }, }; } catch (err) { @@ -79,11 +106,35 @@ function getSchemas(spec: object): Record { return {}; } -function generateTypeScript(schemas: Record): string { +/** 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 ' '; + } +} + +/** 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, 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 +177,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 +199,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) { - const values = s.enum.map(v => typeof v === 'string' ? `'${v}'` : v).join(' | '); - lines.push(`export type ${safeName} = ${values};`); + // 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 +231,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 +248,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 +277,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`; } @@ -251,7 +317,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} }`; diff --git a/test/fixtures/enum-test.yaml b/test/fixtures/enum-test.yaml new file mode 100644 index 0000000..f25e975 --- /dev/null +++ b/test/fixtures/enum-test.yaml @@ -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'