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.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user