From c811c43bbcfb6334a1236895a5310f0454d6be5d Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 7 Apr 2026 23:15:40 +0700 Subject: [PATCH] feat: add aggregated router-lists catalog endpoint and update module listing filters 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. --- docs/openapi.yaml | 86 ++++++++++++++++++++- docs/router-lists-ui-integration.md | 2 + internal/httpapi/routes.go | 116 ++++++++++++++++++++++++++++ internal/httpapi/server_test.go | 70 +++++++++++++++++ web/src/lib/api/types.ts | 8 ++ 5 files changed, 281 insertions(+), 1 deletion(-) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 83efebe..70a8df8 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -933,7 +933,7 @@ paths: get: tags: [Modules] summary: Список модулей - description: Модули tenant с опциональными фильтрами по типу и флагу `enabled`. + description: Модули tenant с опциональными фильтрами по типу и флагу `enabled` (фильтры применяются сервером). operationId: listModules parameters: - $ref: "#/components/parameters/TenantId" @@ -962,6 +962,90 @@ paths: $ref: "#/components/responses/Unauthorized" default: $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: tags: [Modules] summary: Создать модуль diff --git a/docs/router-lists-ui-integration.md b/docs/router-lists-ui-integration.md index 8a529f4..9987f51 100644 --- a/docs/router-lists-ui-integration.md +++ b/docs/router-lists-ui-integration.md @@ -23,6 +23,8 @@ | AS | `/api/asns` | `/v1/modules?type=AS_PREFIXES` + `/v1/modules/{module_id}/as-entries` | | Community | `/api/communities` | `/v1/communities` | +Для упрощённой интеграции доступен агрегированный endpoint: `GET /v1/router-lists/catalog` (модули + entries + communities в одном ответе). + ## 3. Маппинг полей | Legacy модель | EvoBGP модель | Комментарий | diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index fd0cfce..9c23b3a 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -47,6 +47,7 @@ func (s *Server) registerRoutes() { func (s *Server) registerV1(m *http.ServeMux) { 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 /peers", s.handleListPeers) 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") { 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) items := make([]map[string]any, 0, len(mods)) for _, mod := range mods { + if typeFilter != "" && mod.Type != typeFilter { + continue + } + if enabledFilter != nil && mod.Enabled != *enabledFilter { + continue + } items = append(items, moduleJSON(mod)) } 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) { a, ok := authFromContext(r.Context()) if !ok { diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index 52d612d..30f68f9 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -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) { req, _ := http.NewRequest(http.MethodPost, base+"/v1/revisions/"+rev+"/rollback", nil) req.Header.Set("Authorization", "Bearer opkey") diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 19f6d93..18c2f5f 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -21,6 +21,14 @@ export type ModuleRow = { }; export type ModulesResponse = Page; +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 = { type: ModuleType; name: string;