commit a02388f5c987ae0fdb0d11fb17b4f2f70244fcb8 Author: Luke Hagar Date: Wed Oct 1 20:01:42 2025 +0000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..4788892 --- /dev/null +++ b/README.md @@ -0,0 +1,177 @@ +# 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). + +## 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 + +## Installation + +```bash +bun install +``` + +## Usage + +### Command Line Interface + +#### Validate a specification +```bash +bun run src/cli.ts validate path/to/spec.json +``` + +#### Parse without validation +```bash +bun run src/cli.ts parse path/to/spec.json +``` + +#### 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 +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 +}); + +// Parse without validation +const parsed = await parse('path/to/spec.json'); +``` + +#### Factory Pattern (For Configuration) + +```typescript +import { createVarsity } from './src/varsity.js'; + +const varsity = createVarsity({ + defaultVersion: '3.0.3', + strictMode: false, + customSchemas: {}, + reportFormats: ['json'] +}); + +// Use the configured instance +const result = await varsity.validate('path/to/spec.json'); +const report = await varsity.generateReport('path/to/spec.json', { + format: 'json', + includeWarnings: true, + includeMetadata: true +}); +``` + +## 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[]` + +#### Factory Function +- `createVarsity(config?: VarsityConfig)`: Creates a configured instance with methods + +#### 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` + +### Types + +- `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 + +### Type Safety + +Varsity leverages the comprehensive `oas-types` package for full TypeScript support: + +```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 +} +``` + +## Development + +### Running Tests +```bash +bun test +``` + +### Building +```bash +bun run build +``` + +### Linting +```bash +bun run lint +``` + +## License + +MIT diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..78dd8e2 --- /dev/null +++ b/bun.lock @@ -0,0 +1,52 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "varsity", + "dependencies": { + "@types/node": "^24.6.1", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "commander": "^14.0.1", + "oas-types": "^1.0.6", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="], + + "@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=="], + + "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "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=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "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=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.13.0", "", {}, "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ=="], + } +} diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..7fcd820 --- /dev/null +++ b/index.ts @@ -0,0 +1,31 @@ +// Main functional exports +export { + validate, + parse, + generateValidationReport, + saveValidationReport, + validateMultiple, + getSupportedVersions, + createVarsity, + // Individual module exports + parseOpenAPISpec, + validateBasicStructure, + validateOpenAPISpec, + generateReport, + saveReport, +} from "./src/varsity.js"; + +// Type exports +export type { + ParsedSpec, + ValidationResult, + ValidationError, + ValidationOptions, + ReportOptions, + VarsityConfig, + OpenAPIVersion, + CLIResult, +} from "./src/types.js"; + +// Default export - functional instance +export { default } from "./src/varsity.js"; diff --git a/package.json b/package.json new file mode 100644 index 0000000..4e0e1cc --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "varsity", + "version": "1.0.0", + "description": "Comprehensive OpenAPI parsing and validation library", + "module": "index.ts", + "type": "module", + "private": true, + "bin": { + "varsity": "./src/cli.ts" + }, + "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" + }, + "keywords": [ + "openapi", + "swagger", + "validation", + "parser", + "api", + "specification" + ], + "author": "", + "license": "MIT", + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "@types/node": "^24.6.1", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "commander": "^14.0.1", + "oas-types": "^1.0.6" + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..57d4171 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,324 @@ +#!/usr/bin/env bun +import { Command } from "commander"; +import { + validate, + parse, + generateValidationReport, + saveValidationReport, + validateMultiple, + validateWithReferences, + validateMultipleWithReferences, + analyzeDocumentReferences, + getSupportedVersions, + createVarsity, +} from "./varsity.js"; +import type { ValidationOptions, ReportOptions } from "./types.js"; + +const program = new Command(); + +program + .name("varsity") + .description("Comprehensive OpenAPI parsing and validation library") + .version("1.0.0"); + +// Validate command +program + .command("validate") + .description("Validate an OpenAPI specification") + .argument("", "Path or URL to OpenAPI specification") + .option("-s, --strict", "Enable strict validation mode") + .option("-e, --examples", "Validate examples in the specification") + .option("-r, --references", "Validate all references") + .option("--recursive", "Recursively validate all $ref references") + .option( + "--max-depth ", + "Maximum reference depth for recursive validation", + "10" + ) + .option("-v, --verbose", "Show detailed output") + .action(async (source: string, options: any) => { + try { + const validationOptions: ValidationOptions = { + strict: options.strict, + validateExamples: options.examples, + validateReferences: options.references, + recursive: options.recursive, + 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}`); + } + } + + process.exit(1); + } + } else { + result = await validate(source, validationOptions); + + if (result.valid) { + console.log("✅ Specification is valid"); + if (options.verbose) { + console.log(`Version: ${result.version}`); + console.log(`Warnings: ${result.warnings.length}`); + } + } else { + console.log("❌ Specification is invalid"); + console.log(`Errors: ${result.errors.length}`); + + 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); + } + } + } catch (error) { + console.error( + "❌ Validation failed:", + error instanceof Error ? error.message : "Unknown error" + ); + process.exit(1); + } + }); + +// Parse command +program + .command("parse") + .description("Parse an OpenAPI specification without validation") + .argument("", "Path or URL to OpenAPI specification") + .option("-j, --json", "Output as JSON") + .action(async (source: string, options: any) => { + try { + const parsed = await parse(source); + + if (options.json) { + console.log(JSON.stringify(parsed, null, 2)); + } else { + console.log("📄 Parsed OpenAPI Specification"); + console.log(`Version: ${parsed.version}`); + console.log(`Source: ${parsed.source}`); + console.log(`Title: ${parsed.metadata.title || "N/A"}`); + console.log(`Version: ${parsed.metadata.version || "N/A"}`); + if (parsed.metadata.description) { + console.log(`Description: ${parsed.metadata.description}`); + } + } + } catch (error) { + console.error( + "❌ Parsing failed:", + error instanceof Error ? error.message : "Unknown error" + ); + process.exit(1); + } + }); + +// Report command +program + .command("report") + .description("Generate a validation report") + .argument("", "Path or URL to OpenAPI specification") + .option( + "-f, --format ", + "Report format (json, yaml, html, markdown)", + "json" + ) + .option("-o, --output ", "Output file path") + .option("-s, --strict", "Enable strict validation mode") + .option("-e, --examples", "Validate examples in the specification") + .option("-r, --references", "Validate all references") + .option("-w, --warnings", "Include warnings in report") + .option("-m, --metadata", "Include metadata in report") + .action(async (source: string, options: any) => { + try { + const validationOptions: ValidationOptions = { + strict: options.strict, + validateExamples: options.examples, + validateReferences: options.references, + }; + + const reportOptions: ReportOptions = { + format: options.format, + output: options.output, + includeWarnings: options.warnings, + includeMetadata: options.metadata, + }; + + if (options.output) { + await saveValidationReport(source, reportOptions, validationOptions); + console.log(`📊 Report saved to: ${options.output}`); + } else { + const report = await generateValidationReport( + source, + reportOptions, + validationOptions + ); + console.log(report); + } + } catch (error) { + console.error( + "❌ Report generation failed:", + error instanceof Error ? error.message : "Unknown error" + ); + process.exit(1); + } + }); + +// 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") + .option("-j, --json", "Output as JSON") + .action(async (source: string, options: any) => { + try { + const analysis = await analyzeDocumentReferences(source); + + if (options.json) { + console.log(JSON.stringify(analysis, null, 2)); + } else { + console.log("🔍 Reference Analysis"); + console.log("=".repeat(40)); + console.log(`Total references: ${analysis.totalReferences}`); + console.log( + `Circular references: ${analysis.circularReferences.length}` + ); + + if (analysis.circularReferences.length > 0) { + console.log("\nCircular references found:"); + for (const circular of analysis.circularReferences) { + console.log(` • ${circular}`); + } + } + + if (analysis.references.length > 0) { + console.log("\nAll references:"); + for (const ref of analysis.references) { + console.log(` • ${ref.path}: ${ref.value}`); + } + } + } + } catch (error) { + console.error( + "❌ Analysis failed:", + error instanceof Error ? error.message : "Unknown error" + ); + process.exit(1); + } + }); + +// Info command +program + .command("info") + .description("Show information about supported OpenAPI versions") + .action(() => { + const versions = getSupportedVersions(); + + console.log("🔍 Supported OpenAPI Versions"); + console.log("=".repeat(40)); + versions.forEach((version) => { + console.log(` • ${version}`); + }); + console.log("\nFor more information, visit: https://spec.openapis.org/"); + }); + +// Parse command line arguments +program.parse(); diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..1bf7ae9 --- /dev/null +++ b/src/parser.ts @@ -0,0 +1,120 @@ +import { readFileSync } from "fs"; +import { resolve } from "path"; +import type { ParsedSpec, OpenAPIVersion, OpenAPISpec } from "./types.js"; +import type { OpenAPI2, OpenAPI3, OpenAPI3_1 } from "oas-types"; + +/** + * Detect OpenAPI version from specification + */ +const detectVersion = (spec: any): OpenAPIVersion => { + // Check for OpenAPI 3.x + if (spec.openapi) { + const version = spec.openapi; + if (version.startsWith("3.0")) { + return version as OpenAPIVersion; + } else if (version.startsWith("3.1")) { + return version as OpenAPIVersion; + } else if (version.startsWith("3.2")) { + return version as OpenAPIVersion; + } + throw new Error(`Unsupported OpenAPI version: ${version}`); + } + + // Check for Swagger 2.0 + if (spec.swagger === "2.0") { + return "2.0"; + } + + throw new Error( + 'Unable to detect OpenAPI version. Specification must have "openapi" or "swagger" field.' + ); +}; + +/** + * Extract metadata from specification + */ +const extractMetadata = ( + spec: OpenAPISpec, + version: OpenAPIVersion +): ParsedSpec["metadata"] => { + // All OpenAPI versions have the same info structure + const info = spec.info; + return { + title: info?.title, + version: info?.version, + description: info?.description, + contact: info?.contact, + license: info?.license, + }; +}; + +/** + * Parse an OpenAPI specification from a file path or URL + */ +export const parseOpenAPISpec = async (source: string): Promise => { + let content: string; + let spec: any; + + try { + // Handle file paths + if (source.startsWith("http://") || source.startsWith("https://")) { + const response = await fetch(source); + if (!response.ok) { + throw new Error( + `Failed to fetch specification: ${response.statusText}` + ); + } + content = await response.text(); + } else { + // Local file + const filePath = resolve(source); + content = readFileSync(filePath, "utf-8"); + } + + // Parse JSON or YAML + if (content.trim().startsWith("{") || content.trim().startsWith("[")) { + spec = JSON.parse(content); + } 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." + ); + } + + const version = detectVersion(spec); + + // Type the spec based on the detected version + const typedSpec = spec as OpenAPISpec; + + return { + spec: typedSpec, + version, + source, + metadata: extractMetadata(typedSpec, version), + }; + } catch (error) { + throw new Error( + `Failed to parse OpenAPI specification: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); + } +}; + +/** + * Validate that the parsed spec has required fields + */ +export const validateBasicStructure = ( + spec: OpenAPISpec, + version: OpenAPIVersion +): boolean => { + if (version === "2.0") { + const swaggerSpec = spec as OpenAPI2.Specification; + return !!(swaggerSpec.swagger && swaggerSpec.info && swaggerSpec.paths); + } else { + const openapiSpec = spec as + | OpenAPI3.Specification + | OpenAPI3_1.Specification; + return !!(openapiSpec.openapi && openapiSpec.info && openapiSpec.paths); + } +}; diff --git a/src/partial-validator.ts b/src/partial-validator.ts new file mode 100644 index 0000000..740e734 --- /dev/null +++ b/src/partial-validator.ts @@ -0,0 +1,228 @@ +import Ajv from "ajv"; +import addFormats from "ajv-formats"; +import { allSchemas } from "oas-types/schemas"; +import type { + OpenAPIVersion, + ValidationError, + ValidationResult, +} from "./types.js"; + +// Initialize AJV instance for partial validation +const createPartialAjvInstance = (): Ajv => { + const ajv = new Ajv({ + allErrors: true, + verbose: true, + strict: false, + validateFormats: true, + }); + addFormats(ajv); + return ajv; +}; + +const partialAjv = createPartialAjvInstance(); + +// Schema map for partial documents +const partialSchemas = new Map(); + +// Initialize partial schemas +partialSchemas.set("2.0.schema", allSchemas["2.0"].schema); +partialSchemas.set("2.0.parameter", allSchemas["2.0"].parameter); +partialSchemas.set("2.0.response", allSchemas["2.0"].response); +partialSchemas.set("2.0.pathitem", allSchemas["2.0"].pathitem); + +partialSchemas.set("3.0.schema", allSchemas["3.0"].schema); +partialSchemas.set("3.0.parameter", allSchemas["3.0"].parameter); +partialSchemas.set("3.0.response", allSchemas["3.0"].response); +partialSchemas.set("3.0.pathitem", allSchemas["3.0"].pathitem); +partialSchemas.set("3.0.requestbody", allSchemas["3.0"].requestbody); +partialSchemas.set("3.0.header", allSchemas["3.0"].header); +partialSchemas.set("3.0.example", allSchemas["3.0"].example); +partialSchemas.set("3.0.link", allSchemas["3.0"].link); +partialSchemas.set("3.0.callback", allSchemas["3.0"].callback); +partialSchemas.set("3.0.securityscheme", allSchemas["3.0"].securityscheme); + +partialSchemas.set("3.1.schema", allSchemas["3.1"].schema); +partialSchemas.set("3.1.parameter", allSchemas["3.1"].parameter); +partialSchemas.set("3.1.response", allSchemas["3.1"].response); +partialSchemas.set("3.1.pathitem", allSchemas["3.1"].pathitem); +partialSchemas.set("3.1.requestbody", allSchemas["3.1"].requestbody); +partialSchemas.set("3.1.header", allSchemas["3.1"].header); +partialSchemas.set("3.1.example", allSchemas["3.1"].example); +partialSchemas.set("3.1.link", allSchemas["3.1"].link); +partialSchemas.set("3.1.callback", allSchemas["3.1"].callback); +partialSchemas.set("3.1.securityscheme", allSchemas["3.1"].securityscheme); + +partialSchemas.set("3.2.schema", allSchemas["3.2"].schema); +partialSchemas.set("3.2.parameter", allSchemas["3.2"].parameter); +partialSchemas.set("3.2.response", allSchemas["3.2"].response); +partialSchemas.set("3.2.pathitem", allSchemas["3.2"].pathitem); +partialSchemas.set("3.2.requestbody", allSchemas["3.2"].requestbody); +partialSchemas.set("3.2.header", allSchemas["3.2"].header); +partialSchemas.set("3.2.example", allSchemas["3.2"].example); +partialSchemas.set("3.2.link", allSchemas["3.2"].link); +partialSchemas.set("3.2.callback", allSchemas["3.2"].callback); +partialSchemas.set("3.2.securityscheme", allSchemas["3.2"].securityscheme); + +/** + * Detect the type of partial document based on its structure + */ +const detectPartialType = ( + doc: any, + version: OpenAPIVersion +): string | null => { + // Check for schema-like structure + if ( + doc.type || + doc.properties || + doc.items || + doc.allOf || + doc.oneOf || + doc.anyOf + ) { + return "schema"; + } + + // Check for parameter-like structure + if (doc.name && (doc.in || doc.parameter)) { + return "parameter"; + } + + // Check for response-like structure + if (doc.description && (doc.content || doc.schema || doc.headers)) { + return "response"; + } + + // Check for path item-like structure + if ( + doc.get || + doc.post || + doc.put || + doc.delete || + doc.patch || + doc.head || + doc.options + ) { + return "pathitem"; + } + + // Check for request body-like structure + if (doc.content && !doc.description) { + return "requestbody"; + } + + // Check for header-like structure + if (doc.schema && !doc.name) { + return "header"; + } + + // Check for example-like structure + if (doc.summary || doc.description || doc.value !== undefined) { + return "example"; + } + + // Check for link-like structure + if (doc.operationRef || doc.operationId) { + return "link"; + } + + // Check for callback-like structure + if (doc.expression && typeof doc.expression === "string") { + return "callback"; + } + + // Check for security scheme-like structure + if (doc.type && (doc.flows || doc.openIdConnectUrl || doc.scheme)) { + return "securityscheme"; + } + + return null; +}; + +/** + * Validate a partial OpenAPI document + */ +export const validatePartialDocument = ( + document: any, + version: OpenAPIVersion, + documentPath?: string +): ValidationResult => { + const errors: ValidationError[] = []; + const warnings: ValidationError[] = []; + + // Detect the type of partial document + const partialType = detectPartialType(document, version); + + if (!partialType) { + errors.push({ + path: "/", + message: + "Unable to determine document type. This doesn't appear to be a valid OpenAPI partial document.", + }); + + return { + valid: false, + errors, + warnings, + spec: document, + version, + }; + } + + // Get the appropriate schema for this partial document type + const schemaKey = `${version}.${partialType}`; + const schema = partialSchemas.get(schemaKey); + + if (!schema) { + errors.push({ + path: "/", + message: `No validation schema available for ${partialType} in OpenAPI ${version}`, + }); + + return { + valid: false, + errors, + warnings, + spec: document, + version, + }; + } + + // Validate against the schema + const validate = partialAjv.compile(schema); + const valid = validate(document); + + if (!valid && validate.errors) { + for (const error of validate.errors) { + const validationError: ValidationError = { + path: error.instancePath || error.schemaPath || "/", + message: error.message || "Validation error", + data: error.data, + schemaPath: error.schemaPath, + }; + + if (error.keyword === "required" || error.keyword === "type") { + errors.push(validationError); + } else { + warnings.push(validationError); + } + } + } + + // Add document path information to errors if available + if (documentPath) { + errors.forEach((error) => { + error.path = `${documentPath}${error.path}`; + }); + warnings.forEach((warning) => { + warning.path = `${documentPath}${warning.path}`; + }); + } + + return { + valid: errors.length === 0, + errors, + warnings, + spec: document, + version, + }; +}; diff --git a/src/recursive-validator.ts b/src/recursive-validator.ts new file mode 100644 index 0000000..f6a5ceb --- /dev/null +++ b/src/recursive-validator.ts @@ -0,0 +1,204 @@ +import { parseOpenAPISpec } from "./parser.js"; +import { validateOpenAPISpec } from "./validator.js"; +import { validatePartialDocument } from "./partial-validator.js"; +import { resolveAllReferences, findReferences } from "./ref-resolver.js"; +import type { + ValidationResult, + ValidationError, + ValidationOptions, + OpenAPIVersion, + OpenAPISpec, +} from "./types.js"; + +export interface RecursiveValidationResult extends ValidationResult { + partialValidations: Array<{ + path: string; + result: ValidationResult; + isCircular: boolean; + }>; + circularReferences: string[]; + totalDocuments: number; + validDocuments: number; +} + +/** + * Recursively validate an OpenAPI specification and all its references + */ +export const validateRecursively = async ( + source: string, + options: ValidationOptions = {} +): Promise => { + // Parse the root document + const rootParsed = await parseOpenAPISpec(source); + + // Validate the root document + const rootValidation = validateOpenAPISpec( + rootParsed.spec, + rootParsed.version, + options + ); + + // Resolve all references + const { resolvedRefs, circularRefs } = await resolveAllReferences( + rootParsed.spec, + source, + options.maxRefDepth || 10 + ); + + // Validate each resolved reference + const partialValidations: Array<{ + path: string; + result: ValidationResult; + isCircular: boolean; + }> = []; + + let validDocuments = rootValidation.valid ? 1 : 0; + + for (const ref of resolvedRefs) { + if (ref.isCircular) { + partialValidations.push({ + path: ref.path, + result: { + valid: false, + errors: [ + { + path: "/", + message: "Circular reference detected", + }, + ], + warnings: [], + spec: null, + version: ref.version || "3.0", + }, + isCircular: true, + }); + continue; + } + + if (ref.content === null) { + continue; + } + + // Determine the version for this partial document + const version = ref.version || rootParsed.version; + + // Validate the partial document + const partialResult = validatePartialDocument( + ref.content, + version, + ref.path + ); + + partialValidations.push({ + path: ref.path, + result: partialResult, + isCircular: false, + }); + + if (partialResult.valid) { + validDocuments++; + } + } + + // Combine all errors and warnings + const allErrors: ValidationError[] = [...rootValidation.errors]; + const allWarnings: ValidationError[] = [...rootValidation.warnings]; + + for (const partial of partialValidations) { + allErrors.push(...partial.result.errors); + allWarnings.push(...partial.result.warnings); + } + + return { + valid: + rootValidation.valid && partialValidations.every((p) => p.result.valid), + errors: allErrors, + warnings: allWarnings, + spec: rootParsed.spec, + version: rootParsed.version, + partialValidations, + circularReferences: circularRefs, + totalDocuments: 1 + partialValidations.length, + validDocuments, + }; +}; + +/** + * Validate multiple OpenAPI specifications recursively + */ +export const validateMultipleRecursively = async ( + sources: string[], + options: ValidationOptions = {} +): Promise => { + const results: RecursiveValidationResult[] = []; + + for (const source of sources) { + try { + const result = await validateRecursively(source, options); + results.push(result); + } catch (error) { + // Create error result for failed parsing + const errorResult: RecursiveValidationResult = { + valid: false, + errors: [ + { + path: "/", + message: `Failed to parse specification: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + }, + ], + warnings: [], + spec: null, + version: "3.0", + partialValidations: [], + circularReferences: [], + totalDocuments: 0, + validDocuments: 0, + }; + results.push(errorResult); + } + } + + return results; +}; + +/** + * Find all references in a document without resolving them + */ +export const analyzeReferences = async ( + source: string +): Promise<{ + references: Array<{ path: string; value: string }>; + circularReferences: string[]; + totalReferences: number; +}> => { + const parsed = await parseOpenAPISpec(source); + const references = findReferences(parsed.spec); + + // Check for circular references by analyzing reference paths + const circularReferences: string[] = []; + const referenceMap = new Map(); + + for (const ref of references) { + const refValue = ref.value; + if (!referenceMap.has(refValue)) { + referenceMap.set(refValue, []); + } + referenceMap.get(refValue)!.push(ref.path); + } + + // Simple circular reference detection based on reference patterns + for (const [refValue, paths] of referenceMap) { + if (paths.length > 1) { + // This is a potential circular reference + circularReferences.push(refValue); + } + } + + return { + references, + circularReferences, + totalReferences: references.length, + }; +}; diff --git a/src/ref-resolver.ts b/src/ref-resolver.ts new file mode 100644 index 0000000..48ce34c --- /dev/null +++ b/src/ref-resolver.ts @@ -0,0 +1,211 @@ +import { readFileSync } from "fs"; +import { resolve, dirname, join } from "path"; +import { URL } from "url"; +import type { OpenAPIVersion, OpenAPISpec } from "./types.js"; + +export interface ResolvedReference { + path: string; + content: any; + version?: OpenAPIVersion; + isCircular: boolean; + depth: number; +} + +export interface ReferenceContext { + basePath: string; + visited: Set; + maxDepth: number; + currentDepth: number; + baseDocument: any; +} + +/** + * Detect OpenAPI version from a document + */ +const detectDocumentVersion = (doc: any): OpenAPIVersion | null => { + if (doc.openapi) { + const version = doc.openapi; + if (version.startsWith("3.0")) return "3.0"; + if (version.startsWith("3.1")) return "3.1"; + if (version.startsWith("3.2")) return "3.2"; + } + if (doc.swagger === "2.0") return "2.0"; + return null; +}; + +/** + * Parse a JSON or YAML file + */ +const parseFile = (filePath: string): any => { + const content = readFileSync(filePath, "utf-8"); + + if (content.trim().startsWith("{") || content.trim().startsWith("[")) { + return JSON.parse(content); + } else { + // For now, throw error for YAML - can be enhanced later + throw new Error(`YAML parsing not implemented for file: ${filePath}`); + } +}; + +/** + * Resolve a $ref to its content + */ +export const resolveReference = async ( + ref: string, + context: ReferenceContext +): Promise => { + const { basePath, visited, maxDepth, currentDepth } = context; + + // Check for circular reference + if (visited.has(ref)) { + return { + path: ref, + content: null, + isCircular: true, + depth: currentDepth, + }; + } + + // Check depth limit + if (currentDepth >= maxDepth) { + throw new Error(`Maximum reference depth (${maxDepth}) exceeded`); + } + + // Add to visited set + visited.add(ref); + + try { + let resolvedPath: string; + let content: any; + + if (ref.startsWith("http://") || ref.startsWith("https://")) { + // External URL reference + const response = await fetch(ref); + if (!response.ok) { + throw new Error( + `Failed to fetch external reference: ${response.statusText}` + ); + } + content = await response.json(); + resolvedPath = ref; + } else if (ref.startsWith("#/")) { + // Internal reference - resolve within the same document + const pathSegments = ref.substring(2).split("/"); + let current = context.baseDocument; + + for (const segment of pathSegments) { + if (current && typeof current === "object" && segment in current) { + current = (current as any)[segment]; + } else { + throw new Error(`Reference not found: ${ref}`); + } + } + + return { + path: ref, + content: current, + isCircular: false, + depth: currentDepth, + }; + } else { + // Local file reference + const baseDir = dirname(basePath); + resolvedPath = resolve(baseDir, ref); + content = parseFile(resolvedPath); + } + + // Detect version of the resolved document + const version = detectDocumentVersion(content); + + return { + path: ref, + content, + version: version || undefined, + isCircular: false, + depth: currentDepth, + }; + } catch (error) { + throw new Error( + `Failed to resolve reference '${ref}': ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); + } finally { + // Remove from visited set when done + visited.delete(ref); + } +}; + +/** + * Find all $ref references in a document + */ +export const findReferences = ( + obj: any, + path = "" +): Array<{ path: string; value: string }> => { + const refs: Array<{ path: string; value: string }> = []; + + if (typeof obj === "object" && obj !== null) { + for (const [key, value] of Object.entries(obj)) { + const currentPath = path ? `${path}.${key}` : key; + + if (key === "$ref" && typeof value === "string") { + refs.push({ path: currentPath, value }); + } else if (typeof value === "object") { + refs.push(...findReferences(value, currentPath)); + } + } + } + + return refs; +}; + +/** + * Recursively resolve all references in a document + */ +export const resolveAllReferences = async ( + document: any, + basePath: string, + maxDepth: number = 10 +): Promise<{ + document: any; + resolvedRefs: ResolvedReference[]; + circularRefs: string[]; +}> => { + const visited = new Set(); + const resolvedRefs: ResolvedReference[] = []; + const circularRefs: string[] = []; + + const context: ReferenceContext = { + basePath, + visited, + maxDepth, + currentDepth: 0, + baseDocument: document, + }; + + const refs = findReferences(document); + + for (const ref of refs) { + try { + const resolved = await resolveReference(ref.value, context); + resolvedRefs.push(resolved); + + if (resolved.isCircular) { + circularRefs.push(ref.value); + } + } catch (error) { + console.warn( + `Warning: Failed to resolve reference '${ref.value}': ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); + } + } + + return { + document, + resolvedRefs, + circularRefs, + }; +}; diff --git a/src/reporter.ts b/src/reporter.ts new file mode 100644 index 0000000..8899281 --- /dev/null +++ b/src/reporter.ts @@ -0,0 +1,331 @@ +import { writeFileSync } from "fs"; +import { resolve } from "path"; +import type { ValidationResult, ReportOptions } from "./types.js"; + +/** + * Generate a comprehensive validation report + */ +export const generateReport = ( + result: ValidationResult, + options: ReportOptions +): string => { + switch (options.format) { + case "json": + return generateJSONReport(result, options); + case "yaml": + return generateYAMLReport(result, options); + case "html": + return generateHTMLReport(result, options); + case "markdown": + return generateMarkdownReport(result, options); + default: + throw new Error(`Unsupported report format: ${options.format}`); + } +}; + +/** + * Save report to file + */ +export const saveReport = (content: string, outputPath: string): void => { + const fullPath = resolve(outputPath); + writeFileSync(fullPath, content, "utf-8"); +}; + +/** + * Extract metadata from specification + */ +const extractMetadata = (spec: any): Record => { + return { + title: spec.info?.title, + version: spec.info?.version, + description: spec.info?.description, + contact: spec.info?.contact ? JSON.stringify(spec.info.contact) : undefined, + license: spec.info?.license ? JSON.stringify(spec.info.license) : undefined, + }; +}; + +/** + * Generate summary section for HTML + */ +const generateSummarySection = (result: ValidationResult): string => { + return ` +
+

