File size: 6,369 Bytes
81e3673
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
#!/usr/bin/env python3
"""Detect breaking changes in OpenAPI specification using openapi-diff."""
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path

# Set PYTHONPATH to include backend directory
backend_dir = Path(__file__).parent.parent.parent
if str(backend_dir) not in sys.path:
    sys.path.insert(0, str(backend_dir))


def check_openapi_diff_installed():
    """Check if openapi-diff is available."""
    try:
        result = subprocess.run(
            ["npx", "openapi-diff", "--version"],
            capture_output=True,
            text=True,
            timeout=10
        )
        return result.returncode == 0
    except (FileNotFoundError, subprocess.TimeoutExpired):
        return False


def detect_breaking_changes(base_spec, current_spec, output_format="json"):
    """Compare OpenAPI specs and detect breaking changes.

    Args:
        base_spec: Path to baseline OpenAPI spec (e.g., openapi.json)
        current_spec: Path to current OpenAPI spec (newly generated)
        output_format: Output format (json or text)

    Returns:
        Dict with breaking_changes detected and details
    """
    if not check_openapi_diff_installed():
        print("ERROR: openapi-diff not found. Install with: npm install -g openapi-diff")
        sys.exit(1)

    print(f"Comparing OpenAPI specs:")
    print(f"  Base: {base_spec}")
    print(f"  Current: {current_spec}")

    result = subprocess.run([
        "npx", "openapi-diff",
        base_spec,
        current_spec
    ], capture_output=True, text=True, timeout=30)

    diff_data = {
        "breaking_changes": [],
        "non_breaking_changes": [],
        "exit_code": result.returncode,
        "has_breaking_changes": result.returncode != 0,
        "raw_output": result.stdout,
        "raw_error": result.stderr
    }

    # Check if this is a validation error (spec issue, not diff issue)
    is_validation_error = "Validation errors" in result.stderr

    # Distinguish false positives (Pydantic 2.0+) from real validation errors
    is_pydantic_false_positive = (
        is_validation_error and
        ("anyOf" in result.stderr or "null" in result.stderr)
    )

    # Parse output for breaking changes
    if result.returncode != 0 and not is_validation_error:
        # Genuine breaking changes detected
        diff_data["breaking_changes"] = ["Breaking changes detected (see output)"]
        diff_data["has_breaking_changes"] = True
    elif is_validation_error and not is_pydantic_false_positive:
        # Real validation error - spec is malformed
        diff_data["validation_errors"] = True
        diff_data["has_breaking_changes"] = True  # FAIL on real validation errors
        diff_data["breaking_changes"] = ["OpenAPI spec validation error - see stderr"]
    elif is_pydantic_false_positive:
        # Pydantic 2.0+ false positive - anyOf + null pattern
        diff_data["validation_errors"] = True
        diff_data["pydantic_false_positive"] = True
        diff_data["has_breaking_changes"] = False
        diff_data["breaking_changes"] = []

    return diff_data


def main():
    """CLI entry point for breaking change detection."""
    parser = argparse.ArgumentParser(
        description="Detect breaking API changes using OpenAPI diff"
    )
    parser.add_argument(
        "--base",
        default=str(backend_dir / "openapi.json"),
        help="Baseline OpenAPI spec (default: backend/openapi.json)"
    )
    parser.add_argument(
        "--current",
        help="Current OpenAPI spec (default: auto-generate)"
    )
    parser.add_argument(
        "--allow-breaking",
        action="store_true",
        help="Exit 0 even if breaking changes found (for documentation)"
    )
    parser.add_argument(
        "--update-baseline",
        action="store_true",
        help="Update baseline with current spec (use with care)"
    )

    args = parser.parse_args()

    # Generate current spec if not provided
    if args.current:
        current_spec = args.current
    else:
        # Generate temporary current spec
        current_spec = "/tmp/openapi_current.json"
        print("Generating current OpenAPI spec...")
        env = os.environ.copy()
        env['PYTHONPATH'] = str(backend_dir)
        subprocess.run([
            "python3", "tests/scripts/generate_openapi_spec.py",
            "-o", current_spec
        ], check=True, env=env)

    base_spec = args.base

    # Verify files exist
    if not Path(base_spec).exists():
        print(f"ERROR: Baseline spec not found: {base_spec}")
        print("Run: python3 tests/scripts/generate_openapi_spec.py")
        sys.exit(1)

    if not Path(current_spec).exists():
        print(f"ERROR: Current spec not found: {current_spec}")
        sys.exit(1)

    # Detect breaking changes
    result = detect_breaking_changes(base_spec, current_spec)

    # Report results
    if result.get("raw_output"):
        print(f"\n{result['raw_output']}")

    if result.get("pydantic_false_positive"):
        print("\n⚠️  OpenAPI spec validation warning (Pydantic 2.0+ false positive)")
        print("   The diff tool detected 'anyOf + null' patterns from Pydantic 2.0+")
        print("   These are known false positives and don't affect functionality")
        print("\n✅ No breaking changes detected between specs")
    elif result.get("validation_errors"):
        print("\n❌ OpenAPI spec validation error")
        print("   The OpenAPI spec is malformed and cannot be validated")
        print("   Check the stderr output above for specific validation issues")
        print("\n❌ Build failed due to validation errors")
    elif result.get("has_breaking_changes"):
        if result.get("breaking_changes"):
            print(f"\n❌ Found {len(result['breaking_changes'])} breaking changes")
        else:
            print("\n❌ Breaking changes detected")
    else:
        print("\n✅ No breaking changes detected")

    # Update baseline if requested
    if args.update_baseline:
        import shutil
        shutil.copy(current_spec, base_spec)
        print(f"\nUpdated baseline: {base_spec}")

    # Exit with error on breaking changes (unless --allow-breaking)
    if result.get("has_breaking_changes") and not args.allow_breaking:
        sys.exit(1)
    else:
        sys.exit(0)


if __name__ == "__main__":
    main()