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:
parent
9f3ed9fddc
commit
17a2eb6f9f
2 changed files with 224 additions and 0 deletions
119
internal/netbox/custom_fields.go
Normal file
119
internal/netbox/custom_fields.go
Normal 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
|
||||
}
|
||||
105
internal/netbox/custom_fields_test.go
Normal file
105
internal/netbox/custom_fields_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue