← 返回
未分类 中文

Phy Api Changelog Gen

OpenAPI/Swagger spec differ that generates human-readable API changelogs. Diffs two OpenAPI 3.x or Swagger 2.0 spec files (YAML or JSON) with a semantic diff...
生成可读API变更日志的OpenAPI/Swagger规范差异工具,支持语义对比两个OpenAPI3.x或Swagger 2.0规范文件(YAML或JSON)。
phy041 phy041 来源
未分类 clawhub v1.0.3 1 版本 100000 Key: 无需
★ 0
Stars
📥 114
下载
💾 0
安装
1
版本
#latest

概述

API Changelog Generator

You ship a new API version. Your frontend team asks: "what changed?" You send them a GitHub diff of two 3,000-line YAML files.

This skill diffs two OpenAPI specs semantically — not line by line — and produces a consumer-facing changelog that clearly separates breaking changes from additions, grouped by endpoint.

Supports OpenAPI 3.x and Swagger 2.0. Zero external API.


Trigger Phrases

  • "api diff", "openapi diff", "swagger diff"
  • "what changed in the API", "breaking changes"
  • "api changelog", "spec diff", "schema diff"
  • "which endpoints changed", "removed fields"
  • "was this a breaking change", "api migration guide"
  • "/api-changelog"

How to Provide Input

# Option 1: Diff two local spec files
/api-changelog openapi-v1.yaml openapi-v2.yaml

# Option 2: Diff against git history (current vs previous commit)
/api-changelog openapi.yaml --vs HEAD~1
/api-changelog openapi.yaml --vs main

# Option 3: Diff against a tagged version
/api-changelog openapi.yaml --vs v2.0.0:openapi.yaml

# Option 4: Output only breaking changes
/api-changelog v1.yaml v2.yaml --breaking-only

# Option 5: Generate consumer migration guide
/api-changelog v1.yaml v2.yaml --migration-guide

# Option 6: Output JSON for CI
/api-changelog v1.yaml v2.yaml --json

# Option 7: Fail CI if breaking changes detected
/api-changelog v1.yaml v2.yaml --fail-on-breaking

Step 1: Load and Normalize Specs

import json
import yaml
from pathlib import Path

def load_spec(path: str) -> dict:
    """Load OpenAPI 3.x or Swagger 2.0 spec from YAML or JSON."""
    content = Path(path).read_text(encoding='utf-8')

    if path.endswith('.json'):
        spec = json.loads(content)
    else:
        spec = yaml.safe_load(content)

    # Detect version
    version = spec.get('openapi', spec.get('swagger', 'unknown'))

    # Normalize Swagger 2.0 → OpenAPI 3.0 structure for comparison
    if str(version).startswith('2.'):
        spec = normalize_swagger2(spec)

    return spec


def normalize_swagger2(spec: dict) -> dict:
    """Normalize Swagger 2.0 paths to OpenAPI 3.0 structure for diffing."""
    # Convert basePath + paths → servers + paths
    base = spec.get('basePath', '')
    if base and 'servers' not in spec:
        spec['servers'] = [{'url': base}]

    # Convert definitions → components.schemas
    if 'definitions' in spec and 'components' not in spec:
        spec['components'] = {'schemas': spec.pop('definitions')}

    return spec


def extract_endpoints(spec: dict) -> dict:
    """Extract all endpoints as {(method, path): operation_object}."""
    endpoints = {}
    for path, path_item in spec.get('paths', {}).items():
        for method in ['get', 'post', 'put', 'patch', 'delete', 'head', 'options']:
            if method in path_item:
                endpoints[(method.upper(), path)] = path_item[method]
    return endpoints


def extract_schemas(spec: dict) -> dict:
    """Extract all component schemas."""
    return spec.get('components', {}).get('schemas', {})

Step 2: Semantic Diff Engine

from dataclasses import dataclass
from typing import Literal

@dataclass
class ApiChange:
    endpoint: str           # "POST /users" or "Schema: UserResponse"
    change_type: str        # e.g., "REMOVED_ENDPOINT", "ADDED_REQUIRED_FIELD"
    breaking: bool
    description: str
    old_value: str | None
    new_value: str | None
    migration_hint: str | None


