From c4a250359fa3fe6c3a583c7829d7037de0617532 Mon Sep 17 00:00:00 2001 From: Luke Hagar Date: Wed, 1 Oct 2025 22:56:10 +0000 Subject: [PATCH] Enhance .gitignore, update dependencies, and improve CLI functionality - Expanded .gitignore to include additional build outputs, environment files, and IDE configurations. - Updated bun.lock to include new dependencies: js-yaml and its types. - Refactored index.ts to export new validation functions and types for better modularity. - Updated package.json to reflect version bump to 1.0.3, added repository and homepage information, and improved script commands. - Enhanced README.md with clearer usage instructions and examples for library and CLI usage. - Improved CLI commands for validation, parsing, and reporting, including support for multiple sources and detailed output options. - Added logging functionality throughout the codebase for better debugging and user feedback. - Implemented recursive validation and reference analysis features for comprehensive OpenAPI specification validation. --- .github/workflows/publish.yml | 155 ++++++++++++ .github/workflows/test.yml | 29 +++ .gitignore | 117 +++++++-- LICENSE | 21 ++ README.md | 364 +++++++++++++++++--------- bun.lock | 8 + index.ts | 21 +- package.json | 54 +++- src/cli.ts | 396 ++++++++++++++++++++--------- src/logger.ts | 443 ++++++++++++++++++++++++++++++++ src/parser.ts | 124 ++++++++- src/recursive-validator.ts | 80 +++++- src/summary-analyzer.ts | 465 ++++++++++++++++++++++++++++++++++ src/validator.ts | 129 +++++++++- src/varsity.ts | 178 ++++++++++++- test/sample-openapi.yaml | 45 ++++ 16 files changed, 2328 insertions(+), 301 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/test.yml create mode 100644 LICENSE mode change 100644 => 100755 src/cli.ts create mode 100644 src/logger.ts create mode 100644 src/summary-analyzer.ts create mode 100644 test/sample-openapi.yaml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..4506457 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,155 @@ +name: Publish to npm + +on: + workflow_run: + workflows: ["Test"] + types: [completed] + branches: [main] + +permissions: + contents: write # Needed to create tags/releases + packages: write # If you also publish GitHub Packages + id-token: write # Optional (for OIDC to cloud registries) + +jobs: + publish: + if: > + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' && + github.event.workflow_run.head_repository.full_name == github.repository + + runs-on: ubuntu-latest + + steps: + # Check out the exact commit that passed CI + - name: Checkout the successful commit + uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + + - name: Use Node 20 + uses: actions/setup-node@v5 + with: + node-version: latest + registry-url: https://registry.npmjs.org + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install + run: bun install --frozen-lockfile + + # Optional: Re-run build to ensure publish artifacts exist + - name: Build + run: bun run build + + # Read current version from package.json + - name: Read current version + id: current-version + shell: bash + run: | + ver=$(node -p "require('./package.json').version") + echo "version=$ver" >> "$GITHUB_OUTPUT" + + # Check if version was already bumped + - name: Check if version was bumped + id: version-check + shell: bash + run: | + # Get the latest GitHub release version using jq for reliable JSON parsing + LATEST_RELEASE=$(curl -s "https://api.github.com/repos/${{ github.repository }}/releases/latest" | jq -r '.tag_name // empty' 2>/dev/null || echo "") + CURRENT_VERSION="${{ steps.current-version.outputs.version }}" + + echo "Latest release: $LATEST_RELEASE" + echo "Current version: $CURRENT_VERSION" + + # Remove 'v' prefix from release tag for comparison + if [ -n "$LATEST_RELEASE" ] && [ "$LATEST_RELEASE" != "null" ]; then + LATEST_VERSION=$(echo "$LATEST_RELEASE" | sed 's/^v//') + else + LATEST_VERSION="0.0.0" + fi + + if [ "$LATEST_VERSION" != "$CURRENT_VERSION" ]; then + echo "Version was already bumped from $LATEST_VERSION to $CURRENT_VERSION" + echo "bumped=false" >> "$GITHUB_OUTPUT" + echo "final_version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" + else + echo "No version bump detected, will auto-patch bump" + echo "bumped=true" >> "$GITHUB_OUTPUT" + fi + + # Auto-patch bump version if no version change was made + - name: Auto-patch bump version + if: steps.version-check.outputs.bumped == 'true' + id: bump-version + shell: bash + run: | + npm version patch --no-git-tag-version + NEW_VERSION=$(node -p "require('./package.json').version") + echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT" + echo "Auto-bumped version to $NEW_VERSION" + + # Set final version + - name: Set final version + id: final-version + shell: bash + run: | + if [ "${{ steps.version-check.outputs.bumped }}" = "true" ]; then + echo "version=${{ steps.bump-version.outputs.version }}" >> "$GITHUB_OUTPUT" + else + echo "version=${{ steps.current-version.outputs.version }}" >> "$GITHUB_OUTPUT" + fi + + # Commit version bump if auto-bumped + - name: Commit auto-bumped version + if: steps.version-check.outputs.bumped == 'true' + shell: bash + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git add package.json + git commit -m "chore: auto-bump version to ${{ steps.bump-version.outputs.version }}" + git push origin HEAD:main + + # Create a git tag like v1.2.3 if it doesn't already exist + - name: Create tag if missing + shell: bash + run: | + TAG="v${{ steps.final-version.outputs.version }}" + + # Check if tag exists locally + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists locally." + # Try to push it anyway (in case it's not on remote) + if git push origin "$TAG" 2>/dev/null; then + echo "Successfully pushed existing tag $TAG to remote." + else + echo "Tag $TAG already exists on remote as well." + fi + else + echo "Creating new tag $TAG" + git tag "$TAG" ${{ github.event.workflow_run.head_sha }} + if git push origin "$TAG" 2>/dev/null; then + echo "Successfully created and pushed tag $TAG" + else + echo "Tag $TAG already exists on remote, skipping push." + fi + fi + + # Publish to npm (requires NPM_TOKEN in repo secrets) + - name: Publish to npm + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # Create a GitHub Release for the tag + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.final-version.outputs.version }} + name: v${{ steps.final-version.outputs.version }} + generate_release_notes: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..0412524 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: Test + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Run tests + run: bun test + + - name: Run linting + run: bun run lint diff --git a/.gitignore b/.gitignore index a14702c..a89b915 100644 --- a/.gitignore +++ b/.gitignore @@ -1,34 +1,101 @@ -# dependencies (bun install) -node_modules +# Dependencies +node_modules/ +bun.lockb -# output -out -dist -*.tgz +# Build outputs +dist/ +build/ +*.tsbuildinfo -# code coverage -coverage -*.lcov - -# logs -logs -_.log -report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json - -# dotenv environment variable files +# Environment files .env +.env.local .env.development.local .env.test.local .env.production.local -.env.local -# caches -.eslintcache -.cache -*.tsbuildinfo +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ -# IntelliJ based IDEs -.idea - -# Finder (MacOS) folder config +# OS files .DS_Store +Thumbs.db + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +bun-debug.log* +bun-error.log* + +# Runtime data +pids/ +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# nyc test coverage +.nyc_output/ + +# Dependency directories +jspm_packages/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Storybook build outputs +.out +.storybook-out + +# Temporary folders +tmp/ +temp/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..385e614 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Luke + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 4788892..6b2a898 100644 --- a/README.md +++ b/README.md @@ -1,177 +1,303 @@ # Varsity -A comprehensive OpenAPI parsing and validation library built with TypeScript and Bun. Varsity provides both a command-line interface and a programmatic API for validating OpenAPI specifications across all versions (2.0, 3.0.x, 3.1.0). +A comprehensive OpenAPI parsing and validation library that supports both programmatic usage and command-line operations. ## Features -- šŸ” **Multi-version Support**: Validates OpenAPI 2.0 (Swagger) and OpenAPI 3.0.x/3.1.0 specifications -- šŸ“Š **Comprehensive Reporting**: Generate reports in JSON, YAML, HTML, and Markdown formats -- šŸš€ **High Performance**: Built with Bun for fast execution -- šŸ› ļø **Flexible Usage**: Use as a CLI tool or import as a library -- āœ… **AJV Integration**: Robust validation using the industry-standard AJV library -- šŸ“ **Detailed Error Reporting**: Clear error messages with path information -- šŸ”§ **Extensible**: Support for custom validation rules and schemas -- šŸŽÆ **Type Safety**: Full TypeScript support with comprehensive OpenAPI type definitions from `oas-types` -- šŸ“‹ **Comprehensive Schemas**: Uses official JSON schemas for accurate validation +- šŸ” **Comprehensive Validation**: Validate OpenAPI 2.0, 3.0.x, and 3.1.x specifications +- šŸ”„ **Recursive Validation**: Validate all `$ref` references and detect circular dependencies +- šŸ“Š **Rich Reporting**: Generate reports in JSON, YAML, HTML, and Markdown formats +- šŸš€ **CLI & Library**: Use as both a command-line tool and a JavaScript/TypeScript library +- šŸŽÆ **TypeScript Support**: Full TypeScript definitions included +- ⚔ **Fast**: Built with Bun for optimal performance +- šŸ”§ **Flexible**: Support for custom validation rules and configurations ## Installation ```bash -bun install +# Using npm +npm install varsity + +# Using yarn +yarn add varsity + +# Using pnpm +pnpm add varsity + +# Using bun +bun add varsity ``` ## Usage -### Command Line Interface +### As a Library -#### Validate a specification -```bash -bun run src/cli.ts validate path/to/spec.json -``` +#### Basic Validation -#### Parse without validation -```bash -bun run src/cli.ts parse path/to/spec.json -``` +```javascript +import { validate, parse } from 'varsity'; -#### Generate a report -```bash -bun run src/cli.ts report path/to/spec.json --format html --output report.html -``` - -#### Batch validation -```bash -bun run src/cli.ts batch spec1.json spec2.json spec3.json -``` - -#### Show supported versions -```bash -bun run src/cli.ts info -``` - -### CLI Options - -#### Validate Command -- `-s, --strict`: Enable strict validation mode -- `-e, --examples`: Validate examples in the specification -- `-r, --references`: Validate all references -- `-v, --verbose`: Show detailed output - -#### Report Command -- `-f, --format `: Report format (json, yaml, html, markdown) -- `-o, --output `: Output file path -- `-w, --warnings`: Include warnings in report -- `-m, --metadata`: Include metadata in report - -### Programmatic Usage - -#### Functional Approach (Recommended) - -```typescript -import { validate, parse, generateValidationReport } from './src/varsity.js'; - -// Parse and validate +// Validate an OpenAPI specification const result = await validate('path/to/spec.json'); - -// Generate a report -const report = await generateValidationReport('path/to/spec.json', { - format: 'json', - includeWarnings: true, - includeMetadata: true -}); +if (result.valid) { + console.log('āœ… Specification is valid'); +} else { + console.log('āŒ Validation errors:', result.errors); +} // Parse without validation const parsed = await parse('path/to/spec.json'); +console.log('Version:', parsed.version); +console.log('Title:', parsed.metadata.title); ``` -#### Factory Pattern (For Configuration) +#### Advanced Validation -```typescript -import { createVarsity } from './src/varsity.js'; +```javascript +import { + validate, + validateWithReferences, + createVarsity +} from 'varsity'; -const varsity = createVarsity({ - defaultVersion: '3.0.3', - strictMode: false, - customSchemas: {}, - reportFormats: ['json'] +// Validate with custom options +const result = await validate('spec.json', { + strict: true, + validateExamples: true, + validateReferences: true, + recursive: true, + maxRefDepth: 10 }); -// Use the configured instance -const result = await varsity.validate('path/to/spec.json'); -const report = await varsity.generateReport('path/to/spec.json', { - format: 'json', +// Recursive validation with reference resolution +const recursiveResult = await validateWithReferences('spec.json', { + strict: true, + validateExamples: true +}); + +// Create a configured instance +const varsity = createVarsity({ + defaultVersion: '3.0', + strictMode: true, + reportFormats: ['json', 'html'] +}); + +const result = await varsity.validate('spec.json'); +``` + +#### Report Generation + +```javascript +import { generateValidationReport, saveValidationReport } from 'varsity'; + +// Generate a report +const report = await generateValidationReport('spec.json', { + format: 'html', includeWarnings: true, includeMetadata: true }); + +// Save report to file +await saveValidationReport('spec.json', { + format: 'json', + output: 'validation-report.json', + includeWarnings: true +}); +``` + +#### Reference Analysis + +```javascript +import { analyzeDocumentReferences, analyzeReferences } from 'varsity'; + +// Analyze references in a document +const analysis = await analyzeDocumentReferences('spec.json'); +console.log('Total references:', analysis.totalReferences); +console.log('Circular references:', analysis.circularReferences); + +// Find all references +const references = await analyzeReferences('spec.json'); +``` + +### As a CLI Tool + +#### Basic Commands + +```bash +# Validate a specification +varsity validate spec.json + +# Parse without validation +varsity parse spec.json + +# Show supported OpenAPI versions +varsity info +``` + +#### Advanced Validation + +```bash +# Strict validation with examples +varsity validate spec.json --strict --examples + +# Recursive validation with references +varsity validate spec.json --recursive --references + +# Verbose output +varsity validate spec.json --verbose +``` + +#### Report Generation + +```bash +# Generate HTML report +varsity report spec.json --format html --output report.html + +# Generate JSON report with warnings +varsity report spec.json --format json --warnings --metadata +``` + +#### Batch Processing + +```bash +# Validate multiple specifications +varsity batch spec1.json spec2.json spec3.json + +# Batch validation with JSON output +varsity batch *.json --json +``` + +#### Reference Analysis + +```bash +# Analyze references +varsity analyze spec.json + +# JSON output for analysis +varsity analyze spec.json --json ``` ## API Reference ### Core Functions -#### Direct Functions -- `validate(source: string, options?: ValidationOptions, config?: VarsityConfig): Promise` -- `parse(source: string): Promise` -- `generateValidationReport(source: string, reportOptions: ReportOptions, validationOptions?: ValidationOptions, config?: VarsityConfig): Promise` -- `saveValidationReport(source: string, reportOptions: ReportOptions, validationOptions?: ValidationOptions, config?: VarsityConfig): Promise` -- `validateMultiple(sources: string[], options?: ValidationOptions, config?: VarsityConfig): Promise` -- `getSupportedVersions(): string[]` +#### `validate(source, options?, config?)` +Validates an OpenAPI specification. -#### Factory Function -- `createVarsity(config?: VarsityConfig)`: Creates a configured instance with methods +- `source`: Path, URL, or array of paths/URLs to OpenAPI specifications +- `options`: Validation options (optional) +- `config`: Varsity configuration (optional) -#### Individual Module Functions -- `parseOpenAPISpec(source: string): Promise` -- `validateBasicStructure(spec: any, version: OpenAPIVersion): boolean` -- `validateOpenAPISpec(spec: any, version: OpenAPIVersion, options?: ValidationOptions): ValidationResult` -- `generateReport(result: ValidationResult, options: ReportOptions): string` -- `saveReport(content: string, outputPath: string): void` +#### `parse(source)` +Parses an OpenAPI specification without validation. -### Types +- `source`: Path or URL to OpenAPI specification -- `ValidationResult`: Contains validation results with errors and warnings -- `ParsedSpec`: Parsed specification with metadata -- `ValidationOptions`: Configuration for validation behavior -- `ReportOptions`: Configuration for report generation -- `VarsityConfig`: Global configuration for the library -- `OpenAPISpec`: Union type for all OpenAPI specification versions -- `OpenAPIVersion`: Supported OpenAPI version strings +#### `validateWithReferences(source, options?, config?)` +Recursively validates an OpenAPI specification and all its references. -### Type Safety +#### `validateMultipleWithReferences(sources, options?, config?)` +Validates multiple OpenAPI specifications with reference resolution. -Varsity leverages the comprehensive `oas-types` package for full TypeScript support: +### Validation Options ```typescript -import type { OpenAPI2, OpenAPI3, OpenAPI3_1 } from 'oas-types'; - -// All parsed specifications are properly typed -const result = await validate('spec.json'); -// result.spec is typed as OpenAPISpec (OpenAPI2 | OpenAPI3 | OpenAPI3_1) - -// Type guards for version-specific handling -if (result.version === '2.0') { - const swaggerSpec = result.spec as OpenAPI2; - // swaggerSpec.swagger, swaggerSpec.info, etc. are fully typed +interface ValidationOptions { + strict?: boolean; // Enable strict validation + validateExamples?: boolean; // Validate examples in the spec + validateReferences?: boolean; // Validate all references + recursive?: boolean; // Enable recursive validation + maxRefDepth?: number; // Maximum reference depth + customRules?: Record; // Custom validation rules } ``` -## Development +### Report Options -### Running Tests -```bash -bun test +```typescript +interface ReportOptions { + format: 'json' | 'yaml' | 'html' | 'markdown'; + output?: string; // Output file path + includeWarnings?: boolean; // Include warnings in report + includeMetadata?: boolean; // Include metadata in report +} ``` -### Building +### Configuration + +```typescript +interface VarsityConfig { + defaultVersion?: OpenAPIVersion; + strictMode?: boolean; + customSchemas?: Record>; + reportFormats?: ReportOptions['format'][]; +} +``` + +## Supported OpenAPI Versions + +- OpenAPI 2.0 (Swagger 2.0) +- OpenAPI 3.0.0, 3.0.1, 3.0.2, 3.0.3 +- OpenAPI 3.1.0 + +## Development + +### Prerequisites + +- [Bun](https://bun.sh) (recommended) or Node.js 18+ +- TypeScript 5+ + +### Setup + ```bash +# Clone the repository +git clone https://github.com/luke/varsity.git +cd varsity + +# Install dependencies +bun install + +# Run tests +bun test + +# Run linting +bun run lint + +# Build the project bun run build ``` -### Linting +### Testing + ```bash -bun run lint +# Run all tests +bun test + +# Run tests in watch mode +bun test --watch + +# Run specific test file +bun test test/basic.test.ts ``` +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add some amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + ## License -MIT +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Changelog + +### 1.0.0 +- Initial release +- Support for OpenAPI 2.0, 3.0.x, and 3.1.x +- CLI and library usage +- Recursive validation with reference resolution +- Multiple report formats +- TypeScript support \ No newline at end of file diff --git a/bun.lock b/bun.lock index 78dd8e2..e52f314 100644 --- a/bun.lock +++ b/bun.lock @@ -8,10 +8,12 @@ "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "commander": "^14.0.1", + "js-yaml": "^4.1.0", "oas-types": "^1.0.6", }, "devDependencies": { "@types/bun": "latest", + "@types/js-yaml": "^4.0.9", }, "peerDependencies": { "typescript": "^5", @@ -21,6 +23,8 @@ "packages": { "@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="], + "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], + "@types/node": ["@types/node@24.6.1", "", { "dependencies": { "undici-types": "~7.13.0" } }, "sha512-ljvjjs3DNXummeIaooB4cLBKg2U6SPI6Hjra/9rRIy7CpM0HpLtG9HptkMKAb4HYWy5S7HUvJEuWgr/y0U8SHw=="], "@types/react": ["@types/react@19.1.17", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA=="], @@ -29,6 +33,8 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="], "commander": ["commander@14.0.1", "", {}, "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A=="], @@ -39,6 +45,8 @@ "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "oas-types": ["oas-types@1.0.6", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-86K+t8TGsBBetPaN4+9LY4pifAC/aguyylihhkbHa40nK8J5chpHYWhfmmFVT2QQS6aw+pCdjFM8fAaY352mZA=="], diff --git a/index.ts b/index.ts index 7fcd820..65a3c28 100644 --- a/index.ts +++ b/index.ts @@ -4,7 +4,9 @@ export { parse, generateValidationReport, saveValidationReport, - validateMultiple, + validateWithReferences, + validateMultipleWithReferences, + analyzeDocumentReferences, getSupportedVersions, createVarsity, // Individual module exports @@ -13,6 +15,16 @@ export { validateOpenAPISpec, generateReport, saveReport, + // Recursive validation exports + validateRecursively, + validateMultipleRecursively, + analyzeReferences, + // Reference resolver exports + resolveReference, + findReferences, + resolveAllReferences, + // Partial validation exports + validatePartialDocument, } from "./src/varsity.js"; // Type exports @@ -25,7 +37,14 @@ export type { VarsityConfig, OpenAPIVersion, CLIResult, + RecursiveValidationResult, } from "./src/types.js"; +// Export types from other modules +export type { + ResolvedReference, + ReferenceContext, +} from "./src/ref-resolver.js"; + // Default export - functional instance export { default } from "./src/varsity.js"; diff --git a/package.json b/package.json index 4e0e1cc..28727d0 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,32 @@ { "name": "varsity", - "version": "1.0.0", + "version": "1.0.3", "description": "Comprehensive OpenAPI parsing and validation library", - "module": "index.ts", "type": "module", - "private": true, + "main": "./dist/index.js", + "module": "./dist/index.js", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./index.ts" + }, + "./cli": { + "import": "./dist/cli.js", + "types": "./src/cli.ts" + } + }, "bin": { - "varsity": "./src/cli.ts" + "varsity": "dist/cli.js" }, "scripts": { "start": "bun run src/cli.ts", "dev": "bun run --watch src/cli.ts", "test": "bun test", - "build": "bun build index.ts --outdir dist --target bun", - "lint": "bun run --bun tsc --noEmit" + "build": "bun build index.ts --outdir dist --target node --format esm", + "build:cli": "bun build src/cli.ts --outdir dist --target node --format esm --outfile varsity", + "build:all": "bun run build && bun run build:cli", + "lint": "bun run --bun tsc --noEmit", + "prepublishOnly": "bun run build:all" }, "keywords": [ "openapi", @@ -21,12 +34,34 @@ "validation", "parser", "api", - "specification" + "specification", + "cli", + "typescript", + "bun", + "json-schema", + "api-validation", + "openapi-validator" ], - "author": "", + "author": "Luke", "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/luke/varsity.git" + }, + "homepage": "https://github.com/luke/varsity#readme", + "bugs": { + "url": "https://github.com/luke/varsity/issues" + }, + "files": [ + "index.ts", + "src/", + "dist/", + "README.md", + "LICENSE" + ], "devDependencies": { - "@types/bun": "latest" + "@types/bun": "latest", + "@types/js-yaml": "^4.0.9" }, "peerDependencies": { "typescript": "^5" @@ -36,6 +71,7 @@ "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "commander": "^14.0.1", + "js-yaml": "^4.1.0", "oas-types": "^1.0.6" } } diff --git a/src/cli.ts b/src/cli.ts old mode 100644 new mode 100755 index 57d4171..f58dc32 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,12 +5,13 @@ import { parse, generateValidationReport, saveValidationReport, - validateMultiple, validateWithReferences, validateMultipleWithReferences, analyzeDocumentReferences, + generateSpecificationSummary, getSupportedVersions, createVarsity, + log, } from "./varsity.js"; import type { ValidationOptions, ReportOptions } from "./types.js"; @@ -18,14 +19,19 @@ const program = new Command(); program .name("varsity") - .description("Comprehensive OpenAPI parsing and validation library") + .description( + "Comprehensive OpenAPI parsing and validation library (supports JSON and YAML)" + ) .version("1.0.0"); // Validate command program .command("validate") - .description("Validate an OpenAPI specification") - .argument("", "Path or URL to OpenAPI specification") + .description("Validate one or more OpenAPI specifications") + .argument( + "", + "Path(s) or URL(s) to OpenAPI specification(s) (JSON or YAML)" + ) .option("-s, --strict", "Enable strict validation mode") .option("-e, --examples", "Validate examples in the specification") .option("-r, --references", "Validate all references") @@ -36,7 +42,14 @@ program "10" ) .option("-v, --verbose", "Show detailed output") - .action(async (source: string, options: any) => { + .option("-j, --json", "Output as JSON") + .option("--no-progress", "Disable progress indicators") + .option("--no-colors", "Disable colored output") + .action(async (sources: string[], options: any) => { + // Configure logger based on options + log.setVerbose(options.verbose); + log.setShowProgress(!options.noProgress); + log.setUseColors(!options.noColors); try { const validationOptions: ValidationOptions = { strict: options.strict, @@ -46,74 +59,201 @@ program maxRefDepth: parseInt(options.maxDepth) || 10, }; - let result; - if (options.recursive) { - result = await validateWithReferences(source, validationOptions); - - if (result.valid) { - console.log("āœ… Specification and all references are valid"); - if (options.verbose) { - console.log(`Version: ${result.version}`); - console.log(`Total documents: ${result.totalDocuments}`); - console.log(`Valid documents: ${result.validDocuments}`); - console.log( - `Circular references: ${result.circularReferences.length}` - ); - console.log(`Warnings: ${result.warnings.length}`); - } - } else { - console.log("āŒ Specification or references are invalid"); - console.log(`Errors: ${result.errors.length}`); - console.log(`Total documents: ${result.totalDocuments}`); - console.log(`Valid documents: ${result.validDocuments}`); - - if (result.circularReferences.length > 0) { - console.log( - `Circular references: ${result.circularReferences.length}` - ); - for (const circular of result.circularReferences) { - console.log(` • ${circular}`); - } - } - - for (const error of result.errors) { - console.log(` • ${error.path}: ${error.message}`); - } - - if (options.verbose && result.warnings.length > 0) { - console.log(`Warnings: ${result.warnings.length}`); - for (const warning of result.warnings) { - console.log(` • ${warning.path}: ${warning.message}`); - } - } - + // Handle single vs multiple sources + if (sources.length === 1) { + const source = sources[0]; + if (!source) { + console.log("āŒ No source provided"); process.exit(1); } - } else { - result = await validate(source, validationOptions); + let result; - if (result.valid) { - console.log("āœ… Specification is valid"); - if (options.verbose) { - console.log(`Version: ${result.version}`); - console.log(`Warnings: ${result.warnings.length}`); + if (options.recursive) { + result = await validateWithReferences(source, validationOptions); + + if (result.valid) { + console.log("āœ… Specification and all references are valid"); + + // Show summary if not in JSON mode + if (!options.json) { + try { + const { summary } = await generateSpecificationSummary( + source, + validationOptions + ); + console.log("\nšŸ“Š Summary:"); + console.log(` Version: ${summary.version}`); + console.log(` Paths: ${summary.paths}`); + console.log(` Endpoints: ${summary.endpoints}`); + console.log(` Components: ${summary.components}`); + console.log(` Schemas: ${summary.schemas}`); + console.log(` Total Documents: ${result.totalDocuments}`); + console.log(` Valid Documents: ${result.validDocuments}`); + console.log( + ` References: ${summary.referenceAnalysis.totalReferences}` + ); + console.log( + ` Circular References: ${result.circularReferences.length}` + ); + console.log(` Errors: ${result.errors.length}`); + console.log(` Warnings: ${result.warnings.length}`); + } catch (error) { + // Fallback to basic info if summary generation fails + console.log(`Version: ${result.version}`); + console.log(`Total documents: ${result.totalDocuments}`); + console.log(`Valid documents: ${result.validDocuments}`); + console.log( + `Circular references: ${result.circularReferences.length}` + ); + console.log(`Warnings: ${result.warnings.length}`); + } + } else if (options.verbose) { + console.log(`Version: ${result.version}`); + console.log(`Total documents: ${result.totalDocuments}`); + console.log(`Valid documents: ${result.validDocuments}`); + console.log( + `Circular references: ${result.circularReferences.length}` + ); + console.log(`Warnings: ${result.warnings.length}`); + } + } else { + console.log("āŒ Specification or references are invalid"); + console.log(`Errors: ${result.errors.length}`); + console.log(`Total documents: ${result.totalDocuments}`); + console.log(`Valid documents: ${result.validDocuments}`); + + if (result.circularReferences.length > 0) { + console.log( + `Circular references: ${result.circularReferences.length}` + ); + for (const circular of result.circularReferences) { + console.log(` • ${circular}`); + } + } + + for (const error of result.errors) { + console.log(` • ${error.path}: ${error.message}`); + } + + if (options.verbose && result.warnings.length > 0) { + console.log(`Warnings: ${result.warnings.length}`); + for (const warning of result.warnings) { + console.log(` • ${warning.path}: ${warning.message}`); + } + } + + process.exit(1); } } else { - console.log("āŒ Specification is invalid"); - console.log(`Errors: ${result.errors.length}`); + result = await validate([source], validationOptions); - for (const error of result.errors) { - console.log(` • ${error.path}: ${error.message}`); + // Handle both single result and array of results + const validationResult = Array.isArray(result) ? result[0] : result; + + if (!validationResult) { + console.log("āŒ No validation result received"); + process.exit(1); } - if (options.verbose && result.warnings.length > 0) { - console.log(`Warnings: ${result.warnings.length}`); - for (const warning of result.warnings) { - console.log(` • ${warning.path}: ${warning.message}`); + if (validationResult.valid) { + console.log("āœ… Specification is valid"); + + // Show summary if not in JSON mode + if (!options.json) { + try { + const { summary } = await generateSpecificationSummary( + source, + validationOptions + ); + console.log("\nšŸ“Š Summary:"); + console.log(` Version: ${summary.version}`); + console.log(` Paths: ${summary.paths}`); + console.log(` Endpoints: ${summary.endpoints}`); + console.log(` Components: ${summary.components}`); + console.log(` Schemas: ${summary.schemas}`); + console.log( + ` References: ${summary.referenceAnalysis.totalReferences}` + ); + console.log( + ` Circular References: ${summary.referenceAnalysis.circularReferences}` + ); + console.log(` Errors: ${summary.validationResults.errors}`); + console.log( + ` Warnings: ${summary.validationResults.warnings}` + ); + } catch (error) { + // Fallback to basic info if summary generation fails + console.log(`Version: ${validationResult.version}`); + console.log(`Warnings: ${validationResult.warnings.length}`); + } + } else if (options.verbose) { + console.log(`Version: ${validationResult.version}`); + console.log(`Warnings: ${validationResult.warnings.length}`); + } + } else { + console.log("āŒ Specification is invalid"); + console.log(`Errors: ${validationResult.errors.length}`); + + for (const error of validationResult.errors) { + console.log(` • ${error.path}: ${error.message}`); + } + + if (options.verbose && validationResult.warnings.length > 0) { + console.log(`Warnings: ${validationResult.warnings.length}`); + for (const warning of validationResult.warnings) { + console.log(` • ${warning.path}: ${warning.message}`); + } + } + + process.exit(1); + } + } + } else { + // Multiple sources - use batch validation logic + const results = await validateMultipleWithReferences( + sources, + validationOptions + ); + + if (options.json) { + console.log(JSON.stringify(results, null, 2)); + } else { + console.log("šŸ“‹ Validation Results"); + console.log("=".repeat(50)); + + let validCount = 0; + let errorCount = 0; + + for (let i = 0; i < sources.length; i++) { + const source = sources[i]; + const result = results[i]; + + console.log(`\n${i + 1}. ${source}`); + if (result && result.valid) { + console.log(" āœ… Valid"); + if (options.verbose) { + console.log(` Version: ${result.version}`); + console.log(` Warnings: ${result.warnings.length}`); + } + validCount++; + } else { + console.log(" āŒ Invalid"); + console.log(` Errors: ${result?.errors.length || 0}`); + if (options.verbose && result?.errors) { + for (const error of result.errors) { + console.log(` • ${error.path}: ${error.message}`); + } + } + errorCount++; } } - process.exit(1); + console.log("\n" + "=".repeat(50)); + console.log(`Summary: ${validCount} valid, ${errorCount} invalid`); + + if (errorCount > 0) { + process.exit(1); + } } } } catch (error) { @@ -129,9 +269,15 @@ program program .command("parse") .description("Parse an OpenAPI specification without validation") - .argument("", "Path or URL to OpenAPI specification") + .argument("", "Path or URL to OpenAPI specification (JSON or YAML)") .option("-j, --json", "Output as JSON") + .option("--no-progress", "Disable progress indicators") + .option("--no-colors", "Disable colored output") .action(async (source: string, options: any) => { + // Configure logger based on options + log.setVerbose(options.verbose); + log.setShowProgress(!options.noProgress); + log.setUseColors(!options.noColors); try { const parsed = await parse(source); @@ -160,7 +306,7 @@ program program .command("report") .description("Generate a validation report") - .argument("", "Path or URL to OpenAPI specification") + .argument("", "Path or URL to OpenAPI specification (JSON or YAML)") .option( "-f, --format ", "Report format (json, yaml, html, markdown)", @@ -207,66 +353,11 @@ program } }); -// Batch command -program - .command("batch") - .description("Validate multiple OpenAPI specifications") - .argument("", "Paths or URLs to OpenAPI specifications") - .option("-s, --strict", "Enable strict validation mode") - .option("-e, --examples", "Validate examples in the specification") - .option("-r, --references", "Validate all references") - .option("-j, --json", "Output as JSON") - .action(async (sources: string[], options: any) => { - try { - const validationOptions: ValidationOptions = { - strict: options.strict, - validateExamples: options.examples, - validateReferences: options.references, - }; - - const results = await validateMultiple(sources, validationOptions); - - if (options.json) { - console.log(JSON.stringify(results, null, 2)); - } else { - console.log("šŸ“‹ Batch Validation Results"); - console.log("=".repeat(50)); - - let validCount = 0; - let errorCount = 0; - - for (let i = 0; i < sources.length; i++) { - const source = sources[i]; - const result = results[i]; - - console.log(`\n${i + 1}. ${source}`); - if (result && result.valid) { - console.log(" āœ… Valid"); - validCount++; - } else { - console.log(" āŒ Invalid"); - console.log(` Errors: ${result?.errors.length || 0}`); - errorCount++; - } - } - - console.log("\n" + "=".repeat(50)); - console.log(`Summary: ${validCount} valid, ${errorCount} invalid`); - } - } catch (error) { - console.error( - "āŒ Batch validation failed:", - error instanceof Error ? error.message : "Unknown error" - ); - process.exit(1); - } - }); - // Analyze command program .command("analyze") .description("Analyze references in an OpenAPI specification") - .argument("", "Path or URL to OpenAPI specification") + .argument("", "Path or URL to OpenAPI specification (JSON or YAML)") .option("-j, --json", "Output as JSON") .action(async (source: string, options: any) => { try { @@ -305,6 +396,65 @@ program } }); +// Summary command +program + .command("summary") + .description("Generate a comprehensive summary of an OpenAPI specification") + .argument("", "Path or URL to OpenAPI specification (JSON or YAML)") + .option("-j, --json", "Output as JSON") + .option("-d, --detailed", "Show detailed summary") + .option("-s, --strict", "Enable strict validation mode") + .option("-e, --examples", "Validate examples in the specification") + .option("-r, --references", "Validate all references") + .option("--no-progress", "Disable progress indicators") + .option("--no-colors", "Disable colored output") + .action(async (source: string, options: any) => { + // Configure logger based on options + log.setVerbose(options.verbose); + log.setShowProgress(!options.noProgress); + log.setUseColors(!options.noColors); + + try { + const validationOptions: ValidationOptions = { + strict: options.strict, + validateExamples: options.examples, + validateReferences: options.references, + }; + + const { summary, detailedSummary, jsonSummary } = + await generateSpecificationSummary(source, validationOptions); + + if (options.json) { + console.log(jsonSummary); + } else if (options.detailed) { + console.log(detailedSummary); + } else { + console.log("šŸ“Š OpenAPI Specification Summary"); + console.log("=".repeat(50)); + console.log(`Version: ${summary.version}`); + console.log(`Title: ${summary.title || "N/A"}`); + console.log(`Paths: ${summary.paths}`); + console.log(`Endpoints: ${summary.endpoints}`); + console.log(`Components: ${summary.components}`); + console.log(`Schemas: ${summary.schemas}`); + console.log(`Valid: ${summary.validationResults.valid ? "Yes" : "No"}`); + if (summary.validationResults.errors > 0) { + console.log(`Errors: ${summary.validationResults.errors}`); + } + if (summary.validationResults.warnings > 0) { + console.log(`Warnings: ${summary.validationResults.warnings}`); + } + console.log("=".repeat(50)); + } + } catch (error) { + console.error( + "āŒ Summary generation failed:", + error instanceof Error ? error.message : "Unknown error" + ); + process.exit(1); + } + }); + // Info command program .command("info") @@ -320,5 +470,7 @@ program console.log("\nFor more information, visit: https://spec.openapis.org/"); }); -// Parse command line arguments -program.parse(); +// Only parse command line arguments if this file is being run directly +if (import.meta.main) { + program.parse(); +} diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..de425f6 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,443 @@ +import { performance } from "perf_hooks"; + +export interface LogLevel { + ERROR: 0; + WARN: 1; + INFO: 2; + DEBUG: 3; + TRACE: 4; +} + +export const LOG_LEVELS: LogLevel = { + ERROR: 0, + WARN: 1, + INFO: 2, + DEBUG: 3, + TRACE: 4, +}; + +export interface LoggerConfig { + level: keyof LogLevel; + verbose: boolean; + showTimestamps: boolean; + showProgress: boolean; + useColors: boolean; +} + +export interface ProgressInfo { + current: number; + total: number; + label: string; + percentage: number; +} + +export interface ValidationSummary { + filesProcessed: number; + schemasFound: number; + endpointsFound: number; + pathsFound: number; + componentsFound: number; + callbacksFound: number; + webhooksFound: number; + referencesFound: number; + circularReferences: number; + validationErrors: number; + validationWarnings: number; + processingTime: number; +} + +export class Logger { + private config: LoggerConfig; + private startTime: number; + private operationStartTime: number; + private currentProgress: ProgressInfo | null = null; + + constructor(config: Partial = {}) { + this.config = { + level: "INFO", + verbose: false, + showTimestamps: true, + showProgress: true, + useColors: true, + ...config, + }; + this.startTime = performance.now(); + this.operationStartTime = this.startTime; + } + + private getTimestamp(): string { + if (!this.config.showTimestamps) return ""; + const now = new Date(); + return `[${now.toISOString().split("T")[1].split(".")[0]}] `; + } + + private getColorCode(level: keyof LogLevel): string { + if (!this.config.useColors) return ""; + + const colors = { + ERROR: "\x1b[31m", // Red + WARN: "\x1b[33m", // Yellow + INFO: "\x1b[36m", // Cyan + DEBUG: "\x1b[35m", // Magenta + TRACE: "\x1b[90m", // Gray + }; + + return colors[level] || ""; + } + + private getResetColor(): string { + return this.config.useColors ? "\x1b[0m" : ""; + } + + private shouldLog(level: keyof LogLevel): boolean { + return LOG_LEVELS[level] <= LOG_LEVELS[this.config.level]; + } + + private formatMessage( + level: keyof LogLevel, + message: string, + data?: any + ): string { + const timestamp = this.getTimestamp(); + const color = this.getColorCode(level); + const reset = this.getResetColor(); + const levelStr = level.padEnd(5); + + let formatted = `${timestamp}${color}${levelStr}${reset} ${message}`; + + if (data && this.config.verbose) { + formatted += `\n${JSON.stringify(data, null, 2)}`; + } + + return formatted; + } + + private log(level: keyof LogLevel, message: string, data?: any): void { + if (!this.shouldLog(level)) return; + + const formatted = this.formatMessage(level, message, data); + console.log(formatted); + } + + public error(message: string, data?: any): void { + this.log("ERROR", message, data); + } + + public warn(message: string, data?: any): void { + this.log("WARN", message, data); + } + + public info(message: string, data?: any): void { + this.log("INFO", message, data); + } + + public debug(message: string, data?: any): void { + this.log("DEBUG", message, data); + } + + public trace(message: string, data?: any): void { + this.log("TRACE", message, data); + } + + public startOperation(operation: string): void { + this.operationStartTime = performance.now(); + this.info(`šŸš€ Starting ${operation}`); + } + + public endOperation(operation: string, success: boolean = true): void { + const duration = performance.now() - this.operationStartTime; + const status = success ? "āœ…" : "āŒ"; + this.info(`${status} Completed ${operation} in ${duration.toFixed(2)}ms`); + } + + public startProgress(total: number, label: string): void { + this.currentProgress = { + current: 0, + total, + label, + percentage: 0, + }; + this.updateProgress(); + } + + public updateProgress(current: number): void { + if (!this.currentProgress) return; + + this.currentProgress.current = current; + this.currentProgress.percentage = Math.round( + (current / this.currentProgress.total) * 100 + ); + this.updateProgress(); + } + + public updateProgress(): void { + if (!this.currentProgress || !this.config.showProgress) return; + + const { current, total, label, percentage } = this.currentProgress; + const barLength = 20; + const filledLength = Math.round((percentage / 100) * barLength); + const bar = "ā–ˆ".repeat(filledLength) + "ā–‘".repeat(barLength - filledLength); + + process.stdout.write( + `\r${this.getTimestamp()}${this.getColorCode( + "INFO" + )}PROGRESS${this.getResetColor()} ${label}: [${bar}] ${percentage}% (${current}/${total})` + ); + + if (current >= total) { + process.stdout.write("\n"); + this.currentProgress = null; + } + } + + public endProgress(): void { + if (this.currentProgress) { + this.updateProgress(this.currentProgress.total); + } + } + + public step(step: string, details?: string): void { + const detailsStr = details ? ` - ${details}` : ""; + this.info(`šŸ“‹ ${step}${detailsStr}`); + } + + public fileOperation( + operation: string, + filePath: string, + details?: string + ): void { + const detailsStr = details ? ` (${details})` : ""; + this.info(`šŸ“ ${operation}: ${filePath}${detailsStr}`); + } + + public parsingStep(step: string, details?: string): void { + const detailsStr = details ? ` - ${details}` : ""; + this.info(`šŸ” Parsing: ${step}${detailsStr}`); + } + + public validationStep(step: string, details?: string): void { + const detailsStr = details ? ` - ${details}` : ""; + this.info(`āœ… Validation: ${step}${detailsStr}`); + } + + public referenceStep(step: string, refPath: string, details?: string): void { + const detailsStr = details ? ` - ${details}` : ""; + this.info(`šŸ”— Reference: ${step} ${refPath}${detailsStr}`); + } + + public schemaStep(step: string, schemaName: string, details?: string): void { + const detailsStr = details ? ` - ${details}` : ""; + this.info(`šŸ“‹ Schema: ${step} ${schemaName}${detailsStr}`); + } + + public endpointStep( + step: string, + method: string, + path: string, + details?: string + ): void { + const detailsStr = details ? ` - ${details}` : ""; + this.info( + `🌐 Endpoint: ${step} ${method.toUpperCase()} ${path}${detailsStr}` + ); + } + + public componentStep( + step: string, + componentType: string, + componentName: string, + details?: string + ): void { + const detailsStr = details ? ` - ${details}` : ""; + this.info( + `🧩 Component: ${step} ${componentType} ${componentName}${detailsStr}` + ); + } + + public webhookStep( + step: string, + webhookName: string, + details?: string + ): void { + const detailsStr = details ? ` - ${details}` : ""; + this.info(`šŸŽ£ Webhook: ${step} ${webhookName}${detailsStr}`); + } + + public callbackStep( + step: string, + callbackName: string, + details?: string + ): void { + const detailsStr = details ? ` - ${details}` : ""; + this.info(`šŸ”„ Callback: ${step} ${callbackName}${detailsStr}`); + } + + public summary(summary: ValidationSummary): void { + const duration = performance.now() - this.startTime; + + this.info("šŸ“Š Validation Summary"); + this.info("=".repeat(50)); + this.info(`Files Processed: ${summary.filesProcessed}`); + this.info(`Schemas Found: ${summary.schemasFound}`); + this.info(`Endpoints Found: ${summary.endpointsFound}`); + this.info(`Paths Found: ${summary.pathsFound}`); + this.info(`Components Found: ${summary.componentsFound}`); + this.info(`Callbacks Found: ${summary.callbacksFound}`); + this.info(`Webhooks Found: ${summary.webhooksFound}`); + this.info(`References Found: ${summary.referencesFound}`); + this.info(`Circular References: ${summary.circularReferences}`); + this.info(`Validation Errors: ${summary.validationErrors}`); + this.info(`Validation Warnings: ${summary.validationWarnings}`); + this.info(`Total Processing Time: ${duration.toFixed(2)}ms`); + this.info("=".repeat(50)); + } + + public detailedSummary(spec: any, version: string): void { + this.info("šŸ“‹ Detailed Specification Summary"); + this.info("=".repeat(50)); + + // Basic info + this.info(`OpenAPI Version: ${version}`); + this.info(`Title: ${spec.info?.title || "N/A"}`); + this.info(`Version: ${spec.info?.version || "N/A"}`); + this.info(`Description: ${spec.info?.description || "N/A"}`); + + // Paths analysis + if (spec.paths) { + const paths = Object.keys(spec.paths); + this.info(`Total Paths: ${paths.length}`); + + let totalEndpoints = 0; + const methods = new Set(); + + for (const path of paths) { + const pathItem = spec.paths[path]; + if (typeof pathItem === "object" && pathItem !== null) { + for (const [method, operation] of Object.entries(pathItem)) { + if ( + typeof operation === "object" && + operation !== null && + "responses" in operation + ) { + totalEndpoints++; + methods.add(method.toUpperCase()); + } + } + } + } + + this.info(`Total Endpoints: ${totalEndpoints}`); + this.info(`HTTP Methods Used: ${Array.from(methods).join(", ")}`); + } + + // Components analysis + if (spec.components) { + this.info("Components Analysis:"); + for (const [componentType, components] of Object.entries( + spec.components + )) { + if (typeof components === "object" && components !== null) { + const componentCount = Object.keys(components).length; + this.info(` ${componentType}: ${componentCount}`); + } + } + } + + // Security analysis + if (spec.security) { + this.info(`Security Requirements: ${spec.security.length}`); + } + + if (spec.components?.securitySchemes) { + const securitySchemes = Object.keys(spec.components.securitySchemes); + this.info(`Security Schemes: ${securitySchemes.length}`); + } + + // Servers analysis + if (spec.servers) { + this.info(`Servers: ${spec.servers.length}`); + } + + // Tags analysis + if (spec.tags) { + this.info(`Tags: ${spec.tags.length}`); + } + + // External docs + if (spec.externalDocs) { + this.info("External Documentation: Yes"); + } + + this.info("=".repeat(50)); + } + + public setVerbose(verbose: boolean): void { + this.config.verbose = verbose; + } + + public setShowProgress(showProgress: boolean): void { + this.config.showProgress = showProgress; + } + + public setUseColors(useColors: boolean): void { + this.config.useColors = useColors; + } + + public setLevel(level: keyof LogLevel): void { + this.config.level = level; + } +} + +// Global logger instance +export const logger = new Logger(); + +// Export convenience functions +export const log = { + error: (message: string, data?: any) => logger.error(message, data), + warn: (message: string, data?: any) => logger.warn(message, data), + info: (message: string, data?: any) => logger.info(message, data), + debug: (message: string, data?: any) => logger.debug(message, data), + trace: (message: string, data?: any) => logger.trace(message, data), + step: (step: string, details?: string) => logger.step(step, details), + fileOperation: (operation: string, filePath: string, details?: string) => + logger.fileOperation(operation, filePath, details), + parsingStep: (step: string, details?: string) => + logger.parsingStep(step, details), + validationStep: (step: string, details?: string) => + logger.validationStep(step, details), + referenceStep: (step: string, refPath: string, details?: string) => + logger.referenceStep(step, refPath, details), + schemaStep: (step: string, schemaName: string, details?: string) => + logger.schemaStep(step, schemaName, details), + endpointStep: ( + step: string, + method: string, + path: string, + details?: string + ) => logger.endpointStep(step, method, path, details), + componentStep: ( + step: string, + componentType: string, + componentName: string, + details?: string + ) => logger.componentStep(step, componentType, componentName, details), + webhookStep: (step: string, webhookName: string, details?: string) => + logger.webhookStep(step, webhookName, details), + callbackStep: (step: string, callbackName: string, details?: string) => + logger.callbackStep(step, callbackName, details), + summary: (summary: ValidationSummary) => logger.summary(summary), + detailedSummary: (spec: any, version: string) => + logger.detailedSummary(spec, version), + startOperation: (operation: string) => logger.startOperation(operation), + endOperation: (operation: string, success: boolean) => + logger.endOperation(operation, success), + startProgress: (total: number, label: string) => + logger.startProgress(total, label), + updateProgress: (current: number) => logger.updateProgress(current), + endProgress: () => logger.endProgress(), + setVerbose: (verbose: boolean) => logger.setVerbose(verbose), + setShowProgress: (showProgress: boolean) => + logger.setShowProgress(showProgress), + setUseColors: (useColors: boolean) => logger.setUseColors(useColors), + setLevel: (level: keyof LogLevel) => logger.setLevel(level), +}; diff --git a/src/parser.ts b/src/parser.ts index 1bf7ae9..a358e9d 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,30 +1,47 @@ import { readFileSync } from "fs"; import { resolve } from "path"; +import * as yaml from "js-yaml"; import type { ParsedSpec, OpenAPIVersion, OpenAPISpec } from "./types.js"; import type { OpenAPI2, OpenAPI3, OpenAPI3_1 } from "oas-types"; +import { log } from "./logger.js"; /** * Detect OpenAPI version from specification */ const detectVersion = (spec: any): OpenAPIVersion => { + log.parsingStep("Detecting OpenAPI version"); + // Check for OpenAPI 3.x if (spec.openapi) { const version = spec.openapi; + log.parsingStep("Found OpenAPI 3.x specification", `Version: ${version}`); + if (version.startsWith("3.0")) { + log.parsingStep("Detected OpenAPI 3.0.x", version); return version as OpenAPIVersion; } else if (version.startsWith("3.1")) { + log.parsingStep("Detected OpenAPI 3.1.x", version); return version as OpenAPIVersion; } else if (version.startsWith("3.2")) { + log.parsingStep("Detected OpenAPI 3.2.x", version); return version as OpenAPIVersion; } + log.error("Unsupported OpenAPI version", { version }); throw new Error(`Unsupported OpenAPI version: ${version}`); } // Check for Swagger 2.0 if (spec.swagger === "2.0") { + log.parsingStep("Detected Swagger 2.0 specification"); return "2.0"; } + log.error("Unable to detect OpenAPI version", { + hasOpenapi: !!spec.openapi, + hasSwagger: !!spec.swagger, + openapiValue: spec.openapi, + swaggerValue: spec.swagger, + }); throw new Error( 'Unable to detect OpenAPI version. Specification must have "openapi" or "swagger" field.' ); @@ -37,62 +54,132 @@ const extractMetadata = ( spec: OpenAPISpec, version: OpenAPIVersion ): ParsedSpec["metadata"] => { + log.parsingStep("Extracting metadata from specification"); + // All OpenAPI versions have the same info structure const info = spec.info; - return { + + const metadata = { title: info?.title, version: info?.version, description: info?.description, contact: info?.contact, license: info?.license, }; + + log.parsingStep( + "Metadata extracted", + `Title: ${metadata.title}, Version: ${ + metadata.version + }, HasDescription: ${!!metadata.description}, HasContact: ${!!metadata.contact}, HasLicense: ${!!metadata.license}` + ); + + return metadata; }; /** * Parse an OpenAPI specification from a file path or URL */ export const parseOpenAPISpec = async (source: string): Promise => { + log.startOperation("Parsing OpenAPI specification"); + log.fileOperation("Reading specification", source); + let content: string; let spec: any; try { // Handle file paths if (source.startsWith("http://") || source.startsWith("https://")) { + log.parsingStep("Fetching remote specification", source); const response = await fetch(source); if (!response.ok) { + log.error("Failed to fetch remote specification", { + url: source, + status: response.status, + statusText: response.statusText, + }); throw new Error( `Failed to fetch specification: ${response.statusText}` ); } content = await response.text(); + log.parsingStep( + "Remote specification fetched", + `Size: ${content.length} characters` + ); } else { // Local file + log.parsingStep("Reading local file", source); const filePath = resolve(source); + log.fileOperation("Reading file", filePath); content = readFileSync(filePath, "utf-8"); + log.parsingStep("Local file read", `Size: ${content.length} characters`); } // Parse JSON or YAML + log.parsingStep("Determining content format"); if (content.trim().startsWith("{") || content.trim().startsWith("[")) { + log.parsingStep("Detected JSON format"); + log.parsingStep("Parsing JSON content"); spec = JSON.parse(content); + log.parsingStep("JSON parsing completed"); } else { - // For YAML parsing, we'll use a simple approach or add yaml dependency later - throw new Error( - "YAML parsing not yet implemented. Please use JSON format." - ); + // Parse YAML + log.parsingStep("Detected YAML format"); + log.parsingStep("Parsing YAML content"); + try { + spec = yaml.load(content); + log.parsingStep("YAML parsing completed"); + } catch (yamlError) { + log.error("YAML parsing failed", { + error: + yamlError instanceof Error + ? yamlError.message + : "Unknown YAML error", + }); + throw new Error( + `Failed to parse YAML: ${ + yamlError instanceof Error + ? yamlError.message + : "Unknown YAML error" + }` + ); + } } const version = detectVersion(spec); + log.parsingStep("Version detection completed", `Detected: ${version}`); // Type the spec based on the detected version const typedSpec = spec as OpenAPISpec; + log.parsingStep("Specification typed", `Type: OpenAPISpec`); - return { + const metadata = extractMetadata(typedSpec, version); + + const result = { spec: typedSpec, version, source, - metadata: extractMetadata(typedSpec, version), + metadata, }; + + log.endOperation("Parsing OpenAPI specification", true); + log.parsingStep( + "Parsing completed successfully", + `Version: ${version}, Source: ${source}, Title: ${ + metadata.title + }, HasPaths: ${!!typedSpec.paths}, PathCount: ${ + typedSpec.paths ? Object.keys(typedSpec.paths).length : 0 + }` + ); + + return result; } catch (error) { + log.error("Parsing failed", { + source, + error: error instanceof Error ? error.message : "Unknown error", + }); + log.endOperation("Parsing OpenAPI specification", false); throw new Error( `Failed to parse OpenAPI specification: ${ error instanceof Error ? error.message : "Unknown error" @@ -108,13 +195,32 @@ export const validateBasicStructure = ( spec: OpenAPISpec, version: OpenAPIVersion ): boolean => { + log.parsingStep("Validating basic structure"); + + let isValid: boolean; + if (version === "2.0") { + log.parsingStep("Validating Swagger 2.0 structure"); const swaggerSpec = spec as OpenAPI2.Specification; - return !!(swaggerSpec.swagger && swaggerSpec.info && swaggerSpec.paths); + isValid = !!(swaggerSpec.swagger && swaggerSpec.info && swaggerSpec.paths); + + log.parsingStep( + "Swagger 2.0 structure validation", + `HasSwagger: ${!!swaggerSpec.swagger}, HasInfo: ${!!swaggerSpec.info}, HasPaths: ${!!swaggerSpec.paths}, IsValid: ${isValid}` + ); } else { + log.parsingStep("Validating OpenAPI 3.x structure"); const openapiSpec = spec as | OpenAPI3.Specification | OpenAPI3_1.Specification; - return !!(openapiSpec.openapi && openapiSpec.info && openapiSpec.paths); + isValid = !!(openapiSpec.openapi && openapiSpec.info && openapiSpec.paths); + + log.parsingStep( + "OpenAPI 3.x structure validation", + `HasOpenapi: ${!!openapiSpec.openapi}, HasInfo: ${!!openapiSpec.info}, HasPaths: ${!!openapiSpec.paths}, IsValid: ${isValid}` + ); } + + log.parsingStep("Basic structure validation completed", `Valid: ${isValid}`); + return isValid; }; diff --git a/src/recursive-validator.ts b/src/recursive-validator.ts index f6a5ceb..0d99275 100644 --- a/src/recursive-validator.ts +++ b/src/recursive-validator.ts @@ -9,6 +9,7 @@ import type { OpenAPIVersion, OpenAPISpec, } from "./types.js"; +import { log } from "./logger.js"; export interface RecursiveValidationResult extends ValidationResult { partialValidations: Array<{ @@ -45,6 +46,8 @@ export const validateRecursively = async ( options.maxRefDepth || 10 ); + log.info(`šŸ”— Following ${resolvedRefs.length} references...`); + // Validate each resolved reference const partialValidations: Array<{ path: string; @@ -54,8 +57,12 @@ export const validateRecursively = async ( let validDocuments = rootValidation.valid ? 1 : 0; - for (const ref of resolvedRefs) { + for (let i = 0; i < resolvedRefs.length; i++) { + const ref = resolvedRefs[i]; + if (!ref) continue; + if (ref.isCircular) { + log.info(`šŸ”„ Circular reference: ${ref.path}`); partialValidations.push({ path: ref.path, result: { @@ -67,7 +74,7 @@ export const validateRecursively = async ( }, ], warnings: [], - spec: null, + spec: {} as OpenAPISpec, version: ref.version || "3.0", }, isCircular: true, @@ -97,6 +104,11 @@ export const validateRecursively = async ( if (partialResult.valid) { validDocuments++; + log.info(`āœ… Reference: ${ref.path}`); + } else { + log.info( + `āŒ Reference: ${ref.path} (${partialResult.errors.length} errors)` + ); } } @@ -109,7 +121,7 @@ export const validateRecursively = async ( allWarnings.push(...partial.result.warnings); } - return { + const result = { valid: rootValidation.valid && partialValidations.every((p) => p.result.valid), errors: allErrors, @@ -121,6 +133,8 @@ export const validateRecursively = async ( totalDocuments: 1 + partialValidations.length, validDocuments, }; + + return result; }; /** @@ -130,13 +144,37 @@ export const validateMultipleRecursively = async ( sources: string[], options: ValidationOptions = {} ): Promise => { + log.startOperation("Multiple recursive validation"); + log.validationStep( + "Starting batch validation", + `${sources.length} specifications` + ); + const results: RecursiveValidationResult[] = []; - for (const source of sources) { + log.startProgress(sources.length, "Validating specifications"); + + for (let i = 0; i < sources.length; i++) { + const source = sources[i]; + if (!source) continue; + + log.updateProgress(i); + log.fileOperation( + "Processing specification", + source, + `${i + 1}/${sources.length}` + ); + try { const result = await validateRecursively(source, options); results.push(result); + log.validationStep("Specification validated", `Valid: ${result.valid}`); } catch (error) { + log.error("Specification validation failed", { + source, + error: error instanceof Error ? error.message : "Unknown error", + }); + // Create error result for failed parsing const errorResult: RecursiveValidationResult = { valid: false, @@ -149,7 +187,7 @@ export const validateMultipleRecursively = async ( }, ], warnings: [], - spec: null, + spec: {} as OpenAPISpec, version: "3.0", partialValidations: [], circularReferences: [], @@ -160,6 +198,16 @@ export const validateMultipleRecursively = async ( } } + log.endProgress(); + log.endOperation("Multiple recursive validation", true); + + const validCount = results.filter((r) => r.valid).length; + const invalidCount = results.length - validCount; + log.validationStep( + "Batch validation completed", + `Valid: ${validCount}, Invalid: ${invalidCount}` + ); + return results; }; @@ -173,10 +221,17 @@ export const analyzeReferences = async ( circularReferences: string[]; totalReferences: number; }> => { + log.startOperation("Analyzing references"); + log.fileOperation("Analyzing references", source); + const parsed = await parseOpenAPISpec(source); + log.validationStep("Parsing completed for reference analysis"); + const references = findReferences(parsed.spec); + log.validationStep("References found", `${references.length} total`); // Check for circular references by analyzing reference paths + log.validationStep("Analyzing circular references"); const circularReferences: string[] = []; const referenceMap = new Map(); @@ -192,13 +247,26 @@ export const analyzeReferences = async ( for (const [refValue, paths] of referenceMap) { if (paths.length > 1) { // This is a potential circular reference + log.referenceStep( + "Circular reference detected", + refValue, + `${paths.length} occurrences` + ); circularReferences.push(refValue); } } - return { + const result = { references, circularReferences, totalReferences: references.length, }; + + log.endOperation("Analyzing references", true); + log.validationStep( + "Reference analysis completed", + `Total: ${result.totalReferences}, Circular: ${result.circularReferences.length}` + ); + + return result; }; diff --git a/src/summary-analyzer.ts b/src/summary-analyzer.ts new file mode 100644 index 0000000..2c421b2 --- /dev/null +++ b/src/summary-analyzer.ts @@ -0,0 +1,465 @@ +import type { OpenAPISpec, OpenAPIVersion } from "./types.js"; +import type { OpenAPI2, OpenAPI3, OpenAPI3_1, OpenAPI3_2 } from "oas-types"; +import { log } from "./logger.js"; + +export interface SpecificationSummary { + // Basic info + version: string; + title?: string; + description?: string; + + // Counts + paths: number; + endpoints: number; + schemas: number; + components: number; + callbacks: number; + webhooks: number; + securitySchemes: number; + servers: number; + tags: number; + operations: number; + + // HTTP methods used + httpMethods: string[]; + + // Component breakdown + componentBreakdown: { + schemas: number; + responses: number; + parameters: number; + examples: number; + requestBodies: number; + headers: number; + securitySchemes: number; + links: number; + callbacks: number; + pathItems: number; + }; + + // Security analysis + securityAnalysis: { + hasSecurity: boolean; + securitySchemes: number; + securityRequirements: number; + oauthFlows: number; + apiKeys: number; + httpAuth: number; + }; + + // Reference analysis + referenceAnalysis: { + totalReferences: number; + internalReferences: number; + externalReferences: number; + circularReferences: number; + }; + + // Validation results + validationResults: { + valid: boolean; + errors: number; + warnings: number; + processingTime: number; + }; +} + +/** + * Analyze an OpenAPI specification and generate a comprehensive summary + */ +export const analyzeSpecification = ( + spec: OpenAPISpec, + version: OpenAPIVersion, + validationResults?: { + valid: boolean; + errors: number; + warnings: number; + processingTime: number; + } +): SpecificationSummary => { + log.validationStep("Analyzing specification for summary"); + + const summary: SpecificationSummary = { + version, + title: spec.info?.title, + description: spec.info?.description, + paths: 0, + endpoints: 0, + schemas: 0, + components: 0, + callbacks: 0, + webhooks: 0, + securitySchemes: 0, + servers: 0, + tags: 0, + operations: 0, + httpMethods: [], + componentBreakdown: { + schemas: 0, + responses: 0, + parameters: 0, + examples: 0, + requestBodies: 0, + headers: 0, + securitySchemes: 0, + links: 0, + callbacks: 0, + pathItems: 0, + }, + securityAnalysis: { + hasSecurity: false, + securitySchemes: 0, + securityRequirements: 0, + oauthFlows: 0, + apiKeys: 0, + httpAuth: 0, + }, + referenceAnalysis: { + totalReferences: 0, + internalReferences: 0, + externalReferences: 0, + circularReferences: 0, + }, + validationResults: validationResults || { + valid: false, + errors: 0, + warnings: 0, + processingTime: 0, + }, + }; + + // Analyze paths and endpoints + if (spec.paths) { + log.validationStep("Analyzing paths and endpoints"); + summary.paths = Object.keys(spec.paths).length; + + const methods = new Set(); + let endpointCount = 0; + + for (const [path, pathItem] of Object.entries(spec.paths)) { + if (typeof pathItem === "object" && pathItem !== null) { + for (const [method, operation] of Object.entries(pathItem)) { + if ( + typeof operation === "object" && + operation !== null && + "responses" in operation + ) { + endpointCount++; + methods.add(method.toUpperCase()); + log.endpointStep("Found endpoint", method, path); + } + } + } + } + + summary.endpoints = endpointCount; + summary.httpMethods = Array.from(methods); + summary.operations = endpointCount; + + log.validationStep( + "Path analysis completed", + `Paths: ${summary.paths}, Endpoints: ${summary.endpoints}` + ); + } + + // Analyze components (OpenAPI 3.x) + if (version.startsWith("3.") && (spec as any).components) { + log.validationStep("Analyzing components"); + const components = (spec as any).components; + + if (components.schemas) { + summary.componentBreakdown.schemas = Object.keys( + components.schemas + ).length; + summary.schemas = summary.componentBreakdown.schemas; + } + + if (components.responses) { + summary.componentBreakdown.responses = Object.keys( + components.responses + ).length; + } + + if (components.parameters) { + summary.componentBreakdown.parameters = Object.keys( + components.parameters + ).length; + } + + if (components.examples) { + summary.componentBreakdown.examples = Object.keys( + components.examples + ).length; + } + + if (components.requestBodies) { + summary.componentBreakdown.requestBodies = Object.keys( + components.requestBodies + ).length; + } + + if (components.headers) { + summary.componentBreakdown.headers = Object.keys( + components.headers + ).length; + } + + if (components.securitySchemes) { + summary.componentBreakdown.securitySchemes = Object.keys( + components.securitySchemes + ).length; + summary.securitySchemes = summary.componentBreakdown.securitySchemes; + } + + if (components.links) { + summary.componentBreakdown.links = Object.keys(components.links).length; + } + + if (components.callbacks) { + summary.componentBreakdown.callbacks = Object.keys( + components.callbacks + ).length; + summary.callbacks = summary.componentBreakdown.callbacks; + } + + if (components.pathItems) { + summary.componentBreakdown.pathItems = Object.keys( + components.pathItems + ).length; + } + + // Calculate total components + summary.components = Object.values(summary.componentBreakdown).reduce( + (sum, count) => sum + count, + 0 + ); + + log.validationStep( + "Component analysis completed", + `Total components: ${summary.components}` + ); + } + + // Analyze webhooks (OpenAPI 3.1+) + if (version.startsWith("3.1") && (spec as any).webhooks) { + log.validationStep("Analyzing webhooks"); + const webhooks = (spec as any).webhooks; + summary.webhooks = Object.keys(webhooks).length; + log.validationStep( + "Webhook analysis completed", + `Webhooks: ${summary.webhooks}` + ); + } + + // Analyze servers + if ((spec as any).servers) { + log.validationStep("Analyzing servers"); + summary.servers = (spec as any).servers.length; + log.validationStep( + "Server analysis completed", + `Servers: ${summary.servers}` + ); + } + + // Analyze tags + if (spec.tags) { + log.validationStep("Analyzing tags"); + summary.tags = spec.tags.length; + log.validationStep("Tag analysis completed", `Tags: ${summary.tags}`); + } + + // Analyze security + log.validationStep("Analyzing security"); + if (spec.security && spec.security.length > 0) { + summary.securityAnalysis.hasSecurity = true; + summary.securityAnalysis.securityRequirements = spec.security.length; + } + + if (version.startsWith("3.") && (spec as any).components?.securitySchemes) { + const securitySchemes = (spec as any).components.securitySchemes; + summary.securityAnalysis.securitySchemes = + Object.keys(securitySchemes).length; + + // Analyze security scheme types + for (const [name, scheme] of Object.entries(securitySchemes)) { + if (typeof scheme === "object" && scheme !== null) { + const schemeObj = scheme as any; + if (schemeObj.type === "oauth2") { + summary.securityAnalysis.oauthFlows++; + } else if (schemeObj.type === "apiKey") { + summary.securityAnalysis.apiKeys++; + } else if (schemeObj.type === "http") { + summary.securityAnalysis.httpAuth++; + } + } + } + } else if (version === "2.0" && (spec as any).securityDefinitions) { + const securityDefinitions = (spec as any).securityDefinitions; + summary.securityAnalysis.securitySchemes = + Object.keys(securityDefinitions).length; + } + + log.validationStep( + "Security analysis completed", + JSON.stringify({ + hasSecurity: summary.securityAnalysis.hasSecurity ? "Yes" : "No", + schemes: summary.securityAnalysis.securitySchemes, + requirements: summary.securityAnalysis.securityRequirements, + }) + ); + + // Analyze references (basic count) + log.validationStep("Analyzing references"); + const references = findReferencesInSpec(spec); + summary.referenceAnalysis.totalReferences = references.length; + summary.referenceAnalysis.internalReferences = references.filter((ref) => + ref.startsWith("#/") + ).length; + summary.referenceAnalysis.externalReferences = references.filter( + (ref) => !ref.startsWith("#/") + ).length; + + log.validationStep( + "Reference analysis completed", + JSON.stringify({ + total: summary.referenceAnalysis.totalReferences, + internal: summary.referenceAnalysis.internalReferences, + external: summary.referenceAnalysis.externalReferences, + }) + ); + + log.validationStep( + "Specification analysis completed", + JSON.stringify({ + version: summary.version, + paths: summary.paths, + endpoints: summary.endpoints, + components: summary.components, + schemas: summary.schemas, + }) + ); + + return summary; +}; + +/** + * Find all references in a specification + */ +const findReferencesInSpec = (obj: any, path = ""): string[] => { + const refs: string[] = []; + + if (typeof obj === "object" && obj !== null) { + for (const [key, value] of Object.entries(obj)) { + if (key === "$ref" && typeof value === "string") { + refs.push(value); + } else if (typeof value === "object") { + refs.push(...findReferencesInSpec(value, path)); + } + } + } + + return refs; +}; + +/** + * Generate a detailed summary report + */ +export const generateDetailedSummary = ( + summary: SpecificationSummary +): string => { + const lines: string[] = []; + + lines.push("šŸ“Š OpenAPI Specification Summary"); + lines.push("=".repeat(50)); + + // Basic information + lines.push(`šŸ“‹ Basic Information`); + lines.push(` Version: ${summary.version}`); + lines.push(` Title: ${summary.title || "N/A"}`); + lines.push(` Description: ${summary.description ? "Yes" : "No"}`); + lines.push(""); + + // Paths and endpoints + lines.push(`šŸ›£ļø Paths & Endpoints`); + lines.push(` Total Paths: ${summary.paths}`); + lines.push(` Total Endpoints: ${summary.endpoints}`); + lines.push(` HTTP Methods: ${summary.httpMethods.join(", ")}`); + lines.push(""); + + // Components + lines.push(`🧩 Components`); + lines.push(` Total Components: ${summary.components}`); + lines.push(` Schemas: ${summary.componentBreakdown.schemas}`); + lines.push(` Responses: ${summary.componentBreakdown.responses}`); + lines.push(` Parameters: ${summary.componentBreakdown.parameters}`); + lines.push(` Examples: ${summary.componentBreakdown.examples}`); + lines.push(` Request Bodies: ${summary.componentBreakdown.requestBodies}`); + lines.push(` Headers: ${summary.componentBreakdown.headers}`); + lines.push( + ` Security Schemes: ${summary.componentBreakdown.securitySchemes}` + ); + lines.push(` Links: ${summary.componentBreakdown.links}`); + lines.push(` Callbacks: ${summary.componentBreakdown.callbacks}`); + lines.push(` Path Items: ${summary.componentBreakdown.pathItems}`); + lines.push(""); + + // Security + lines.push(`šŸ”’ Security`); + lines.push( + ` Has Security: ${summary.securityAnalysis.hasSecurity ? "Yes" : "No"}` + ); + lines.push(` Security Schemes: ${summary.securityAnalysis.securitySchemes}`); + lines.push( + ` Security Requirements: ${summary.securityAnalysis.securityRequirements}` + ); + lines.push(` OAuth Flows: ${summary.securityAnalysis.oauthFlows}`); + lines.push(` API Keys: ${summary.securityAnalysis.apiKeys}`); + lines.push(` HTTP Auth: ${summary.securityAnalysis.httpAuth}`); + lines.push(""); + + // References + lines.push(`šŸ”— References`); + lines.push( + ` Total References: ${summary.referenceAnalysis.totalReferences}` + ); + lines.push( + ` Internal References: ${summary.referenceAnalysis.internalReferences}` + ); + lines.push( + ` External References: ${summary.referenceAnalysis.externalReferences}` + ); + lines.push( + ` Circular References: ${summary.referenceAnalysis.circularReferences}` + ); + lines.push(""); + + // Additional features + lines.push(`🌐 Additional Features`); + lines.push(` Servers: ${summary.servers}`); + lines.push(` Tags: ${summary.tags}`); + lines.push(` Webhooks: ${summary.webhooks}`); + lines.push(""); + + // Validation results + lines.push(`āœ… Validation Results`); + lines.push(` Valid: ${summary.validationResults.valid ? "Yes" : "No"}`); + lines.push(` Errors: ${summary.validationResults.errors}`); + lines.push(` Warnings: ${summary.validationResults.warnings}`); + lines.push( + ` Processing Time: ${summary.validationResults.processingTime.toFixed( + 2 + )}ms` + ); + lines.push(""); + + lines.push("=".repeat(50)); + + return lines.join("\n"); +}; + +/** + * Generate a JSON summary report + */ +export const generateJSONSummary = (summary: SpecificationSummary): string => { + return JSON.stringify(summary, null, 2); +}; diff --git a/src/validator.ts b/src/validator.ts index e15ab99..3797ab3 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -11,6 +11,7 @@ import type { import { allSchemas } from "oas-types/schemas"; import type { OpenAPI3_1, OpenAPI3_2, OpenAPI3, OpenAPI2 } from "oas-types"; +import { log } from "./logger.js"; // Initialize AJV instance const createAjvInstance = (): Ajv => { @@ -55,6 +56,8 @@ const findReferences = ( obj: OpenAPISpec, path = "" ): Array<{ path: string; value: string }> => { + log.validationStep("Finding references in specification"); + const refs: Array<{ path: string; value: string }> = []; if (typeof obj === "object" && obj !== null) { @@ -62,6 +65,7 @@ const findReferences = ( const currentPath = path ? `${path}.${key}` : key; if (key === "$ref" && typeof value === "string") { + log.referenceStep("Found reference", value, `at ${currentPath}`); refs.push({ path: currentPath, value }); } else if (typeof value === "object") { refs.push(...findReferences(value, currentPath)); @@ -69,6 +73,10 @@ const findReferences = ( } } + log.validationStep( + "Reference search completed", + `Found ${refs.length} references` + ); return refs; }; @@ -79,6 +87,8 @@ const resolveReference = ( spec: OpenAPISpec, ref: { path: string; value: string } ): boolean => { + log.referenceStep("Resolving reference", ref.value, `from ${ref.path}`); + // Simple reference resolution - in a real implementation, this would be more comprehensive if (ref.value.startsWith("#/")) { const path = ref.value.substring(2).split("/"); @@ -87,14 +97,30 @@ const resolveReference = ( for (const segment of path) { if (current && typeof current === "object" && segment in current) { current = (current as any)[segment]; + log.referenceStep( + "Traversing path segment", + segment, + `current type: ${typeof current}` + ); } else { + log.referenceStep( + "Reference resolution failed", + `segment '${segment}' not found` + ); return false; } } - return current !== undefined; + const isValid = current !== undefined; + log.referenceStep( + "Reference resolution completed", + ref.value, + `Valid: ${isValid}` + ); + return isValid; } + log.referenceStep("External reference not supported", ref.value); return false; // External references not supported in this simple implementation }; @@ -107,29 +133,43 @@ const performStrictValidation = ( errors: ValidationError[], warnings: ValidationError[] ): void => { + log.validationStep("Performing strict validation checks"); + // Check for required fields based on version if (version === "2.0") { + log.validationStep("Validating Swagger 2.0 strict requirements"); const swaggerSpec = spec as OpenAPI2.Specification; if (!swaggerSpec.host) { + log.validationStep("Missing host field in Swagger 2.0"); errors.push({ path: "/", message: 'Either "host" or "servers" must be specified in Swagger 2.0', }); + } else { + log.validationStep("Host field found in Swagger 2.0", swaggerSpec.host); } } else { + log.validationStep("Validating OpenAPI 3.x strict requirements"); const openapiSpec = spec as | OpenAPI3.Specification | OpenAPI3_1.Specification | OpenAPI3_2.Specification; if (!openapiSpec.servers || openapiSpec.servers.length === 0) { + log.validationStep("No servers specified in OpenAPI 3.x"); warnings.push({ path: "/", message: "No servers specified. Consider adding at least one server.", }); + } else { + log.validationStep( + "Servers found in OpenAPI 3.x", + `${openapiSpec.servers.length} servers` + ); } } // Check for security definitions + log.validationStep("Validating security definitions"); if (version === "2.0") { const swaggerSpec = spec as OpenAPI2.Specification; if ( @@ -137,10 +177,16 @@ const performStrictValidation = ( swaggerSpec.security.length > 0 && !swaggerSpec.securityDefinitions ) { + log.validationStep("Security used without definitions in Swagger 2.0"); errors.push({ path: "/", message: "Security schemes must be defined when using security", }); + } else if (swaggerSpec.securityDefinitions) { + log.validationStep( + "Security definitions found in Swagger 2.0", + `${Object.keys(swaggerSpec.securityDefinitions).length} schemes` + ); } } else { const openapiSpec = spec as @@ -152,12 +198,23 @@ const performStrictValidation = ( openapiSpec.security.length > 0 && !openapiSpec.components?.securitySchemes ) { + log.validationStep("Security used without schemes in OpenAPI 3.x"); errors.push({ path: "/", message: "Security schemes must be defined when using security", }); + } else if (openapiSpec.components?.securitySchemes) { + log.validationStep( + "Security schemes found in OpenAPI 3.x", + `${Object.keys(openapiSpec.components.securitySchemes).length} schemes` + ); } } + + log.validationStep( + "Strict validation completed", + `Errors: ${errors.length}, Warnings: ${warnings.length}` + ); }; /** @@ -169,9 +226,12 @@ const validateExamples = ( errors: ValidationError[], warnings: ValidationError[] ): void => { - // This would implement example validation logic - // For now, just a placeholder + log.validationStep("Validating examples in specification"); + + let exampleCount = 0; + if (spec.paths) { + log.validationStep("Analyzing examples in paths"); for (const [path, pathItem] of Object.entries(spec.paths)) { if (typeof pathItem === "object" && pathItem !== null) { for (const [method, operation] of Object.entries(pathItem)) { @@ -180,6 +240,7 @@ const validateExamples = ( operation !== null && "responses" in operation ) { + log.endpointStep("Validating examples", method, path); // Check response examples for (const [statusCode, response] of Object.entries( operation.responses @@ -189,6 +250,11 @@ const validateExamples = ( response !== null && "examples" in response ) { + log.validationStep( + "Found examples in response", + `${method} ${path} ${statusCode}` + ); + exampleCount++; // Validate examples here } } @@ -197,6 +263,11 @@ const validateExamples = ( } } } + + log.validationStep( + "Example validation completed", + `Found ${exampleCount} examples` + ); }; /** @@ -208,17 +279,31 @@ const validateReferences = ( errors: ValidationError[], warnings: ValidationError[] ): void => { + log.validationStep("Validating references in specification"); + // This would implement reference validation logic // Check for broken $ref references const refs = findReferences(spec); + let validRefs = 0; + let brokenRefs = 0; + for (const ref of refs) { if (!resolveReference(spec, ref)) { + log.referenceStep("Broken reference found", ref.value, `at ${ref.path}`); errors.push({ path: ref.path, message: `Broken reference: ${ref.value}`, }); + brokenRefs++; + } else { + validRefs++; } } + + log.validationStep( + "Reference validation completed", + `Valid: ${validRefs}, Broken: ${brokenRefs}` + ); }; /** @@ -229,21 +314,36 @@ export const validateOpenAPISpec = ( version: OpenAPIVersion, options: ValidationOptions = {} ): ValidationResult => { + log.startOperation("Validating OpenAPI specification"); + log.validationStep("Initializing validation", `Version: ${version}`); + const normalizedVersion = normalizeVersion(version); + log.validationStep("Normalized version", normalizedVersion); + const schema = schemas.get(normalizedVersion); if (!schema) { + log.error("No schema available for version", { + version, + normalizedVersion, + }); throw new Error( `No schema available for OpenAPI version: ${version} (normalized to ${normalizedVersion})` ); } + log.validationStep("Compiling schema for validation"); const validate = ajv.compile(schema); + log.validationStep("Running schema validation"); const valid = validate(spec); const errors: ValidationError[] = []; const warnings: ValidationError[] = []; if (!valid && validate.errors) { + log.validationStep( + "Schema validation found errors", + `${validate.errors.length} errors` + ); for (const error of validate.errors) { const validationError: ValidationError = { path: error.instancePath || error.schemaPath || "/", @@ -253,31 +353,52 @@ export const validateOpenAPISpec = ( }; if (error.keyword === "required" || error.keyword === "type") { + log.validationStep( + "Schema validation error", + `${error.keyword}: ${validationError.message}` + ); errors.push(validationError); } else { + log.validationStep( + "Schema validation warning", + `${error.keyword}: ${validationError.message}` + ); warnings.push(validationError); } } + } else { + log.validationStep("Schema validation passed"); } // Additional custom validations if (options.strict) { + log.validationStep("Running strict validation"); performStrictValidation(spec, version, errors, warnings); } if (options.validateExamples) { + log.validationStep("Running example validation"); validateExamples(spec, version, errors, warnings); } if (options.validateReferences) { + log.validationStep("Running reference validation"); validateReferences(spec, version, errors, warnings); } - return { + const result = { valid: errors.length === 0, errors, warnings, spec, version, }; + + log.endOperation("Validating OpenAPI specification", result.valid); + log.validationStep( + "Validation completed", + `Valid: ${result.valid}, Errors: ${errors.length}, Warnings: ${warnings.length}` + ); + + return result; }; diff --git a/src/varsity.ts b/src/varsity.ts index 862304f..f092ec0 100644 --- a/src/varsity.ts +++ b/src/varsity.ts @@ -6,6 +6,12 @@ import { validateMultipleRecursively, analyzeReferences, } from "./recursive-validator.js"; +import { + analyzeSpecification, + generateDetailedSummary, + generateJSONSummary, +} from "./summary-analyzer.js"; +import { log } from "./logger.js"; import type { ParsedSpec, ValidationResult, @@ -36,11 +42,26 @@ export const validate = async ( if (Array.isArray(source)) { const results: ValidationResult[] = []; - for (const singleSource of source) { + for (let i = 0; i < source.length; i++) { + const singleSource = source[i]; + if (!singleSource) continue; + + log.info(`šŸ“„ Parsing: ${singleSource}`); try { const result = await validateSingle(singleSource, options, config); results.push(result); + log.info( + `āœ… Validated: ${singleSource} - ${ + result.valid ? "Valid" : "Invalid" + }` + ); } catch (error) { + log.error( + `āŒ Failed: ${singleSource} - ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); + // Create error result for failed parsing const errorResult: ValidationResult = { valid: false, @@ -60,11 +81,19 @@ export const validate = async ( } } + const validCount = results.filter((r) => r.valid).length; + const invalidCount = results.length - validCount; + log.info(`šŸ“Š Summary: ${validCount} valid, ${invalidCount} invalid`); + return results; } // Single specification validation - return validateSingle(source, options, config); + log.info(`šŸ“„ Parsing: ${source}`); + const result = await validateSingle(source, options, config); + log.info(`āœ… Validated: ${source} - ${result.valid ? "Valid" : "Invalid"}`); + + return result; }; /** @@ -92,6 +121,7 @@ const validateSingle = async ( source, validationOptions ); + return { valid: recursiveResult.valid, errors: recursiveResult.errors, @@ -101,14 +131,33 @@ const validateSingle = async ( }; } - return validateOpenAPISpec(parsed.spec, parsed.version, validationOptions); + const result = validateOpenAPISpec( + parsed.spec, + parsed.version, + validationOptions + ); + + return result; }; /** * Parse an OpenAPI specification without validation */ export const parse = async (source: string): Promise => { - return parseOpenAPISpec(source); + log.startOperation("Parsing OpenAPI specification"); + log.fileOperation("Parsing specification", source); + + const result = await parseOpenAPISpec(source); + + log.endOperation("Parsing OpenAPI specification", true); + log.validationStep( + "Parsing completed", + `Version: ${result.version}, Title: ${ + result.metadata.title + }, HasPaths: ${!!result.spec.paths}` + ); + + return result; }; /** @@ -120,9 +169,23 @@ export const generateValidationReport = async ( validationOptions: ValidationOptions = {}, config: VarsityConfig = defaultConfig ): Promise => { + log.startOperation("Generating validation report"); + log.fileOperation("Generating report", source); + const result = await validate(source, validationOptions, config); // Since source is a string, result will be ValidationResult, not ValidationResult[] - return generateReport(result as ValidationResult, reportOptions); + const validationResult = result as ValidationResult; + + log.validationStep("Generating report", `Format: ${reportOptions.format}`); + const report = generateReport(validationResult, reportOptions); + + log.endOperation("Generating validation report", true); + log.validationStep( + "Report generated", + `Format: ${reportOptions.format}, Valid: ${validationResult.valid}, Errors: ${validationResult.errors.length}, Warnings: ${validationResult.warnings.length}` + ); + + return report; }; /** @@ -173,7 +236,89 @@ export const validateMultipleWithReferences = async ( * Analyze references in an OpenAPI specification */ export const analyzeDocumentReferences = async (source: string) => { - return analyzeReferences(source); + log.startOperation("Analyzing document references"); + log.fileOperation("Analyzing references", source); + + const result = await analyzeReferences(source); + + log.endOperation("Analyzing document references", true); + log.validationStep( + "Reference analysis completed", + `Total: ${result.totalReferences}, Circular: ${result.circularReferences.length}` + ); + + return result; +}; + +/** + * Generate a comprehensive summary of an OpenAPI specification + */ +export const generateSpecificationSummary = async ( + source: string, + validationOptions: ValidationOptions = {}, + config: VarsityConfig = defaultConfig +): Promise<{ + summary: any; + detailedSummary: string; + jsonSummary: string; +}> => { + log.startOperation("Generating specification summary"); + log.fileOperation("Generating summary", source); + + // Parse the specification + const parsed = await parseOpenAPISpec(source); + log.validationStep( + "Specification parsed for summary", + `Version: ${parsed.version}` + ); + + // Validate if requested + let validationResults; + if ( + validationOptions.strict || + validationOptions.validateExamples || + validationOptions.validateReferences + ) { + log.validationStep("Running validation for summary"); + const validation = await validate(source, validationOptions, config); + const result = Array.isArray(validation) ? validation[0] : validation; + if (result) { + validationResults = { + valid: result.valid, + errors: result.errors.length, + warnings: result.warnings.length, + processingTime: 0, // This would be calculated from actual timing + }; + } + } + + // Analyze the specification + log.validationStep("Analyzing specification structure"); + const summary = analyzeSpecification( + parsed.spec, + parsed.version, + validationResults + ); + + // Generate detailed summary + log.validationStep("Generating detailed summary"); + const detailedSummary = generateDetailedSummary(summary); + + // Generate JSON summary + log.validationStep("Generating JSON summary"); + const jsonSummary = generateJSONSummary(summary); + + log.endOperation("Generating specification summary", true); + log.validationStep( + "Summary generation completed", + `Version: ${summary.version}, Paths: ${summary.paths}, Endpoints: ${summary.endpoints}, Components: ${summary.components}, Valid: ${summary.validationResults.valid}` + ); + + return { + summary, + detailedSummary, + jsonSummary, + }; }; /** @@ -216,6 +361,23 @@ export const createVarsity = (config: VarsityConfig = {}) => { export { parseOpenAPISpec, validateBasicStructure } from "./parser.js"; export { validateOpenAPISpec } from "./validator.js"; export { generateReport, saveReport } from "./reporter.js"; +export { + validateRecursively, + validateMultipleRecursively, + analyzeReferences, +} from "./recursive-validator.js"; +export { + resolveReference, + findReferences, + resolveAllReferences, +} from "./ref-resolver.js"; +export { validatePartialDocument } from "./partial-validator.js"; +export { + analyzeSpecification, + generateDetailedSummary, + generateJSONSummary, +} from "./summary-analyzer.js"; +export { log, Logger } from "./logger.js"; export type { ParsedSpec, @@ -226,7 +388,11 @@ export type { VarsityConfig, OpenAPIVersion, CLIResult, + RecursiveValidationResult, } from "./types.js"; +// Export types from other modules +export type { ResolvedReference, ReferenceContext } from "./ref-resolver.js"; + // Default export - create a default instance export default createVarsity(); diff --git a/test/sample-openapi.yaml b/test/sample-openapi.yaml new file mode 100644 index 0000000..e401cce --- /dev/null +++ b/test/sample-openapi.yaml @@ -0,0 +1,45 @@ +openapi: 3.0.3 +info: + title: Sample API + version: 1.0.0 + description: A sample OpenAPI specification for testing +servers: + - url: https://api.example.com/v1 +paths: + /users: + get: + summary: Get all users + responses: + "200": + description: Successful response + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + /products: + get: + summary: Get all products + responses: + "200": + description: Successful response + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + price: + type: number + format: float