Summary

+

Errors: ${result.errors.length}

+

Warnings: ${result.warnings.length}

+
+ `; +}; + +/** + * Generate errors section for HTML + */ +const generateErrorsSection = (result: ValidationResult): string => { + if (result.errors.length === 0) { + return '

Errors

No errors found.

'; + } + + const errorItems = result.errors + .map( + (error) => ` +
+
Path: ${error.path}
+
Message: ${error.message}
+ ${ + error.schemaPath + ? `
Schema Path: ${error.schemaPath}
` + : "" + } +
+ ` + ) + .join(""); + + return ` +
+

Errors (${result.errors.length})

+ ${errorItems} +
+ `; +}; + +/** + * Generate warnings section for HTML + */ +const generateWarningsSection = (result: ValidationResult): string => { + if (result.warnings.length === 0) { + return '

Warnings

No warnings found.

'; + } + + const warningItems = result.warnings + .map( + (warning) => ` +
+
Path: ${ + warning.path + }
+
Message: ${warning.message}
+ ${ + warning.schemaPath + ? `
Schema Path: ${warning.schemaPath}
` + : "" + } +
+ ` + ) + .join(""); + + return ` +
+

Warnings (${result.warnings.length})

+ ${warningItems} +
+ `; +}; + +/** + * Generate metadata section for HTML + */ +const generateMetadataSection = (result: ValidationResult): string => { + const metadata = extractMetadata(result.spec); + const metadataItems = Object.entries(metadata) + .filter(([_, value]) => value) + .map( + ([key, value]) => ` + + ` + ) + .join(""); + + return ` +
+

