refactor: phase 2 structural alignment — reports, importer, nodecli, pagination

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-05-20 00:22:39 +07:00
co-authored by Cursor
parent 1dd713514c
commit 250cdea35d
10 changed files with 895 additions and 686 deletions
+27 -147
View File
@@ -3,6 +3,7 @@ package httpapi
import (
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -10,6 +11,7 @@ import (
"strings"
"time"
"evobgp/internal/importer"
"evobgp/internal/pipeline"
"evobgp/internal/store"
)
@@ -689,163 +691,41 @@ func (s *Server) handleImportModuleEntriesCSV(w http.ResponseWriter, r *http.Req
return
}
moduleID := r.PathValue("module_id")
mod, err := s.store.GetModule(a.TenantID, moduleID)
res, err := importer.ImportModuleEntriesCSV(s.store, a.TenantID, moduleID, r.Body)
if err != nil {
if errors.Is(err, store.ErrInvalidInput) {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "csv is empty")
return
}
if strings.Contains(err.Error(), "importer: invalid csv") {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid csv")
return
}
if strings.Contains(err.Error(), "importer: line") {
detail := strings.TrimPrefix(err.Error(), "importer: ")
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", detail)
return
}
if strings.Contains(err.Error(), "importer: csv import/export") {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "csv import/export is supported only for AS_PREFIXES, DOMAINS, IP_RANGES")
return
}
writeStoreErr(w, err)
return
}
cr := csv.NewReader(io.LimitReader(r.Body, 8<<20))
cr.TrimLeadingSpace = true
cr.FieldsPerRecord = -1
rows, err := cr.ReadAll()
if err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid csv")
return
}
if len(rows) == 0 {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "csv is empty")
return
}
communities, err := s.store.ListCommunities(a.TenantID)
if err != nil {
writeStoreErr(w, err)
return
}
communityIDByID := make(map[string]string, len(communities))
communityIDByValue := make(map[string]string, len(communities))
for _, c := range communities {
communityIDByID[c.ID] = c.ID
communityIDByValue[strings.TrimSpace(c.Community)] = c.ID
}
resolveCommunity := func(raw string, required bool) (*string, error) {
v := strings.TrimSpace(raw)
if v == "" {
if required {
return nil, fmt.Errorf("community is required")
}
return nil, nil
}
if id, ok := communityIDByID[v]; ok {
return &id, nil
}
if id, ok := communityIDByValue[v]; ok {
return &id, nil
}
return nil, fmt.Errorf("unknown community %q", v)
}
start := 0
if len(rows[0]) >= 2 {
key := strings.ToLower(strings.TrimSpace(rows[0][0]))
switch key {
case "asn", "domain", "iprange":
start = 1
}
}
imported := 0
switch mod.Type {
case "AS_PREFIXES":
for i := start; i < len(rows); i++ {
rec := rows[i]
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
continue
}
if len(rec) < 2 {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: expected 2 columns", i+1))
return
}
asn, err := strconv.ParseInt(strings.TrimSpace(rec[0]), 10, 64)
if err != nil || asn <= 0 {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: invalid asn", i+1))
return
}
cid, err := resolveCommunity(rec[1], false)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: %v", i+1, err))
return
}
_, err = s.store.CreateASEntry(a.TenantID, moduleID, &store.ASEntry{ASN: asn, CommunityID: cid})
if err != nil {
writeStoreErr(w, err)
return
}
imported++
}
if imported > 0 {
if res.Imported > 0 {
switch res.ModuleType {
case "AS_PREFIXES":
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "as_entry_import_csv")
}
case "DOMAINS":
for i := start; i < len(rows); i++ {
rec := rows[i]
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
continue
}
if len(rec) < 2 {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: expected 2 columns", i+1))
return
}
fqdn := strings.TrimSpace(rec[0])
if fqdn == "" {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: domain is required", i+1))
return
}
cid, err := resolveCommunity(rec[1], false)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: %v", i+1, err))
return
}
_, err = s.store.CreateDomainEntry(a.TenantID, moduleID, &store.DomainEntry{FQDN: fqdn, CommunityID: cid})
if err != nil {
writeStoreErr(w, err)
return
}
imported++
}
if imported > 0 {
case "DOMAINS":
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "domain_entry_import_csv")
}
case "IP_RANGES":
for i := start; i < len(rows); i++ {
rec := rows[i]
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
continue
}
if len(rec) < 2 {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: expected 2 columns", i+1))
return
}
prefix := strings.TrimSpace(rec[0])
if prefix == "" {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: ipRange is required", i+1))
return
}
cid, err := resolveCommunity(rec[1], true)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: %v", i+1, err))
return
}
_, err = s.store.CreateIPRangeEntry(a.TenantID, moduleID, &store.IPRangeEntry{Prefix: prefix, CommunityID: cid})
if err != nil {
writeStoreErr(w, err)
return
}
imported++
}
if imported > 0 {
case "IP_RANGES":
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "ip_range_import_csv")
}
default:
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "csv import/export is supported only for AS_PREFIXES, DOMAINS, IP_RANGES")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"imported": imported,
"module_type": mod.Type,
"imported": res.Imported,
"module_type": res.ModuleType,
})
}