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
This commit is contained in:
Mikkel Georgsen 2026-04-10 05:17:11 +00:00
parent 9f3ed9fddc
commit 17a2eb6f9f
2 changed files with 224 additions and 0 deletions

View file

@ -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
}

View file

@ -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)
}
}