From 5858d0889ef2b4f31023222ecfea0dc7ac544db0 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 20 May 2026 14:48:32 +0700 Subject: [PATCH] fix(httpapi): paginate module nested list endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пагинация cursor/limit для CDN, AS, domain и IP range list; ответы с next_cursor и has_more по OpenAPI. Добавлен writePaginatedListJSON и тест. Co-authored-by: Cursor --- internal/httpapi/list_params.go | 14 ++++ internal/httpapi/routes_crud.go | 24 ++----- internal/httpapi/routes_crud_list_test.go | 87 +++++++++++++++++++++++ 3 files changed, 105 insertions(+), 20 deletions(-) create mode 100644 internal/httpapi/routes_crud_list_test.go diff --git a/internal/httpapi/list_params.go b/internal/httpapi/list_params.go index 0a586c9..c846d07 100644 --- a/internal/httpapi/list_params.go +++ b/internal/httpapi/list_params.go @@ -3,6 +3,8 @@ package httpapi import ( "net/http" "strconv" + + "evobgp/internal/store" ) func parseListLimit(r *http.Request) int { @@ -22,3 +24,15 @@ func strPtrOrNull(s string) any { } return s } + +// writePaginatedListJSON returns a cursor/limit page as OpenAPI list envelopes (items, next_cursor, has_more). +func writePaginatedListJSON[T any](w http.ResponseWriter, r *http.Request, all []T, toItem func(T) map[string]any) { + page, next, more := store.PaginateOffset(all, r.URL.Query().Get("cursor"), parseListLimit(r)) + items := make([]map[string]any, 0, len(page)) + for _, x := range page { + items = append(items, toItem(x)) + } + writeJSON(w, http.StatusOK, map[string]any{ + "items": items, "next_cursor": strPtrOrNull(next), "has_more": more, + }) +} diff --git a/internal/httpapi/routes_crud.go b/internal/httpapi/routes_crud.go index 701ea6e..d304d81 100644 --- a/internal/httpapi/routes_crud.go +++ b/internal/httpapi/routes_crud.go @@ -193,11 +193,7 @@ func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) { writeStoreErr(w, err) return } - items := make([]map[string]any, 0, len(list)) - for _, x := range list { - items = append(items, cdnSourceJSON(x)) - } - writeJSON(w, http.StatusOK, map[string]any{"items": items, "next_cursor": nil, "has_more": false}) + writePaginatedListJSON(w, r, list, cdnSourceJSON) } func cdnSourceJSON(x *store.CDNSource) map[string]any { @@ -354,11 +350,7 @@ func (s *Server) handleListAS(w http.ResponseWriter, r *http.Request) { writeStoreErr(w, err) return } - items := make([]map[string]any, 0, len(list)) - for _, x := range list { - items = append(items, asEntryJSON(x)) - } - writeJSON(w, http.StatusOK, map[string]any{"items": items}) + writePaginatedListJSON(w, r, list, asEntryJSON) } func asEntryJSON(x *store.ASEntry) map[string]any { @@ -450,11 +442,7 @@ func (s *Server) handleListDomain(w http.ResponseWriter, r *http.Request) { writeStoreErr(w, err) return } - items := make([]map[string]any, 0, len(list)) - for _, x := range list { - items = append(items, domainEntryJSON(x)) - } - writeJSON(w, http.StatusOK, map[string]any{"items": items}) + writePaginatedListJSON(w, r, list, domainEntryJSON) } func domainEntryJSON(x *store.DomainEntry) map[string]any { @@ -531,11 +519,7 @@ func (s *Server) handleListIPRange(w http.ResponseWriter, r *http.Request) { writeStoreErr(w, err) return } - items := make([]map[string]any, 0, len(list)) - for _, x := range list { - items = append(items, ipRangeJSON(x)) - } - writeJSON(w, http.StatusOK, map[string]any{"items": items}) + writePaginatedListJSON(w, r, list, ipRangeJSON) } func ipRangeJSON(x *store.IPRangeEntry) map[string]any { diff --git a/internal/httpapi/routes_crud_list_test.go b/internal/httpapi/routes_crud_list_test.go new file mode 100644 index 0000000..e220bf2 --- /dev/null +++ b/internal/httpapi/routes_crud_list_test.go @@ -0,0 +1,87 @@ +package httpapi + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestNestedModuleListPagination(t *testing.T) { + srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed}) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + tenant, _, modIP, _, _ := srv.Store().DemoIDs() + srv.apiKeys = parseAPIKeysSpec("edkey|" + tenant + "|editor") + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + client := ts.Client() + base := ts.URL + mid := modIP + + for i := 0; i < 3; i++ { + body := strings.NewReader(fmt.Sprintf(`{"prefix":"10.%d.0.0/24"}`, 200+i)) + req, _ := http.NewRequest(http.MethodPost, base+"/v1/modules/"+mid+"/ip-range-entries", body) + req.Header.Set("Authorization", "Bearer edkey") + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create entry %d: status %d", i, resp.StatusCode) + } + } + + req, _ := http.NewRequest(http.MethodGet, base+"/v1/modules/"+mid+"/ip-range-entries?limit=2", nil) + req.Header.Set("Authorization", "Bearer edkey") + 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("list status %d: %s", resp.StatusCode, b) + } + var page1 struct { + Items []map[string]any `json:"items"` + NextCursor *string `json:"next_cursor"` + HasMore bool `json:"has_more"` + } + if err := json.NewDecoder(resp.Body).Decode(&page1); err != nil { + t.Fatal(err) + } + if len(page1.Items) != 2 { + t.Fatalf("page1 items: got %d want 2", len(page1.Items)) + } + if !page1.HasMore || page1.NextCursor == nil || *page1.NextCursor == "" { + t.Fatalf("page1: has_more=%v next_cursor=%v", page1.HasMore, page1.NextCursor) + } + + req2, _ := http.NewRequest(http.MethodGet, base+"/v1/modules/"+mid+"/ip-range-entries?limit=2&cursor="+*page1.NextCursor, nil) + req2.Header.Set("Authorization", "Bearer edkey") + resp2, err := client.Do(req2) + if err != nil { + t.Fatal(err) + } + defer resp2.Body.Close() + var page2 struct { + Items []map[string]any `json:"items"` + HasMore bool `json:"has_more"` + } + if err := json.NewDecoder(resp2.Body).Decode(&page2); err != nil { + t.Fatal(err) + } + if len(page1.Items)+len(page2.Items) < 3 { + t.Fatalf("expected at least 3 entries across pages, got %d+%d", len(page1.Items), len(page2.Items)) + } +}