-
Notifications
You must be signed in to change notification settings - Fork 0
Authoring Plugins
How to build a GDX third-party module. A plugin is a pip package that exports a
PluginManifest under the gdx.modules entry-point group. The reference plugin
gdx-plugin-example
(in the separate
gdx_dispatch_plugins
repo) exercises everything below end to end — copy it as a starting point.
gdx-plugin-foo/
pyproject.toml
gdx_plugin_foo/
__init__.py # exports `manifest`
models.py # tables on PluginBase, named plug_foo_*
router.py # APIRouter; routes relative, mounted at /api/plugins/foo
ui.py # the declarative UI manifest
[project]
name = "gdx-plugin-foo"
version = "0.1.0"
[project.entry-points."gdx.modules"]
foo = "gdx_plugin_foo:manifest"pip install makes this discoverable; the plugin-host's discover_plugins()
finds it via the gdx.modules group.
from gdx_dispatch.plugin_api import PluginManifest
from gdx_plugin_foo import models # noqa: F401 — registers tables on PluginBase
from gdx_plugin_foo.router import router
from gdx_plugin_foo.ui import UI
manifest = PluginManifest(
key="foo", # lowercase, stable; becomes the module key + /api/plugins/foo
name="Foo",
tier="professional", # starter | professional | business
requires="gdx>=1.0", # host-version gate; "" = any
router=router,
ui=UI,
)Validation is enforced at construction (key lowercase/non-empty, valid tier).
The version gate requires accepts "gdx>=X.Y[.Z]" or ""; anything else
fails closed (won't load).
The manifest also takes optional fields:
-
permissions— elevated capabilities the plugin needs, each consent-gated by the owner at install (ADR-014). Currently only"browser": the plugin-host image is browser-capable (Playwright/Chromium), so a consented plugin can drive a streamed headless browser the operator watches — e.g. to capture pricing from a site with no API. Unknown permission names fail validation. -
migrations_path— path to the plugin's Alembic version dir, if it manages schema beyondcreate_all. -
catalog_types/pricing_strategies— Catalog Pack contributions (ADR-015): catalog types shipped as data (field schema + a code-free pricing strategy) that appear in the core New Catalog dialog with no pack code needed at run time.
from sqlalchemy import Column, Integer, String
from gdx_dispatch.plugin_api.base import PluginBase
class FooItem(PluginBase): # MUST inherit PluginBase
__tablename__ = "plug_foo_items" # namespace plug_<key>_*
id = Column(Integer, primary_key=True)
company_id = Column(String(64), nullable=False, index=True) # scope to tenant
name = Column(String(200), nullable=False)The plugin-host creates these tables at boot (PluginBase.metadata.create_all).
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from gdx_dispatch.plugin_api.context import PluginContext, get_plugin_db, require_module
router = APIRouter() # mounted under /api/plugins/foo by the host
@router.get("/items")
def list_items(ctx: PluginContext = Depends(require_module("foo")),
db: Session = Depends(get_plugin_db)):
# ctx.tenant_id / user_id / role come from the core proxy's forwarded identity.
# require_module 403s unless 'foo' is enabled for the tenant.
...require_module("foo") gates the route on the tenant's enabled modules (forwarded
by the core proxy — no DB round trip). get_plugin_db is a session on the shared DB.
The host renders these declarative screens with its own PrimeVue components.
v0 vocabulary: a list screen (table over endpoint with columns) plus an
optional inline create form.
UI = {
"screens": [{
"type": "list",
"title": "Foo Items",
"endpoint": "/api/plugins/foo/items",
"columns": [{"field": "id", "label": "ID"}, {"field": "name", "label": "Name"}],
"create": {
"endpoint": "/api/plugins/foo/items",
"fields": [{"name": "name", "label": "Name", "type": "text", "required": True}],
},
}]
}UI the manifest can't express is out of scope by design — the escape hatch is contributing the capability to the host, not shipping browser JS.
discover_plugins() loads every gdx.modules entry point, keeps the ones that
return a valid PluginManifest and pass the version gate, and skips (with a log
line) — never crashes on — a plugin that errors on import, returns the wrong
type, or is incompatible. The plugin-host then creates your tables and mounts your
router under /api/plugins/<key>.
A plugin that captures items (e.g. the CHI pricing plugin scrapes a door's spec
- price) can persist them into a browsable, reusable catalog — not just an
estimate line — via the host helper
upsert_catalog_items:
from gdx_dispatch.plugin_api.catalog import upsert_catalog_items
@router.post("/capture-to-catalog")
def capture(ctx = Depends(require_module("chi")), db = Depends(get_plugin_db)):
res = upsert_catalog_items(db, target_catalog_id, [
{"sku": "CHI-2216", "name": "CHI Door 16x7", "cost": 1850,
"vendor": "CHI", "attributes": {"width": 16, "height": 7}},
], source="chi-pricing")
return {"created": res.created, "updated": res.updated}- Items land through the same pricing-strategy / vendor / custom-attribute path as the UI and CSV import — a plugin can't accidentally store retail=cost.
- Dedupes by SKU within the catalog (re-capturing updates + reprices).
-
vendordefaults tosourceso captured items are traceable to the plugin. - The
dbsession fromget_plugin_dbIS the tenant connection, so writes are tenant-scoped automatically. - Raises
CatalogUpsertErrorfor a missing/deleted/virtual catalog.
- Architecture: ADR-013
- SDK source:
gdx_dispatch/plugin_api/(catalog upsert:plugin_api/catalog.py) - Working example:
gdx-plugin-examplein thegdx_dispatch_pluginsrepo