Plugin Development
Create custom plugins to extend SmartRoute with reusable functionality like logging, validation, caching, and authorization.
Overview
Plugins in SmartRoute:
Extend behavior without modifying handler code
Per-instance state - each router gets independent plugin instances
Two hooks:
on_decore()for metadata,wrap_handler()for executionConfigurable - runtime configuration via
routedclass.configure()Composable - multiple plugins work together automatically
Inherit automatically - parent plugins apply to child routers
Built-in Plugins
SmartRoute includes two production-ready plugins:
LoggingPlugin (logging):
from smartroute import RoutedClass, Router, route
class Service(RoutedClass):
def __init__(self):
self.api = Router(self, name="api").plug("logging")
@route("api")
def process(self, data: str):
return f"processed:{data}"
svc = Service()
result = svc.api.get("process")("test") # Automatically logged
PydanticPlugin (pydantic):
class ValidatedService(RoutedClass):
def __init__(self):
self.api = Router(self, name="api").plug("pydantic")
@route("api")
def concat(self, text: str, number: int = 1) -> str:
return f"{text}:{number}"
svc = ValidatedService()
svc.api.get("concat")("hello", 3) # ✅ Valid
# svc.api.get("concat")(123, "oops") # ❌ ValidationError
See Quick Start - Plugins for more examples.
Publish-ready plugin (ecosystem)
The built-in scope/channel plugin was moved out of the core. To attach scopes and channel policies when publishing, install SmartPublisher and plug its PublishPlugin:
from smartpublisher.smartroute_plugins.publish import PublishPlugin
from smartroute import Router
router = Router(self, name="api").plug("publish") # import registers the plugin
This keeps SmartRoute lean while SmartPublisher owns the canonical scope/channel rules. Projects that do not publish externally can skip the plugin entirely.
Creating Custom Plugins
Extend BasePlugin and implement hooks. Every plugin must define two class attributes:
plugin_code- unique identifier used for registration (e.g."logging")plugin_description- human-readable description
Basic Plugin Structure
from smartroute import BasePlugin, Router, RoutedClass, route
class CapturePlugin(BasePlugin):
# Required class attributes
plugin_code = "capture"
plugin_description = "Captures handler calls for testing"
# Optional: custom instance state (use __slots__ for efficiency)
__slots__ = ("calls",)
def __init__(self, router, **config):
self.calls = []
super().__init__(router, **config)
def configure(self, enabled: bool = True):
"""Define accepted configuration parameters.
The method body can be empty - the wrapper handles storage.
Parameters become the configuration schema validated by Pydantic.
"""
pass
def on_decore(self, router, func, entry):
"""Called once when handler is registered."""
entry.metadata["capture"] = True
def wrap_handler(self, router, entry, call_next):
"""Called to build middleware chain."""
def wrapper(*args, **kwargs):
self.calls.append(entry.name)
return call_next(*args, **kwargs)
return wrapper
# Register plugin globally
Router.register_plugin(CapturePlugin)
# Use in service
class PluginService(RoutedClass):
def __init__(self):
self.api = Router(self, name="api").plug("capture")
@route("api")
def do_work(self):
return "ok"
svc = PluginService()
result = svc.api.get("do_work")()
assert svc.api.capture.calls == ["do_work"]
Constructor Signature
The constructor must accept router as first argument and **config:
def __init__(self, router, **config):
# 1. Initialize your own state FIRST
self.my_state = []
# 2. Call super().__init__ which:
# - Sets self.name = self.plugin_code
# - Stores self._router = router
# - Initializes the config store
# - Calls self.configure(**config)
super().__init__(router, **config)
Important: Initialize your state before calling super().__init__() because the parent constructor calls configure() which might need your state.
Plugin Hooks
SmartRoute plugins can override these methods:
Hook |
When Called |
Purpose |
Required |
|---|---|---|---|
|
At plugin init and runtime |
Define configuration schema |
No |
|
Handler registration |
Add metadata, validate signatures |
No |
|
Handler invocation |
Middleware (logging, auth, etc.) |
No |
|
|
Filter visible handlers |
No |
|
|
Add plugin metadata to output |
No |
All hooks are optional. Override only what you need. A minimal plugin can have just plugin_code and plugin_description with no hooks.
configure(**kwargs)
Define accepted configuration parameters. The method signature becomes the configuration schema, validated by Pydantic.
def configure(
self,
enabled: bool = True,
threshold: int = 10,
level: str = "info"
):
"""Body can be empty - the wrapper handles storage."""
pass
The wrapper added by __init_subclass__ automatically:
Parses
flagsstring (e.g."enabled,before:off") into booleansRoutes to
_target("_all_"for router-level,"handler_name"for per-handler)Validates parameters via Pydantic’s
@validate_callWrites config to the router’s store
on_decore(router, func, entry)
Called once when a handler is registered.
Parameters:
router- The Router instancefunc- The original methodentry- MethodEntry withname,func,router,plugins,metadata
Use for:
Adding metadata to handlers
Validating handler signatures
Building handler indexes
Pre-computing handler information (e.g., Pydantic models)
Example:
def on_decore(self, router, func, entry):
# Add timestamp to metadata
entry.metadata["registered_at"] = time.time()
# Validate signature
sig = inspect.signature(func)
if "user_id" not in sig.parameters:
raise ValueError(f"{entry.name} must have user_id parameter")
wrap_handler(router, entry, call_next)
Called to build the middleware chain. Return a callable that wraps call_next.
Parameters:
router- The Router instanceentry- MethodEntry for the handlercall_next- Callable to invoke next plugin or handler
Returns: Wrapper function with same signature as call_next
Use for:
Logging and monitoring
Authorization checks
Input/output transformation
Caching
Error handling
Example:
def wrap_handler(self, router, entry, call_next):
def wrapper(*args, **kwargs):
# Before handler
start = time.time()
try:
# Call handler (or next plugin)
result = call_next(*args, **kwargs)
# After handler
duration = time.time() - start
print(f"{entry.name} took {duration:.3f}s")
return result
except Exception as e:
print(f"{entry.name} failed: {e}")
raise
return wrapper
allow_entry(router, entry, **filters)
Control handler visibility during introspection (members()).
The plugin receives all filter arguments passed to members(**filters) and is responsible for:
Interpreting the filters according to its own logic
Validating filter values if needed
Comparing filters against handler metadata or configuration
Parameters:
router- The Router instanceentry- MethodEntry being checked**filters- All filter criteria passed tomembers(). The plugin decides which filters to handle and how to interpret them.
Returns: True to include, False to exclude, None to defer to other plugins
Example:
def allow_entry(self, router, entry, visibility=None, **filters):
# Plugin interprets 'visibility' filter against entry metadata
if visibility:
entry_visibility = entry.metadata.get("visibility", "public")
return entry_visibility == visibility
return None # no filter applied, defer to other plugins
entry_metadata(router, entry)
Provide plugin-specific metadata for members() output.
Parameters:
router- The Router instanceentry- MethodEntry being described
Returns: Dict stored in plugins[plugin_name]["metadata"]
Example:
def entry_metadata(self, router, entry):
cfg = self.configuration(entry.name)
return {
"enabled": cfg.get("enabled", True),
"threshold": cfg.get("threshold", 10),
}
The result appears in members() output:
{
"entries": {
"handler_name": {
"plugins": {
"my_plugin": {
"config": {"enabled": True, "threshold": 10},
"metadata": {"enabled": True, "threshold": 10}
}
}
}
}
}
Plugin Registration
Register plugins globally with Router.register_plugin():
class CustomPlugin(BasePlugin):
plugin_code = "custom"
plugin_description = "My custom plugin"
def __init__(self, router, **config):
super().__init__(router, **config)
# Register once - uses plugin_code as the name
Router.register_plugin(CustomPlugin)
# Now available in all routers
class Service(RoutedClass):
def __init__(self):
self.api = Router(self, name="api").plug("custom")
Registration rules:
Plugin class must extend
BasePluginPlugin class must define
plugin_code(used as registration name)Cannot re-register same name with different class
Registration is global across all routers
Check available plugins:
# List all registered plugins
plugins = Router.available_plugins()
assert "logging" in plugins
assert "pydantic" in plugins
assert "custom" in plugins
Per-Instance State
Each router instance gets independent plugin state:
class CapturePlugin(BasePlugin):
plugin_code = "capture"
plugin_description = "Captures handler calls"
__slots__ = ("calls",)
def __init__(self, router, **config):
self.calls = [] # Per-instance state
super().__init__(router, **config)
def wrap_handler(self, router, entry, call_next):
def wrapper(*args, **kwargs):
self.calls.append(entry.name)
return call_next(*args, **kwargs)
return wrapper
Router.register_plugin(CapturePlugin)
# Each instance is isolated
svc1 = PluginService()
svc2 = PluginService()
svc1.api.get("do_work")()
assert svc1.api.capture.calls == ["do_work"]
assert svc2.api.capture.calls == [] # Independent state
Benefits:
No global state pollution
Thread-safe by default
Independent configuration per instance
Easy testing with isolated state
Plugin Configuration
Plugins define their configuration schema via the configure() method. The configuration system provides:
Router-level defaults: Apply to all handlers
Per-handler overrides: Target specific handlers
Flags shorthand: Boolean options as comma-separated string
Pydantic validation: Type checking on all parameters
Defining Configuration
class MyPlugin(BasePlugin):
plugin_code = "my_plugin"
plugin_description = "Example plugin with configuration"
def __init__(self, router, **config):
super().__init__(router, **config)
def configure(
self,
enabled: bool = True,
level: str = "info",
threshold: int = 10
):
"""Define accepted parameters. Body can be empty."""
pass
Reading Configuration
Use configuration(method_name) to read merged config (base + per-handler):
def wrap_handler(self, router, entry, call_next):
def wrapper(*args, **kwargs):
# Get merged config for this handler
cfg = self.configuration(entry.name)
if not cfg.get("enabled", True):
return call_next(*args, **kwargs)
level = cfg.get("level", "info")
# ... use configuration
return call_next(*args, **kwargs)
return wrapper
Configuring at Runtime
# At plugin attachment (initial config)
router.plug("my_plugin", enabled=True, level="debug")
# Or via the plugin instance
router.my_plugin.configure(threshold=20)
# Per-handler config
router.my_plugin.configure(_target="critical_handler", level="error")
# Multiple handlers
router.my_plugin.configure(_target="handler1,handler2", enabled=False)
# Using flags shorthand
router.my_plugin.configure(flags="enabled,log:off")
The _target Parameter
"_all_"(default): Router-level config, applies to all handlers"handler_name": Config for specific handler only"h1,h2,h3": Apply same config to multiple handlers
The flags Parameter
Shorthand for boolean options:
# These are equivalent:
router.my_plugin.configure(enabled=True, before=True, after=False)
router.my_plugin.configure(flags="enabled,before,after:off")
Format: "flag1,flag2:off,flag3:on" - bare names are True, :off is False.
Best Practices
Single responsibility:
# ✅ Good: One plugin, one concern
class LoggingPlugin(BasePlugin): ...
class ValidationPlugin(BasePlugin): ...
class CachingPlugin(BasePlugin): ...
# ❌ Bad: One plugin doing everything
class EverythingPlugin(BasePlugin): ...
Composition over complexity:
# ✅ Good: Multiple simple plugins
self.api = Router(self, name="api")\
.plug("logging")\
.plug("pydantic")\
.plug("caching")\
.plug("auth")
# ❌ Bad: One complex plugin
self.api = Router(self, name="api").plug("monolith")
Configuration defaults:
# ✅ Good: Sensible defaults in configure() signature
def configure(
self,
enabled: bool = True, # Enabled by default
level: str = "info", # Reasonable default
strict: bool = False # Permissive by default
):
pass
Error handling:
def wrap_handler(self, router, entry, call_next):
def wrapper(*args, **kwargs):
try:
return call_next(*args, **kwargs)
except Exception as e:
# Log error but don't suppress unless configured
cfg = self.configuration(entry.name)
if cfg.get("suppress_errors", False):
return None
raise
return wrapper
Next Steps
Plugin Configuration - Configure plugins at runtime
Built-in Plugins API - LoggingPlugin and PydanticPlugin reference
Hierarchies - Plugin inheritance in hierarchies
API Reference - Complete API documentation