Apprise is a Python library that speaks 100+ notification services behind one URL grammar — mailto://, tgram://, ntfy://, discord://. None of that is portable to a server that takes no dependencies, and none of it needs to be: caronc/apprise-api wraps the library in HTTP and is meant to run as a container beside us. So the connector carries no notification protocols of its own. It posts a body to an endpoint the operator runs and lets Apprise fan it out, which is also why adding a service later costs nothing here. Targets are addressed one of two ways and configKey is the switch. Stateful means the URLs live on the Apprise server under a key, narrowed by a tag expression, and recipients are then edited there — no credential for any downstream service is ever held in DriverVault. Stateless means the URLs travel with the request, from a secret config field, which is simpler for one destination and worse for ten. A call that names its own key or urls takes that destination alone rather than merging with the configured one: honouring a caller's URLs while still falling back to the configured key would deliver the message somewhere nobody asked for. baseUrl is Required, which no other connector's address is. Toyota, Anker and Greencell leave everything blank at the global layer because the superadmin → org → user cascade exists to fill it in, and a blank there means "let the user choose". There is no cascade behind this one — a notification gateway is infrastructure the operator runs, not an account a driver owns — so nothing further down can supply the address, and a blank is simply a plugin that cannot work. Better to fail at enable than at the first notification nobody sees. Three limits are choices rather than gaps. /add and /del are not implemented: the Apprise config belongs to the operator, we post to it, and a connector that can delete a notification config has a wider blast radius than one that can only send through it. privacy=1 is forced on /json/urls rather than offered as a parameter, so a target listing reads mailto://user:****@host and downstream tokens stay on the Apprise side of the wire. Attachments are remote URLs the Apprise server fetches; multipart upload is the API's own path for files and not ours. Health follows the rule Greencell set. A reachable server whose config holds nothing to notify is degraded, not down: the half we address works and the missing half is the operator's config. Two cases earn their own line — a config key set against a server running with stateful mode disabled can never resolve, and /status answers 417 rather than 500 when Apprise finds a problem with itself, so that is a parsed answer and not a transport failure. A proxy that strips our Accept header gets the same codes back as plain text, which is read rather than called unreadable; an HTML error page from something that is not Apprise is not, and a test pins the difference. Notifications needed a category of their own, and that is the one change outside the plugin: the constant, the tab order in PluginsCard.vue, and the label in all three panel languages. The cost is now written down in the plugins README beside the Descriptor example, since the previous five categories predate anyone having to add a sixth. The plugin's tests run against an apprise-api stand-in built from that project's views.py — both notify paths, the override rules, 204-as-empty against 424-as-failure, and every health branch. builtin_test.go is the other half: the blank-import list in builtin.go is a silent failure mode, since a connector left out of it compiles, passes its own tests, and never appears in the panel. What is not covered is a live instance; there is no Docker on this machine, so the wire contract comes from reading upstream's source rather than from running it, and a smoke test against a real deployment is still worth doing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
353 lines
14 KiB
Markdown
353 lines
14 KiB
Markdown
# Building DriverVault Plugins
|
|
|
|
A **plugin** integrates an external third-party service (vehicle data, parts
|
|
catalogs, notifications, file storage, …) behind one uniform contract. There are
|
|
two kinds:
|
|
|
|
| Kind | Written as | Added by | Rebuild? | Use when |
|
|
|---|---|---|---|---|
|
|
| **built-in** | Go code in this repo | a rebuild | yes | first-party, high-trust, type-safe connectors |
|
|
| **external** | any HTTP service | registering a URL at runtime | **no** | third-party / less-trusted / independently deployed |
|
|
|
|
Both implement the same behaviour; the server treats them identically. Enable
|
|
state and per-plugin config persist to PocketBase and load on boot. Every plugin
|
|
is managed by a **superadmin** from the panel (`/`) or the `/api/admin/plugins*`
|
|
API.
|
|
|
|
---
|
|
|
|
## The contract
|
|
|
|
All plugins satisfy the Go interface in [`plugin.go`](plugin.go):
|
|
|
|
```go
|
|
type Plugin interface {
|
|
Descriptor() Descriptor
|
|
Init(ctx context.Context, config map[string]string) error
|
|
HealthCheck(ctx context.Context) Health
|
|
Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error)
|
|
Shutdown(ctx context.Context) error
|
|
}
|
|
```
|
|
|
|
- **`Descriptor`** — static metadata (name, provider, version, capabilities,
|
|
auth type, config fields). Drives the panel UI.
|
|
- **`Init`** — called with the resolved config (secrets included) whenever the
|
|
plugin is enabled or its config changes. Prepare clients/tokens here.
|
|
- **`HealthCheck`** — probe the upstream and classify: `Health{Status, LatencyMs, Detail}`
|
|
where `Status` is `StatusOK` / `StatusDegraded` / `StatusDown`.
|
|
- **`Invoke`** — run a named capability. There is no *generic* invoke endpoint yet,
|
|
but this is live: the integration routes and the vehicle-provider layer call it
|
|
through `Manager.InvokeWith` / `InvokeBatchWith`, so implement it properly. It
|
|
must be safe for concurrent use — the live instance is shared across requests,
|
|
and `InvokeBatchWith` runs a batch of actions in parallel on one instance.
|
|
- **`Shutdown`** — release resources.
|
|
|
|
### Descriptor & config fields
|
|
|
|
```go
|
|
Descriptor{
|
|
Name: "acme", // unique id, [a-z0-9-]
|
|
Provider: "ACME Corp", // human label
|
|
Version: "1.0.0",
|
|
Kind: plugins.KindBuiltin, // or KindExternal
|
|
Category: plugins.CategoryAPIsExternal, // which panel tab it lands under
|
|
Capabilities: []plugins.Capability{
|
|
{ID: "widgets.list", Method: "GET", Endpoint: "/widgets", Description: "List widgets."},
|
|
},
|
|
AuthType: plugins.AuthAPIKey, // None | APIKey | Basic | OAuth2 | Webhook (metadata only)
|
|
ConfigFields: []plugins.ConfigField{
|
|
{Key: "apiKey", Label: "API key", Type: "password", Required: true, Secret: true,
|
|
Help: "Found under ACME → Settings → API."},
|
|
{Key: "region", Label: "Region", Type: "text", Help: "e.g. eu-west-1"},
|
|
},
|
|
}
|
|
```
|
|
|
|
`Category` groups the plugin under a tab in the panel's Plugins card — one of
|
|
`CategoryVehicles`, `CategoryChargers`, `CategoryNotifications`,
|
|
`CategoryAPIsExternal`, `CategoryDrivesExternal` or `CategoryDrivesLocal`. An
|
|
empty or unrecognised value is shown under *Other APIs*, so a plugin never
|
|
disappears. Adding a **new** category means adding the constant in
|
|
[`plugin.go`](plugin.go), the tab order in `panel/src/components/PluginsCard.vue`,
|
|
and its label in all three `panel/src/i18n/*.json`.
|
|
|
|
`ConfigField.Type` is `"text"`, `"password"`, or `"number"` (form input hint).
|
|
Set **`Secret: true`** for credentials — the server never echoes them back in
|
|
clear; the panel shows a mask (`••••••••`), and on save a field left at the mask
|
|
keeps its stored value (so operators don't retype secrets). **`Required: true`**
|
|
fields must be non-empty before the plugin can be enabled.
|
|
|
|
---
|
|
|
|
## Building a built-in plugin
|
|
|
|
1. **Create a package** under `internal/plugins/builtin/<name>/`.
|
|
2. **Implement `Plugin`** and **register it in `init()`**.
|
|
3. **Blank-import** your package from [`builtin/builtin.go`](builtin/builtin.go).
|
|
4. **Rebuild** the server.
|
|
|
|
### Minimal example — `internal/plugins/builtin/acme/acme.go`
|
|
|
|
```go
|
|
package acme
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"drivervault/apiserver/internal/plugins"
|
|
)
|
|
|
|
func init() {
|
|
plugins.Register("acme", func() plugins.Plugin { return &Plugin{} })
|
|
}
|
|
|
|
type Plugin struct {
|
|
apiKey string
|
|
region string
|
|
client *http.Client
|
|
}
|
|
|
|
func (p *Plugin) Descriptor() plugins.Descriptor {
|
|
return plugins.Descriptor{
|
|
Name: "acme", Provider: "ACME Corp", Version: "1.0.0",
|
|
Kind: plugins.KindBuiltin, AuthType: plugins.AuthAPIKey,
|
|
Capabilities: []plugins.Capability{
|
|
{ID: "widgets.list", Method: "GET", Endpoint: "/widgets", Description: "List widgets."},
|
|
},
|
|
ConfigFields: []plugins.ConfigField{
|
|
{Key: "apiKey", Label: "API key", Type: "password", Required: true, Secret: true},
|
|
{Key: "region", Label: "Region", Type: "text"},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
|
|
p.apiKey = strings.TrimSpace(config["apiKey"])
|
|
p.region = strings.TrimSpace(config["region"])
|
|
p.client = &http.Client{Timeout: 10 * time.Second}
|
|
return nil
|
|
}
|
|
|
|
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
|
|
start := time.Now()
|
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.acme.example/ping", nil)
|
|
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
|
resp, err := p.client.Do(req)
|
|
lat := time.Since(start).Milliseconds()
|
|
if err != nil {
|
|
return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: err.Error()}
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
return plugins.Health{Status: plugins.StatusOK, LatencyMs: lat, Detail: "reachable"}
|
|
}
|
|
return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: "HTTP " + resp.Status}
|
|
}
|
|
|
|
func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
|
|
// Implement your capabilities; return normalized JSON. Must be safe for
|
|
// concurrent use — one instance serves many requests.
|
|
return json.RawMessage(`{"ok":true}`), nil
|
|
}
|
|
|
|
func (p *Plugin) Shutdown(context.Context) error { return nil }
|
|
```
|
|
|
|
### Register it for compilation — `internal/plugins/builtin/builtin.go`
|
|
|
|
```go
|
|
import (
|
|
_ "drivervault/apiserver/internal/plugins/builtin/acme"
|
|
)
|
|
```
|
|
|
|
### Rebuild
|
|
|
|
```powershell
|
|
cd "API Server"
|
|
go build -o bin/api-server.exe ./cmd/server
|
|
```
|
|
|
|
Restart the server. The plugin appears in the panel's **Plugins** card,
|
|
**disabled** by default.
|
|
|
|
> DriverVault ships four built-in connectors today — `toyota` (Toyota Connected /
|
|
> MyToyota, read-only vehicle data), `anker-solix` (Anker Solix V1 EV charger),
|
|
> `greencell` (Greencell HabuDen EV charger, read over the owner's MQTT broker
|
|
> rather than a cloud API) and `apprise` (notifications, through an apprise-api
|
|
> gateway the operator runs) — all blank-imported from `builtin/builtin.go`. The
|
|
> **external** kind below needs no rebuild and is the easier place to start a new
|
|
> one.
|
|
|
|
---
|
|
|
|
## Building an external plugin (no rebuild)
|
|
|
|
An external plugin is **any HTTP service** you host (Go recommended, but any
|
|
language works). You register its base URL at runtime; the server drives it over
|
|
a tiny JSON contract.
|
|
|
|
### The HTTP contract
|
|
|
|
| Method & path | Purpose | Response |
|
|
|---|---|---|
|
|
| `GET {base}/manifest` | describe the plugin (optional) | `{provider, version, capabilities, authType, configFields}` |
|
|
| `GET {base}/health` | health probe (required) | `2xx` = healthy; optional body `{status, detail}` |
|
|
| `POST {base}/invoke` | run a capability (optional; unused in v1) | `{action, params}` in → arbitrary JSON out |
|
|
|
|
Health rules the server applies: transport error or `5xx` → `down`; `2xx` → `ok`;
|
|
anything else → `degraded`. An explicit `{"status":"ok|degraded|down","detail":"…"}`
|
|
body overrides the status-code heuristic. Bodies are size-limited (health 64 KiB,
|
|
manifest 1 MiB).
|
|
|
|
### Minimal example — a Go plugin service
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
)
|
|
|
|
func main() {
|
|
http.HandleFunc("/manifest", func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"provider": "ACME Cloud",
|
|
"version": "2.1.0",
|
|
"authType": "apikey",
|
|
"capabilities": []map[string]any{
|
|
{"id": "widgets.list", "method": "GET", "endpoint": "/widgets", "description": "List widgets."},
|
|
}, // a plain []string{"widgets.list"} is also accepted
|
|
"configFields": []map[string]any{
|
|
{"key": "apiKey", "label": "API key", "type": "password", "required": true, "secret": true},
|
|
},
|
|
})
|
|
})
|
|
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(map[string]any{"status": "ok", "detail": "acme cloud reachable"})
|
|
})
|
|
http.HandleFunc("/invoke", func(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
Action string `json:"action"`
|
|
Params json.RawMessage `json:"params"`
|
|
}
|
|
json.NewDecoder(r.Body).Decode(&in)
|
|
json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": in.Action})
|
|
})
|
|
http.ListenAndServe(":9100", nil)
|
|
}
|
|
```
|
|
|
|
### Register it
|
|
|
|
From the panel's **Plugins** card → *Register external plugin* (name + base URL),
|
|
or via the API:
|
|
|
|
```bash
|
|
curl -X POST http://localhost:8080/api/admin/plugins \
|
|
-H "Authorization: $SUPERADMIN_TOKEN" -H "Content-Type: application/json" \
|
|
-d '{"name":"acme-cloud","baseURL":"http://127.0.0.1:9100","provider":"ACME Cloud"}'
|
|
```
|
|
|
|
It starts **disabled**; enable it and run a health check from the panel. Because
|
|
it runs as its own process/container, an external plugin is also the
|
|
**sandboxing** path for less-trusted integrations.
|
|
|
|
---
|
|
|
|
## Lifecycle, config & secrets
|
|
|
|
- **Enable/disable** and **config** persist to PocketBase: the `app_settings`
|
|
record keyed `global`, in its `pluginSettings` field. That is the top (L1)
|
|
layer of the integration cascade, stored the same way the org (L2) and user
|
|
(L3) layers are. Enabling calls `Init`; disabling calls `Shutdown`.
|
|
- **Before the settings have been read** — a cold database, or a service account
|
|
still to be configured — every `/api/admin/plugins*` endpoint answers **503**
|
|
and no write is accepted. The server never treats an unreachable database as
|
|
"no plugins configured", so an outage cannot quietly erase the settings; it
|
|
retries in the background until the read succeeds.
|
|
- **If the `app_settings` collection is missing** — an upgrade on a stack that
|
|
runs with `PB_BOOTSTRAP=false`, so the on-boot schema pass never created it —
|
|
the server creates that one collection itself and reads again. A missing
|
|
collection is told apart from a database that is merely unreachable, because
|
|
the remedy differs: creating collections is the wrong reflex during an outage.
|
|
- **Secrets** (`Secret: true` fields) are returned masked. On save, a field still
|
|
equal to the mask keeps its stored value; send a new value to change it, or an
|
|
empty string to clear it.
|
|
- **Required** fields are validated when enabling — enabling fails with a clear
|
|
error if one is blank.
|
|
- If `Init` fails (e.g. bad credentials), the state is still saved and the API
|
|
returns the plugin plus a `warning`; fix the config and re-save.
|
|
|
|
---
|
|
|
|
## Managing plugins (superadmin API)
|
|
|
|
All endpoints require a superadmin bearer token (`Authorization: <token>` from
|
|
`POST /api/auth/login`). See the panel's **Management API** reference too.
|
|
|
|
| Method | Path | Body | Purpose |
|
|
|---|---|---|---|
|
|
| `GET` | `/api/admin/plugins` | — | list all plugins + state + last health |
|
|
| `GET` | `/api/admin/plugins/{name}` | — | one plugin |
|
|
| `PUT` | `/api/admin/plugins/{name}` | `{enabled?, config?}` | enable/disable + configure |
|
|
| `POST` | `/api/admin/plugins` | `{name, baseURL, provider?}` | register an external plugin |
|
|
| `DELETE` | `/api/admin/plugins/{name}` | — | remove an external plugin (built-ins only disable) |
|
|
| `POST` | `/api/admin/plugins/{name}/health` | — | run a health check now |
|
|
|
|
---
|
|
|
|
## Testing your plugin
|
|
|
|
1. Build + restart (built-in) or start your service (external) and register it.
|
|
2. `GET /api/admin/plugins` → confirm your descriptor, config fields, capabilities.
|
|
3. `PUT /api/admin/plugins/{name} {"enabled":true, "config":{…}}` → enable with config.
|
|
4. `POST /api/admin/plugins/{name}/health` → confirm the live probe classifies correctly.
|
|
5. Restart the server → confirm state reloads from PocketBase.
|
|
|
|
A Go unit test can exercise a built-in directly:
|
|
|
|
```go
|
|
p := &acme.Plugin{}
|
|
_ = p.Init(context.Background(), map[string]string{"apiKey": "test"})
|
|
if h := p.HealthCheck(context.Background()); h.Status == "" {
|
|
t.Fatal("expected a health status")
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Not yet implemented (roadmap)
|
|
|
|
The contract is shaped for these; see [`doc.go`](doc.go):
|
|
|
|
- **Generic invocation API** — an endpoint to call *any* plugin's `Invoke` from a
|
|
client, with a normalized request/response envelope. The purpose-built callers
|
|
exist (`Manager.InvokeWith` / `InvokeBatchWith`, driven by the integration routes
|
|
and `internal/api/vehicleproviders.go`); what is missing is the generic route.
|
|
- **Resilience** — retry/backoff, circuit breaker, per-plugin latency/error metrics.
|
|
- **Per-tenant credentials _for arbitrary plugins_** — the three per-user
|
|
connectors already have them, through the hand-written
|
|
`/api/integrations/toyota`, `/api/integrations/anker-solix` and
|
|
`/api/integrations/greencell` routes and their **superadmin → org admin →
|
|
user** config cascade. Each is a near-copy of the last, which is the argument
|
|
for the generic version: per-org/per-user config keyed off `ConfigFields`, so a
|
|
newly registered plugin gets the same treatment without new endpoints.
|
|
`apprise` deliberately sits outside that cascade — the notification gateway is
|
|
infrastructure the operator runs, not an account a driver owns — so its config
|
|
is global only, and `baseUrl` is `Required` because nothing further down can
|
|
supply it.
|
|
- **Audit logging** of plugin access. (Charger *control* commands are already
|
|
audited to the `control_audit` collection; this is the wider plugin case.)
|
|
|
|
Until the generic invocation API lands, `Invoke` is reachable only through the
|
|
purpose-built routes: the two integrations' own endpoints, and the vehicle-provider
|
|
layer that builds a car from a manufacturer account and feeds the car's provider
|
|
tab (see `internal/api/vehicleproviders.go`).
|