def diff_endpoints(old_spec: dict, new_spec: dict) -> list[ApiChange]:
    """Find all endpoint-level changes."""
    changes = []
    old_eps = extract_endpoints(old_spec)
    new_eps = extract_endpoints(new_spec)

    old_keys = set(old_eps.keys())
    new_keys = set(new_eps.keys())

    # Removed endpoints (BREAKING)
    for key in old_keys - new_keys:
        method, path = key
        old_op = old_eps[key]
        changes.append(ApiChange(
            endpoint=f'{method} {path}',
            change_type='REMOVED_ENDPOINT',
            breaking=True,
            description=f'Endpoint `{method} {path}` was removed.',
            old_value=old_op.get('summary', '(no summary)'),
            new_value=None,
            migration_hint='Check for replacement endpoint. If deprecated, use the replacement.',
        ))

    # Added endpoints (NON-BREAKING)
    for key in new_keys - old_keys:
        method, path = key
        new_op = new_eps[key]
        changes.append(ApiChange(
            endpoint=f'{method} {path}',
            change_type='ADDED_ENDPOINT',
            breaking=False,
            description=f'New endpoint `{method} {path}` added.',
            old_value=None,
            new_value=new_op.get('summary', '(no summary)'),
            migration_hint=None,
        ))

    # Changed endpoints (analyze deeper)
    for key in old_keys & new_keys:
        method, path = key
        endpoint_str = f'{method} {path}'
        old_op = old_eps[key]
        new_op = new_eps[key]

        changes.extend(diff_operation(endpoint_str, old_op, new_op))

    return changes


def diff_operation(endpoint: str, old_op: dict, new_op: dict) -> list[ApiChange]:
    """Diff a single operation object."""
    changes = []

    # ── Parameters ──
    old_params = {p['name']: p for p in old_op.get('parameters', [])}
    new_params = {p['name']: p for p in new_op.get('parameters', [])}

    # Removed parameter
    for name in set(old_params) - set(new_params):
        param = old_params[name]
        changes.append(ApiChange(
            endpoint=endpoint,
            change_type='REMOVED_PARAMETER',
            breaking=True,
            description=f'Parameter `{name}` ({param.get("in", "?")}) removed.',
            old_value=f'required={param.get("required", False)}',
            new_value=None,
            migration_hint=f'Remove `{name}` from all requests to `{endpoint}`.',
        ))

    # Added required parameter (BREAKING), added optional (NON-BREAKING)
    for name in set(new_params) - set(old_params):
        param = new_params[name]
        is_required = param.get('required', False)
        changes.append(ApiChange(
            endpoint=endpoint,
            change_type='ADDED_REQUIRED_PARAMETER' if is_required else 'ADDED_OPTIONAL_PARAMETER',
            breaking=is_required,
            description=f'{"Required" if is_required else "Optional"} parameter `{name}` '
                        f'({param.get("in", "?")} — {param.get("schema", {}).get("type", "?")}) added.',
            old_value=None,
            new_value=str(param.get('schema', {})),
            migration_hint=f'Add `{name}` to requests to `{endpoint}`.' if is_required else None,
        ))

    # Changed parameter type (BREAKING)
    for name in set(old_params) & set(new_params):
        old_p = old_params[name]
        new_p = new_params[name]
        old_type = old_p.get('schema', {}).get('type')
        new_type = new_p.get('schema', {}).get('type')
        if old_type and new_type and old_type != new_type:
            changes.append(ApiChange(
                endpoint=endpoint,
                change_type='CHANGED_PARAMETER_TYPE',
                breaking=True,
                description=f'Parameter `{name}` type changed: `{old_type}` → `{new_type}`.',
                old_value=old_type,
                new_value=new_type,
                migration_hint=f'Update `{name}` values from {old_type} format to {new_type} format.',
            ))

    # ── Response Schemas ──
    old_responses = old_op.get('responses', {})
    new_responses = new_op.get('responses', {})

    for status_code in set(old_responses) & set(new_responses):
        changes.extend(diff_response_schema(
            endpoint, status_code, old_responses[status_code], new_responses[status_code]
        ))

    # Removed successful response (BREAKING)
    for code in set(old_responses) - set(new_responses):
        if str(code).startswith('2'):
            changes.append(ApiChange(
                endpoint=endpoint,
                change_type='REMOVED_RESPONSE_CODE',
                breaking=True,
                description=f'Response code `{code}` removed.',
                old_value=str(code),
                new_value=None,
                migration_hint=f'Update response handling — `{code}` no longer returned.',
            ))

    # ── Auth Changes ──
    old_security = old_op.get('security', [])
    new_security = new_op.get('security', [])
    if old_security != new_security:
        changes.append(ApiChange(
            endpoint=endpoint,
            change_type='CHANGED_SECURITY',
            breaking=True,
            description=f'Security requirements changed for `{endpoint}`.',
            old_value=str(old_security),
            new_value=str(new_security),
            migration_hint='Update authentication headers/tokens for this endpoint.',
        ))

    return changes


