Files
DriverVault/API Server/internal/plugins/README.md
T
tajniak81andClaude Opus 4.8 1e76c2b7f9 Refresh docs and fix Docker builds for current layout
READMEs: correct the auth model (PocketBase token relay, not JWT/sessions),
document the full feature set (technical checks, fuel, maintenance, documents,
reminders, attachments, integrations, OCPP charging control), the shipping
built-in connectors (toyota, anker-solix), and the current endpoint surface.

Docker: build against the current repo layout — Go 1.26, cmd/server entry
point, Web App source under web/. Add the missing Web App Dockerfile (Go BFF)
and .dockerignore, drop the obsolete AUTH_SECRET, modernise CORS var naming,
and standardise on drivervault-* naming.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 11:05:46 +02:00

12 KiB

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 plugins.json 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:

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. Part of the contract for the future; no HTTP endpoint exposes it in v1. Implement it anyway so the connector is ready.
  • Shutdown — release resources.

Descriptor & config fields

Descriptor{
    Name:         "acme",              // unique id, [a-z0-9-]
    Provider:     "ACME Corp",         // human label
    Version:      "1.0.0",
    Kind:         plugins.KindBuiltin, // or KindExternal
    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"},
    },
}

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.
  4. Rebuild the server.

Minimal example — internal/plugins/builtin/acme/acme.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. (Not yet called in v1.)
    return json.RawMessage(`{"ok":true}`), nil
}

func (p *Plugin) Shutdown(context.Context) error { return nil }

Register it for compilation — internal/plugins/builtin/builtin.go

import (
    _ "drivervault/apiserver/internal/plugins/builtin/acme"
)

Rebuild

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 two built-in connectors today — toyota (Toyota Connected / MyToyota, read-only vehicle data) and anker-solix (Anker Solix V1 EV charger) — both 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 5xxdown; 2xxok; 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

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:

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 plugins.json (gitignored; override the path with PLUGINS_FILE). Enabling calls Init; disabling calls Shutdown.
  • 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 plugins.json.

A Go unit test can exercise a built-in directly:

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:

  • Invocation API — an endpoint to call Invoke from clients, with a normalized request/response envelope and a provider→internal mapper.
  • Resilience — retry/backoff, circuit breaker, per-plugin latency/error metrics.
  • Per-tenant credentials — config keyed by org/user so users connect their own accounts.
  • Audit logging of plugin access.

Until the invocation API lands, Invoke is dormant — plugins are discoverable, configurable, and health-checked, but not yet callable over HTTP.