From 4b77359042c65d0d6cc4b7f19e76d4bf85de137a Mon Sep 17 00:00:00 2001 From: Fahri Shihab <22738442+fahrishih@users.noreply.github.com> Date: Mon, 1 Dec 2025 22:57:42 +0700 Subject: [PATCH] [management] Groups API with name query parameter (#4831) --- .../http/handlers/groups/groups_handler.go | 23 +++++ .../handlers/groups/groups_handler_test.go | 91 ++++++++++++++++++- shared/management/client/rest/groups.go | 25 +++++ shared/management/http/api/openapi.yml | 10 ++ shared/management/http/api/types.gen.go | 6 ++ 5 files changed, 154 insertions(+), 1 deletion(-) diff --git a/management/server/http/handlers/groups/groups_handler.go b/management/server/http/handlers/groups/groups_handler.go index 208a2e828..56ccc9d0b 100644 --- a/management/server/http/handlers/groups/groups_handler.go +++ b/management/server/http/handlers/groups/groups_handler.go @@ -48,6 +48,29 @@ func (h *handler) getAllGroups(w http.ResponseWriter, r *http.Request) { } accountID, userID := userAuth.AccountId, userAuth.UserId + // Check if filtering by name + groupName := r.URL.Query().Get("name") + if groupName != "" { + // Get single group by name + group, err := h.accountManager.GetGroupByName(r.Context(), groupName, accountID) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "") + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + // Return as array with single element to maintain API consistency + groupsResponse := []*api.Group{toGroupResponse(accountPeers, group)} + util.WriteJSONObject(r.Context(), w, groupsResponse) + return + } + + // Get all groups groups, err := h.accountManager.GetAllGroups(r.Context(), accountID, userID) if err != nil { util.WriteError(r.Context(), err, w) diff --git a/management/server/http/handlers/groups/groups_handler_test.go b/management/server/http/handlers/groups/groups_handler_test.go index b7dd3944a..458a15c11 100644 --- a/management/server/http/handlers/groups/groups_handler_test.go +++ b/management/server/http/handlers/groups/groups_handler_test.go @@ -60,12 +60,23 @@ func initGroupTestData(initGroups ...*types.Group) *handler { return group, nil }, + GetAllGroupsFunc: func(ctx context.Context, accountID, userID string) ([]*types.Group, error) { + groups := []*types.Group{ + {ID: "id-jwt-group", Name: "From JWT", Issued: types.GroupIssuedJWT}, + {ID: "id-existed", Name: "Existed", Peers: []string{"A", "B"}, Issued: types.GroupIssuedAPI}, + {ID: "id-all", Name: "All", Issued: types.GroupIssuedAPI}, + } + + groups = append(groups, initGroups...) + + return groups, nil + }, GetGroupByNameFunc: func(ctx context.Context, groupName, _ string) (*types.Group, error) { if groupName == "All" { return &types.Group{ID: "id-all", Name: "All", Issued: types.GroupIssuedAPI}, nil } - return nil, fmt.Errorf("unknown group name") + return nil, status.Errorf(status.NotFound, "unknown group name") }, GetPeersFunc: func(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) { return maps.Values(TestPeers), nil @@ -287,6 +298,84 @@ func TestWriteGroup(t *testing.T) { } } +func TestGetAllGroups(t *testing.T) { + tt := []struct { + name string + expectedStatus int + expectedBody bool + requestType string + requestPath string + expectedCount int + }{ + { + name: "Get All Groups", + expectedBody: true, + requestType: http.MethodGet, + requestPath: "/api/groups", + expectedStatus: http.StatusOK, + expectedCount: 3, // id-jwt-group, id-existed, id-all + }, + { + name: "Get Group By Name - Existing", + expectedBody: true, + requestType: http.MethodGet, + requestPath: "/api/groups?name=All", + expectedStatus: http.StatusOK, + expectedCount: 1, + }, + { + name: "Get Group By Name - Not Found", + expectedBody: false, + requestType: http.MethodGet, + requestPath: "/api/groups?name=NonExistent", + expectedStatus: http.StatusNotFound, + }, + } + + p := initGroupTestData() + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + req := httptest.NewRequest(tc.requestType, tc.requestPath, nil) + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ + UserId: "test_user", + Domain: "hotmail.com", + AccountId: "test_id", + }) + + router := mux.NewRouter() + router.HandleFunc("/api/groups", p.getAllGroups).Methods("GET") + router.ServeHTTP(recorder, req) + + res := recorder.Result() + defer res.Body.Close() + + if status := recorder.Code; status != tc.expectedStatus { + t.Errorf("handler returned wrong status code: got %v want %v", + status, tc.expectedStatus) + return + } + + if !tc.expectedBody { + return + } + + content, err := io.ReadAll(res.Body) + if err != nil { + t.Fatalf("Failed to read response body: %v", err) + } + + var groups []api.Group + if err = json.Unmarshal(content, &groups); err != nil { + t.Fatalf("Response is not in correct json format; %v", err) + } + + assert.Equal(t, tc.expectedCount, len(groups)) + }) + } +} + func TestDeleteGroup(t *testing.T) { tt := []struct { name string diff --git a/shared/management/client/rest/groups.go b/shared/management/client/rest/groups.go index af068e077..7cd9535dd 100644 --- a/shared/management/client/rest/groups.go +++ b/shared/management/client/rest/groups.go @@ -4,10 +4,14 @@ import ( "bytes" "context" "encoding/json" + "errors" "github.com/netbirdio/netbird/shared/management/http/api" ) +// ErrGroupNotFound is returned when a group is not found +var ErrGroupNotFound = errors.New("group not found") + // GroupsAPI APIs for Groups, do not use directly type GroupsAPI struct { c *Client @@ -27,6 +31,27 @@ func (a *GroupsAPI) List(ctx context.Context) ([]api.Group, error) { return ret, err } +// GetByName get group by name +// See more: https://docs.netbird.io/api/resources/groups#list-all-groups +func (a *GroupsAPI) GetByName(ctx context.Context, groupName string) (*api.Group, error) { + params := map[string]string{"name": groupName} + resp, err := a.c.NewRequest(ctx, "GET", "/api/groups", nil, params) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[[]api.Group](resp) + if err != nil { + return nil, err + } + if len(ret) == 0 { + return nil, ErrGroupNotFound + } + return &ret[0], nil +} + // Get get group info // See more: https://docs.netbird.io/api/resources/groups#retrieve-a-group func (a *GroupsAPI) Get(ctx context.Context, groupID string) (*api.Group, error) { diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 4a5454002..2d063a7b5 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -3362,6 +3362,14 @@ paths: security: - BearerAuth: [ ] - TokenAuth: [ ] + parameters: + - in: query + name: name + required: false + schema: + type: string + description: Filter groups by name (exact match) + example: "devs" responses: '200': description: A JSON Array of Groups @@ -3375,6 +3383,8 @@ paths: "$ref": "#/components/responses/bad_request" '401': "$ref": "#/components/responses/requires_authentication" + '404': + "$ref": "#/components/responses/not_found" '403': "$ref": "#/components/responses/forbidden" '500': diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 9611d26d6..d3e425548 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -1908,6 +1908,12 @@ type GetApiEventsNetworkTrafficParamsConnectionType string // GetApiEventsNetworkTrafficParamsDirection defines parameters for GetApiEventsNetworkTraffic. type GetApiEventsNetworkTrafficParamsDirection string +// GetApiGroupsParams defines parameters for GetApiGroups. +type GetApiGroupsParams struct { + // Name Filter groups by name (exact match) + Name *string `form:"name,omitempty" json:"name,omitempty"` +} + // GetApiPeersParams defines parameters for GetApiPeers. type GetApiPeersParams struct { // Name Filter peers by name