def diff_response_schema(endpoint: str, code: str, old_resp: dict, new_resp: dict) -> list[ApiChange]:
    """Diff response schema for a specific status code."""
    changes = []

    def get_schema(resp):
        content = resp.get('content', {})
        for media_type in ['application/json', '*/*']:
            if media_type in content:
                return content[media_type].get('schema', {})
        return resp.get('schema', {})  # Swagger 2.0

    old_schema = get_schema(old_resp)
    new_schema = get_schema(new_resp)

    if not old_schema or not new_schema:
        return []

    old_props = old_schema.get('properties', {})
    new_props = new_schema.get('properties', {})
    old_required = set(old_schema.get('required', []))
    new_required = set(new_schema.get('required', []))

    # Removed field from response (may be BREAKING if consumers rely on it)
    for field in set(old_props) - set(new_props):
        changes.append(ApiChange(
            endpoint=endpoint,
            change_type='REMOVED_RESPONSE_FIELD',
            breaking=True,
            description=f'Response field `{field}` removed from `{code}` response.',
            old_value=str(old_props[field].get('type', '?')),
            new_value=None,
            migration_hint=f'Remove references to `response.{field}` in client code.',
        ))

    # Added field to response (NON-BREAKING)
    for field in set(new_props) - set(old_props):
        changes.append(ApiChange(
            endpoint=endpoint,
            change_type='ADDED_RESPONSE_FIELD',
            breaking=False,
            description=f'New field `{field}` ({new_props[field].get("type", "?")}) '
                        f'added to `{code}` response.',
            old_value=None,
            new_value=str(new_props[field].get('type', '?')),
            migration_hint=None,
        ))

    # Changed field type (BREAKING)
    for field in set(old_props) & set(new_props):
        old_type = old_props[field].get('type')
        new_type = new_props[field].get('type')
        if old_type and new_type and old_type != new_type:
            changes.append(ApiChange(
                endpoint=endpoint,
                change_type='CHANGED_RESPONSE_FIELD_TYPE',
                breaking=True,
                description=f'Response field `{field}` type changed: `{old_type}` → `{new_type}`.',
                old_value=old_type,
                new_value=new_type,
                migration_hint=f'Update client code to handle `{field}` as `{new_type}`.',
            ))

    # New required field in request body (BREAKING)
    new_required_added = new_required - old_required
    for field in new_required_added:
        if field in new_props:
            changes.append(ApiChange(
                endpoint=endpoint,
                change_type='ADDED_REQUIRED_FIELD',
                breaking=True,
                description=f'Field `{field}` is now required in `{code}` response contract.',
                old_value='optional',
                new_value='required',
                migration_hint=f'Ensure `{field}` is always included in request payloads.',
            ))

    return changes

Step 3: Output Report

## API Changelog
Diff: `openapi-v1.yaml` → `openapi-v2.yaml`
Generated: 2026-03-19

---

### Summary

| Category | Count |
|----------|-------|
| 🔴 Breaking Changes | 4 |
| 🟢 Non-Breaking Additions | 7 |
| 🟡 Deprecations | 2 |

**This release contains BREAKING changes. Clients MUST update before upgrading.**

---

## 🔴 Breaking Changes

### `DELETE /users/{id}` — REMOVED
The endpoint for deleting users has been removed.

**Migration:** Use `PATCH /users/{id}` with `{ "status": "deactivated" }` instead.
Soft-delete replaces hard-delete as of this version.

---

### `GET /orders` — Removed response field `order_total`
The `order_total` field was removed from order list responses.

**Migration:** Use `items_subtotal + tax_amount` from the same response to calculate the total.

// Before:

