diff --git a/internal/httpapi/routes_community_prefixes_test.go b/internal/httpapi/routes_community_prefixes_test.go new file mode 100644 index 0000000..3c0f158 --- /dev/null +++ b/internal/httpapi/routes_community_prefixes_test.go @@ -0,0 +1,106 @@ +package httpapi + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestListCommunityPrefixesByIDAndLabel(t *testing.T) { + srv, err := New(Options{ + InsecureDev: true, + SeedDemo: true, + }) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + + tenant, _, _, _, _ := srv.Store().DemoIDs() + mustSetTestAPIKeys(t, srv, "vwkey|"+tenant+"|viewer") + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + client := ts.Client() + base := ts.URL + + reqList, _ := http.NewRequest(http.MethodGet, base+"/v1/communities?limit=10", nil) + reqList.Header.Set("Authorization", "Bearer vwkey") + respList, err := client.Do(reqList) + if err != nil { + t.Fatal(err) + } + defer func() { _ = respList.Body.Close() }() + if respList.StatusCode != http.StatusOK { + b, _ := io.ReadAll(respList.Body) + t.Fatalf("communities status %d: %s", respList.StatusCode, b) + } + var listBody struct { + Items []struct { + ID string `json:"id"` + Community string `json:"community"` + Title string `json:"title"` + } `json:"items"` + } + if err := json.NewDecoder(respList.Body).Decode(&listBody); err != nil { + t.Fatal(err) + } + if len(listBody.Items) == 0 { + t.Fatal("expected seeded community") + } + comm := listBody.Items[0] + + assertPrefixesOK := func(t *testing.T, path string) { + t.Helper() + req, _ := http.NewRequest(http.MethodGet, base+path, nil) + req.Header.Set("Authorization", "Bearer vwkey") + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("%s status %d: %s", path, resp.StatusCode, b) + } + var body struct { + Items []map[string]any `json:"items"` + Prefixes []string `json:"prefixes"` + HasMore bool `json:"has_more"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Items == nil { + t.Fatalf("%s: expected items array (got nil)", path) + } + if body.Prefixes == nil { + t.Fatalf("%s: expected prefixes array (got nil)", path) + } + } + + // UUID id + assertPrefixesOK(t, "/v1/communities/"+comm.ID+"/prefixes?limit=100") + // Community string (legacy / autocomplete label without title) + assertPrefixesOK(t, "/v1/communities/"+url.PathEscape(comm.Community)+"/prefixes?limit=100") + if comm.Title != "" { + // Full Base UI {value,label} display string + label := comm.Community + " · " + comm.Title + assertPrefixesOK(t, "/v1/communities/"+url.PathEscape(label)+"/prefixes?limit=100") + } + + req404, _ := http.NewRequest(http.MethodGet, base+"/v1/communities/missing-community/prefixes", nil) + req404.Header.Set("Authorization", "Bearer vwkey") + resp404, err := client.Do(req404) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp404.Body.Close() }() + if resp404.StatusCode != http.StatusNotFound { + b, _ := io.ReadAll(resp404.Body) + t.Fatalf("expected 404, got %d: %s", resp404.StatusCode, b) + } +} diff --git a/internal/httpapi/routes_crud.go b/internal/httpapi/routes_crud.go index 716cf58..1ac4e5a 100644 --- a/internal/httpapi/routes_crud.go +++ b/internal/httpapi/routes_crud.go @@ -57,8 +57,8 @@ func (s *Server) registerCRUDRoutes(m *http.ServeMux) { m.HandleFunc("GET /communities", s.handleListComm) m.HandleFunc("POST /communities", s.handlePostComm) - m.HandleFunc("GET /communities/{id}", s.handleGetComm) m.HandleFunc("GET /communities/{id}/prefixes", s.handleListCommPrefixes) + m.HandleFunc("GET /communities/{id}", s.handleGetComm) m.HandleFunc("PATCH /communities/{id}", s.handlePatchComm) m.HandleFunc("DELETE /communities/{id}", s.handleDeleteComm) @@ -232,6 +232,9 @@ func writePostgresStoreErr(w http.ResponseWriter, err error) bool { case "23505": writeProblem(w, http.StatusConflict, "Conflict", "resource already exists") return true + case "22P02": + writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid id format") + return true } return false } diff --git a/internal/repository/postgres.go b/internal/repository/postgres.go index e043033..9cdcf3d 100644 --- a/internal/repository/postgres.go +++ b/internal/repository/postgres.go @@ -1237,12 +1237,32 @@ func (p *Postgres) ListCommunities(tenantID string) ([]*store.Community, error) return out, nil } -func (p *Postgres) GetCommunity(tenantID, id string) (*store.Community, error) { +func (p *Postgres) GetCommunity(tenantID, idOrKey string) (*store.Community, error) { ctx := context.Background() + key := strings.TrimSpace(idOrKey) + if key == "" { + return nil, store.ErrNotFound + } var c store.Community c.TenantID = tenantID - err := p.pool.QueryRow(ctx, `SELECT id::text, community, title, value_json::text FROM bgp_community WHERE id=$1 AND tenant_id=$2`, id, tenantID).Scan( - &c.ID, &c.Community, &c.Title, &c.ValueJSON) + // Prefer UUID id; fall back to community / title so clients that store the + // autocomplete label (Base UI {value,label} → label) still resolve. + var err error + if _, perr := uuid.Parse(key); perr == nil { + err = p.pool.QueryRow(ctx, `SELECT id::text, community, title, value_json::text FROM bgp_community WHERE id=$1 AND tenant_id=$2`, key, tenantID).Scan( + &c.ID, &c.Community, &c.Title, &c.ValueJSON) + } else { + err = p.pool.QueryRow(ctx, ` + SELECT id::text, community, title, value_json::text FROM bgp_community + WHERE tenant_id=$1 AND ( + community = $2 + OR title = $2 + OR (NULLIF(trim(title), '') IS NOT NULL AND (community || ' · ' || title) = $2) + ) + ORDER BY community + LIMIT 1`, tenantID, key).Scan( + &c.ID, &c.Community, &c.Title, &c.ValueJSON) + } if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, store.ErrNotFound @@ -1253,9 +1273,11 @@ func (p *Postgres) GetCommunity(tenantID, id string) (*store.Community, error) { } func (p *Postgres) ListCommunityPrefixes(tenantID, communityID, cursor string, limit int) ([]store.PrefixRow, string, bool, error) { - if _, err := p.GetCommunity(tenantID, communityID); err != nil { + comm, err := p.GetCommunity(tenantID, communityID) + if err != nil { return nil, "", false, err } + resolvedID := comm.ID if limit <= 0 { limit = 500 } @@ -1271,7 +1293,6 @@ func (p *Postgres) ListCommunityPrefixes(tenantID, communityID, cursor string, l ctx := context.Background() useSnap := prefixSnapshotTableExists(ctx, p.pool) var rows pgx.Rows - var err error if useSnap { rows, err = p.pool.Query(ctx, ` WITH latest AS ( @@ -1293,7 +1314,7 @@ func (p *Postgres) ListCommunityPrefixes(tenantID, communityID, cursor string, l ) SELECT prefix, source FROM combined ORDER BY prefix - LIMIT $3 OFFSET $4`, tenantID, communityID, limit+1, off) + LIMIT $3 OFFSET $4`, tenantID, resolvedID, limit+1, off) } else { rows, err = p.pool.Query(ctx, ` WITH latest AS ( @@ -1307,20 +1328,19 @@ func (p *Postgres) ListCommunityPrefixes(tenantID, communityID, cursor string, l JOIN latest l ON l.id = rmp.revision_id WHERE rmp.community_id = $2::uuid ORDER BY 1 - LIMIT $3 OFFSET $4`, tenantID, communityID, limit+1, off) + LIMIT $3 OFFSET $4`, tenantID, resolvedID, limit+1, off) } if err != nil { return nil, "", false, err } defer rows.Close() var all []store.PrefixRow - comm := communityID for rows.Next() { var pr store.PrefixRow if err := rows.Scan(&pr.Prefix, &pr.Source); err != nil { continue } - pr.CommunityID = &comm + pr.CommunityID = &resolvedID all = append(all, pr) } more := len(all) > limit diff --git a/internal/store/memory_crud.go b/internal/store/memory_crud.go index 40e36af..022d7e4 100644 --- a/internal/store/memory_crud.go +++ b/internal/store/memory_crud.go @@ -585,20 +585,36 @@ func (m *Memory) ListCommunities(tenantID string) ([]*Community, error) { return out, nil } -func (m *Memory) GetCommunity(tenantID, id string) (*Community, error) { +func (m *Memory) GetCommunity(tenantID, idOrKey string) (*Community, error) { m.mu.RLock() defer m.mu.RUnlock() - c, ok := m.communities[id] - if !ok || c.TenantID != tenantID { + key := strings.TrimSpace(idOrKey) + if key == "" { return nil, ErrNotFound } - return c, nil + if c, ok := m.communities[key]; ok && c.TenantID == tenantID { + return c, nil + } + for _, c := range m.communities { + if c.TenantID != tenantID { + continue + } + if c.Community == key || c.Title == key { + return c, nil + } + if strings.TrimSpace(c.Title) != "" && c.Community+" · "+c.Title == key { + return c, nil + } + } + return nil, ErrNotFound } func (m *Memory) ListCommunityPrefixes(tenantID, communityID, cursor string, limit int) ([]PrefixRow, string, bool, error) { - if _, err := m.GetCommunity(tenantID, communityID); err != nil { + commRow, err := m.GetCommunity(tenantID, communityID) + if err != nil { return nil, "", false, err } + resolvedID := commRow.ID if limit <= 0 { limit = 500 } @@ -626,10 +642,9 @@ func (m *Memory) ListCommunityPrefixes(tenantID, communityID, cursor string, lim } seen := map[string]struct{}{} var all []PrefixRow - comm := communityID for _, rev := range latestByModule { for _, pr := range m.revPrefixes[rev.ID] { - if pr.CommunityID == nil || *pr.CommunityID != communityID { + if pr.CommunityID == nil || *pr.CommunityID != resolvedID { continue } pfx := strings.TrimSpace(pr.Prefix) @@ -640,7 +655,7 @@ func (m *Memory) ListCommunityPrefixes(tenantID, communityID, cursor string, lim continue } seen[pfx] = struct{}{} - all = append(all, PrefixRow{Prefix: pfx, CommunityID: &comm, Source: pr.Source}) + all = append(all, PrefixRow{Prefix: pfx, CommunityID: &resolvedID, Source: pr.Source}) } } sort.Slice(all, func(i, j int) bool { return all[i].Prefix < all[j].Prefix })