You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

488 lines
14 KiB

"""
Tests for the OpenAPI plugin system.
"""
from typing import Any
import pytest
from fastapi import FastAPI
from fastapi.openapi.plugins import (
OpenAPIPluginBase,
OpenAPIPluginSettings,
PluginExecutor,
PluginRegistry,
create_plugin,
)
from fastapi.testclient import TestClient
from pydantic import BaseModel
# =============================================================================
# Test Models
# =============================================================================
class Item(BaseModel):
name: str
price: float
# =============================================================================
# Test Plugins
# =============================================================================
class CustomExtensionPlugin(OpenAPIPluginBase):
"""Plugin that adds custom x- extensions to all operations."""
@property
def name(self) -> str:
return "custom-extension"
@property
def priority(self) -> int:
return 50 # Higher priority (runs first)
def modify_operation(
self,
route: Any,
method: str,
operation: dict[str, Any],
) -> dict[str, Any]:
operation["x-custom-extension"] = True
operation["x-route-name"] = route.name
return operation
class SchemaModifierPlugin(OpenAPIPluginBase):
"""Plugin that modifies the schema after generation."""
@property
def name(self) -> str:
return "schema-modifier"
def modify_schema(self, schema: dict[str, Any]) -> dict[str, Any]:
schema["x-generated-by"] = "FastAPI Plugin System"
if "info" in schema:
schema["info"]["x-plugin-version"] = "1.0"
return schema
class PostGenerationPlugin(OpenAPIPluginBase):
"""Plugin that runs after schema generation."""
@property
def name(self) -> str:
return "post-generation"
@property
def priority(self) -> int:
return 200 # Lower priority (runs last)
def post_schema_generation(self, schema: dict[str, Any]) -> dict[str, Any]:
schema["x-post-processed"] = True
return schema
class PreSchemaPlugin(OpenAPIPluginBase):
"""Plugin that tracks pre_schema_generation calls."""
def __init__(self):
self.pre_called = False
self.settings_received: OpenAPIPluginSettings | None = None
@property
def name(self) -> str:
return "pre-schema"
def pre_schema_generation(
self,
app: FastAPI,
settings: OpenAPIPluginSettings,
) -> None:
self.pre_called = True
self.settings_received = settings
class ErrorPlugin(OpenAPIPluginBase):
"""Plugin that raises errors for testing error handling."""
@property
def name(self) -> str:
return "error-plugin"
def modify_operation(
self,
route: Any,
method: str,
operation: dict[str, Any],
) -> dict[str, Any]:
raise ValueError("Intentional error for testing")
# =============================================================================
# Plugin Registry Tests
# =============================================================================
class TestPluginRegistry:
"""Tests for PluginRegistry class."""
def test_register_plugin(self):
"""Test basic plugin registration."""
registry = PluginRegistry()
plugin = CustomExtensionPlugin()
registry.register(plugin)
assert "custom-extension" in registry
assert len(registry) == 1
assert registry.get_plugin("custom-extension") is plugin
def test_register_duplicate_raises(self):
"""Test that registering duplicate name raises error."""
registry = PluginRegistry()
plugin1 = CustomExtensionPlugin()
plugin2 = CustomExtensionPlugin()
registry.register(plugin1)
with pytest.raises(ValueError, match="already registered"):
registry.register(plugin2)
def test_register_with_replace(self):
"""Test replacing an existing plugin."""
registry = PluginRegistry()
plugin1 = CustomExtensionPlugin()
plugin2 = CustomExtensionPlugin()
registry.register(plugin1)
registry.register(plugin2, replace=True)
assert registry.get_plugin("custom-extension") is plugin2
def test_unregister_plugin(self):
"""Test unregistering a plugin."""
registry = PluginRegistry()
plugin = CustomExtensionPlugin()
registry.register(plugin)
assert registry.unregister("custom-extension") is True
assert "custom-extension" not in registry
assert registry.unregister("custom-extension") is False
def test_enable_disable_plugin(self):
"""Test enabling and disabling plugins."""
registry = PluginRegistry()
plugin = CustomExtensionPlugin()
registry.register(plugin, enabled=False)
assert not registry.is_enabled("custom-extension")
assert len(registry.get_active_plugins()) == 0
registry.enable("custom-extension")
assert registry.is_enabled("custom-extension")
assert len(registry.get_active_plugins()) == 1
registry.disable("custom-extension")
assert not registry.is_enabled("custom-extension")
def test_priority_ordering(self):
"""Test that plugins are sorted by priority."""
registry = PluginRegistry()
low_priority = PostGenerationPlugin() # priority 200
high_priority = CustomExtensionPlugin() # priority 50
normal_priority = SchemaModifierPlugin() # priority 100 (default)
registry.register(low_priority)
registry.register(high_priority)
registry.register(normal_priority)
active = registry.get_active_plugins()
assert active[0].name == "custom-extension" # priority 50
assert active[1].name == "schema-modifier" # priority 100
assert active[2].name == "post-generation" # priority 200
def test_generation_count_increments(self):
"""Test that generation count increments on changes."""
registry = PluginRegistry()
initial_count = registry.generation_count
plugin = CustomExtensionPlugin()
registry.register(plugin)
assert registry.generation_count == initial_count + 1
registry.disable("custom-extension")
assert registry.generation_count == initial_count + 2
registry.enable("custom-extension")
assert registry.generation_count == initial_count + 3
registry.unregister("custom-extension")
assert registry.generation_count == initial_count + 4
def test_cache_invalidation_callback(self):
"""Test that cache invalidation callback is called."""
callback_called = []
def on_invalidate():
callback_called.append(True)
registry = PluginRegistry(on_cache_invalidation=on_invalidate)
plugin = CustomExtensionPlugin()
registry.register(plugin)
assert len(callback_called) == 1
registry.disable("custom-extension")
assert len(callback_called) == 2
# =============================================================================
# Plugin Execution Tests
# =============================================================================
class TestPluginExecution:
"""Tests for plugin execution during schema generation."""
def test_modify_operation_plugin(self):
"""Test that modify_operation hook is called."""
plugin = CustomExtensionPlugin()
app = FastAPI(
title="Test API",
version="1.0.0",
openapi_plugins=[plugin],
)
@app.get("/items")
def get_items():
return []
schema = app.openapi()
# Check the custom extension was added
operation = schema["paths"]["/items"]["get"]
assert operation.get("x-custom-extension") is True
assert operation.get("x-route-name") == "get_items"
def test_modify_schema_plugin(self):
"""Test that modify_schema hook is called."""
plugin = SchemaModifierPlugin()
app = FastAPI(
title="Test API",
version="1.0.0",
openapi_plugins=[plugin],
)
@app.get("/items")
def get_items():
return []
schema = app.openapi()
assert schema.get("x-generated-by") == "FastAPI Plugin System"
assert schema["info"].get("x-plugin-version") == "1.0"
def test_post_generation_plugin(self):
"""Test that post_schema_generation hook is called."""
plugin = PostGenerationPlugin()
app = FastAPI(
title="Test API",
version="1.0.0",
openapi_plugins=[plugin],
)
@app.get("/items")
def get_items():
return []
schema = app.openapi()
assert schema.get("x-post-processed") is True
def test_pre_schema_generation_plugin(self):
"""Test that pre_schema_generation hook is called."""
plugin = PreSchemaPlugin()
app = FastAPI(
title="Test API",
version="1.0.0",
openapi_plugins=[plugin],
)
@app.get("/items")
def get_items():
return []
schema = app.openapi()
assert plugin.pre_called is True
assert plugin.settings_received is not None
assert plugin.settings_received.title == "Test API"
assert plugin.settings_received.version == "1.0.0"
def test_multiple_plugins(self):
"""Test multiple plugins working together."""
app = FastAPI(
title="Test API",
version="1.0.0",
openapi_plugins=[
CustomExtensionPlugin(),
SchemaModifierPlugin(),
PostGenerationPlugin(),
],
)
@app.get("/items")
def get_items():
return []
schema = app.openapi()
# All plugins should have modified the schema
operation = schema["paths"]["/items"]["get"]
assert operation.get("x-custom-extension") is True
assert schema.get("x-generated-by") == "FastAPI Plugin System"
assert schema.get("x-post-processed") is True
def test_plugin_error_handling(self):
"""Test that plugin errors don't break schema generation."""
error_plugin = ErrorPlugin()
normal_plugin = SchemaModifierPlugin()
app = FastAPI(
title="Test API",
version="1.0.0",
openapi_plugins=[error_plugin, normal_plugin],
)
@app.get("/items")
def get_items():
return []
# Should not raise, error is caught
with pytest.warns(UserWarning, match="raised an error"):
schema = app.openapi()
# Normal plugin should still work
assert schema.get("x-generated-by") == "FastAPI Plugin System"
def test_plugin_cache_invalidation(self):
"""Test that schema is regenerated when plugins change."""
app = FastAPI(
title="Test API",
version="1.0.0",
)
@app.get("/items")
def get_items():
return []
# First generation - no plugins
schema1 = app.openapi()
assert schema1.get("x-generated-by") is None
# Register a plugin
app.openapi_plugins.register(SchemaModifierPlugin())
# Should regenerate with plugin
schema2 = app.openapi()
assert schema2.get("x-generated-by") == "FastAPI Plugin System"
# Disable plugin
app.openapi_plugins.disable("schema-modifier")
# Should regenerate without plugin effect
schema3 = app.openapi()
assert schema3.get("x-generated-by") is None
# =============================================================================
# create_plugin() Tests
# =============================================================================
class TestCreatePlugin:
"""Tests for the create_plugin convenience function."""
def test_create_simple_plugin(self):
"""Test creating a plugin from callbacks."""
plugin = create_plugin(
"simple-plugin",
modify_operation=lambda route, method, op: {**op, "x-simple": True},
)
assert plugin.name == "simple-plugin"
assert plugin.priority == 100
# Test the callback works
result = plugin.modify_operation(None, "GET", {"summary": "Test"})
assert result["x-simple"] is True
assert result["summary"] == "Test"
def test_create_plugin_with_priority(self):
"""Test creating a plugin with custom priority."""
plugin = create_plugin(
"priority-plugin",
priority=25,
)
assert plugin.priority == 25
# =============================================================================
# Integration Tests
# =============================================================================
class TestIntegration:
"""Integration tests with actual HTTP requests."""
def test_plugin_with_test_client(self):
"""Test plugin output via test client."""
app = FastAPI(
title="Test API",
version="1.0.0",
openapi_plugins=[CustomExtensionPlugin()],
)
@app.get("/items", response_model=list[Item])
def get_items():
return []
@app.post("/items", response_model=Item)
def create_item(item: Item):
return item
client = TestClient(app)
response = client.get("/openapi.json")
assert response.status_code == 200
schema = response.json()
# Check both operations have custom extension
assert schema["paths"]["/items"]["get"].get("x-custom-extension") is True
assert schema["paths"]["/items"]["post"].get("x-custom-extension") is True
def test_docs_with_plugins(self):
"""Test that Swagger UI still works with plugins."""
app = FastAPI(
title="Test API",
version="1.0.0",
openapi_plugins=[SchemaModifierPlugin()],
)
@app.get("/items")
def get_items():
return []
client = TestClient(app)
# Swagger UI should load
response = client.get("/docs")
assert response.status_code == 200
assert "swagger-ui" in response.text.lower()
# ReDoc should load
response = client.get("/redoc")
assert response.status_code == 200
assert "redoc" in response.text.lower()