pythonasyncioapiasyncfastapiframeworkjsonjson-schemaopenapiopenapi3pydanticpython-typespython3redocreststarletteswaggerswagger-uiuvicornweb
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.
376 lines
12 KiB
376 lines
12 KiB
"""OpenAPI plugin system for extending schema generation."""
|
|
|
|
import threading
|
|
import warnings
|
|
from abc import ABC, abstractmethod
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING, Any, Optional, Protocol, Union, runtime_checkable
|
|
|
|
if TYPE_CHECKING:
|
|
from fastapi import FastAPI
|
|
from fastapi.routing import APIRoute
|
|
|
|
|
|
@dataclass
|
|
class OpenAPIPluginSettings:
|
|
"""Settings passed to plugins during schema generation."""
|
|
|
|
title: str
|
|
version: str
|
|
openapi_version: str
|
|
description: Optional[str] = None
|
|
routes_count: int = 0
|
|
separate_input_output_schemas: bool = True
|
|
|
|
|
|
@runtime_checkable
|
|
class OpenAPIPlugin(Protocol):
|
|
"""Protocol defining the interface for OpenAPI plugins."""
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
"""Unique identifier for the plugin."""
|
|
...
|
|
|
|
@property
|
|
def priority(self) -> int:
|
|
"""Execution priority. Lower numbers execute first. Default is 100."""
|
|
...
|
|
|
|
def pre_schema_generation(
|
|
self,
|
|
app: "FastAPI",
|
|
settings: OpenAPIPluginSettings,
|
|
) -> None:
|
|
"""Called before schema generation begins."""
|
|
...
|
|
|
|
def modify_operation(
|
|
self,
|
|
route: "APIRoute",
|
|
method: str,
|
|
operation: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
"""Called for each operation. Returns the modified operation dict."""
|
|
...
|
|
|
|
def modify_schema(
|
|
self,
|
|
schema: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
"""Called after base schema is built. Returns the modified schema."""
|
|
...
|
|
|
|
def post_schema_generation(
|
|
self,
|
|
schema: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
"""Called as the final step. Returns the final schema."""
|
|
...
|
|
|
|
|
|
class OpenAPIPluginBase(ABC):
|
|
"""Abstract base class for OpenAPI plugins with default implementations."""
|
|
|
|
@property
|
|
@abstractmethod
|
|
def name(self) -> str:
|
|
"""Unique plugin identifier."""
|
|
raise NotImplementedError
|
|
|
|
@property
|
|
def priority(self) -> int:
|
|
"""Execution priority. Override to change order."""
|
|
return 100
|
|
|
|
def pre_schema_generation(
|
|
self,
|
|
app: "FastAPI",
|
|
settings: OpenAPIPluginSettings,
|
|
) -> None:
|
|
pass
|
|
|
|
def modify_operation(
|
|
self,
|
|
route: "APIRoute",
|
|
method: str,
|
|
operation: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
return operation
|
|
|
|
def modify_schema(
|
|
self,
|
|
schema: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
return schema
|
|
|
|
def post_schema_generation(
|
|
self,
|
|
schema: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
return schema
|
|
|
|
|
|
@dataclass
|
|
class PluginState:
|
|
"""Internal state for a registered plugin."""
|
|
|
|
plugin: Union[OpenAPIPlugin, OpenAPIPluginBase]
|
|
enabled: bool = True
|
|
error_count: int = 0
|
|
last_error: Optional[Exception] = None
|
|
|
|
|
|
class PluginRegistry:
|
|
"""Thread-safe registry for managing OpenAPI plugins."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
max_errors_before_disable: int = 3,
|
|
on_cache_invalidation: Optional[callable] = None,
|
|
) -> None:
|
|
self._plugins: dict[str, PluginState] = {}
|
|
self._lock = threading.RLock()
|
|
self._max_errors = max_errors_before_disable
|
|
self._cache_invalidation_callback = on_cache_invalidation
|
|
self._generation_count = 0
|
|
|
|
def register(
|
|
self,
|
|
plugin: Union[OpenAPIPlugin, OpenAPIPluginBase],
|
|
*,
|
|
enabled: bool = True,
|
|
replace: bool = False,
|
|
) -> None:
|
|
"""Register a plugin. Raises ValueError if name exists and replace=False."""
|
|
if not hasattr(plugin, "name"):
|
|
raise TypeError(
|
|
f"Plugin must have a 'name' property, got {type(plugin).__name__}"
|
|
)
|
|
|
|
name = plugin.name
|
|
if not isinstance(name, str) or not name:
|
|
raise ValueError(f"Plugin name must be a non-empty string, got {name!r}")
|
|
|
|
with self._lock:
|
|
if name in self._plugins and not replace:
|
|
raise ValueError(
|
|
f"Plugin '{name}' is already registered. "
|
|
f"Use replace=True to override or unregister first."
|
|
)
|
|
self._plugins[name] = PluginState(plugin=plugin, enabled=enabled)
|
|
self._invalidate_cache()
|
|
|
|
def unregister(self, name: str) -> bool:
|
|
"""Remove a plugin. Returns True if removed, False if not found."""
|
|
with self._lock:
|
|
if name in self._plugins:
|
|
del self._plugins[name]
|
|
self._invalidate_cache()
|
|
return True
|
|
return False
|
|
|
|
def enable(self, name: str) -> bool:
|
|
"""Enable a plugin. Returns True if enabled, False if not found."""
|
|
with self._lock:
|
|
if name in self._plugins:
|
|
state = self._plugins[name]
|
|
if not state.enabled:
|
|
state.enabled = True
|
|
state.error_count = 0
|
|
self._invalidate_cache()
|
|
return True
|
|
return False
|
|
|
|
def disable(self, name: str) -> bool:
|
|
"""Disable a plugin. Returns True if disabled, False if not found."""
|
|
with self._lock:
|
|
if name in self._plugins:
|
|
state = self._plugins[name]
|
|
if state.enabled:
|
|
state.enabled = False
|
|
self._invalidate_cache()
|
|
return True
|
|
return False
|
|
|
|
def is_enabled(self, name: str) -> bool:
|
|
"""Check if a plugin is enabled."""
|
|
with self._lock:
|
|
state = self._plugins.get(name)
|
|
return state.enabled if state else False
|
|
|
|
def get_plugin(
|
|
self, name: str
|
|
) -> Optional[Union[OpenAPIPlugin, OpenAPIPluginBase]]:
|
|
"""Get a plugin by name."""
|
|
with self._lock:
|
|
state = self._plugins.get(name)
|
|
return state.plugin if state else None
|
|
|
|
def get_active_plugins(self) -> list[Union[OpenAPIPlugin, OpenAPIPluginBase]]:
|
|
"""Get all enabled plugins sorted by priority (lowest first)."""
|
|
with self._lock:
|
|
active = [
|
|
state.plugin for state in self._plugins.values() if state.enabled
|
|
]
|
|
return sorted(active, key=lambda p: getattr(p, "priority", 100))
|
|
|
|
def get_all_plugins(
|
|
self,
|
|
) -> dict[str, tuple[Union[OpenAPIPlugin, OpenAPIPluginBase], bool]]:
|
|
"""Get all plugins with their enabled status."""
|
|
with self._lock:
|
|
return {
|
|
name: (state.plugin, state.enabled)
|
|
for name, state in self._plugins.items()
|
|
}
|
|
|
|
def record_error(self, name: str, error: Exception) -> None:
|
|
"""Record an error. Auto-disables plugin after threshold."""
|
|
with self._lock:
|
|
state = self._plugins.get(name)
|
|
if state:
|
|
state.error_count += 1
|
|
state.last_error = error
|
|
if state.error_count >= self._max_errors:
|
|
warnings.warn(
|
|
f"OpenAPI plugin '{name}' auto-disabled after "
|
|
f"{state.error_count} errors. Last error: {error}",
|
|
stacklevel=2,
|
|
)
|
|
state.enabled = False
|
|
self._invalidate_cache()
|
|
|
|
def clear(self) -> None:
|
|
"""Remove all plugins."""
|
|
with self._lock:
|
|
self._plugins.clear()
|
|
self._invalidate_cache()
|
|
|
|
@property
|
|
def generation_count(self) -> int:
|
|
"""Counter incremented on cache invalidation."""
|
|
return self._generation_count
|
|
|
|
def _invalidate_cache(self) -> None:
|
|
self._generation_count += 1
|
|
if self._cache_invalidation_callback:
|
|
try:
|
|
self._cache_invalidation_callback()
|
|
except Exception:
|
|
pass
|
|
|
|
def __len__(self) -> int:
|
|
return len(self._plugins)
|
|
|
|
def __contains__(self, name: str) -> bool:
|
|
return name in self._plugins
|
|
|
|
|
|
class PluginExecutor:
|
|
"""Executes plugin hooks during schema generation."""
|
|
|
|
def __init__(self, registry: PluginRegistry) -> None:
|
|
self._registry = registry
|
|
|
|
def execute_pre_schema_generation(
|
|
self,
|
|
app: "FastAPI",
|
|
settings: OpenAPIPluginSettings,
|
|
) -> None:
|
|
for plugin in self._registry.get_active_plugins():
|
|
try:
|
|
if hasattr(plugin, "pre_schema_generation"):
|
|
plugin.pre_schema_generation(app, settings)
|
|
except Exception as e:
|
|
self._handle_error(plugin, "pre_schema_generation", e)
|
|
|
|
def execute_modify_operation(
|
|
self,
|
|
route: "APIRoute",
|
|
method: str,
|
|
operation: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
result = operation
|
|
for plugin in self._registry.get_active_plugins():
|
|
try:
|
|
if hasattr(plugin, "modify_operation"):
|
|
result = plugin.modify_operation(route, method, result)
|
|
if result is None:
|
|
result = operation
|
|
except Exception as e:
|
|
self._handle_error(plugin, "modify_operation", e)
|
|
return result
|
|
|
|
def execute_modify_schema(
|
|
self,
|
|
schema: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
result = schema
|
|
for plugin in self._registry.get_active_plugins():
|
|
try:
|
|
if hasattr(plugin, "modify_schema"):
|
|
result = plugin.modify_schema(result)
|
|
if result is None:
|
|
result = schema
|
|
except Exception as e:
|
|
self._handle_error(plugin, "modify_schema", e)
|
|
return result
|
|
|
|
def execute_post_schema_generation(
|
|
self,
|
|
schema: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
result = schema
|
|
for plugin in self._registry.get_active_plugins():
|
|
try:
|
|
if hasattr(plugin, "post_schema_generation"):
|
|
result = plugin.post_schema_generation(result)
|
|
if result is None:
|
|
result = schema
|
|
except Exception as e:
|
|
self._handle_error(plugin, "post_schema_generation", e)
|
|
return result
|
|
|
|
def _handle_error(self, plugin: Any, hook_name: str, error: Exception) -> None:
|
|
plugin_name = getattr(plugin, "name", type(plugin).__name__)
|
|
warnings.warn(
|
|
f"OpenAPI plugin '{plugin_name}' raised an error in {hook_name}: {error}",
|
|
stacklevel=3,
|
|
)
|
|
self._registry.record_error(plugin_name, error)
|
|
|
|
|
|
def create_plugin(
|
|
name: str,
|
|
*,
|
|
priority: int = 100,
|
|
pre_schema_generation: Optional[callable] = None,
|
|
modify_operation: Optional[callable] = None,
|
|
modify_schema: Optional[callable] = None,
|
|
post_schema_generation: Optional[callable] = None,
|
|
) -> OpenAPIPluginBase:
|
|
"""Create a plugin from callback functions."""
|
|
|
|
class CallbackPlugin(OpenAPIPluginBase):
|
|
@property
|
|
def name(self) -> str:
|
|
return name
|
|
|
|
@property
|
|
def priority(self) -> int:
|
|
return priority
|
|
|
|
plugin = CallbackPlugin()
|
|
|
|
if pre_schema_generation:
|
|
plugin.pre_schema_generation = pre_schema_generation # type: ignore
|
|
if modify_operation:
|
|
plugin.modify_operation = modify_operation # type: ignore
|
|
if modify_schema:
|
|
plugin.modify_schema = modify_schema # type: ignore
|
|
if post_schema_generation:
|
|
plugin.post_schema_generation = post_schema_generation # type: ignore
|
|
|
|
return plugin
|
|
|