Metadata

+ +
+ `; +}; + +/** + * Generate JSON report + */ +const generateJSONReport = ( + result: ValidationResult, + options: ReportOptions +): string => { + const report = { + summary: { + valid: result.valid, + version: result.version, + errorCount: result.errors.length, + warningCount: result.warnings.length, + timestamp: new Date().toISOString(), + }, + errors: result.errors, + warnings: options.includeWarnings ? result.warnings : undefined, + metadata: options.includeMetadata + ? extractMetadata(result.spec) + : undefined, + }; + + return JSON.stringify(report, null, 2); +}; + +/** + * Generate YAML report + */ +const generateYAMLReport = ( + result: ValidationResult, + options: ReportOptions +): string => { + // Simple YAML generation - in production, use a proper YAML library + const lines: string[] = []; + + lines.push("summary:"); + lines.push(` valid: ${result.valid}`); + lines.push(` version: ${result.version}`); + lines.push(` errorCount: ${result.errors.length}`); + lines.push(` warningCount: ${result.warnings.length}`); + lines.push(` timestamp: ${new Date().toISOString()}`); + + if (result.errors.length > 0) { + lines.push("errors:"); + for (const error of result.errors) { + lines.push(` - path: ${error.path}`); + lines.push(` message: ${error.message}`); + if (error.schemaPath) { + lines.push(` schemaPath: ${error.schemaPath}`); + } + } + } + + if (options.includeWarnings && result.warnings.length > 0) { + lines.push("warnings:"); + for (const warning of result.warnings) { + lines.push(` - path: ${warning.path}`); + lines.push(` message: ${warning.message}`); + if (warning.schemaPath) { + lines.push(` schemaPath: ${warning.schemaPath}`); + } + } + } + + return lines.join("\n"); +}; + +/** + * Generate HTML report + */ +const generateHTMLReport = ( + result: ValidationResult, + options: ReportOptions +): string => { + const status = result.valid ? "valid" : "invalid"; + const statusColor = result.valid ? "#28a745" : "#dc3545"; + + return ` + + + + + + OpenAPI Validation Report + + + +
+
+

