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;