feat(api): enhance community retrieval with flexible ID handling
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Skipped
CI / go (push) Successful in 1m16s
CI / bird2 (push) Successful in 14s
CI / release (push) Successful in 4m5s

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.
This commit is contained in:
Denozordec
2026-07-23 11:30:46 +07:00
parent ff6efec4c5
commit fd3a217cbe
4 changed files with 162 additions and 18 deletions
@@ -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)
}
}
+4 -1
View File
@@ -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
}
+29 -9
View File
@@ -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
+23 -8
View File
@@ -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 })