# 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.pywith a functiondouble()/plugins/secure/grade_calculations.pythat adds a route. It can usefrom math_utils import doublewithout 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_asyncis True, you will get an error if the run function is not async (async def run(...)) call_hookis for sync, there is also acall_hook_asyncfor 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
orderwins (default order is 0). - The
HookDefinitionand the route that callscall_hookare usually together in the same plugin. The hooks are normally separate plugins. - A typical usecase is to also define a
Hookwith default order of-1togehter with theHookDefinition, that way, your pm.call will have a default hook - There can only be 1 hook definition per
nameor 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-walrusdriver - Give you an instance of it named
myredisavailable in your plugins - Load if the hostname
some-external-redisanswers - 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:
pmis the plugin-manager instance, that is available asself.pminside the driver-instanceoptswill be populated with the original OPTS for this driverconnect()- Can by
asyncorsync. - Is run when we load the application.
optsis the opts configured, exampleopts.URL- If the configuration
LOADis 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
LOADconfig isyes- or if connect
did notreturnFalse
- 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
.pyfile. Create a file with-meta.json- Exammple on
my_utils.pyyou will havemy_utils-meta.json
- Exammple on
- If it is a python package, make
/meta.jsonin 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"]
}