OpenAPI Validation Report

+ ${status.toUpperCase()} +

OpenAPI Version: ${result.version}

+

Generated: ${new Date().toLocaleString()}

+
+
+ ${generateSummarySection(result)} + ${generateErrorsSection(result)} + ${options.includeWarnings ? generateWarningsSection(result) : ""} + ${options.includeMetadata ? generateMetadataSection(result) : ""} +
+
+ +`.trim(); +}; + +/** + * Generate Markdown report + */ +const generateMarkdownReport = ( + result: ValidationResult, + options: ReportOptions +): string => { + const lines: string[] = []; + + lines.push("# OpenAPI Validation Report"); + lines.push(""); + lines.push(`**Status:** ${result.valid ? "✅ Valid" : "❌ Invalid"}`); + lines.push(`**Version:** ${result.version}`); + lines.push(`**Generated:** ${new Date().toLocaleString()}`); + lines.push(""); + + lines.push("## Summary"); + lines.push(`- **Errors:** ${result.errors.length}`); + lines.push(`- **Warnings:** ${result.warnings.length}`); + lines.push(""); + + if (result.errors.length > 0) { + lines.push("## Errors"); + lines.push(""); + for (const error of result.errors) { + lines.push(`### \`${error.path}\``); + lines.push(`**Message:** ${error.message}`); + if (error.schemaPath) { + lines.push(`**Schema Path:** \`${error.schemaPath}\``); + } + lines.push(""); + } + } + + if (options.includeWarnings && result.warnings.length > 0) { + lines.push("## Warnings"); + lines.push(""); + for (const warning of result.warnings) { + lines.push(`### \`${warning.path}\``); + lines.push(`**Message:** ${warning.message}`); + if (warning.schemaPath) { + lines.push(`**Schema Path:** \`${warning.schemaPath}\``); + } + lines.push(""); + } + } + + if (options.includeMetadata) { + lines.push("## Metadata"); + lines.push(""); + const metadata = extractMetadata(result.spec); + for (const [key, value] of Object.entries(metadata)) { + if (value) { + lines.push(`- **${key}:** ${value}`); + } + } + } + + return lines.join("\n"); +}; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..111813d --- /dev/null +++ b/src/types.ts @@ -0,0 +1,92 @@ +import type { JSONSchemaType } from "ajv"; +import type { OpenAPI2, OpenAPI3, OpenAPI3_1, OpenAPI3_2 } from "oas-types"; + +export interface ValidationError { + path: string; + message: string; + data?: any; + schemaPath?: string; +} + +export interface ValidationResult { + valid: boolean; + errors: ValidationError[]; + warnings: ValidationError[]; + spec: OpenAPISpec; + version: OpenAPIVersion; +} + +export interface ValidationOptions { + strict?: boolean; + validateExamples?: boolean; + validateReferences?: boolean; + customRules?: Record; + maxRefDepth?: number; + recursive?: boolean; +} + +export interface ReportOptions { + format: "json" | "yaml" | "html" | "markdown"; + output?: string; + includeWarnings?: boolean; + includeMetadata?: boolean; +} + +export interface ParsedSpec { + spec: OpenAPISpec; + version: OpenAPIVersion; + source: string; + metadata: { + title?: string; + version?: string; + description?: string; + contact?: any; + license?: any; + }; +} + +export type OpenAPIVersion = + | "2.0" + | "3.0" + | "3.0.0" + | "3.0.1" + | "3.0.2" + | "3.0.3" + | "3.0.4" + | "3.1" + | "3.1.0" + | "3.1.1" + | "3.2" + | "3.2.0"; + +// Union type for all OpenAPI specifications +export type OpenAPISpec = + | OpenAPI2.Specification + | OpenAPI3.Specification + | OpenAPI3_1.Specification + | OpenAPI3_2.Specification; + +export interface VarsityConfig { + defaultVersion?: OpenAPIVersion; + strictMode?: boolean; + customSchemas?: Record>; + reportFormats?: ReportOptions["format"][]; +} + +export interface CLIResult { + success: boolean; + message: string; + data?: any; + errors?: ValidationError[]; +} + +export interface RecursiveValidationResult extends ValidationResult { + partialValidations: Array<{ + path: string; + result: ValidationResult; + isCircular: boolean; + }>; + circularReferences: string[]; + totalDocuments: number; + validDocuments: number; +} diff --git a/src/validator.ts b/src/validator.ts new file mode 100644 index 0000000..e15ab99 --- /dev/null +++ b/src/validator.ts @@ -0,0 +1,283 @@ +import Ajv from "ajv"; +import type { JSONSchemaType } from "ajv"; +import addFormats from "ajv-formats"; +import type { + ValidationResult, + ValidationError, + ValidationOptions, + OpenAPIVersion, + OpenAPISpec, +} from "./types.js"; + +import { allSchemas } from "oas-types/schemas"; +import type { OpenAPI3_1, OpenAPI3_2, OpenAPI3, OpenAPI2 } from "oas-types"; + +// Initialize AJV instance +const createAjvInstance = (): Ajv => { + const ajv = new Ajv({ + allErrors: true, + verbose: true, + strict: false, + validateFormats: true, + }); + addFormats(ajv); + return ajv; +}; + +// Global instances +const ajv = createAjvInstance(); + +// Create schemas map from oas-types +const schemas = new Map(); +schemas.set("2.0", allSchemas["2.0"].specification); +schemas.set("3.0", allSchemas["3.0"].specification); +schemas.set("3.1", allSchemas["3.1"].specification); +schemas.set("3.2", allSchemas["3.2"].specification); + +/** + * Normalize OpenAPI version to the base version for schema lookup + */ +const normalizeVersion = (version: OpenAPIVersion): OpenAPIVersion => { + if (version.startsWith("3.0")) { + return "3.0"; + } else if (version.startsWith("3.1")) { + return "3.1"; + } else if (version.startsWith("3.2")) { + return "3.2"; + } + return version; +}; + +/** + * Find all $ref references in the specification + */ +const findReferences = ( + obj: OpenAPISpec, + path = "" +): Array<{ path: string; value: string }> => { + const refs: Array<{ path: string; value: string }> = []; + + if (typeof obj === "object" && obj !== null) { + for (const [key, value] of Object.entries(obj)) { + const currentPath = path ? `${path}.${key}` : key; + + if (key === "$ref" && typeof value === "string") { + refs.push({ path: currentPath, value }); + } else if (typeof value === "object") { + refs.push(...findReferences(value, currentPath)); + } + } + } + + return refs; +}; + +/** + * Resolve a reference to check if it's valid + */ +const resolveReference = ( + spec: OpenAPISpec, + ref: { path: string; value: string } +): boolean => { + // Simple reference resolution - in a real implementation, this would be more comprehensive + if (ref.value.startsWith("#/")) { + const path = ref.value.substring(2).split("/"); + let current = spec; + + for (const segment of path) { + if (current && typeof current === "object" && segment in current) { + current = (current as any)[segment]; + } else { + return false; + } + } + + return current !== undefined; + } + + return false; // External references not supported in this simple implementation +}; + +/** + * Perform strict validation checks + */ +const performStrictValidation = ( + spec: OpenAPISpec, + version: OpenAPIVersion, + errors: ValidationError[], + warnings: ValidationError[] +): void => { + // Check for required fields based on version + if (version === "2.0") { + const swaggerSpec = spec as OpenAPI2.Specification; + if (!swaggerSpec.host) { + errors.push({ + path: "/", + message: 'Either "host" or "servers" must be specified in Swagger 2.0', + }); + } + } else { + const openapiSpec = spec as + | OpenAPI3.Specification + | OpenAPI3_1.Specification + | OpenAPI3_2.Specification; + if (!openapiSpec.servers || openapiSpec.servers.length === 0) { + warnings.push({ + path: "/", + message: "No servers specified. Consider adding at least one server.", + }); + } + } + + // Check for security definitions + if (version === "2.0") { + const swaggerSpec = spec as OpenAPI2.Specification; + if ( + swaggerSpec.security && + swaggerSpec.security.length > 0 && + !swaggerSpec.securityDefinitions + ) { + errors.push({ + path: "/", + message: "Security schemes must be defined when using security", + }); + } + } else { + const openapiSpec = spec as + | OpenAPI3.Specification + | OpenAPI3_1.Specification + | OpenAPI3_2.Specification; + if ( + openapiSpec.security && + openapiSpec.security.length > 0 && + !openapiSpec.components?.securitySchemes + ) { + errors.push({ + path: "/", + message: "Security schemes must be defined when using security", + }); + } + } +}; + +/** + * Validate examples in the specification + */ +const validateExamples = ( + spec: OpenAPISpec, + version: OpenAPIVersion, + errors: ValidationError[], + warnings: ValidationError[] +): void => { + // This would implement example validation logic + // For now, just a placeholder + if (spec.paths) { + 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 + ) { + // Check response examples + for (const [statusCode, response] of Object.entries( + operation.responses + )) { + if ( + typeof response === "object" && + response !== null && + "examples" in response + ) { + // Validate examples here + } + } + } + } + } + } + } +}; + +/** + * Validate references in the specification + */ +const validateReferences = ( + spec: OpenAPISpec, + version: OpenAPIVersion, + errors: ValidationError[], + warnings: ValidationError[] +): void => { + // This would implement reference validation logic + // Check for broken $ref references + const refs = findReferences(spec); + for (const ref of refs) { + if (!resolveReference(spec, ref)) { + errors.push({ + path: ref.path, + message: `Broken reference: ${ref.value}`, + }); + } + } +}; + +/** + * Validate an OpenAPI specification + */ +export const validateOpenAPISpec = ( + spec: OpenAPISpec, + version: OpenAPIVersion, + options: ValidationOptions = {} +): ValidationResult => { + const normalizedVersion = normalizeVersion(version); + const schema = schemas.get(normalizedVersion); + if (!schema) { + throw new Error( + `No schema available for OpenAPI version: ${version} (normalized to ${normalizedVersion})` + ); + } + + const validate = ajv.compile(schema); + const valid = validate(spec); + + const errors: ValidationError[] = []; + const warnings: ValidationError[] = []; + + if (!valid && validate.errors) { + for (const error of validate.errors) { + const validationError: ValidationError = { + path: error.instancePath || error.schemaPath || "/", + message: error.message || "Validation error", + data: error.data, + schemaPath: error.schemaPath, + }; + + if (error.keyword === "required" || error.keyword === "type") { + errors.push(validationError); + } else { + warnings.push(validationError); + } + } + } + + // Additional custom validations + if (options.strict) { + performStrictValidation(spec, version, errors, warnings); + } + + if (options.validateExamples) { + validateExamples(spec, version, errors, warnings); + } + + if (options.validateReferences) { + validateReferences(spec, version, errors, warnings); + } + + return { + valid: errors.length === 0, + errors, + warnings, + spec, + version, + }; +}; diff --git a/src/varsity.ts b/src/varsity.ts new file mode 100644 index 0000000..862304f --- /dev/null +++ b/src/varsity.ts @@ -0,0 +1,232 @@ +import { parseOpenAPISpec, validateBasicStructure } from "./parser.js"; +import { validateOpenAPISpec } from "./validator.js"; +import { generateReport, saveReport } from "./reporter.js"; +import { + validateRecursively, + validateMultipleRecursively, + analyzeReferences, +} from "./recursive-validator.js"; +import type { + ParsedSpec, + ValidationResult, + ValidationOptions, + ReportOptions, + VarsityConfig, + OpenAPISpec, + RecursiveValidationResult, +} from "./types.js"; + +// Default configuration +const defaultConfig: VarsityConfig = { + defaultVersion: "3.0", + strictMode: false, + customSchemas: {}, + reportFormats: ["json"], +}; + +/** + * Parse and validate an OpenAPI specification or multiple specifications + */ +export const validate = async ( + source: string | string[], + options: ValidationOptions = {}, + config: VarsityConfig = defaultConfig +): Promise => { + // If source is an array, validate multiple specifications + if (Array.isArray(source)) { + const results: ValidationResult[] = []; + + for (const singleSource of source) { + try { + const result = await validateSingle(singleSource, options, config); + results.push(result); + } catch (error) { + // Create error result for failed parsing + const errorResult: ValidationResult = { + valid: false, + errors: [ + { + path: "/", + message: `Failed to parse specification: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + }, + ], + warnings: [], + spec: {} as OpenAPISpec, + version: config.defaultVersion!, + }; + results.push(errorResult); + } + } + + return results; + } + + // Single specification validation + return validateSingle(source, options, config); +}; + +/** + * Internal function to validate a single OpenAPI specification + */ +const validateSingle = async ( + source: string, + options: ValidationOptions = {}, + config: VarsityConfig = defaultConfig +): Promise => { + const parsed = await parseOpenAPISpec(source); + + // Merge options with config + const validationOptions: ValidationOptions = { + strict: options.strict ?? config.strictMode, + validateExamples: options.validateExamples ?? false, + validateReferences: options.validateReferences ?? false, + customRules: options.customRules, + ...options, + }; + + // If recursive validation is requested, use the recursive validator + if (options.recursive) { + const recursiveResult = await validateRecursively( + source, + validationOptions + ); + return { + valid: recursiveResult.valid, + errors: recursiveResult.errors, + warnings: recursiveResult.warnings, + spec: recursiveResult.spec, + version: recursiveResult.version, + }; + } + + return validateOpenAPISpec(parsed.spec, parsed.version, validationOptions); +}; + +/** + * Parse an OpenAPI specification without validation + */ +export const parse = async (source: string): Promise => { + return parseOpenAPISpec(source); +}; + +/** + * Generate a validation report + */ +export const generateValidationReport = async ( + source: string, + reportOptions: ReportOptions, + validationOptions: ValidationOptions = {}, + config: VarsityConfig = defaultConfig +): Promise => { + const result = await validate(source, validationOptions, config); + // Since source is a string, result will be ValidationResult, not ValidationResult[] + return generateReport(result as ValidationResult, reportOptions); +}; + +/** + * Save a validation report to file + */ +export const saveValidationReport = async ( + source: string, + reportOptions: ReportOptions, + validationOptions: ValidationOptions = {}, + config: VarsityConfig = defaultConfig +): Promise => { + const report = await generateValidationReport( + source, + reportOptions, + validationOptions, + config + ); + if (reportOptions.output) { + saveReport(report, reportOptions.output); + } else { + console.log(report); + } +}; + +/** + * Recursively validate an OpenAPI specification and all its references + */ +export const validateWithReferences = async ( + source: string, + options: ValidationOptions = {}, + config: VarsityConfig = defaultConfig +): Promise => { + return validateRecursively(source, { ...options, recursive: true }); +}; + +/** + * Recursively validate multiple OpenAPI specifications + */ +export const validateMultipleWithReferences = async ( + sources: string[], + options: ValidationOptions = {}, + config: VarsityConfig = defaultConfig +): Promise => { + return validateMultipleRecursively(sources, { ...options, recursive: true }); +}; + +/** + * Analyze references in an OpenAPI specification + */ +export const analyzeDocumentReferences = async (source: string) => { + return analyzeReferences(source); +}; + +/** + * Get supported OpenAPI versions + */ +export const getSupportedVersions = (): string[] => { + return ["2.0", "3.0.0", "3.0.1", "3.0.2", "3.0.3", "3.1.0"]; +}; + +/** + * Create a Varsity instance with configuration + */ +export const createVarsity = (config: VarsityConfig = {}) => { + const mergedConfig = { ...defaultConfig, ...config }; + + return { + validate: (source: string | string[], options: ValidationOptions = {}) => + validate(source, options, mergedConfig), + parse, + generateReport: ( + source: string, + reportOptions: ReportOptions, + validationOptions: ValidationOptions = {} + ) => + generateValidationReport( + source, + reportOptions, + validationOptions, + mergedConfig + ), + getSupportedVersions, + getConfig: () => ({ ...mergedConfig }), + updateConfig: (newConfig: Partial) => { + Object.assign(mergedConfig, newConfig); + }, + }; +}; + +// Export individual functions for direct use +export { parseOpenAPISpec, validateBasicStructure } from "./parser.js"; +export { validateOpenAPISpec } from "./validator.js"; +export { generateReport, saveReport } from "./reporter.js"; + +export type { + ParsedSpec, + ValidationResult, + ValidationError, + ValidationOptions, + ReportOptions, + VarsityConfig, + OpenAPIVersion, + CLIResult, +} from "./types.js"; + +// Default export - create a default instance +export default createVarsity(); diff --git a/test/backwards-refs/main-api.json b/test/backwards-refs/main-api.json new file mode 100644 index 0000000..0994165 --- /dev/null +++ b/test/backwards-refs/main-api.json @@ -0,0 +1,53 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Backwards References API", + "version": "1.0.0", + "description": "API with backwards $ref paths for testing" + }, + "paths": { + "/users": { + "get": { + "summary": "Get users", + "responses": { + "200": { + "$ref": "../shared/responses/users-response.json" + } + } + } + }, + "/products": { + "get": { + "summary": "Get products", + "responses": { + "200": { + "$ref": "../shared/responses/products-response.json" + } + } + } + }, + "/orders": { + "get": { + "summary": "Get orders", + "responses": { + "200": { + "$ref": "../shared/responses/orders-response.json" + } + } + } + } + }, + "components": { + "schemas": { + "User": { + "$ref": "../shared/schemas/user-schema.json" + }, + "Product": { + "$ref": "../shared/schemas/product-schema.json" + }, + "Order": { + "$ref": "../shared/schemas/order-schema.json" + } + } + } +} diff --git a/test/basic.test.ts b/test/basic.test.ts new file mode 100644 index 0000000..b768289 --- /dev/null +++ b/test/basic.test.ts @@ -0,0 +1,145 @@ +import { describe, test, expect } from "bun:test"; +import { + validate, + parse, + generateValidationReport, + getSupportedVersions, + createVarsity, + type ValidationResult, +} from "../src/varsity.js"; +import { resolve } from "path"; + +describe("Varsity Library", () => { + const sampleSpecPath = resolve(__dirname, "sample-openapi.json"); + + test("should parse a valid OpenAPI specification", async () => { + const parsed = await parse(sampleSpecPath); + + expect(parsed).toBeDefined(); + expect(parsed.version).toBe("3.0.3"); + expect(parsed.spec.info.title).toBe("Sample API"); + }); + + test("should validate a valid OpenAPI specification", async () => { + const result = (await validate(sampleSpecPath)) as ValidationResult; + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.version).toBe("3.0.3"); + }); + + test("should generate a JSON report", async () => { + const report = await generateValidationReport(sampleSpecPath, { + format: "json", + includeWarnings: true, + includeMetadata: true, + }); + + const parsedReport = JSON.parse(report); + expect(parsedReport.summary.valid).toBe(true); + expect(parsedReport.summary.version).toBe("3.0.3"); + }); + + test("should generate a markdown report", async () => { + const report = await generateValidationReport(sampleSpecPath, { + format: "markdown", + includeWarnings: true, + includeMetadata: true, + }); + + expect(report).toContain("# OpenAPI Validation Report"); + expect(report).toContain("✅ Valid"); + expect(report).toContain("Sample API"); + }); + + test("should get supported versions", () => { + const versions = getSupportedVersions(); + + expect(versions).toContain("2.0"); + expect(versions).toContain("3.0.3"); + expect(versions).toContain("3.1.0"); + }); + + test("should handle invalid specification", async () => { + const invalidSpec = { + openapi: "3.0.3", + // Missing required 'info' and 'paths' fields + }; + + // Create a temporary file with invalid spec + const fs = await import("fs"); + const tempPath = resolve(__dirname, "invalid-spec.json"); + fs.writeFileSync(tempPath, JSON.stringify(invalidSpec)); + + try { + const result = (await validate(tempPath)) as ValidationResult; + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + } finally { + // Clean up + fs.unlinkSync(tempPath); + } + }); + + test("should work with createVarsity factory", async () => { + const varsity = createVarsity(); + + const result = (await varsity.validate(sampleSpecPath)) as ValidationResult; + expect(result.valid).toBe(true); + + const versions = varsity.getSupportedVersions(); + expect(versions).toContain("3.0.3"); + }); + + test("should work with createVarsity factory for multiple specs", async () => { + const varsity = createVarsity(); + + const results = (await varsity.validate([ + sampleSpecPath, + sampleSpecPath, + ])) as ValidationResult[]; + expect(Array.isArray(results)).toBe(true); + expect(results).toHaveLength(2); + expect(results[0]?.valid).toBe(true); + expect(results[1]?.valid).toBe(true); + }); + + test("should validate multiple specifications", async () => { + const results = (await validate([ + sampleSpecPath, + sampleSpecPath, + ])) as ValidationResult[]; + + expect(Array.isArray(results)).toBe(true); + expect(results).toHaveLength(2); + expect(results[0]?.valid).toBe(true); + expect(results[1]?.valid).toBe(true); + }); + + test("should handle mixed valid and invalid specifications", async () => { + const invalidSpec = { + openapi: "3.0.3", + // Missing required 'info' and 'paths' fields + }; + + // Create a temporary file with invalid spec + const fs = await import("fs"); + const tempPath = resolve(__dirname, "invalid-spec-mixed.json"); + fs.writeFileSync(tempPath, JSON.stringify(invalidSpec)); + + try { + const results = (await validate([ + sampleSpecPath, + tempPath, + ])) as ValidationResult[]; + + expect(Array.isArray(results)).toBe(true); + expect(results).toHaveLength(2); + expect(results[0]?.valid).toBe(true); // Valid spec + expect(results[1]?.valid).toBe(false); // Invalid spec + } finally { + // Clean up + fs.unlinkSync(tempPath); + } + }); +}); diff --git a/test/circular-refs-api.json b/test/circular-refs-api.json new file mode 100644 index 0000000..5ceaf81 --- /dev/null +++ b/test/circular-refs-api.json @@ -0,0 +1,30 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Circular References API", + "version": "1.0.0", + "description": "API with intentional circular references for testing" + }, + "paths": { + "/users": { + "get": { + "summary": "Get users", + "responses": { + "200": { + "$ref": "./responses/users-response.json" + } + } + } + } + }, + "components": { + "schemas": { + "User": { + "$ref": "./schemas/user-schema.json" + }, + "Category": { + "$ref": "./schemas/category-schema.json" + } + } + } +} diff --git a/test/deep/nested/api/main-api.json b/test/deep/nested/api/main-api.json new file mode 100644 index 0000000..cc8a7d8 --- /dev/null +++ b/test/deep/nested/api/main-api.json @@ -0,0 +1,40 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Deep Nested API with Backwards References", + "version": "1.0.0", + "description": "API in deep nested directory with backwards $ref paths" + }, + "paths": { + "/users": { + "get": { + "summary": "Get users", + "responses": { + "200": { + "$ref": "../../../shared/responses/users-response.json" + } + } + } + }, + "/products": { + "get": { + "summary": "Get products", + "responses": { + "200": { + "$ref": "../../../shared/responses/products-response.json" + } + } + } + } + }, + "components": { + "schemas": { + "User": { + "$ref": "../../../shared/schemas/user-schema.json" + }, + "Product": { + "$ref": "../../../shared/schemas/product-schema.json" + } + } + } +} diff --git a/test/edge-cases/very-deep/api/main-api.json b/test/edge-cases/very-deep/api/main-api.json new file mode 100644 index 0000000..1c9f1b8 --- /dev/null +++ b/test/edge-cases/very-deep/api/main-api.json @@ -0,0 +1,27 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Edge Cases API", + "version": "1.0.0", + "description": "API testing various edge cases for $ref resolution" + }, + "paths": { + "/test": { + "get": { + "summary": "Test endpoint", + "responses": { + "200": { + "$ref": "../../../../shared/responses/test-response.json" + } + } + } + } + }, + "components": { + "schemas": { + "TestSchema": { + "$ref": "../../../../shared/schemas/test-schema.json" + } + } + } +} diff --git a/test/main-api.json b/test/main-api.json new file mode 100644 index 0000000..b6781fa --- /dev/null +++ b/test/main-api.json @@ -0,0 +1,108 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Main API", + "version": "1.0.0", + "description": "Main API with external references" + }, + "servers": [ + { + "url": "https://api.example.com/v1" + } + ], + "paths": { + "/users": { + "get": { + "summary": "Get all users", + "responses": { + "200": { + "$ref": "./responses/users-response.json" + } + } + }, + "post": { + "summary": "Create user", + "requestBody": { + "$ref": "./request-bodies/user-request.json" + }, + "responses": { + "201": { + "$ref": "./responses/user-created-response.json" + }, + "400": { + "$ref": "./responses/error-response.json" + } + } + } + }, + "/users/{id}": { + "get": { + "summary": "Get user by ID", + "parameters": [ + { + "$ref": "./parameters/user-id-param.json" + } + ], + "responses": { + "200": { + "$ref": "./responses/user-response.json" + }, + "404": { + "$ref": "./responses/not-found-response.json" + } + } + }, + "put": { + "summary": "Update user", + "parameters": [ + { + "$ref": "./parameters/user-id-param.json" + } + ], + "requestBody": { + "$ref": "./request-bodies/user-update-request.json" + }, + "responses": { + "200": { + "$ref": "./responses/user-response.json" + }, + "400": { + "$ref": "./responses/error-response.json" + } + } + } + }, + "/products": { + "$ref": "./paths/products-path.json" + }, + "/orders": { + "$ref": "./paths/orders-path.json" + } + }, + "components": { + "schemas": { + "User": { + "$ref": "./schemas/user-schema.json" + }, + "Product": { + "$ref": "./schemas/product-schema.json" + }, + "Error": { + "$ref": "./schemas/error-schema.json" + } + }, + "parameters": { + "PageParam": { + "$ref": "./parameters/page-param.json" + }, + "LimitParam": { + "$ref": "./parameters/limit-param.json" + } + }, + "responses": { + "UnauthorizedResponse": { + "$ref": "./responses/unauthorized-response.json" + } + } + } +} diff --git a/test/parameters/category-filter-param.json b/test/parameters/category-filter-param.json new file mode 100644 index 0000000..37cea72 --- /dev/null +++ b/test/parameters/category-filter-param.json @@ -0,0 +1,10 @@ +{ + "name": "category", + "in": "query", + "description": "Filter products by category", + "required": false, + "schema": { + "type": "string", + "minLength": 1 + } +} diff --git a/test/parameters/limit-param.json b/test/parameters/limit-param.json new file mode 100644 index 0000000..d3d9b25 --- /dev/null +++ b/test/parameters/limit-param.json @@ -0,0 +1,12 @@ +{ + "name": "limit", + "in": "query", + "description": "Number of items per page", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } +} diff --git a/test/parameters/order-id-param.json b/test/parameters/order-id-param.json new file mode 100644 index 0000000..5927a63 --- /dev/null +++ b/test/parameters/order-id-param.json @@ -0,0 +1,10 @@ +{ + "name": "orderId", + "in": "path", + "description": "Order ID", + "required": true, + "schema": { + "type": "string", + "pattern": "^ORD-[0-9]{8}$" + } +} diff --git a/test/parameters/page-param.json b/test/parameters/page-param.json new file mode 100644 index 0000000..bf154c3 --- /dev/null +++ b/test/parameters/page-param.json @@ -0,0 +1,11 @@ +{ + "name": "page", + "in": "query", + "description": "Page number for pagination", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } +} diff --git a/test/parameters/status-filter-param.json b/test/parameters/status-filter-param.json new file mode 100644 index 0000000..64d0403 --- /dev/null +++ b/test/parameters/status-filter-param.json @@ -0,0 +1,10 @@ +{ + "name": "status", + "in": "query", + "description": "Filter orders by status", + "required": false, + "schema": { + "type": "string", + "enum": ["pending", "processing", "shipped", "delivered", "cancelled"] + } +} diff --git a/test/parameters/user-id-param.json b/test/parameters/user-id-param.json new file mode 100644 index 0000000..8cc306f --- /dev/null +++ b/test/parameters/user-id-param.json @@ -0,0 +1,11 @@ +{ + "name": "id", + "in": "path", + "description": "User ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } +} diff --git a/test/paths/orders-path.json b/test/paths/orders-path.json new file mode 100644 index 0000000..2e36080 --- /dev/null +++ b/test/paths/orders-path.json @@ -0,0 +1,74 @@ +{ + "get": { + "summary": "Get all orders", + "description": "Retrieve orders for the authenticated user", + "parameters": [ + { + "$ref": "../parameters/page-param.json" + }, + { + "$ref": "../parameters/limit-param.json" + }, + { + "$ref": "../parameters/status-filter-param.json" + } + ], + "responses": { + "200": { + "description": "List of orders", + "$ref": "../responses/orders-response.json" + }, + "401": { + "description": "Unauthorized", + "$ref": "../responses/unauthorized-response.json" + } + } + }, + "post": { + "summary": "Create a new order", + "description": "Place a new order", + "requestBody": { + "$ref": "../request-bodies/order-request.json" + }, + "responses": { + "201": { + "description": "Order created successfully", + "$ref": "../responses/order-created-response.json" + }, + "400": { + "description": "Bad request", + "$ref": "../responses/error-response.json" + }, + "401": { + "description": "Unauthorized", + "$ref": "../responses/unauthorized-response.json" + } + } + }, + "put": { + "summary": "Update order status", + "description": "Update the status of an existing order", + "parameters": [ + { + "$ref": "../parameters/order-id-param.json" + } + ], + "requestBody": { + "$ref": "../request-bodies/order-status-update.json" + }, + "responses": { + "200": { + "description": "Order updated successfully", + "$ref": "../responses/order-response.json" + }, + "400": { + "description": "Bad request", + "$ref": "../responses/error-response.json" + }, + "404": { + "description": "Order not found", + "$ref": "../responses/not-found-response.json" + } + } + } +} diff --git a/test/paths/products-path.json b/test/paths/products-path.json new file mode 100644 index 0000000..13a8503 --- /dev/null +++ b/test/paths/products-path.json @@ -0,0 +1,48 @@ +{ + "get": { + "summary": "Get all products", + "description": "Retrieve a paginated list of products with optional filtering", + "parameters": [ + { + "$ref": "../parameters/page-param.json" + }, + { + "$ref": "../parameters/limit-param.json" + }, + { + "$ref": "../parameters/category-filter-param.json" + } + ], + "responses": { + "200": { + "description": "List of products", + "$ref": "../responses/products-response.json" + }, + "400": { + "description": "Bad request", + "$ref": "../responses/error-response.json" + } + } + }, + "post": { + "summary": "Create a new product", + "description": "Add a new product to the catalog", + "requestBody": { + "$ref": "../request-bodies/product-request.json" + }, + "responses": { + "201": { + "description": "Product created successfully", + "$ref": "../responses/product-created-response.json" + }, + "400": { + "description": "Bad request", + "$ref": "../responses/error-response.json" + }, + "401": { + "description": "Unauthorized", + "$ref": "../responses/unauthorized-response.json" + } + } + } +} diff --git a/test/request-bodies/order-request.json b/test/request-bodies/order-request.json new file mode 100644 index 0000000..6811e65 --- /dev/null +++ b/test/request-bodies/order-request.json @@ -0,0 +1,34 @@ +{ + "description": "Order creation data", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["items", "shippingAddress"], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "../schemas/order-item-schema.json" + }, + "minItems": 1 + }, + "shippingAddress": { + "$ref": "../schemas/address-schema.json" + }, + "billingAddress": { + "$ref": "../schemas/address-schema.json" + }, + "paymentMethod": { + "$ref": "../schemas/payment-method-schema.json" + }, + "notes": { + "type": "string", + "maxLength": 500 + } + } + } + } + } +} diff --git a/test/request-bodies/order-status-update.json b/test/request-bodies/order-status-update.json new file mode 100644 index 0000000..ce4b7f2 --- /dev/null +++ b/test/request-bodies/order-status-update.json @@ -0,0 +1,32 @@ +{ + "description": "Order status update data", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["status"], + "properties": { + "status": { + "type": "string", + "enum": [ + "pending", + "processing", + "shipped", + "delivered", + "cancelled" + ] + }, + "trackingNumber": { + "type": "string", + "pattern": "^[A-Z0-9]{10,20}$" + }, + "notes": { + "type": "string", + "maxLength": 500 + } + } + } + } + } +} diff --git a/test/request-bodies/product-request.json b/test/request-bodies/product-request.json new file mode 100644 index 0000000..a7f23ae --- /dev/null +++ b/test/request-bodies/product-request.json @@ -0,0 +1,38 @@ +{ + "description": "Product creation data", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["name", "price"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "description": { + "type": "string", + "maxLength": 1000 + }, + "price": { + "$ref": "../schemas/price-schema.json" + }, + "category": { + "$ref": "../schemas/category-schema.json" + }, + "inventory": { + "$ref": "../schemas/inventory-schema.json" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } +} diff --git a/test/request-bodies/user-request.json b/test/request-bodies/user-request.json new file mode 100644 index 0000000..dca3b94 --- /dev/null +++ b/test/request-bodies/user-request.json @@ -0,0 +1,34 @@ +{ + "description": "User creation data", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["name", "email"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "email": { + "type": "string", + "format": "email" + }, + "age": { + "type": "integer", + "minimum": 0, + "maximum": 150 + }, + "address": { + "$ref": "../schemas/address-schema.json" + }, + "preferences": { + "$ref": "../schemas/user-preferences-schema.json" + } + } + } + } + } +} diff --git a/test/request-bodies/user-update-request.json b/test/request-bodies/user-update-request.json new file mode 100644 index 0000000..44ffd6f --- /dev/null +++ b/test/request-bodies/user-update-request.json @@ -0,0 +1,33 @@ +{ + "description": "User update data", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "email": { + "type": "string", + "format": "email" + }, + "age": { + "type": "integer", + "minimum": 0, + "maximum": 150 + }, + "address": { + "$ref": "../schemas/address-schema.json" + }, + "preferences": { + "$ref": "../schemas/user-preferences-schema.json" + } + } + } + } + } +} diff --git a/test/responses/error-response.json b/test/responses/error-response.json new file mode 100644 index 0000000..b271e78 --- /dev/null +++ b/test/responses/error-response.json @@ -0,0 +1,10 @@ +{ + "description": "Bad request error", + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/error-schema.json" + } + } + } +} diff --git a/test/responses/not-found-response.json b/test/responses/not-found-response.json new file mode 100644 index 0000000..c61ecd3 --- /dev/null +++ b/test/responses/not-found-response.json @@ -0,0 +1,10 @@ +{ + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/error-schema.json" + } + } + } +} diff --git a/test/responses/order-created-response.json b/test/responses/order-created-response.json new file mode 100644 index 0000000..da6a4d9 --- /dev/null +++ b/test/responses/order-created-response.json @@ -0,0 +1,18 @@ +{ + "description": "Order created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "../schemas/order-schema.json" + }, + "message": { + "type": "string" + } + } + } + } + } +} diff --git a/test/responses/order-response.json b/test/responses/order-response.json new file mode 100644 index 0000000..8d725e7 --- /dev/null +++ b/test/responses/order-response.json @@ -0,0 +1,15 @@ +{ + "description": "Order details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "../schemas/order-schema.json" + } + } + } + } + } +} diff --git a/test/responses/orders-response.json b/test/responses/orders-response.json new file mode 100644 index 0000000..0152a17 --- /dev/null +++ b/test/responses/orders-response.json @@ -0,0 +1,21 @@ +{ + "description": "List of orders", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "../schemas/order-schema.json" + } + }, + "pagination": { + "$ref": "../schemas/pagination-schema.json" + } + } + } + } + } +} diff --git a/test/responses/product-created-response.json b/test/responses/product-created-response.json new file mode 100644 index 0000000..9cfc9f2 --- /dev/null +++ b/test/responses/product-created-response.json @@ -0,0 +1,18 @@ +{ + "description": "Product created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "../schemas/product-schema.json" + }, + "message": { + "type": "string" + } + } + } + } + } +} diff --git a/test/responses/products-response.json b/test/responses/products-response.json new file mode 100644 index 0000000..4be3c28 --- /dev/null +++ b/test/responses/products-response.json @@ -0,0 +1,21 @@ +{ + "description": "List of products", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "../schemas/product-schema.json" + } + }, + "pagination": { + "$ref": "../schemas/pagination-schema.json" + } + } + } + } + } +} diff --git a/test/responses/unauthorized-response.json b/test/responses/unauthorized-response.json new file mode 100644 index 0000000..14995c7 --- /dev/null +++ b/test/responses/unauthorized-response.json @@ -0,0 +1,10 @@ +{ + "description": "Unauthorized access", + "content": { + "application/json": { + "schema": { + "$ref": "../schemas/error-schema.json" + } + } + } +} diff --git a/test/responses/user-created-response.json b/test/responses/user-created-response.json new file mode 100644 index 0000000..8e529a3 --- /dev/null +++ b/test/responses/user-created-response.json @@ -0,0 +1,18 @@ +{ + "description": "User created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "../schemas/user-schema.json" + }, + "message": { + "type": "string" + } + } + } + } + } +} diff --git a/test/responses/user-response.json b/test/responses/user-response.json new file mode 100644 index 0000000..f43409f --- /dev/null +++ b/test/responses/user-response.json @@ -0,0 +1,15 @@ +{ + "description": "User details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "../schemas/user-schema.json" + } + } + } + } + } +} diff --git a/test/responses/users-response.json b/test/responses/users-response.json new file mode 100644 index 0000000..a5bb599 --- /dev/null +++ b/test/responses/users-response.json @@ -0,0 +1,18 @@ +{ + "description": "List of users", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "../schemas/user-schema.json" + } + } + } + } + } + } +} diff --git a/test/sample-openapi.json b/test/sample-openapi.json new file mode 100644 index 0000000..0c5a453 --- /dev/null +++ b/test/sample-openapi.json @@ -0,0 +1,43 @@ +{ + "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" + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/test/schemas/address-schema.json b/test/schemas/address-schema.json new file mode 100644 index 0000000..c83f999 --- /dev/null +++ b/test/schemas/address-schema.json @@ -0,0 +1,28 @@ +{ + "type": "object", + "properties": { + "street": { + "type": "string", + "description": "Street address" + }, + "city": { + "type": "string", + "description": "City name" + }, + "state": { + "type": "string", + "description": "State or province" + }, + "zipCode": { + "type": "string", + "pattern": "^[0-9]{5}(-[0-9]{4})?$", + "description": "ZIP or postal code" + }, + "country": { + "type": "string", + "minLength": 2, + "maxLength": 2, + "description": "Two-letter country code" + } + } +} diff --git a/test/schemas/category-schema.json b/test/schemas/category-schema.json new file mode 100644 index 0000000..94d3065 --- /dev/null +++ b/test/schemas/category-schema.json @@ -0,0 +1,21 @@ +{ + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "parent": { + "$ref": "./category-schema.json" + }, + "children": { + "type": "array", + "items": { + "$ref": "./category-schema.json" + } + } + } +} diff --git a/test/schemas/error-schema.json b/test/schemas/error-schema.json new file mode 100644 index 0000000..6fd6e83 --- /dev/null +++ b/test/schemas/error-schema.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "details": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } +} diff --git a/test/schemas/inventory-schema.json b/test/schemas/inventory-schema.json new file mode 100644 index 0000000..049bc1e --- /dev/null +++ b/test/schemas/inventory-schema.json @@ -0,0 +1,20 @@ +{ + "type": "object", + "properties": { + "quantity": { + "type": "integer", + "minimum": 0 + }, + "reserved": { + "type": "integer", + "minimum": 0 + }, + "available": { + "type": "integer", + "minimum": 0 + }, + "warehouse": { + "$ref": "./warehouse-schema.json" + } + } +} diff --git a/test/schemas/order-item-schema.json b/test/schemas/order-item-schema.json new file mode 100644 index 0000000..0dea28b --- /dev/null +++ b/test/schemas/order-item-schema.json @@ -0,0 +1,23 @@ +{ + "type": "object", + "required": ["productId", "quantity"], + "properties": { + "productId": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "quantity": { + "type": "integer", + "minimum": 1 + }, + "price": { + "$ref": "./price-schema.json" + }, + "discount": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } +} diff --git a/test/schemas/order-schema.json b/test/schemas/order-schema.json new file mode 100644 index 0000000..a616224 --- /dev/null +++ b/test/schemas/order-schema.json @@ -0,0 +1,44 @@ +{ + "type": "object", + "required": ["id", "status", "items", "total"], + "properties": { + "id": { + "type": "string", + "pattern": "^ORD-[0-9]{8}$" + }, + "status": { + "type": "string", + "enum": ["pending", "processing", "shipped", "delivered", "cancelled"] + }, + "items": { + "type": "array", + "items": { + "$ref": "./order-item-schema.json" + } + }, + "total": { + "$ref": "./price-schema.json" + }, + "shippingAddress": { + "$ref": "./address-schema.json" + }, + "billingAddress": { + "$ref": "./address-schema.json" + }, + "paymentMethod": { + "$ref": "./payment-method-schema.json" + }, + "trackingNumber": { + "type": "string", + "pattern": "^[A-Z0-9]{10,20}$" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } +} diff --git a/test/schemas/pagination-schema.json b/test/schemas/pagination-schema.json new file mode 100644 index 0000000..5eef452 --- /dev/null +++ b/test/schemas/pagination-schema.json @@ -0,0 +1,28 @@ +{ + "type": "object", + "properties": { + "page": { + "type": "integer", + "minimum": 1 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "total": { + "type": "integer", + "minimum": 0 + }, + "totalPages": { + "type": "integer", + "minimum": 0 + }, + "hasNext": { + "type": "boolean" + }, + "hasPrev": { + "type": "boolean" + } + } +} diff --git a/test/schemas/payment-method-schema.json b/test/schemas/payment-method-schema.json new file mode 100644 index 0000000..9048279 --- /dev/null +++ b/test/schemas/payment-method-schema.json @@ -0,0 +1,35 @@ +{ + "type": "object", + "required": ["type", "details"], + "properties": { + "type": { + "type": "string", + "enum": ["credit_card", "debit_card", "paypal", "bank_transfer"] + }, + "details": { + "type": "object", + "properties": { + "cardNumber": { + "type": "string", + "pattern": "^[0-9]{13,19}$" + }, + "expiryMonth": { + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "expiryYear": { + "type": "integer", + "minimum": 2024 + }, + "cvv": { + "type": "string", + "pattern": "^[0-9]{3,4}$" + }, + "accountId": { + "type": "string" + } + } + } + } +} diff --git a/test/schemas/price-schema.json b/test/schemas/price-schema.json new file mode 100644 index 0000000..bed6e0a --- /dev/null +++ b/test/schemas/price-schema.json @@ -0,0 +1,30 @@ +{ + "type": "object", + "required": ["amount", "currency"], + "properties": { + "amount": { + "type": "number", + "minimum": 0, + "multipleOf": 0.01 + }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$", + "description": "ISO 4217 currency code" + }, + "discount": { + "type": "object", + "properties": { + "percentage": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "validUntil": { + "type": "string", + "format": "date" + } + } + } + } +} diff --git a/test/schemas/privacy-settings-schema.json b/test/schemas/privacy-settings-schema.json new file mode 100644 index 0000000..449e945 --- /dev/null +++ b/test/schemas/privacy-settings-schema.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "properties": { + "profileVisibility": { + "type": "string", + "enum": ["public", "private", "friends"], + "default": "private" + }, + "dataSharing": { + "type": "boolean", + "default": false + }, + "analytics": { + "type": "boolean", + "default": true + } + } +} diff --git a/test/schemas/product-schema.json b/test/schemas/product-schema.json new file mode 100644 index 0000000..29b7486 --- /dev/null +++ b/test/schemas/product-schema.json @@ -0,0 +1,34 @@ +{ + "type": "object", + "required": ["id", "name", "price"], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "description": { + "type": "string", + "maxLength": 1000 + }, + "price": { + "$ref": "./price-schema.json" + }, + "category": { + "$ref": "./category-schema.json" + }, + "inventory": { + "$ref": "./inventory-schema.json" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } +} diff --git a/test/schemas/user-preferences-schema.json b/test/schemas/user-preferences-schema.json new file mode 100644 index 0000000..fcdc571 --- /dev/null +++ b/test/schemas/user-preferences-schema.json @@ -0,0 +1,37 @@ +{ + "type": "object", + "properties": { + "theme": { + "type": "string", + "enum": ["light", "dark", "auto"], + "default": "auto", + "description": "User's preferred theme" + }, + "language": { + "type": "string", + "pattern": "^[a-z]{2}(-[A-Z]{2})?$", + "default": "en", + "description": "User's preferred language code" + }, + "notifications": { + "type": "object", + "properties": { + "email": { + "type": "boolean", + "default": true + }, + "push": { + "type": "boolean", + "default": false + }, + "sms": { + "type": "boolean", + "default": false + } + } + }, + "privacy": { + "$ref": "./privacy-settings-schema.json" + } + } +} diff --git a/test/schemas/user-schema.json b/test/schemas/user-schema.json new file mode 100644 index 0000000..30b3baa --- /dev/null +++ b/test/schemas/user-schema.json @@ -0,0 +1,44 @@ +{ + "type": "object", + "required": ["id", "name", "email"], + "properties": { + "id": { + "type": "integer", + "format": "int64", + "description": "Unique identifier for the user" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "User's full name" + }, + "email": { + "type": "string", + "format": "email", + "description": "User's email address" + }, + "age": { + "type": "integer", + "minimum": 0, + "maximum": 150, + "description": "User's age" + }, + "address": { + "$ref": "./address-schema.json" + }, + "preferences": { + "$ref": "./user-preferences-schema.json" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the user was created" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "When the user was last updated" + } + } +} diff --git a/test/schemas/warehouse-schema.json b/test/schemas/warehouse-schema.json new file mode 100644 index 0000000..4f6dddb --- /dev/null +++ b/test/schemas/warehouse-schema.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "location": { + "$ref": "./address-schema.json" + } + } +} diff --git a/test/shared/responses/orders-response.json b/test/shared/responses/orders-response.json new file mode 100644 index 0000000..c193bc7 --- /dev/null +++ b/test/shared/responses/orders-response.json @@ -0,0 +1,18 @@ +{ + "description": "List of orders", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "../schemas/order-schema.json" + } + } + } + } + } + } +} diff --git a/test/shared/responses/products-response.json b/test/shared/responses/products-response.json new file mode 100644 index 0000000..4c1a0bf --- /dev/null +++ b/test/shared/responses/products-response.json @@ -0,0 +1,18 @@ +{ + "description": "List of products", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "../schemas/product-schema.json" + } + } + } + } + } + } +} diff --git a/test/shared/responses/test-response.json b/test/shared/responses/test-response.json new file mode 100644 index 0000000..53a5440 --- /dev/null +++ b/test/shared/responses/test-response.json @@ -0,0 +1,15 @@ +{ + "description": "Test response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } +} diff --git a/test/shared/responses/users-response.json b/test/shared/responses/users-response.json new file mode 100644 index 0000000..a5bb599 --- /dev/null +++ b/test/shared/responses/users-response.json @@ -0,0 +1,18 @@ +{ + "description": "List of users", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "../schemas/user-schema.json" + } + } + } + } + } + } +} diff --git a/test/shared/schemas/address-schema.json b/test/shared/schemas/address-schema.json new file mode 100644 index 0000000..ae3c1c3 --- /dev/null +++ b/test/shared/schemas/address-schema.json @@ -0,0 +1,23 @@ +{ + "type": "object", + "properties": { + "street": { + "type": "string" + }, + "city": { + "type": "string" + }, + "state": { + "type": "string" + }, + "zipCode": { + "type": "string", + "pattern": "^[0-9]{5}(-[0-9]{4})?$" + }, + "country": { + "type": "string", + "minLength": 2, + "maxLength": 2 + } + } +} diff --git a/test/shared/schemas/category-schema.json b/test/shared/schemas/category-schema.json new file mode 100644 index 0000000..1f391c4 --- /dev/null +++ b/test/shared/schemas/category-schema.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } +} diff --git a/test/shared/schemas/order-item-schema.json b/test/shared/schemas/order-item-schema.json new file mode 100644 index 0000000..6c71fdc --- /dev/null +++ b/test/shared/schemas/order-item-schema.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "required": ["productId", "quantity"], + "properties": { + "productId": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "quantity": { + "type": "integer", + "minimum": 1 + }, + "price": { + "$ref": "./price-schema.json" + } + } +} diff --git a/test/shared/schemas/order-schema.json b/test/shared/schemas/order-schema.json new file mode 100644 index 0000000..3d6f198 --- /dev/null +++ b/test/shared/schemas/order-schema.json @@ -0,0 +1,23 @@ +{ + "type": "object", + "required": ["id", "status", "items"], + "properties": { + "id": { + "type": "string", + "pattern": "^ORD-[0-9]{8}$" + }, + "status": { + "type": "string", + "enum": ["pending", "processing", "shipped", "delivered", "cancelled"] + }, + "items": { + "type": "array", + "items": { + "$ref": "./order-item-schema.json" + } + }, + "total": { + "$ref": "./price-schema.json" + } + } +} diff --git a/test/shared/schemas/price-schema.json b/test/shared/schemas/price-schema.json new file mode 100644 index 0000000..8610b98 --- /dev/null +++ b/test/shared/schemas/price-schema.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "required": ["amount", "currency"], + "properties": { + "amount": { + "type": "number", + "minimum": 0, + "multipleOf": 0.01 + }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$" + } + } +} diff --git a/test/shared/schemas/product-schema.json b/test/shared/schemas/product-schema.json new file mode 100644 index 0000000..bd91784 --- /dev/null +++ b/test/shared/schemas/product-schema.json @@ -0,0 +1,21 @@ +{ + "type": "object", + "required": ["id", "name", "price"], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "price": { + "$ref": "./price-schema.json" + }, + "category": { + "$ref": "./category-schema.json" + } + } +} diff --git a/test/shared/schemas/test-schema.json b/test/shared/schemas/test-schema.json new file mode 100644 index 0000000..1f391c4 --- /dev/null +++ b/test/shared/schemas/test-schema.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } +} diff --git a/test/shared/schemas/user-schema.json b/test/shared/schemas/user-schema.json new file mode 100644 index 0000000..9f1b8f6 --- /dev/null +++ b/test/shared/schemas/user-schema.json @@ -0,0 +1,22 @@ +{ + "type": "object", + "required": ["id", "name", "email"], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "email": { + "type": "string", + "format": "email" + }, + "address": { + "$ref": "./address-schema.json" + } + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}