From 17a2eb6f9f5904a870139c9d43091e65c598f545 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Fri, 10 Apr 2026 05:17:11 +0000 Subject: [PATCH] feat(01-02): custom field read/write wrappers (NB-02) - Add ParseCustomFields: safe type-assertion mapping from map[string]interface{} - Add BuildCustomFieldsPatch: selective flat patch map (avoids clearing unset fields) - Add BuildFullCustomFieldsPatch: full custom fields patch for initial record creation - Add PatchCustomFields method on Client using PatchedWritableDeviceWithConfigContextRequest - Add custom_fields_test.go with 5 unit tests and 1 skippable integration round-trip test --- internal/netbox/custom_fields.go | 119 ++++++++++++++++++++++++++ internal/netbox/custom_fields_test.go | 105 +++++++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 internal/netbox/custom_fields.go create mode 100644 internal/netbox/custom_fields_test.go diff --git a/internal/netbox/custom_fields.go b/internal/netbox/custom_fields.go new file mode 100644 index 0000000..c3394e0 --- /dev/null +++ b/internal/netbox/custom_fields.go @@ -0,0 +1,119 @@ +package netbox + +import ( + "context" + "fmt" + + nb "github.com/netbox-community/go-netbox/v4" +) + +// ParseCustomFields maps NetBox's map[string]interface{} custom fields response +// to the typed CustomFields struct. NetBox returns values as interface{} — we +// perform safe type assertions for each expected field. +func ParseCustomFields(raw map[string]interface{}) CustomFields { + cf := CustomFields{} + if raw == nil { + return cf + } + + if v, ok := raw["hw_id"].(string); ok { + cf.HWID = v + } + if v, ok := raw["catalog_status"].(string); ok { + cf.CatalogStatus = v + } + if v, ok := raw["product_url"].(string); ok { + cf.ProductURL = v + } + if v, ok := raw["firmware_version"].(string); ok { + cf.FirmwareVersion = v + } + if v, ok := raw["test_date"].(string); ok { + cf.TestDate = v + } + if v, ok := raw["test_data"].(string); ok { + cf.TestData = v + } + if v, ok := raw["ai_notes"].(string); ok { + cf.AINotes = v + } + // photo_urls is a multi-value field — NetBox returns []interface{} + if v, ok := raw["photo_urls"].([]interface{}); ok { + urls := make([]string, 0, len(v)) + for _, u := range v { + if s, ok := u.(string); ok { + urls = append(urls, s) + } + } + cf.PhotoURLs = urls + } + + return cf +} + +// BuildCustomFieldsPatch constructs the flat map[string]interface{} payload +// required by NetBox PATCH endpoints. Only include fields that are non-empty +// to avoid accidentally clearing existing values. +// +// NetBox custom field write format differs from read format: +// - Text/URL/date fields: send string value directly +// - Selection fields (catalog_status): send the choice value as string +// - Multi-value fields (photo_urls): send []string directly +func BuildCustomFieldsPatch(hwID, catalogStatus string, photoURLs []string) map[string]interface{} { + patch := make(map[string]interface{}) + if hwID != "" { + patch["hw_id"] = hwID + } + if catalogStatus != "" { + patch["catalog_status"] = catalogStatus + } + if len(photoURLs) > 0 { + patch["photo_urls"] = photoURLs + } + return patch +} + +// BuildFullCustomFieldsPatch constructs a patch with all custom fields. +// Use for initial record creation where all fields should be set. +func BuildFullCustomFieldsPatch(cf CustomFields) map[string]interface{} { + patch := make(map[string]interface{}) + if cf.HWID != "" { + patch["hw_id"] = cf.HWID + } + if cf.CatalogStatus != "" { + patch["catalog_status"] = cf.CatalogStatus + } + if cf.ProductURL != "" { + patch["product_url"] = cf.ProductURL + } + if cf.FirmwareVersion != "" { + patch["firmware_version"] = cf.FirmwareVersion + } + if cf.TestDate != "" { + patch["test_date"] = cf.TestDate + } + if cf.TestData != "" { + patch["test_data"] = cf.TestData + } + if cf.AINotes != "" { + patch["ai_notes"] = cf.AINotes + } + if len(cf.PhotoURLs) > 0 { + patch["photo_urls"] = cf.PhotoURLs + } + return patch +} + +// PatchCustomFields updates the custom fields of a device identified by deviceID. +// Uses go-netbox v4 PatchedWritableDeviceWithConfigContextRequest to send a partial update. +func (c *Client) PatchCustomFields(ctx context.Context, deviceID int, patch map[string]interface{}) error { + patchReq := nb.PatchedWritableDeviceWithConfigContextRequest{} + patchReq.SetCustomFields(patch) + + _, _, err := c.api.DcimAPI.DcimDevicesPartialUpdate(ctx, int32(deviceID)). + PatchedWritableDeviceWithConfigContextRequest(patchReq).Execute() + if err != nil { + return fmt.Errorf("patch custom fields for device %d: %w", deviceID, err) + } + return nil +} diff --git a/internal/netbox/custom_fields_test.go b/internal/netbox/custom_fields_test.go new file mode 100644 index 0000000..6408439 --- /dev/null +++ b/internal/netbox/custom_fields_test.go @@ -0,0 +1,105 @@ +package netbox_test + +import ( + "context" + "fmt" + "os" + "testing" + + "git.georgsen.dk/hwlab/internal/netbox" +) + +func TestParseCustomFieldsNil(t *testing.T) { + cf := netbox.ParseCustomFields(nil) + if cf.HWID != "" { + t.Error("expected empty HWID for nil map") + } +} + +func TestParseCustomFieldsHWID(t *testing.T) { + raw := map[string]interface{}{ + "hw_id": "HW-00001", + "catalog_status": "draft", + } + cf := netbox.ParseCustomFields(raw) + if cf.HWID != "HW-00001" { + t.Errorf("want HW-00001, got %s", cf.HWID) + } + if cf.CatalogStatus != "draft" { + t.Errorf("want draft, got %s", cf.CatalogStatus) + } +} + +func TestParseCustomFieldsPhotoURLs(t *testing.T) { + raw := map[string]interface{}{ + "photo_urls": []interface{}{"http://a.com/1.jpg", "http://a.com/2.jpg"}, + } + cf := netbox.ParseCustomFields(raw) + if len(cf.PhotoURLs) != 2 { + t.Errorf("want 2 photo urls, got %d", len(cf.PhotoURLs)) + } +} + +func TestBuildCustomFieldsPatch(t *testing.T) { + patch := netbox.BuildCustomFieldsPatch("HW-00001", "draft", nil) + if patch["hw_id"] != "HW-00001" { + t.Errorf("hw_id: want HW-00001, got %v", patch["hw_id"]) + } + if patch["catalog_status"] != "draft" { + t.Errorf("catalog_status: want draft, got %v", patch["catalog_status"]) + } + if _, ok := patch["photo_urls"]; ok { + t.Error("photo_urls should not be present when nil passed") + } +} + +func TestBuildCustomFieldsPatchWithURLs(t *testing.T) { + patch := netbox.BuildCustomFieldsPatch("HW-00001", "indexed", []string{"http://a.com/1.jpg"}) + urls, ok := patch["photo_urls"].([]string) + if !ok { + t.Fatal("photo_urls should be []string") + } + if len(urls) != 1 { + t.Errorf("want 1 url, got %d", len(urls)) + } +} + +// TestPatchCustomFieldsRoundTrip is an integration test that writes and reads back custom fields. +// It requires a real NetBox token and a pre-existing device with a known ID. +func TestPatchCustomFieldsRoundTrip(t *testing.T) { + token := os.Getenv("HWLAB_NETBOX_TOKEN") + if len(token) != 40 { + t.Skip("HWLAB_NETBOX_TOKEN is not a real 40-char token — skipping integration test") + } + + deviceIDStr := os.Getenv("HWLAB_TEST_DEVICE_ID") + if deviceIDStr == "" { + t.Skip("HWLAB_TEST_DEVICE_ID not set — skipping round-trip integration test") + } + + var deviceID int + if _, err := fmt.Sscanf(deviceIDStr, "%d", &deviceID); err != nil { + t.Fatalf("HWLAB_TEST_DEVICE_ID must be an integer: %v", err) + } + + c, err := netbox.NewClient("http://10.5.0.130:8000/api", token) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + patch := netbox.BuildCustomFieldsPatch("HW-99999", "draft", nil) + if err := c.PatchCustomFields(context.Background(), deviceID, patch); err != nil { + t.Fatalf("PatchCustomFields: %v", err) + } + + device, err := c.GetDevice(context.Background(), deviceID) + if err != nil { + t.Fatalf("GetDevice: %v", err) + } + if device.CustomFields.HWID != "HW-99999" { + t.Errorf("round-trip hw_id: want HW-99999, got %q", device.CustomFields.HWID) + } + if device.CustomFields.CatalogStatus != "draft" { + t.Errorf("round-trip catalog_status: want draft, got %q", device.CustomFields.CatalogStatus) + } +}