From 746e42a8fcdc5f189e69bf8e962c4a5afc5db366 Mon Sep 17 00:00:00 2001 From: Francisco Meneses Date: Thu, 11 Jun 2026 17:43:21 -0400 Subject: [PATCH] fix(visualize): parse backend_usages JSON array sidecar format Toolbox exports may write backend_usages.json as a top-level array. Accept both that shape and the wrapped Admin API object so topology and auth resolution work for Copec-style exports. Co-authored-by: Cursor --- internal/visualize/canvas_data_test.go | 127 ++++++++++++++++++++++ internal/visualize/loader.go | 37 +++++-- internal/visualize/loader_helpers_test.go | 21 ++++ 3 files changed, 175 insertions(+), 10 deletions(-) create mode 100644 internal/visualize/canvas_data_test.go diff --git a/internal/visualize/canvas_data_test.go b/internal/visualize/canvas_data_test.go new file mode 100644 index 0000000..7488850 --- /dev/null +++ b/internal/visualize/canvas_data_test.go @@ -0,0 +1,127 @@ +package visualize + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" +) + +// TestWriteCopecCanvasData regenerates embedded canvas DATA when WRITE_COPEC_CANVAS=1. +func TestWriteCopecCanvasData(t *testing.T) { + if os.Getenv("WRITE_COPEC_CANVAS") == "" { + t.Skip("set WRITE_COPEC_CANVAS=1 to regenerate Copec canvas data") + } + + root := os.Getenv("COPEC_EXPORT_ROOT") + if root == "" { + root = "/home/fmeneses/Documents/copec/export" + } + + tenant, err := LoadExport(root) + if err != nil { + t.Fatal(err) + } + + backendNames := make([]string, 0, len(tenant.Backends)) + backendIndex := map[string]int{} + for _, b := range tenant.Backends { + backendIndex[b.SystemName] = len(backendNames) + backendNames = append(backendNames, b.SystemName) + } + + refs := map[string]map[string]struct{}{} + products := make([]map[string]any, 0, len(tenant.Products)) + for _, p := range tenant.Products { + es := make([][2]any, 0, len(p.BackendUsages)) + for _, u := range p.BackendUsages { + if u.Backend == nil { + continue + } + idx := backendIndex[u.Backend.SystemName] + es = append(es, [2]any{idx, u.Path}) + if refs[u.Backend.SystemName] == nil { + refs[u.Backend.SystemName] = map[string]struct{}{} + } + refs[u.Backend.SystemName][p.SystemName] = struct{}{} + } + products = append(products, map[string]any{ + "n": p.SystemName, + "c": canvasCategoryKey(p.SystemName), + "a": authLabel(p.AuthType), + "e": es, + }) + } + + type sharedEntry struct { + B string `json:"b"` + N int `json:"n"` + P []string `json:"p"` + } + var shared []sharedEntry + for b, ps := range refs { + if len(ps) < 2 { + continue + } + names := make([]string, 0, len(ps)) + for name := range ps { + names = append(names, name) + } + sort.Strings(names) + limit := len(names) + if limit > 6 { + limit = 6 + } + shared = append(shared, sharedEntry{B: b, N: len(names), P: names[:limit]}) + } + sort.Slice(shared, func(i, j int) bool { + if shared[i].N != shared[j].N { + return shared[i].N > shared[j].N + } + return shared[i].B < shared[j].B + }) + if len(shared) > 12 { + shared = shared[:12] + } + + catCounts := map[string]int{"I": 0, "B": 0, "S": 0, "P": 0} + for _, p := range products { + catCounts[p["c"].(string)]++ + } + + out := map[string]any{ + "m": tenant.Manifest, + "cat": map[string]string{"I": "Integration (-IS)", "B": "Business API", "S": "SAP", "P": "Platform / misc"}, + "catCounts": catCounts, + "backends": backendNames, + "products": products, + "shared": shared, + } + + outPath := os.Getenv("COPEC_CANVAS_DATA_OUT") + if outPath == "" { + outPath = "/tmp/copec-canvas-data.json" + } + data, err := json.Marshal(out) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(outPath, data, 0o644); err != nil { + t.Fatal(err) + } + t.Logf("wrote %s (%d bytes)", outPath, len(data)) +} + +func canvasCategoryKey(name string) string { + switch { + case strings.HasSuffix(name, "-IS") || name == "Industrial-IS-int": + return "I" + case strings.Contains(name, "SAP") || strings.HasPrefix(name, "SalidaSap"): + return "S" + case name == "dummy-for-alerts" || name == "llm" || name == "ddrr": + return "P" + default: + return "B" + } +} diff --git a/internal/visualize/loader.go b/internal/visualize/loader.go index 28d4adb..a6fd5e1 100644 --- a/internal/visualize/loader.go +++ b/internal/visualize/loader.go @@ -1,6 +1,7 @@ package visualize import ( + "bytes" "encoding/json" "fmt" "os" @@ -286,19 +287,35 @@ func parsePolicies(data []byte) []Policy { } func parseBackendUsages(data []byte) []BackendUsage { - var envelope struct { - BackendUsages []struct { - BackendUsage struct { - BackendID int `json:"backend_id"` - Path string `json:"path"` - } `json:"backend_usage"` - } `json:"backend_usages"` + type usageEntry struct { + BackendUsage struct { + BackendID int `json:"backend_id"` + Path string `json:"path"` + } `json:"backend_usage"` } - if err := json.Unmarshal(data, &envelope); err != nil { + + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 { return nil } - out := make([]BackendUsage, 0, len(envelope.BackendUsages)) - for _, item := range envelope.BackendUsages { + + var entries []usageEntry + if trimmed[0] == '[' { + if err := json.Unmarshal(trimmed, &entries); err != nil { + return nil + } + } else { + var envelope struct { + BackendUsages []usageEntry `json:"backend_usages"` + } + if err := json.Unmarshal(trimmed, &envelope); err != nil { + return nil + } + entries = envelope.BackendUsages + } + + out := make([]BackendUsage, 0, len(entries)) + for _, item := range entries { out = append(out, BackendUsage{ BackendID: item.BackendUsage.BackendID, Path: item.BackendUsage.Path, diff --git a/internal/visualize/loader_helpers_test.go b/internal/visualize/loader_helpers_test.go index 11f0b94..3d62edf 100644 --- a/internal/visualize/loader_helpers_test.go +++ b/internal/visualize/loader_helpers_test.go @@ -5,6 +5,27 @@ import ( "testing" ) +func TestParseBackendUsagesArrayFormat(t *testing.T) { + data := []byte(`[ + {"backend_usage":{"backend_id":495,"path":"/legacy/portalconcesionario/CrearCompensacionDM"}} + ]`) + usages := parseBackendUsages(data) + if len(usages) != 1 { + t.Fatalf("len = %d", len(usages)) + } + if usages[0].BackendID != 495 || usages[0].Path != "/legacy/portalconcesionario/CrearCompensacionDM" { + t.Fatalf("usage = %+v", usages[0]) + } +} + +func TestParseBackendUsagesWrappedFormat(t *testing.T) { + data := []byte(`{"backend_usages":[{"backend_usage":{"backend_id":100,"path":"/payments"}}]}`) + usages := parseBackendUsages(data) + if len(usages) != 1 || usages[0].BackendID != 100 { + t.Fatalf("usage = %+v", usages) + } +} + func TestParseOIDCConfigurationNested(t *testing.T) { data := []byte(`{"oidc":{"issuer_type":"keycloak","issuer_endpoint":"https://sso.example.com/realm/demo"}}`) cfg := parseOIDCConfiguration(data)