# Plugins
A plugin
is how you make things using opa-stack.
They are just python packages or modules available to opa-stack. See general info about the plugin-system to see how it works.
# Types of plugins
Different types of plugins are initialized in different order. Example, all hooks are initialized before drivers are initialized. Drivers are done when setup is run.
# Importables
Every PLUGIN_PATHS
are loaded into pythons sys.path
and imported. Therefor, you can also have plugins that exposes functionality to other plugins.
So, if you got
/plugins/common/math_utils.py
with a functiondouble()
/plugins/secure/grade_calculations.py
that adds a route. It can usefrom math_utils import double
without problems
# Hook Definition
Hooks need to be defined before you can use them, so for this, you need a hook-definition.
A simple example looks like
from opa.core.plugin import HookDefinition
class name_hook(HookDefinition):
required = True
is_async = False
name = 'fullname'
Parameters:
- required (default: False): Set to True if you don't want the application to be able to start unless there is a hook for this definition.
- is_async (default: False): Set to True if the hook should be an async function.
- name (default: function-name): Override the function-name as the name of the hook.
# Hook
Hooks are used if you want to replace or provide a custom function for a part of your code.
Example, think of a tiny generic app like this:
from opa.core.plugin import (
get_plugin_manager,
get_router,
call_hook,
Hook
)
router = get_router()
@router.get("/get-fullname/{firstname}/{surname}")
def show_name(
firstname: str, surname: str
):
fullname = call_hook('fullname', firstname, surname)
return {'name': fullname}
It calculates fullname
based on whatever the hook called fullname
returns.
The fullname
is already defined above in HookDefinition
, so now lets define the function itself in a completly different plugin.
from opa.core.plugin import Hook
class fullname_hook(Hook):
name = 'fullname'
order = 1
def run(self, firstname, lastname):
return f'{firstname} {lastname}'
Some key takeaways:
- If the hook-definition's
is_async
is True, you will get an error if the run function is not async (async def run(...)
) call_hook
is for sync, there is also acall_hook_async
for async- Whatever arguments that the call-functions gets are what the run functions will get.
- If there are multiple hooks for a single definition, the one with the highest
order
wins (default order is 0). - The
HookDefinition
and the route that callscall_hook
are usually together in the same plugin. The hooks are normally separate plugins. - A typical usecase is to also define a
Hook
with default order of-1
togehter with theHookDefinition
, that way, your pm.call will have a default hook - There can only be 1 hook definition per
name
or you will get an error.
Now, this was a really simple example. But it can be used for many things
- Language or environment specific code (load different hook-plugins based on tags)
- A hierarcy of internal plugins, where you define some of their behavior dynamicly using a simple environment variable to choose an env
- Provide additional info if running in dev-env
- Provide a solid base, for others to built small custom plugins on
# Drivers / Optional-components
More than often, you will need a database, cache backend (redis) or another 3rd party component. You can use one of the built-in drivers, or you can make your own very easiely.
# Configuration
The default configuration loads a couple of drivers if it can find the component. That means that, if opa-stack think redis is available, it will try to connect to it. The redis drivers just uses a simple host-lookup for this check (check if redis
avalable).
All drivers are configured under the OPTIONAL_COMPONENTS
in the configuration. The default configuration is here, or see below.
docker-compose.yaml
default:
MODE: "prod"
PROJECT_NAME: "opa-stack"
PROJECT_DESCRIPTION: ""
PROJECT_VERSION: "0.1.0"
# Urls to automatic documentation. Set to null to disable
DOCS_URL: "/docs"
REDOC_URL: "/redoc"
OPENAPI_URL: "/openapi.json" # Can only be null if DOCS_URL and REDOC_URL is also null
OPENAPI_PREFIX: ""
PLUGIN_PATHS: []
PLUGIN_WHITELIST_LIST: []
PLUGIN_WHITELIST_RE: ""
PLUGIN_WHITELIST_TAGS: []
PLUGIN_BLACKLIST_LIST: []
PLUGIN_BLACKLIST_RE: ""
PLUGIN_BLACKLIST_TAGS: []
# CORS
ALLOW_ORIGINS: ["*"]
ALLOW_CREDENTIALS: true
ALLOW_METHODS: ["*"]
ALLOW_HEADERS: ["*"]
SECRET_KEY: ""
# Configuration for optional components..
# They all have a helper-file in ../utils/{component_name.lower()}.py, so check
# in them if you wonder how these values are used. They are also listed in the docs
# @ https://opa-stack.github.io/guide/components.html
#
# The LOAD option is always present, and can be one of auto|yes|no, all defaulting to 'auto'.
# * auto: Makes opa-stack look for the component using a simple dns-check or other simple checks
# Ie.. Check if we "should" be able to use this component.
# It will then try to connect as if you had written "yes", ie, it fails if it is not able to..
# * yes: Makes the coponent required
# * no: Makes it not check at all.
OPTIONAL_COMPONENTS:
MONGODB:
LOAD: "auto"
DRIVER: "mongodb-async-motor"
OPTS:
URL: "mongodb://mongo:mongo@mongo:27017/opa"
WALRUS:
LOAD: "auto"
DRIVER: "redis-walrus"
OPTS:
URL: "redis://redis"
AIOREDIS:
LOAD: "auto"
DRIVER: "redis-aioredis"
OPTS:
URL: "redis://redis"
CELERY:
LOAD: "auto"
DRIVER: "celery"
OPTS:
BACKEND_URL: "redis://redis/1"
BROKER_URL: "pyamqp://guest@rabbitmq//"
# Used different places, currently:
# * Setting FastAPI/Starlette debug-mode (https://www.starlette.io/applications/)
DEBUG: false
# Turns on better exceptions, ie, prettier, and with some more info (like variables)
BETTER_EXCEPTIONS: false
# Python Tools for Visual Studio debug server, running on port 5678
PTVSD: false
# External js files are loaded from the internet as default
SELF_HOSTED: false
dev:
MODE: "dev"
PTVSD: true
BETTER_EXCEPTIONS: true
BETTER_EXCEPTIONS_MAX_LENGTH: 1000
PLUGIN_PATHS:
- "/data/opa/demo-plugins"
- dynaconf_merge
# Simple example
myenv:
OPTIONAL_COMPONENTS:
dynaconf_merge: true
MYREDIS:
LOAD: "auto"
DRIVER: "redis-walrus"
OPTS:
URL: "redis://some-external-redis"
In the example above:
- It will use the
redis-walrus
driver - Give you an instance of it named
myredis
available in your plugins - Load if the hostname
some-external-redis
answers - Merge with the other optional-components already defined (
dynaconf_merge
)
# Using
To use an optional-component (lets say the one defined above), load it as a dependency in your route, like
@router.get("/counter")
def counter_sync():
myredis = get_instance('myredis')
counter = myredis.incr('counter')
return f'Counter is {counter}'
Note that myredis
is the component itself, not just the instance. There might be other functions available, like utility-functions.
Take a look at the optional-components reference for a list of all builtin drivers/optional-components
# Adding a driver
You can add a driver using a plugin. For probably the best example, see the driver-redis plugin at github (or below).
driver_redis.py
import logging
import aioredis as aioredislib
import walrus as walruslib
from opa import Driver
from opa.utils import host_exists
class Aioredis(Driver):
name = 'redis-aioredis'
async def connect(self):
if not host_exists(self.opts.URL, 'database-url'):
return False
self.instance = await aioredislib.create_redis_pool(self.opts.URL)
async def disconnect(self):
self.instance.close()
self.instance.wait_closed()
class Walrus(Driver):
name = 'redis-walrus'
def connect(self):
if not host_exists(self.opts.URL, 'database-url'):
return False
self.instance = walruslib.Database.from_url(self.opts.URL)
def validate(self):
self.instance.client_id()
In driver_redis you will see that a driver is just a class inherited from opa.Driver
:
pm
is the plugin-manager instance, that is available asself.pm
inside the driver-instanceopts
will be populated with the original OPTS for this driverconnect()
- Can by
async
orsync
. - Is run when we load the application.
opts
is the opts configured, exampleopts.URL
- If the configuration
LOAD
is set toauto
- This function needs to return False to signal that the pre-check (ie, hostname check) failed and it should not connect.. If it does not return False, we will validate try to validate the connection even if
LOAD=auto
- This function needs to return False to signal that the pre-check (ie, hostname check) failed and it should not connect.. If it does not return False, we will validate try to validate the connection even if
- Is responsible for setting
self.instance
.
- Can by
validate()
:- Needs to be async if connect is async
- Will run only if
LOAD
config isyes
- or if connect
did not
returnFalse
- Should raise an exception of any kind if it is not able to validate the connection.
disconnect(self)
: Not implemented yetget_instance(self)
: Normally it just returnsself.instance
, but if you want, you can override it
TIP
If you want some logic in your driver, you can use hooks
, just use scall_hook
or call_hook_async
as described above.
# API's and routes
If you want to add a route accessible via an api, there are multiple ways to do it.
Expose an APIRouter
instance named router
in your plugin is probably the easiest. It will work for most of the usecases.
from opa import get_router
router = get_router()
@router.get("/")
def root():
return 'Hello'
TIP
The get_router
function is nothing magical, it is basicly the same as fastapi.APIRouter()
.
Another way is to use the Setup
plugin. It will get the main fastapi app
object as input, which you can use to add a route or do more powerfull things.
# Metadata
A plugin can have metadata attached to it in form of a json
file.
- If it's a flat
.py
file. Create a file with-meta.json
- Exammple on
my_utils.py
you will havemy_utils-meta.json
- Exammple on
- If it is a python package, make
/meta.json
in the same folder as the package.
Currently, you can only define tags as metadata, which can be used for filtering (PLUGIN_WHITELIST_TAGS
and PLUGIN_BLACKLIST_TAGS
) which plugins to load.
Example
{
"tags": ["utils", "admin"]
}