From fd3a217cbeaf81be92fcec20272b0111b3d9a294 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 23 Jul 2026 11:30:46 +0700 Subject: [PATCH] feat(api): enhance community retrieval with flexible ID handling Updated the GetCommunity function to accept both UUIDs and community titles for improved flexibility in community retrieval. Added error handling for invalid ID formats and adjusted related functions to ensure consistent behavior across memory and PostgreSQL storage. This change enhances the API's usability by allowing more intuitive community lookups. --- .../httpapi/routes_community_prefixes_test.go | 106 ++++++++++++++++++ internal/httpapi/routes_crud.go | 5 +- internal/repository/postgres.go | 38 +++++-- internal/store/memory_crud.go | 31 +++-- 4 files changed, 162 insertions(+), 18 deletions(-) create mode 100644 internal/httpapi/routes_community_prefixes_test.go 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 })