Cars: create a car from a manufacturer service, with a per-car data tab
A car can now be imported straight from the account its owner already has
with the manufacturer, and every reading that service exposes shows up on
the car's own tab. MyToyota is the first provider.
API Server — internal/api/vehicleproviders.go adds a generic layer over a
plugin that can enumerate vehicles and read data about them. Adding the
next manufacturer is one vehicleSource adapter plus a line in
vehicleSources(): no new endpoints, no Web App changes.
GET /api/vehicle-providers providers + connect state
GET /api/vehicle-providers/{p}/vehicles the caller's vehicles
POST /api/vehicle-providers/{p}/import create a car from one
GET /api/cars/{id}/provider live snapshot for the tab
POST /api/cars/{id}/provider link / unlink a car
POST /api/cars/{id}/provider/sync re-apply provider data
Two properties shape it. Credentials are always the caller's own, resolved
through the same global -> org -> user cascade as the integration settings,
so a shared car shows provider data only when that vehicle is on the
viewer's account — the owner's credentials are never borrowed. And upstream
shapes are not modelled: these are unofficial APIs, so the layer searches
payloads by key name for the readings worth promoting (odometer, fuel,
battery, range) and flattens the rest to dotted key/value pairs alongside
the raw JSON. A renamed field costs one blank value, not a broken page.
The Toyota gate and its wording now live in toyotaSource, so the older
/api/integrations/toyota/vehicles endpoint and the new ones cannot drift.
Manager.InvokeBatchWith shares one transient plugin instance across a batch
of actions. The tab pulls seven capabilities, and InvokeWith builds a fresh
instance per call — which for a connector that authenticates lazily means a
fresh OAuth login per call. Batching logs in once.
cars gains provider + provider_vehicle_id (schema.go and
setup-pocketbase.mjs both). carPayload deliberately omits them, so an
ordinary car edit can neither reassign the car nor break its link;
carProviderPayload writes the link on its own.
Web App — Dashboard grows an "import from service" button beside "add car",
shown only once an account is connected, opening CarImportModal: pick the
vehicle, choose what to pull (identity / fuel type / dates / odometer, all
on by default), import. ProviderPanel becomes the car's first tab, ahead of
Information, labelled with the service: headline readings, the vehicle
record, one card per capability with its raw response, and an offer to take
the provider's odometer when it is ahead of the stored one. On an unlinked
car the tab instead offers to link it, VIN-matched. Info stays the default
selection — landing on the provider tab would fire a login on every car
page view. Full en/pl/da translations.
Tests cover the payload walking, Toyota normalization, import-selection
defaults, and — through the real handler chain against a stand-in
PocketBase — that every route is registered and that a closed gate is soft
on a listing (200 + a reason the UI can show) but hard on a write (4xx, so
a caller cannot read the reply as a created car).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
47a9aef466
commit
358ee68f94
+36
-1
@@ -165,9 +165,16 @@ DELETE /api/integrations/anker-solix/chargers/{sn}/control/token
|
||||
POST /api/integrations/anker-solix/chargers/{sn}/{action}
|
||||
GET /ocpp/{serial} # charger dials in here (OCPP Basic auth, not bearer)
|
||||
|
||||
# vehicle providers — create a car from a manufacturer service; per-car provider tab
|
||||
GET /api/vehicle-providers
|
||||
GET /api/vehicle-providers/{provider}/vehicles
|
||||
POST /api/vehicle-providers/{provider}/import
|
||||
|
||||
# cars + sharing
|
||||
GET /api/cars POST /api/cars
|
||||
GET /api/cars/{id} PATCH /api/cars/{id} DELETE /api/cars/{id}
|
||||
GET /api/cars/{id}/provider POST /api/cars/{id}/provider
|
||||
POST /api/cars/{id}/provider/sync
|
||||
GET /api/cars/{id}/service-records GET /api/cars/{id}/technical-checks
|
||||
GET /api/cars/{id}/parts GET /api/cars/{id}/fuel-entries GET /api/cars/{id}/fuel-stats
|
||||
GET /api/cars/{id}/maintenance GET /api/cars/{id}/documents GET /api/cars/{id}/reminders
|
||||
@@ -190,7 +197,35 @@ annotated with an `access` field. The per-record list endpoints also accept a
|
||||
> **Gotcha:** `updateCar` rewrites **all** car columns from the payload, so a
|
||||
> `PATCH /api/cars/{id}` must send the **full** car object — omitted spec fields
|
||||
> get blanked. (The phone's odometer quick-edit sends the whole car for this
|
||||
> reason.)
|
||||
> reason.) The two exceptions are `owner` and the provider link (`provider`,
|
||||
> `provider_vehicle_id`), which `carPayload` deliberately leaves out so an
|
||||
> ordinary edit can neither reassign the car nor break its connected service.
|
||||
|
||||
### Vehicle providers
|
||||
|
||||
`internal/api/vehicleproviders.go` turns a manufacturer-service plugin into a car
|
||||
you can create from your own account with that service, plus a per-car tab showing
|
||||
everything the service currently knows about it. Toyota (MyToyota) is the first
|
||||
provider; adding the next one means writing a `vehicleSource` adapter and
|
||||
appending it to `vehicleSources()` — no new endpoints and no Web App changes.
|
||||
|
||||
Two properties shape the design:
|
||||
|
||||
- **Credentials are always the caller's.** Every provider call resolves through
|
||||
the same global → org → user cascade as the integration settings, so a car
|
||||
shared with someone else shows them provider data only when that vehicle is on
|
||||
*their* manufacturer account. The owner's credentials are never borrowed.
|
||||
- **Upstream shapes are not modelled.** These are unofficial APIs. Rather than
|
||||
hard-coding field paths, the layer searches payloads by key name for the handful
|
||||
of readings worth promoting (odometer, fuel, battery, range) and flattens the
|
||||
rest to dotted key/value pairs, shipping the raw payload alongside. A renamed
|
||||
field costs one blank value instead of a broken page.
|
||||
|
||||
`POST .../import` takes `{vehicleId, name?, include?}`, where `include` selects
|
||||
which groups to pull (`identity`, `fuelType`, `dates`, `odometer`). Omitting it
|
||||
means "everything available". `POST /api/cars/{id}/provider/sync` takes the same
|
||||
selection, and only ever moves the odometer forward — a reading that appears to go
|
||||
backwards is a stale provider, not a car driven in reverse.
|
||||
|
||||
## The panel (`/`)
|
||||
|
||||
|
||||
@@ -461,11 +461,16 @@ func (s *Server) handleToyotaHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"health": h})
|
||||
}
|
||||
|
||||
// GET /api/integrations/toyota/vehicles — the caller's Toyota vehicles, fetched
|
||||
// server-side under their resolved credentials. Gated by the same switches as
|
||||
// the settings view (global master, org gate, personal opt-in, credentials
|
||||
// present); when any gate is off it returns 200 with an empty list plus a
|
||||
// reason, so the UI can degrade quietly rather than error.
|
||||
// GET /api/integrations/toyota/vehicles — the caller's Toyota vehicles as the
|
||||
// upstream returned them, fetched server-side under their resolved credentials.
|
||||
// Gated by the same switches as the settings view (global master, org gate,
|
||||
// personal opt-in, credentials present); when any gate is off it returns 200 with
|
||||
// an empty list plus a reason, so the UI can degrade quietly rather than error.
|
||||
//
|
||||
// The gate and its wording live in toyotaSource (vehicleproviders_toyota.go), so
|
||||
// this endpoint and the generic vehicle-provider endpoints cannot drift apart.
|
||||
// This one stays because it relays the raw payload; /api/vehicle-providers/
|
||||
// toyota/vehicles returns the normalized, importable shape.
|
||||
func (s *Server) handleToyotaVehicles(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
if who == nil {
|
||||
@@ -473,31 +478,12 @@ func (s *Server) handleToyotaVehicles(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
userRaw := s.userPluginSettings(r.Context(), who.ID)
|
||||
res := s.resolveToyota(r.Context(), who, userRaw)
|
||||
|
||||
unavailable := func(detail string) {
|
||||
cfg, ok, detail := toyotaSource{}.gate(r.Context(), s, who, userRaw)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"vehicles": []any{}, "unavailable": true, "detail": detail})
|
||||
}
|
||||
switch {
|
||||
case !res.available:
|
||||
unavailable("The Toyota integration is disabled by the administrator")
|
||||
return
|
||||
case !res.orgEnabled:
|
||||
unavailable("The Toyota integration is disabled for your organization")
|
||||
return
|
||||
case !res.enabled:
|
||||
unavailable("Enable the Toyota integration in Settings to load your vehicles")
|
||||
return
|
||||
case strings.TrimSpace(res.eff.Username) == "" || strings.TrimSpace(res.eff.Password) == "":
|
||||
unavailable("Enter your MyToyota email and password to connect")
|
||||
return
|
||||
}
|
||||
|
||||
cfg := map[string]string{
|
||||
"username": res.eff.Username,
|
||||
"password": res.eff.Password,
|
||||
"brand": res.eff.Brand,
|
||||
}
|
||||
raw, err := s.plugins.InvokeWith(r.Context(), toyotaPlugin, cfg, "vehicles", nil)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
|
||||
|
||||
@@ -61,6 +61,8 @@ type carRecord struct {
|
||||
FuelType string `json:"fuel_type"`
|
||||
BuildDate string `json:"build_date"`
|
||||
FirstRegistrationDate string `json:"first_registration_date"`
|
||||
Provider string `json:"provider"`
|
||||
ProviderVehicleID string `json:"provider_vehicle_id"`
|
||||
Owner string `json:"owner"`
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
@@ -88,13 +90,18 @@ func (rec carRecord) toModel() models.Car {
|
||||
FuelType: rec.FuelType,
|
||||
BuildDate: rec.BuildDate,
|
||||
FirstRegistrationDate: rec.FirstRegistrationDate,
|
||||
Provider: rec.Provider,
|
||||
ProviderVehicleID: rec.ProviderVehicleID,
|
||||
Owner: rec.Owner,
|
||||
Created: rec.Created,
|
||||
Updated: rec.Updated,
|
||||
}
|
||||
}
|
||||
|
||||
// carPayload builds the write payload for create/update from a domain Car.
|
||||
// carPayload builds the write payload for create/update from a domain Car. It
|
||||
// deliberately omits owner and the provider link: a car edit must not reassign
|
||||
// ownership, and it must not touch the connected-service link either (that is
|
||||
// carProviderPayload's job, reached only through the provider endpoints).
|
||||
func carPayload(c models.Car) map[string]any {
|
||||
return map[string]any{
|
||||
"name": c.Name,
|
||||
@@ -119,6 +126,16 @@ func carPayload(c models.Car) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
// carProviderPayload is the connected-service link on its own, so linking and
|
||||
// unlinking is a one-field write that leaves the rest of the car alone. An empty
|
||||
// provider clears both fields (unlink).
|
||||
func carProviderPayload(provider, vehicleID string) map[string]any {
|
||||
if provider == "" {
|
||||
return map[string]any{"provider": "", "provider_vehicle_id": ""}
|
||||
}
|
||||
return map[string]any{"provider": provider, "provider_vehicle_id": vehicleID}
|
||||
}
|
||||
|
||||
// --- service records ---
|
||||
|
||||
type serviceRecord struct {
|
||||
|
||||
@@ -51,9 +51,16 @@
|
||||
// POST /api/integrations/anker-solix/health
|
||||
// GET /api/integrations/anker-solix/chargers
|
||||
//
|
||||
// # vehicle providers (create a car from a manufacturer service; per-car tab)
|
||||
// GET /api/vehicle-providers
|
||||
// GET /api/vehicle-providers/{provider}/vehicles
|
||||
// POST /api/vehicle-providers/{provider}/import
|
||||
//
|
||||
// # cars, service records, parts, shares
|
||||
// GET /api/cars POST /api/cars
|
||||
// GET /api/cars/{id} PATCH /api/cars/{id} DELETE /api/cars/{id}
|
||||
// GET /api/cars/{id}/provider POST /api/cars/{id}/provider
|
||||
// POST /api/cars/{id}/provider/sync
|
||||
// GET /api/cars/{id}/service-records
|
||||
// GET /api/cars/{id}/parts
|
||||
// GET /api/cars/{id}/shares POST /api/cars/{id}/shares
|
||||
@@ -330,6 +337,13 @@ func (s *Server) Handler() http.Handler {
|
||||
// OCPP Basic auth (serial + per-charger control token) instead.
|
||||
mux.HandleFunc("GET /ocpp/{serial}", s.handleOCPPConnect)
|
||||
|
||||
// Vehicle providers — manufacturer services a car can be created from, and
|
||||
// the per-car provider tab. Generic over the registered providers; see
|
||||
// vehicleproviders.go.
|
||||
mux.HandleFunc("GET /api/vehicle-providers", s.handleListVehicleProviders)
|
||||
mux.HandleFunc("GET /api/vehicle-providers/{provider}/vehicles", s.handleProviderVehicles)
|
||||
mux.HandleFunc("POST /api/vehicle-providers/{provider}/import", s.handleProviderImport)
|
||||
|
||||
// Cars + sharing.
|
||||
mux.HandleFunc("GET /api/cars", s.listCars)
|
||||
mux.HandleFunc("POST /api/cars", s.createCar)
|
||||
@@ -344,6 +358,9 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/cars/{id}/maintenance", s.listCarMaintenance)
|
||||
mux.HandleFunc("GET /api/cars/{id}/documents", s.listCarDocuments)
|
||||
mux.HandleFunc("GET /api/cars/{id}/reminders", s.listCarReminders)
|
||||
mux.HandleFunc("GET /api/cars/{id}/provider", s.handleCarProvider)
|
||||
mux.HandleFunc("POST /api/cars/{id}/provider", s.handleLinkCarProvider)
|
||||
mux.HandleFunc("POST /api/cars/{id}/provider/sync", s.handleSyncCarProvider)
|
||||
mux.HandleFunc("GET /api/cars/{id}/shares", s.handleListShares)
|
||||
mux.HandleFunc("POST /api/cars/{id}/shares", s.handleUpsertShare)
|
||||
mux.HandleFunc("DELETE /api/cars/{id}/shares/{userId}", s.handleDeleteShare)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,625 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"drivervault/apiserver/internal/config"
|
||||
"drivervault/apiserver/internal/pb"
|
||||
)
|
||||
|
||||
// The vehicle-provider layer deliberately searches payloads by key name instead
|
||||
// of by path, so these tests pin the behaviour that makes that safe: top-level
|
||||
// keys win over nested ones, the three numeric shapes are all understood, and an
|
||||
// unrecognized value is dropped rather than guessed at.
|
||||
|
||||
func decode(t *testing.T, s string) any {
|
||||
t.Helper()
|
||||
var v any
|
||||
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||
t.Fatalf("bad test JSON: %v", err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func TestFindNodePrefersShallowKeys(t *testing.T) {
|
||||
// "model" appears at the top level and again inside a nested dealer record.
|
||||
// The shallow one has to win, or an import picks up the dealer's data.
|
||||
tree := decode(t, `{
|
||||
"model": "Yaris",
|
||||
"dealer": {"model": "Corolla", "name": "City Toyota"}
|
||||
}`)
|
||||
if got := findString(tree, "model"); got != "Yaris" {
|
||||
t.Fatalf("model = %q, want Yaris", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindNodeDescendsWhenAbsentAtTop(t *testing.T) {
|
||||
tree := decode(t, `{"payload": {"vehicle": {"vin": "VIN123"}}}`)
|
||||
if got := findString(tree, "vin"); got != "VIN123" {
|
||||
t.Fatalf("vin = %q, want VIN123", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindNodeMissingKey(t *testing.T) {
|
||||
tree := decode(t, `{"a": 1}`)
|
||||
if got := findString(tree, "vin"); got != "" {
|
||||
t.Fatalf("expected empty string for a missing key, got %q", got)
|
||||
}
|
||||
if _, _, ok := findMeasure(tree, "odometer"); ok {
|
||||
t.Error("expected findMeasure to report not-found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindMeasureShapes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
json string
|
||||
value float64
|
||||
unit string
|
||||
}{
|
||||
{"bare number", `{"fuelLevel": 62}`, 62, ""},
|
||||
{"numeric string", `{"fuelLevel": "62"}`, 62, ""},
|
||||
{"value+unit object", `{"odometer": {"value": 270185, "unit": "km"}}`, 270185, "km"},
|
||||
{"snake case key", `{"fuel_level": 40}`, 40, ""},
|
||||
{"screaming key", `{"FUEL-LEVEL": 40}`, 40, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
n, unit, ok := findMeasure(decode(t, tc.json), "fuelLevel", "odometer")
|
||||
if !ok {
|
||||
t.Fatal("expected a measure")
|
||||
}
|
||||
if n != tc.value {
|
||||
t.Errorf("value = %v, want %v", n, tc.value)
|
||||
}
|
||||
if unit != tc.unit {
|
||||
t.Errorf("unit = %q, want %q", unit, tc.unit)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOdometerKmConvertsMiles(t *testing.T) {
|
||||
km, ok := odometerKm(decode(t, `{"odometer": {"value": 100, "unit": "mi"}}`))
|
||||
if !ok {
|
||||
t.Fatal("expected an odometer reading")
|
||||
}
|
||||
if km != 161 {
|
||||
t.Fatalf("km = %d, want 161 (100 mi)", km)
|
||||
}
|
||||
|
||||
km, ok = odometerKm(decode(t, `{"odometer": {"value": 270185, "unit": "km"}}`))
|
||||
if !ok || km != 270185 {
|
||||
t.Fatalf("km = %d (ok=%v), want 270185", km, ok)
|
||||
}
|
||||
|
||||
// A zero or absent odometer is "no reading", not a reading of zero — it must
|
||||
// never overwrite a real stored value with 0.
|
||||
if _, ok := odometerKm(decode(t, `{"odometer": {"value": 0, "unit": "km"}}`)); ok {
|
||||
t.Error("a zero odometer should not count as a reading")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenJSON(t *testing.T) {
|
||||
fields, truncated := flattenJSON(decode(t, `{
|
||||
"payload": {
|
||||
"odometer": {"value": 270185, "unit": "km"},
|
||||
"nothing": null,
|
||||
"blank": " ",
|
||||
"flag": true,
|
||||
"warnings": ["oil", "tyre"]
|
||||
}
|
||||
}`), 100)
|
||||
if truncated {
|
||||
t.Error("should not be truncated")
|
||||
}
|
||||
|
||||
got := map[string]string{}
|
||||
for _, f := range fields {
|
||||
got[f.Key] = f.Value
|
||||
}
|
||||
want := map[string]string{
|
||||
"payload.odometer.value": "270185",
|
||||
"payload.odometer.unit": "km",
|
||||
"payload.flag": "true",
|
||||
"payload.warnings[0]": "oil",
|
||||
"payload.warnings[1]": "tyre",
|
||||
}
|
||||
for k, v := range want {
|
||||
if got[k] != v {
|
||||
t.Errorf("field %q = %q, want %q", k, got[k], v)
|
||||
}
|
||||
}
|
||||
// Nulls and whitespace-only strings are omitted: they are the provider having
|
||||
// no value, and rendering them as rows would be noise.
|
||||
for _, k := range []string{"payload.nothing", "payload.blank"} {
|
||||
if _, present := got[k]; present {
|
||||
t.Errorf("field %q should have been omitted", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenJSONTruncates(t *testing.T) {
|
||||
fields, truncated := flattenJSON(decode(t, `{"a":1,"b":2,"c":3,"d":4}`), 2)
|
||||
if !truncated {
|
||||
t.Error("expected truncated=true")
|
||||
}
|
||||
if len(fields) != 2 {
|
||||
t.Fatalf("len(fields) = %d, want 2", len(fields))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlineMetrics(t *testing.T) {
|
||||
trees := []any{
|
||||
decode(t, `{"payload": {"odometer": {"value": 270185, "unit": "km"}, "fuelLevel": 62}}`),
|
||||
decode(t, `{"payload": {"batteryLevel": 80, "chargingStatus": "charging"}}`),
|
||||
decode(t, `{"payload": {"vehicleLocation": {"latitude": 52.2297, "longitude": 21.0122}}}`),
|
||||
}
|
||||
got := map[string]providerMetric{}
|
||||
for _, m := range headlineMetrics(trees) {
|
||||
got[m.Key] = m
|
||||
}
|
||||
|
||||
if got["odometer"].Value != "270185" || got["odometer"].Unit != "km" {
|
||||
t.Errorf("odometer = %+v", got["odometer"])
|
||||
}
|
||||
if got["fuelLevel"].Value != "62" || got["fuelLevel"].Unit != "%" {
|
||||
t.Errorf("fuelLevel = %+v", got["fuelLevel"])
|
||||
}
|
||||
if got["batteryLevel"].Value != "80" {
|
||||
t.Errorf("batteryLevel = %+v", got["batteryLevel"])
|
||||
}
|
||||
if got["chargingStatus"].Value != "charging" {
|
||||
t.Errorf("chargingStatus = %+v", got["chargingStatus"])
|
||||
}
|
||||
if got["location"].Value != "52.22970, 21.01220" {
|
||||
t.Errorf("location = %+v", got["location"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProviderFuelType(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"HV": "hybrid",
|
||||
"hybrid": "hybrid",
|
||||
"PETROL_HYBRID": "hybrid", // hybrid wins over the petrol substring
|
||||
"phev": "hybrid",
|
||||
"Diesel": "diesel",
|
||||
"BEV": "electric",
|
||||
"Battery Electric": "electric",
|
||||
"gasoline": "petrol",
|
||||
"FCEV": "hydrogen",
|
||||
"": "",
|
||||
"something else": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeProviderFuelType(in); got != want {
|
||||
t.Errorf("normalizeProviderFuelType(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestISODateOnly(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"2015-06-01": "2015-06-01",
|
||||
"2015-06-01T00:00:00Z": "2015-06-01",
|
||||
// Ambiguous formats are dropped rather than guessed: 01/06/2015 is June
|
||||
// or January depending on who wrote it.
|
||||
"01/06/2015": "",
|
||||
"2015-06": "",
|
||||
"": "",
|
||||
"not a date": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := isoDateOnly(in); got != want {
|
||||
t.Errorf("isoDateOnly(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToyotaVehiclesNormalizes(t *testing.T) {
|
||||
raw := json.RawMessage(`{"payload": [{
|
||||
"vin": "JTDKB20U000000001",
|
||||
"alias": "Daily driver",
|
||||
"brand": "T",
|
||||
"modelName": "Yaris",
|
||||
"modelYear": 2015,
|
||||
"licensePlate": "ABC 1234",
|
||||
"fuelType": "HV",
|
||||
"firstRegistrationDate": "2015-06-01T00:00:00Z",
|
||||
"imageUrl": "https://example.invalid/yaris.png"
|
||||
}]}`)
|
||||
|
||||
got := toyotaSource{}.vehicles(raw)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(got))
|
||||
}
|
||||
v := got[0]
|
||||
if v.ID != "JTDKB20U000000001" || v.VIN != v.ID {
|
||||
t.Errorf("id/vin = %q/%q", v.ID, v.VIN)
|
||||
}
|
||||
if v.Name != "Daily driver" {
|
||||
t.Errorf("name = %q, want the owner's alias", v.Name)
|
||||
}
|
||||
if v.Make != "Toyota" || v.Model != "Yaris" || v.Year != 2015 {
|
||||
t.Errorf("make/model/year = %q/%q/%d", v.Make, v.Model, v.Year)
|
||||
}
|
||||
if v.Registration != "ABC 1234" {
|
||||
t.Errorf("registration = %q", v.Registration)
|
||||
}
|
||||
if v.FuelType != "hybrid" {
|
||||
t.Errorf("fuelType = %q, want hybrid", v.FuelType)
|
||||
}
|
||||
if v.FirstRegistrationDate != "2015-06-01" {
|
||||
t.Errorf("firstRegistrationDate = %q", v.FirstRegistrationDate)
|
||||
}
|
||||
if len(v.Fields) == 0 || len(v.Raw) == 0 {
|
||||
t.Error("the whole upstream object should still reach the UI via Fields/Raw")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToyotaVehiclesFallbacks(t *testing.T) {
|
||||
// No alias and no VIN: the name falls back to make + model and the id to
|
||||
// whatever other identifier the payload carries.
|
||||
got := toyotaSource{}.vehicles(json.RawMessage(`[{"guid": "abc", "brand": "L", "modelName": "IS 300h"}]`))
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(got))
|
||||
}
|
||||
if got[0].ID != "abc" {
|
||||
t.Errorf("id = %q, want abc", got[0].ID)
|
||||
}
|
||||
if got[0].Name != "Lexus IS 300h" {
|
||||
t.Errorf("name = %q, want Lexus IS 300h", got[0].Name)
|
||||
}
|
||||
|
||||
// A vehicle with nothing to address it by is skipped, not imported blind.
|
||||
anonymous := toyotaSource{}.vehicles(json.RawMessage(`{"payload": [{"colour": "blue"}]}`))
|
||||
if len(anonymous) != 0 {
|
||||
t.Errorf("expected an unidentifiable vehicle to be skipped, got %d", len(anonymous))
|
||||
}
|
||||
malformed := toyotaSource{}.vehicles(json.RawMessage(`not json`))
|
||||
if len(malformed) != 0 {
|
||||
t.Errorf("expected no vehicles from malformed JSON, got %d", len(malformed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSelectionDefaultsToEverything(t *testing.T) {
|
||||
// The plain request {"vehicleId": …} must mean "fetch everything you can".
|
||||
all := (*importSelection)(nil).resolve()
|
||||
if !all.identity || !all.fuelType || !all.dates || !all.odometer {
|
||||
t.Fatalf("nil selection = %+v, want everything on", all)
|
||||
}
|
||||
|
||||
no := false
|
||||
partial := (&importSelection{Odometer: &no}).resolve()
|
||||
if partial.odometer {
|
||||
t.Error("odometer should be off")
|
||||
}
|
||||
if !partial.identity || !partial.fuelType || !partial.dates {
|
||||
t.Errorf("unspecified groups should stay on, got %+v", partial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyVehicleToCarSkipsBlanks(t *testing.T) {
|
||||
car := carRecord{Make: "Toyota", Model: "Yaris", Registration: "OLD 111"}.toModel()
|
||||
// The provider reports a model but no registration: the stored plate must
|
||||
// survive rather than being blanked by an absent upstream field.
|
||||
applyVehicleToCar(&car, providerVehicle{Model: "Yaris Hybrid"}, resolvedSelection{identity: true})
|
||||
if car.Model != "Yaris Hybrid" {
|
||||
t.Errorf("model = %q, want the provider value", car.Model)
|
||||
}
|
||||
if car.Registration != "OLD 111" {
|
||||
t.Errorf("registration = %q, want the stored value kept", car.Registration)
|
||||
}
|
||||
|
||||
// A group that was not selected is not written at all.
|
||||
car2 := carRecord{FuelType: "petrol"}.toModel()
|
||||
applyVehicleToCar(&car2, providerVehicle{FuelType: "hybrid"}, resolvedSelection{identity: true})
|
||||
if car2.FuelType != "petrol" {
|
||||
t.Errorf("fuelType = %q, want petrol (group not selected)", car2.FuelType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindVehicleMatchesIDOrVIN(t *testing.T) {
|
||||
vehicles := []providerVehicle{{ID: "abc", VIN: "VIN1"}, {ID: "VIN2", VIN: "VIN2"}}
|
||||
if v, ok := findVehicle(vehicles, "vin1"); !ok || v.ID != "abc" {
|
||||
t.Error("expected a case-insensitive VIN match")
|
||||
}
|
||||
if v, ok := findVehicle(vehicles, "VIN2"); !ok || v.ID != "VIN2" {
|
||||
t.Error("expected an id match")
|
||||
}
|
||||
if _, ok := findVehicle(vehicles, ""); ok {
|
||||
t.Error("an empty id must not match the first vehicle")
|
||||
}
|
||||
if _, ok := findVehicle(vehicles, "nope"); ok {
|
||||
t.Error("unexpected match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleSourceRegistry(t *testing.T) {
|
||||
src, ok := vehicleSourceByID("toyota")
|
||||
if !ok {
|
||||
t.Fatal("toyota should be a registered vehicle source")
|
||||
}
|
||||
if src.label() != "MyToyota" {
|
||||
t.Errorf("label = %q, want MyToyota", src.label())
|
||||
}
|
||||
if len(src.sections()) == 0 {
|
||||
t.Error("expected per-vehicle sections")
|
||||
}
|
||||
if _, ok := vehicleSourceByID("ford"); ok {
|
||||
t.Error("unregistered provider should not resolve")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- routing + gating, through the real Handler ------------------------------
|
||||
//
|
||||
// These go through the whole middleware chain against a stand-in PocketBase, with
|
||||
// the Toyota plugin left disabled. That covers two things a unit test of the
|
||||
// helpers cannot: that every route is actually registered under the pattern the
|
||||
// clients call, and that a closed gate is reported the way each kind of endpoint
|
||||
// needs — a listing answers 200 with a reason so the UI can say "connect this in
|
||||
// Settings", while a write answers 4xx so a caller can never read the reply as a
|
||||
// successful import.
|
||||
//
|
||||
// Note an unauthenticated probe proves neither: withAuth wraps the mux, so every
|
||||
// path — registered or not — answers 401 before routing happens. Hence the bearer.
|
||||
|
||||
const provCarID = "car-1"
|
||||
|
||||
// fakeProviderPB answers the identity, user-record and car-record calls these
|
||||
// endpoints make, and records the last car PATCH so a test can assert what was
|
||||
// written. Nothing else is needed: the caller owns the car, so the access check
|
||||
// never consults car_shares.
|
||||
type fakeProviderPB struct {
|
||||
carProvider, carVehicleID string
|
||||
mu sync.Mutex
|
||||
lastCarPatch map[string]any
|
||||
}
|
||||
|
||||
func (f *fakeProviderPB) patch() map[string]any {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.lastCarPatch
|
||||
}
|
||||
|
||||
func (f *fakeProviderPB) handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/collections/_superusers/auth-with-password", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"token": "svc-token"})
|
||||
})
|
||||
mux.HandleFunc("POST /api/collections/users/auth-refresh", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
writeJSON(w, 401, map[string]any{})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"record": map[string]any{
|
||||
"id": e2eUserID, "email": e2eUserMail, "name": "Owner", "role": "user", "organization": "",
|
||||
}})
|
||||
})
|
||||
mux.HandleFunc("GET /api/collections/users/records/"+e2eUserID, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"id": e2eUserID, "email": e2eUserMail, "role": "user"})
|
||||
})
|
||||
mux.HandleFunc("GET /api/collections/cars/records/"+provCarID, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, f.carRecord())
|
||||
})
|
||||
mux.HandleFunc("PATCH /api/collections/cars/records/"+provCarID, func(w http.ResponseWriter, r *http.Request) {
|
||||
var in map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||||
f.mu.Lock()
|
||||
f.lastCarPatch = in
|
||||
// Apply the provider columns so a follow-up read reflects the write.
|
||||
if v, ok := in["provider"].(string); ok {
|
||||
f.carProvider = v
|
||||
}
|
||||
if v, ok := in["provider_vehicle_id"].(string); ok {
|
||||
f.carVehicleID = v
|
||||
}
|
||||
f.mu.Unlock()
|
||||
writeJSON(w, 200, f.carRecord())
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
func (f *fakeProviderPB) carRecord() map[string]any {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return map[string]any{
|
||||
"id": provCarID, "name": "Yaris", "owner": e2eUserID,
|
||||
"provider": f.carProvider, "provider_vehicle_id": f.carVehicleID,
|
||||
}
|
||||
}
|
||||
|
||||
// newProviderTestServer wires a Server against the fake PocketBase with the
|
||||
// Toyota plugin present but disabled — the "administrator turned it off" state,
|
||||
// which closes the gate without any network call to Toyota.
|
||||
func newProviderTestServer(t *testing.T, fake *fakeProviderPB) *httptest.Server {
|
||||
t.Helper()
|
||||
pbSrv := httptest.NewServer(fake.handler())
|
||||
t.Cleanup(pbSrv.Close)
|
||||
|
||||
pluginsFile := filepath.Join(t.TempDir(), "plugins.json")
|
||||
if err := os.WriteFile(pluginsFile, []byte(`{"toyota":{"enabled":false}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := New(config.Config{UsersCollection: "users", PluginsFile: pluginsFile},
|
||||
pb.New(pbSrv.URL, "admin@test.local", "pw"))
|
||||
if err := s.plugins.Load(); err != nil {
|
||||
t.Fatalf("load plugins: %v", err)
|
||||
}
|
||||
srv := httptest.NewServer(s.Handler())
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// call makes an authenticated request and returns the status and decoded body.
|
||||
func call(t *testing.T, srv *httptest.Server, method, path, body string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
var rdr io.Reader
|
||||
if body != "" {
|
||||
rdr = strings.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, srv.URL+path, rdr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+e2eBearer)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := srv.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out := map[string]any{}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
return resp.StatusCode, out
|
||||
}
|
||||
|
||||
func TestVehicleProviderListing(t *testing.T) {
|
||||
srv := newProviderTestServer(t, &fakeProviderPB{})
|
||||
|
||||
status, body := call(t, srv, http.MethodGet, "/api/vehicle-providers", "")
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", status)
|
||||
}
|
||||
list, _ := body["providers"].([]any)
|
||||
if len(list) == 0 {
|
||||
t.Fatal("expected at least the Toyota provider")
|
||||
}
|
||||
first, _ := list[0].(map[string]any)
|
||||
if first["id"] != "toyota" || first["label"] != "MyToyota" {
|
||||
t.Errorf("provider = %+v, want toyota/MyToyota", first)
|
||||
}
|
||||
// Disabled globally: reported as not connected, with a reason to show the user
|
||||
// rather than an error.
|
||||
if first["connected"] != false {
|
||||
t.Errorf("connected = %v, want false", first["connected"])
|
||||
}
|
||||
if first["detail"] == "" {
|
||||
t.Error("a disconnected provider must explain why")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleProviderGateSoftOnListHardOnWrite(t *testing.T) {
|
||||
srv := newProviderTestServer(t, &fakeProviderPB{})
|
||||
|
||||
// Listing: 200 + unavailable, so the UI degrades to "connect in Settings".
|
||||
status, body := call(t, srv, http.MethodGet, "/api/vehicle-providers/toyota/vehicles", "")
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("vehicles status = %d, want 200", status)
|
||||
}
|
||||
if body["unavailable"] != true || body["detail"] == "" {
|
||||
t.Errorf("vehicles body = %+v, want unavailable with a reason", body)
|
||||
}
|
||||
|
||||
// Import: 400, so a client can never mistake the reply for a created car.
|
||||
status, body = call(t, srv, http.MethodPost, "/api/vehicle-providers/toyota/import", `{"vehicleId":"VIN1"}`)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Errorf("import status = %d, want 400", status)
|
||||
}
|
||||
if body["error"] == "" {
|
||||
t.Error("a refused import must say why")
|
||||
}
|
||||
if _, created := body["car"]; created {
|
||||
t.Error("a refused import must not return a car")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleProviderUnknownProvider(t *testing.T) {
|
||||
srv := newProviderTestServer(t, &fakeProviderPB{})
|
||||
if status, _ := call(t, srv, http.MethodGet, "/api/vehicle-providers/ford/vehicles", ""); status != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404 for an unregistered provider", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCarProviderRoutesOnUnlinkedCar(t *testing.T) {
|
||||
srv := newProviderTestServer(t, &fakeProviderPB{}) // car has no provider link
|
||||
|
||||
// Reaching these messages proves the routes are registered and the car's
|
||||
// access check ran — a missing route would 404 from the mux with no message.
|
||||
status, body := call(t, srv, http.MethodGet, "/api/cars/"+provCarID+"/provider", "")
|
||||
if status != http.StatusNotFound {
|
||||
t.Errorf("snapshot status = %d, want 404", status)
|
||||
}
|
||||
if !strings.Contains(toStr(body["error"]), "not linked") {
|
||||
t.Errorf("snapshot error = %v, want the not-linked message", body["error"])
|
||||
}
|
||||
|
||||
status, body = call(t, srv, http.MethodPost, "/api/cars/"+provCarID+"/provider/sync", `{}`)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Errorf("sync status = %d, want 400", status)
|
||||
}
|
||||
if !strings.Contains(toStr(body["error"]), "not linked") {
|
||||
t.Errorf("sync error = %v, want the not-linked message", body["error"])
|
||||
}
|
||||
|
||||
// Linking needs the vehicle to be on the caller's account, so a closed gate
|
||||
// refuses it outright rather than storing a link that could never be read.
|
||||
status, body = call(t, srv, http.MethodPost, "/api/cars/"+provCarID+"/provider",
|
||||
`{"provider":"toyota","vehicleId":"VIN1"}`)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Errorf("link status = %d, want 400 while the provider is unusable", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCarProviderUnlinkWorksWithProviderUnreachable(t *testing.T) {
|
||||
// Unlinking must not depend on the provider being reachable — otherwise a car
|
||||
// could stay stuck to an account the user has since removed. It writes only
|
||||
// the two link columns, leaving the rest of the car alone.
|
||||
fake := &fakeProviderPB{carProvider: "toyota", carVehicleID: "VIN1"}
|
||||
srv := newProviderTestServer(t, fake)
|
||||
|
||||
status, body := call(t, srv, http.MethodPost, "/api/cars/"+provCarID+"/provider", `{"provider":"","vehicleId":""}`)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("unlink status = %d (%v), want 200", status, body["error"])
|
||||
}
|
||||
if body["provider"] != nil {
|
||||
t.Errorf("returned car still reports provider %v", body["provider"])
|
||||
}
|
||||
|
||||
patch := fake.patch()
|
||||
if patch["provider"] != "" || patch["provider_vehicle_id"] != "" {
|
||||
t.Errorf("patch = %+v, want both link columns cleared", patch)
|
||||
}
|
||||
if len(patch) != 2 {
|
||||
t.Errorf("patch touched %d columns (%+v); unlinking must write only the link", len(patch), patch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCarProviderSnapshotGateClosed(t *testing.T) {
|
||||
// A linked car whose provider the caller cannot currently use: the snapshot is
|
||||
// still a 200 with a reason, so the tab renders an explanation instead of an
|
||||
// error page.
|
||||
srv := newProviderTestServer(t, &fakeProviderPB{carProvider: "toyota", carVehicleID: "VIN1"})
|
||||
|
||||
status, body := call(t, srv, http.MethodGet, "/api/cars/"+provCarID+"/provider", "")
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", status)
|
||||
}
|
||||
if body["provider"] != "toyota" || body["label"] != "MyToyota" {
|
||||
t.Errorf("body = %+v, want the provider identified", body)
|
||||
}
|
||||
if body["unavailable"] != true || body["detail"] == "" {
|
||||
t.Errorf("body = %+v, want unavailable with a reason", body)
|
||||
}
|
||||
if _, fetched := body["sections"]; fetched {
|
||||
t.Error("no sections should be fetched when the gate is closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatNumber(t *testing.T) {
|
||||
cases := map[float64]string{270185: "270185", 62.5: "62.5", 0: "0", -3: "-3"}
|
||||
for in, want := range cases {
|
||||
if got := formatNumber(in); got != want {
|
||||
t.Errorf("formatNumber(%v) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package api
|
||||
|
||||
// toyotaSource is the first vehicleSource: Toyota Connected Europe, the backend
|
||||
// behind the MyToyota app (see internal/plugins/builtin/toyota). It contributes
|
||||
// nothing but wiring — credentials come from the cascade already in
|
||||
// integrations.go, and the payload walking lives in vehicleproviders.go — which
|
||||
// is the shape the next manufacturer's adapter should copy.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// toyotaLabel is what the user sees: the app they know the account by, not the
|
||||
// corporate service name behind it. It titles the car's provider tab.
|
||||
const toyotaLabel = "MyToyota"
|
||||
|
||||
type toyotaSource struct{}
|
||||
|
||||
func (toyotaSource) id() string { return toyotaPlugin }
|
||||
func (toyotaSource) label() string { return toyotaLabel }
|
||||
func (toyotaSource) service() string { return "Toyota Connected Europe" }
|
||||
|
||||
func (toyotaSource) listAction() string { return "vehicles" }
|
||||
|
||||
// sections are the plugin's per-VIN read-only capabilities, in the order the tab
|
||||
// shows them: the live readings first, then the history.
|
||||
func (toyotaSource) sections() []providerSection {
|
||||
return []providerSection{
|
||||
{ID: "telemetry", Action: "telemetry"},
|
||||
{ID: "electric", Action: "electric"},
|
||||
{ID: "status", Action: "status"},
|
||||
{ID: "health", Action: "health"},
|
||||
{ID: "location", Action: "location"},
|
||||
{ID: "serviceHistory", Action: "service-history"},
|
||||
{ID: "notifications", Action: "notifications"},
|
||||
}
|
||||
}
|
||||
|
||||
// gate resolves the caller's MyToyota credentials through the global → org → user
|
||||
// cascade and reports what is missing when they cannot be used. It is the single
|
||||
// place those messages are written: handleToyotaVehicles calls it too.
|
||||
func (toyotaSource) gate(ctx context.Context, s *Server, who *callerIdentity, userRaw json.RawMessage) (map[string]string, bool, string) {
|
||||
res := s.resolveToyota(ctx, who, userRaw)
|
||||
switch {
|
||||
case !res.available:
|
||||
return nil, false, "The Toyota integration is disabled by the administrator"
|
||||
case !res.orgEnabled:
|
||||
return nil, false, "The Toyota integration is disabled for your organization"
|
||||
case !res.enabled:
|
||||
return nil, false, "Enable the Toyota integration in Settings to load your vehicles"
|
||||
case strings.TrimSpace(res.eff.Username) == "" || strings.TrimSpace(res.eff.Password) == "":
|
||||
return nil, false, "Enter your MyToyota email and password to connect"
|
||||
}
|
||||
return map[string]string{
|
||||
"username": res.eff.Username,
|
||||
"password": res.eff.Password,
|
||||
"brand": res.eff.Brand,
|
||||
}, true, ""
|
||||
}
|
||||
|
||||
// vehicles normalizes the /v2/vehicle/guid payload. Every field is looked up by
|
||||
// name rather than by path (see findString in vehicleproviders.go), because this
|
||||
// is an unofficial API: a renamed or moved key costs one blank field instead of a
|
||||
// broken import, and the whole object still reaches the UI via Fields/Raw.
|
||||
func (toyotaSource) vehicles(raw json.RawMessage) []providerVehicle {
|
||||
objects := vehicleObjects(raw)
|
||||
out := make([]providerVehicle, 0, len(objects))
|
||||
|
||||
for _, obj := range objects {
|
||||
vin := findString(obj, "vin")
|
||||
id := vin
|
||||
if id == "" {
|
||||
id = findString(obj, "vehicleId", "guid", "id")
|
||||
}
|
||||
if id == "" {
|
||||
continue // nothing stable to address it by; skip rather than guess
|
||||
}
|
||||
|
||||
v := providerVehicle{
|
||||
ID: id,
|
||||
VIN: vin,
|
||||
Make: toyotaMake(findString(obj, "brand", "brandName", "make")),
|
||||
Model: findString(obj, "modelName", "modelDescription", "carModelName", "carLineName", "model"),
|
||||
Registration: findString(obj, "licensePlate", "registrationNumber", "plateNumber", "licencePlate"),
|
||||
FuelType: normalizeProviderFuelType(findString(obj, "fuelType", "fuel", "engineType", "powerTrain", "drivetrain")),
|
||||
BuildDate: isoDateOnly(findString(obj, "productionDate", "manufacturingDate", "buildDate")),
|
||||
FirstRegistrationDate: isoDateOnly(findString(obj, "firstRegistrationDate", "initialRegistrationDate", "registrationDate")),
|
||||
ImageURL: findString(obj, "imageUrl", "carImageUrl", "image", "picture"),
|
||||
}
|
||||
if year, ok := findInt(obj, "modelYear", "productionYear", "year"); ok && year > 1900 && year < 2200 {
|
||||
v.Year = year
|
||||
}
|
||||
v.Name = vehicleDisplayName(obj, v)
|
||||
|
||||
if b, err := json.Marshal(obj); err == nil {
|
||||
v.Raw = b
|
||||
v.Fields, _ = flattenJSON(obj, maxSectionFields)
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// toyotaMake expands the one-letter brand code the app uses on the wire ("T"/"L")
|
||||
// and otherwise passes the reported brand through in title case.
|
||||
func toyotaMake(brand string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(brand)) {
|
||||
case "T", "TOYOTA", "":
|
||||
return "Toyota"
|
||||
case "L", "LEXUS":
|
||||
return "Lexus"
|
||||
}
|
||||
b := strings.TrimSpace(brand)
|
||||
return strings.ToUpper(b[:1]) + strings.ToLower(b[1:])
|
||||
}
|
||||
|
||||
// vehicleDisplayName is the name the car gets by default: the nickname the owner
|
||||
// already gave the vehicle in the app, else make + model, else the VIN.
|
||||
func vehicleDisplayName(obj map[string]any, v providerVehicle) string {
|
||||
if alias := findString(obj, "alias", "nickName", "displayName", "vehicleName"); alias != "" {
|
||||
return alias
|
||||
}
|
||||
if name := strings.TrimSpace(v.Make + " " + v.Model); name != "" {
|
||||
return name
|
||||
}
|
||||
return v.ID
|
||||
}
|
||||
|
||||
// vehicleObjects digs the list of vehicle objects out of a plugin payload,
|
||||
// accepting the shapes these APIs use interchangeably: a bare array, an
|
||||
// envelope with a "payload" array, an envelope wrapping a single object, or an
|
||||
// array nested somewhere else entirely.
|
||||
func vehicleObjects(raw json.RawMessage) []map[string]any {
|
||||
var tree any
|
||||
if json.Unmarshal(raw, &tree) != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if node, ok := findNode(tree, "payload", "vehicles", "items", "data"); ok {
|
||||
if objs := asObjectSlice(node); objs != nil {
|
||||
return objs
|
||||
}
|
||||
}
|
||||
if objs := asObjectSlice(tree); objs != nil {
|
||||
return objs
|
||||
}
|
||||
return firstObjectSlice(tree)
|
||||
}
|
||||
|
||||
// asObjectSlice reads a node as a list of objects, treating a lone object as a
|
||||
// one-element list.
|
||||
func asObjectSlice(node any) []map[string]any {
|
||||
switch v := node.(type) {
|
||||
case []any:
|
||||
out := make([]map[string]any, 0, len(v))
|
||||
for _, e := range v {
|
||||
if m, ok := e.(map[string]any); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
case map[string]any:
|
||||
return []map[string]any{v}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// firstObjectSlice finds the outermost array of objects anywhere in a tree — the
|
||||
// last resort when the envelope key is not one we know.
|
||||
func firstObjectSlice(root any) []map[string]any {
|
||||
queue, visited := []any{root}, 0
|
||||
for len(queue) > 0 && visited < maxWalkNodes {
|
||||
node := queue[0]
|
||||
queue = queue[1:]
|
||||
visited++
|
||||
|
||||
if arr, ok := node.([]any); ok {
|
||||
if objs := asObjectSlice(arr); objs != nil {
|
||||
return objs
|
||||
}
|
||||
}
|
||||
switch v := node.(type) {
|
||||
case map[string]any:
|
||||
for _, k := range sortedKeys(v) {
|
||||
queue = append(queue, v[k])
|
||||
}
|
||||
case []any:
|
||||
queue = append(queue, v...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeProviderFuelType maps whatever a provider calls a powertrain onto the
|
||||
// app's fuel_type enum. Substring matching on purpose: the same drivetrain
|
||||
// arrives as "HV", "hybrid", "Hybrid Electric" and "PETROL_HYBRID" depending on
|
||||
// the endpoint. An unrecognized value yields "" so the field is simply left
|
||||
// unset rather than written wrong.
|
||||
func normalizeProviderFuelType(v string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(v))
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(s, "hydrogen"), strings.Contains(s, "fcev"), strings.Contains(s, "fuelcell"):
|
||||
return "hydrogen"
|
||||
// Plug-in and mild hybrids both run on petrol too, so hybrid is checked
|
||||
// before the bare fuels — "petrol hybrid" must not land on "petrol".
|
||||
case strings.Contains(s, "hybrid"), s == "hv", s == "phev", s == "mhev", strings.Contains(s, "phv"):
|
||||
return "hybrid"
|
||||
case strings.Contains(s, "diesel"):
|
||||
return "diesel"
|
||||
case strings.Contains(s, "electric"), s == "ev", s == "bev":
|
||||
return "electric"
|
||||
case strings.Contains(s, "petrol"), strings.Contains(s, "gasoline"), s == "gas":
|
||||
return "petrol"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isoDateOnly reduces a provider timestamp to the YYYY-MM-DD the car's date
|
||||
// fields store. Anything that is not already an ISO date is dropped, rather than
|
||||
// guessed at: "01/06/2015" is June or January depending on who wrote it.
|
||||
func isoDateOnly(v string) string {
|
||||
s := strings.TrimSpace(v)
|
||||
if len(s) < 10 || s[4] != '-' || s[7] != '-' {
|
||||
return ""
|
||||
}
|
||||
head := s[:10]
|
||||
for i, r := range head {
|
||||
if i == 4 || i == 7 {
|
||||
continue
|
||||
}
|
||||
if r < '0' || r > '9' {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return head
|
||||
}
|
||||
@@ -34,6 +34,11 @@ var collectionsSchema = map[string][]fieldDef{
|
||||
}, false),
|
||||
fText("build_date", false), // ISO YYYY-MM-DD (date-only)
|
||||
fText("first_registration_date", false), // ISO YYYY-MM-DD
|
||||
// Link to the manufacturer service this car came from: the plugin name plus
|
||||
// that plugin's own id for the vehicle (the VIN, for Toyota). See
|
||||
// internal/api/vehicleproviders.go. Blank for a hand-entered car.
|
||||
fText("provider", false),
|
||||
fText("provider_vehicle_id", false),
|
||||
// Owner of this car. Non-cascading: deleting a user must not wipe their cars.
|
||||
fRelation("owner", "users", false, false),
|
||||
},
|
||||
|
||||
@@ -58,6 +58,14 @@ type Car struct {
|
||||
BuildDate string `json:"buildDate"` // ISO YYYY-MM-DD (date-only)
|
||||
FirstRegistrationDate string `json:"firstRegistrationDate"` // ISO YYYY-MM-DD (date-only)
|
||||
|
||||
// Provider links this car to the manufacturer service it came from — the name
|
||||
// of the plugin ("toyota"), plus that plugin's own id for the vehicle
|
||||
// (ProviderVehicleID; the VIN, for Toyota). Both are blank for a hand-entered
|
||||
// car. They are set by the import/link endpoints only, never by an ordinary
|
||||
// car edit, so saving the form cannot silently break the link.
|
||||
Provider string `json:"provider,omitempty"`
|
||||
ProviderVehicleID string `json:"providerVehicleId,omitempty"`
|
||||
|
||||
// Owner is the user id that owns this car. Access is the requesting user's
|
||||
// permission on it — "owner", "write", or "read" — computed by the API at
|
||||
// read time and never persisted (omitempty; not part of the write payload).
|
||||
|
||||
@@ -36,9 +36,11 @@ type Plugin interface {
|
||||
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.
|
||||
- **`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
|
||||
@@ -139,7 +141,8 @@ func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
|
||||
}
|
||||
|
||||
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.)
|
||||
// Implement your capabilities; return normalized JSON. Must be safe for
|
||||
// concurrent use — one instance serves many requests.
|
||||
return json.RawMessage(`{"ok":true}`), nil
|
||||
}
|
||||
|
||||
@@ -300,8 +303,10 @@ if h := p.HealthCheck(context.Background()); h.Status == "" {
|
||||
|
||||
The contract is shaped for these; see [`doc.go`](doc.go):
|
||||
|
||||
- **Invocation API** — an endpoint to call `Invoke` from clients, with a normalized
|
||||
request/response envelope and a provider→internal mapper.
|
||||
- **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 two built-in connectors
|
||||
already have them, through the hand-written `/api/integrations/toyota` and
|
||||
@@ -312,5 +317,7 @@ The contract is shaped for these; see [`doc.go`](doc.go):
|
||||
- **Audit logging** of plugin access. (Charger *control* commands are already
|
||||
audited to the `control_audit` collection; this is the wider plugin case.)
|
||||
|
||||
Until the invocation API lands, `Invoke` is dormant — plugins are discoverable,
|
||||
configurable, and health-checked, but not yet callable over HTTP.
|
||||
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`).
|
||||
|
||||
@@ -357,6 +357,63 @@ func (m *Manager) InvokeWith(ctx context.Context, name string, cfg map[string]st
|
||||
return p.Invoke(ctx, action, payload)
|
||||
}
|
||||
|
||||
// BatchCall is one capability invocation inside an InvokeBatchWith request.
|
||||
type BatchCall struct {
|
||||
ID string // caller-chosen id, echoed back on the result
|
||||
Action string // capability id
|
||||
Params json.RawMessage // action params; may be nil
|
||||
}
|
||||
|
||||
// BatchResult is the outcome of one BatchCall. Exactly one of Result/Err is set.
|
||||
type BatchResult struct {
|
||||
ID string
|
||||
Result json.RawMessage
|
||||
Err error
|
||||
}
|
||||
|
||||
// batchConcurrency caps how many calls of one batch are in flight at once, so a
|
||||
// snapshot of a whole vehicle doesn't arrive at the upstream as a burst.
|
||||
const batchConcurrency = 4
|
||||
|
||||
// InvokeBatchWith runs several capabilities against one caller-resolved config,
|
||||
// sharing a single transient instance. A connector that authenticates lazily
|
||||
// (Toyota's OAuth login on first request) would otherwise repeat that login for
|
||||
// every action, because InvokeWith builds and tears down an instance per call;
|
||||
// sharing the instance logs in once for the whole batch.
|
||||
//
|
||||
// Calls run concurrently, so a plugin's Invoke must be safe for concurrent use —
|
||||
// which the contract already implies, since the live instance is shared by every
|
||||
// HTTP request. Results come back in request order, each carrying its own error;
|
||||
// a non-nil error return means the batch never started (unknown plugin).
|
||||
func (m *Manager) InvokeBatchWith(ctx context.Context, name string, cfg map[string]string, calls []BatchCall) ([]BatchResult, error) {
|
||||
m.mu.Lock()
|
||||
rec := m.records[name]
|
||||
p := construct(name, m.factories[name], rec)
|
||||
m.mu.Unlock()
|
||||
|
||||
if p == nil {
|
||||
return nil, errUnknown
|
||||
}
|
||||
_ = p.Init(ctx, cfg)
|
||||
defer func() { _ = p.Shutdown(context.Background()) }()
|
||||
|
||||
out := make([]BatchResult, len(calls))
|
||||
sem := make(chan struct{}, batchConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for i, c := range calls {
|
||||
wg.Add(1)
|
||||
go func(i int, c BatchCall) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
res, err := p.Invoke(ctx, c.Action, c.Params)
|
||||
out[i] = BatchResult{ID: c.ID, Result: res, Err: err}
|
||||
}(i, c)
|
||||
}
|
||||
wg.Wait()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RawConfig returns a copy of a plugin's stored (global) config and its enabled
|
||||
// flag. ok is false for an unknown plugin. This is the top layer (L1) of the
|
||||
// per-user cascade: the config a superadmin set in the panel, which lower layers
|
||||
|
||||
@@ -266,6 +266,12 @@ const DESIRED = {
|
||||
]),
|
||||
F.text("build_date"), // ISO YYYY-MM-DD (date-only; VIN 10th digit ≈ model year)
|
||||
F.text("first_registration_date"), // ISO YYYY-MM-DD
|
||||
// Link to the manufacturer service this car came from (see
|
||||
// internal/api/vehicleproviders.go): the plugin name, plus that plugin's own
|
||||
// id for the vehicle (the VIN, for Toyota). Set when a car is imported from
|
||||
// or linked to a connected account; blank for a hand-entered car.
|
||||
F.text("provider"),
|
||||
F.text("provider_vehicle_id"),
|
||||
// Owner of this car. Non-cascading on purpose: deleting a user must not
|
||||
// wipe their cars (account deletion in me.go intentionally leaves cars).
|
||||
// required:false at the DB level — the API always sets owner on create and
|
||||
|
||||
Reference in New Issue
Block a user