{ "id": "ord_123", "order_total": 49.99, "items": [...] }

// After:

{ "id": "ord_123", "items_subtotal": 42.00, "tax_amount": 7.99, "items": [...] }


---

### `POST /checkout` — Added required parameter `idempotency_key`
All checkout requests now require an `idempotency_key` header parameter.

**Migration:** Generate a UUID per request and include it as:

Idempotency-Key:

Without this header, the server returns `400 Bad Request`.

---

### `GET /products/{id}` — Changed `price` type: `number` → `string`
The `price` field changed from a floating-point number to a decimal string to avoid floating-point precision issues.

**Migration:**

// Before: const price = product.price * 100

// After: const price = parseFloat(product.price) * 100


---

## 🟢 Non-Breaking Additions

| Change | Description |
|--------|-------------|
| `GET /users/{id}/preferences` | New endpoint for user preferences |
| `POST /webhooks` | Webhook registration endpoint |
| `GET /orders` `+cursor` | Added cursor-based pagination parameter (optional) |
| `GET /products` `+category_ids[]` | New array filter parameter (optional) |
| `UserResponse` `+avatar_url` | New optional field on user objects |
| `OrderResponse` `+shipping_eta` | New optional field on order objects |
| `GET /search` `+facets` | New optional faceted search support |

---

## 🟡 Deprecations

| Endpoint | Deprecated Field | Replacement | Sunset Date |
|----------|-----------------|-------------|-------------|
| `GET /users` | `full_name` | `first_name` + `last_name` | 2026-09-01 |
| `POST /auth/login` | `remember_me` param | Cookie-based session now automatic | 2026-06-01 |

---

## Migration Guide for API Consumers

1. **Remove `DELETE /users/{id}` calls** — replace with soft-delete via `PATCH /users/{id}` with `status: "deactivated"`
2. **Update order total calculation** — `order_total` removed; compute from `items_subtotal + tax_amount`
3. **Add `Idempotency-Key` header** to all `POST /checkout` requests
4. **Update price parsing** — `price` is now a decimal string, not a float
5. **Optional:** Migrate to cursor pagination on `GET /orders` for better performance

---

## CI Integration

Fail CI if breaking changes are introduced

/api-changelog openapi-current.yaml openapi-new.yaml --fail-on-breaking

Exit code 1 if breaking changes found, 0 if only additions


Add to `.github/workflows/api-review.yml`:
  • name: Check for breaking API changes

run: |

if git show HEAD~1:openapi.yaml > /tmp/openapi-prev.yaml 2>/dev/null; then

python3 scripts/api-changelog.py /tmp/openapi-prev.yaml openapi.yaml --fail-on-breaking

fi


Quick Mode Output

API Changelog: openapi-v1.yaml → openapi-v2.yaml

🔴 4 BREAKING CHANGES:
  - DELETE /users/{id} removed → use PATCH with status: deactivated
  - GET /orders response: removed field order_total
  - POST /checkout: new required param idempotency_key
  - GET /products/{id}: price type changed number → string

🟢 7 non-breaking additions (3 new endpoints, 4 new optional fields)
🟡 2 deprecations (full_name field, remember_me param)

Run /api-changelog --migration-guide for consumer-facing migration steps

Author

Canlah AI — Run performance marketing without breaking your brand.

版本历史

共 1 个版本

  • v1.0.3 当前
    2026-05-29 14:20 安全 安全

安全检测

腾讯云安全 (Keen)

安全,无风险
查看报告

腾讯云安全 (Sanbu)

安全,无风险
查看报告

🔗 相关推荐

dev-programming

Mcporter

steipete
使用 mcporter CLI 直接列出、配置、认证及调用 MCP 服务器/工具(支持 HTTP 或 stdio),涵盖临时服务器、配置编辑及 CLI/类型生成功能。
★ 198 📥 68,236
knowledge-management

Phy Lenny Mentor

phy041
由300+期Lenny播客驱动的AI产品导师。提炼Brian Chesky、Shreyas Doshi、April Dunford等世界级领袖的智慧。T...
★ 1 📥 671
dev-programming

YouTube

byungkyu
使用托管OAuth集成YouTube Data API,支持搜索视频、管理播放列表、获取频道数据及评论互动,适用于用户需要时使用此技能。
★ 142 📥 42,119