feat: add aggregated router-lists catalog endpoint and update module listing filters
CI / changes (push) Successful in 5s
CI / openapi (push) Successful in 22s
CI / go (push) Successful in 38s
CI / bird2 (push) Has been cancelled
CI / docker-go-prime (push) Has been cancelled
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Has been cancelled
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Has been cancelled
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Has been cancelled
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Has been cancelled
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Has been cancelled
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Has been cancelled
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Has been cancelled
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Has been cancelled
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Has started running
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Has been cancelled
CI / docker-bird (push) Has been cancelled

Introduced a new endpoint `GET /v1/router-lists/catalog` that returns a consolidated view of modules, domain entries, ASNs, IP ranges, and communities. Enhanced the existing module listing functionality to support filtering by type and enabled status. Updated documentation to reflect these changes and added tests for the new endpoint and filtering capabilities.
This commit is contained in:
Denozordec
2026-04-07 23:15:40 +07:00
parent b7a8aab2e8
commit c811c43bbc
5 changed files with 281 additions and 1 deletions
+85 -1
View File
@@ -933,7 +933,7 @@ paths:
get: get:
tags: [Modules] tags: [Modules]
summary: Список модулей summary: Список модулей
description: Модули tenant с опциональными фильтрами по типу и флагу `enabled`. description: Модули tenant с опциональными фильтрами по типу и флагу `enabled` (фильтры применяются сервером).
operationId: listModules operationId: listModules
parameters: parameters:
- $ref: "#/components/parameters/TenantId" - $ref: "#/components/parameters/TenantId"
@@ -962,6 +962,90 @@ paths:
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
default: default:
$ref: "#/components/responses/DefaultProblem" $ref: "#/components/responses/DefaultProblem"
/v1/router-lists/catalog:
get:
tags: [Modules]
summary: Агрегированный каталог для router-lists-ui
description: |
Возвращает в одном ответе:
- модули типов `DOMAINS`, `IP_RANGES`, `AS_PREFIXES`;
- entries по каждому модулю;
- справочник community (`id`, `community`, `title`).
operationId: getRouterListsCatalog
parameters:
- $ref: "#/components/parameters/TenantId"
responses:
"200":
description: Агрегированные данные для списков UI.
content:
application/json:
schema:
type: object
required: [modules, domains, asns, ip_ranges, communities]
properties:
modules:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: "#/components/schemas/Module"
domains:
type: object
required: [items]
properties:
items:
type: array
items:
type: object
required: [module_id, entry]
properties:
module_id:
$ref: "#/components/schemas/ResourceId"
entry:
$ref: "#/components/schemas/DomainEntry"
asns:
type: object
required: [items]
properties:
items:
type: array
items:
type: object
required: [module_id, entry]
properties:
module_id:
$ref: "#/components/schemas/ResourceId"
entry:
$ref: "#/components/schemas/AsEntry"
ip_ranges:
type: object
required: [items]
properties:
items:
type: array
items:
type: object
required: [module_id, entry]
properties:
module_id:
$ref: "#/components/schemas/ResourceId"
entry:
$ref: "#/components/schemas/IpRangeEntry"
communities:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: "#/components/schemas/BgpCommunity"
"401":
$ref: "#/components/responses/Unauthorized"
default:
$ref: "#/components/responses/DefaultProblem"
post: post:
tags: [Modules] tags: [Modules]
summary: Создать модуль summary: Создать модуль
+2
View File
@@ -23,6 +23,8 @@
| AS | `/api/asns` | `/v1/modules?type=AS_PREFIXES` + `/v1/modules/{module_id}/as-entries` | | AS | `/api/asns` | `/v1/modules?type=AS_PREFIXES` + `/v1/modules/{module_id}/as-entries` |
| Community | `/api/communities` | `/v1/communities` | | Community | `/api/communities` | `/v1/communities` |
Для упрощённой интеграции доступен агрегированный endpoint: `GET /v1/router-lists/catalog` (модули + entries + communities в одном ответе).
## 3. Маппинг полей ## 3. Маппинг полей
| Legacy модель | EvoBGP модель | Комментарий | | Legacy модель | EvoBGP модель | Комментарий |
+116
View File
@@ -47,6 +47,7 @@ func (s *Server) registerRoutes() {
func (s *Server) registerV1(m *http.ServeMux) { func (s *Server) registerV1(m *http.ServeMux) {
m.HandleFunc("GET /modules", s.handleListModules) m.HandleFunc("GET /modules", s.handleListModules)
m.HandleFunc("GET /router-lists/catalog", s.handleRouterListsCatalog)
m.HandleFunc("GET /modules/{module_id}", s.handleGetModule) m.HandleFunc("GET /modules/{module_id}", s.handleGetModule)
m.HandleFunc("GET /peers", s.handleListPeers) m.HandleFunc("GET /peers", s.handleListPeers)
m.HandleFunc("GET /speakers", s.handleListSpeakers) m.HandleFunc("GET /speakers", s.handleListSpeakers)
@@ -161,9 +162,27 @@ func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
if !s.requireAtLeast(w, a, "viewer") { if !s.requireAtLeast(w, a, "viewer") {
return return
} }
typeFilter := strings.TrimSpace(r.URL.Query().Get("type"))
enabledRaw := strings.TrimSpace(r.URL.Query().Get("enabled"))
var enabledFilter *bool
if enabledRaw != "" {
v, err := strconv.ParseBool(enabledRaw)
if err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "enabled must be boolean")
return
}
enabledFilter = &v
}
mods := s.store.ListModules(a.TenantID) mods := s.store.ListModules(a.TenantID)
items := make([]map[string]any, 0, len(mods)) items := make([]map[string]any, 0, len(mods))
for _, mod := range mods { for _, mod := range mods {
if typeFilter != "" && mod.Type != typeFilter {
continue
}
if enabledFilter != nil && mod.Enabled != *enabledFilter {
continue
}
items = append(items, moduleJSON(mod)) items = append(items, moduleJSON(mod))
} }
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
@@ -171,6 +190,103 @@ func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
}) })
} }
func (s *Server) handleRouterListsCatalog(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
mods := s.store.ListModules(a.TenantID)
moduleItems := make([]map[string]any, 0, len(mods))
domains := make([]map[string]any, 0)
asns := make([]map[string]any, 0)
ipRanges := make([]map[string]any, 0)
for _, mod := range mods {
switch mod.Type {
case "DOMAINS", "AS_PREFIXES", "IP_RANGES":
moduleItems = append(moduleItems, moduleJSON(mod))
default:
continue
}
switch mod.Type {
case "DOMAINS":
list, err := s.store.ListDomainEntries(a.TenantID, mod.ID)
if err != nil {
writeStoreErr(w, err)
return
}
for _, x := range list {
domains = append(domains, map[string]any{
"module_id": mod.ID,
"entry": domainEntryJSON(x),
})
}
case "AS_PREFIXES":
list, err := s.store.ListASEntries(a.TenantID, mod.ID)
if err != nil {
writeStoreErr(w, err)
return
}
for _, x := range list {
asns = append(asns, map[string]any{
"module_id": mod.ID,
"entry": asEntryJSON(x),
})
}
case "IP_RANGES":
list, err := s.store.ListIPRangeEntries(a.TenantID, mod.ID)
if err != nil {
writeStoreErr(w, err)
return
}
for _, x := range list {
ipRanges = append(ipRanges, map[string]any{
"module_id": mod.ID,
"entry": ipRangeJSON(x),
})
}
}
}
comms, err := s.store.ListCommunities(a.TenantID)
if err != nil {
writeStoreErr(w, err)
return
}
communityItems := make([]map[string]any, 0, len(comms))
for _, c := range comms {
communityItems = append(communityItems, map[string]any{
"id": c.ID,
"community": c.Community,
"title": c.Title,
})
}
writeJSON(w, http.StatusOK, map[string]any{
"modules": map[string]any{
"items": moduleItems,
},
"domains": map[string]any{
"items": domains,
},
"asns": map[string]any{
"items": asns,
},
"ip_ranges": map[string]any{
"items": ipRanges,
},
"communities": map[string]any{
"items": communityItems,
},
})
}
func (s *Server) handleGetModule(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetModule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context()) a, ok := authFromContext(r.Context())
if !ok { if !ok {
+70
View File
@@ -149,6 +149,76 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
} }
}) })
t.Run("modules filter by type", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, base+"/v1/modules?type=IP_RANGES", nil)
req.Header.Set("Authorization", "Bearer opkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
var body struct {
Items []struct {
Type string `json:"type"`
} `json:"items"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if len(body.Items) == 0 {
t.Fatalf("expected at least one IP_RANGES module")
}
for _, item := range body.Items {
if item.Type != "IP_RANGES" {
t.Fatalf("unexpected module type %q", item.Type)
}
}
})
t.Run("router lists catalog endpoint", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, base+"/v1/router-lists/catalog", nil)
req.Header.Set("Authorization", "Bearer opkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
var body struct {
Modules struct {
Items []map[string]any `json:"items"`
} `json:"modules"`
Domains struct {
Items []map[string]any `json:"items"`
} `json:"domains"`
ASNs struct {
Items []map[string]any `json:"items"`
} `json:"asns"`
IPRanges struct {
Items []map[string]any `json:"items"`
} `json:"ip_ranges"`
Communities struct {
Items []map[string]any `json:"items"`
} `json:"communities"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if len(body.Modules.Items) == 0 {
t.Fatalf("expected modules in catalog")
}
if body.Communities.Items == nil {
t.Fatalf("expected communities.items field in catalog")
}
})
t.Run("rollback queues job", func(t *testing.T) { t.Run("rollback queues job", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, base+"/v1/revisions/"+rev+"/rollback", nil) req, _ := http.NewRequest(http.MethodPost, base+"/v1/revisions/"+rev+"/rollback", nil)
req.Header.Set("Authorization", "Bearer opkey") req.Header.Set("Authorization", "Bearer opkey")
+8
View File
@@ -21,6 +21,14 @@ export type ModuleRow = {
}; };
export type ModulesResponse = Page<ModuleRow>; export type ModulesResponse = Page<ModuleRow>;
export type RouterListsCatalogResponse = {
modules: { items: ModuleRow[] };
domains: { items: { module_id: string; entry: DomainEntry }[] };
asns: { items: { module_id: string; entry: AsEntry }[] };
ip_ranges: { items: { module_id: string; entry: IpRangeEntry }[] };
communities: { items: BgpCommunity[] };
};
export type ModuleCreate = { export type ModuleCreate = {
type: ModuleType; type: ModuleType;
name: string; name: string;