28818 - Reintroduce search by name for subgroups

Closes #28818

Signed-off-by: Stefan Guilhen <sguilhen@redhat.com>
This commit is contained in:
Stefan Guilhen 2024-04-23 09:29:22 -03:00 committed by Pedro Igor
parent a3d67a2b64
commit 8fa2890f68
6 changed files with 123 additions and 27 deletions

View file

@ -86,11 +86,13 @@ public interface GroupResource {
void remove();
/**
* Get the paginated list of subgroups belonging to this group
* Get the paginated list of subgroups belonging to this group.
*
* @param first
* @param max
* @param briefRepresentation
* @param first the position of the first result to be returned.
* @param max the maximum number of results that are to be returned.
* @param briefRepresentation if {@code true}, each returned subgroup representation will only contain basic information
* (id, name, path, and parentId). If {@code false}, the complete representations of the subgroups
* are returned (include role mappings and attributes).
*/
@GET
@Path("children")
@ -98,6 +100,31 @@ public interface GroupResource {
@Consumes(MediaType.APPLICATION_JSON)
List<GroupRepresentation> getSubGroups(@QueryParam("first") Integer first, @QueryParam("max") Integer max, @QueryParam("briefRepresentation") Boolean briefRepresentation);
/**
* Get the paginated list of subgroups belonging to this group, filtered according to the specified parameters.
*
* @param search a {@code String} representing either an exact group name or a partial name. If empty or {@code null}
* then all subgroups of this group are returned.
* @param exact if {@code true}, the subgroups will be searched using exact match for the {@code search} param. If false
* or {@code null}, the method returns all subgroups that partially match the specified name.
* @param first the position of the first result to be returned.
* @param max the maximum number of results that are to be returned.
* @param briefRepresentation if {@code true}, each returned subgroup representation will only contain basic information
* (id, name, path, and parentId). If {@code false}, the complete representations of the subgroups
* are returned (including role mappings and attributes).
*/
@GET
@Path("children")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
List<GroupRepresentation> getSubGroups(
@QueryParam("search") String search,
@QueryParam("exact") Boolean exact,
@QueryParam("first") Integer first,
@QueryParam("max") Integer max,
@QueryParam("briefRepresentation") Boolean briefRepresentation);
/**
* Set or create child. This will just set the parent if it exists. Create it and set the parent
* if the group doesn't exist.

View file

@ -41,8 +41,8 @@ public interface GroupsResource {
/**
* Get groups by pagination params.
* @param first index of the first element
* @param max max number of occurrences
* @param first index of the first element (pagination offset).
* @param max the maximum number of results.
* @return A list containing the slice of all groups.
*/
@GET
@ -52,9 +52,9 @@ public interface GroupsResource {
/**
* Get groups by pagination params.
* @param search max number of occurrences
* @param first index of the first element
* @param max max number of occurrences
* @param search A {@code String} representing either an exact or partial group name.
* @param first index of the first element (pagination offset).
* @param max the maximum number of results.
* @return A list containing the slice of all groups.
*/
@GET
@ -66,10 +66,12 @@ public interface GroupsResource {
/**
* Get groups by pagination params.
* @param search max number of occurrences
* @param first index of the first element
* @param max max number of occurrences
* @param briefRepresentation if false, return groups with their attributes
* @param search A {@code String} representing either an exact or partial group name.
* @param first index of the first element (pagination offset).
* @param max the maximum number of results.
* @param briefRepresentation if {@code true}, each returned group representation will only contain basic information
* (id, name, path, and parentId). If {@code false}, the complete representations of the groups
* are returned (including role mappings and attributes).
* @return A list containing the slice of all groups.
*/
@GET
@ -82,11 +84,14 @@ public interface GroupsResource {
/**
* Get groups by pagination params.
* @param search search string for group
* @param exact exact match for search
* @param first index of the first element
* @param max max number of occurrences
* @param briefRepresentation if false, return groups with their attributes
* @param search A {@code String} representing either an exact or partial group name.
* @param exact if {@code true}, the groups will be searched using exact match for the {@code search} param. If false,
* * the method returns all groups that partially match the specified name.
* @param first index of the first element (pagination offset).
* @param max the maximum number of results.
* @param briefRepresentation if {@code true}, each returned group representation will only contain basic information
* (id, name, path, and parentId). If {@code false}, the complete representations of the groups
* are returned (including role mappings and attributes).
* @return A list containing the slice of all groups.
*/
@GET

View file

@ -50,6 +50,7 @@ export const GroupTable = ({ refresh: viewRefresh }: GroupTableProps) => {
let groupsData = undefined;
if (id) {
const args: SubGroupQuery = {
search: search || "",
first: first,
max: max,
parentId: id,

View file

@ -23,7 +23,8 @@ interface SummarizedQuery {
}
export type GroupQuery = Query & PaginatedQuery & SummarizedQuery;
export type SubGroupQuery = PaginatedQuery &
export type SubGroupQuery = Query &
PaginatedQuery &
SummarizedQuery & {
parentId: string;
};
@ -142,7 +143,7 @@ export class Groups extends Resource<{ realm?: string }> {
method: "GET",
path: "/{parentId}/children",
urlParamKeys: ["parentId"],
queryParamKeys: ["first", "max", "briefRepresentation"],
queryParamKeys: ["search", "first", "max", "briefRepresentation"],
catchNotFound: true,
},
);

View file

@ -159,15 +159,18 @@ public class GroupResource {
@Produces(MediaType.APPLICATION_JSON)
@Tag(name = KeycloakOpenAPI.Admin.Tags.GROUPS)
@Operation( summary = "Return a paginated list of subgroups that have a parent group corresponding to the group on the URL")
public Stream<GroupRepresentation> getSubGroups(@QueryParam("first") @DefaultValue("0") Integer first,
@QueryParam("max") @DefaultValue("10") Integer max,
@QueryParam("briefRepresentation") @DefaultValue("false") Boolean briefRepresentation) {
public Stream<GroupRepresentation> getSubGroups(
@Parameter(description = "A String representing either an exact group name or a partial name") @QueryParam("search") String search,
@Parameter(description = "Boolean which defines whether the params \"search\" must match exactly or not") @QueryParam("exact") Boolean exact,
@Parameter(description = "The position of the first result to be returned (pagination offset).") @QueryParam("first") @DefaultValue("0") Integer first,
@Parameter(description = "The maximum number of results that are to be returned. Defaults to 10") @QueryParam("max") @DefaultValue("10") Integer max,
@Parameter(description = "Boolean which defines whether brief groups representations are returned or not (default: false)") @QueryParam("briefRepresentation") @DefaultValue("false") Boolean briefRepresentation) {
this.auth.groups().requireView(group);
boolean canViewGlobal = auth.groups().canView();
return paginatedStream(group.getSubGroupsStream(-1, -1)
.filter(g -> canViewGlobal || auth.groups().canView(g))
.map(g -> GroupUtils.populateSubGroupCount(g, GroupUtils.toRepresentation(auth.groups(), g, !briefRepresentation)))
, first, max);
return paginatedStream(
group.getSubGroupsStream(search, exact, -1, -1)
.filter(g -> canViewGlobal || auth.groups().canView(g)), first, max)
.map(g -> GroupUtils.populateSubGroupCount(g, GroupUtils.toRepresentation(auth.groups(), g, !briefRepresentation)));
}
/**

View file

@ -1,7 +1,10 @@
package org.keycloak.testsuite.admin.group;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.not;
import static org.hamcrest.CoreMatchers.nullValue;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.anEmptyMap;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.equalTo;
@ -11,6 +14,7 @@ import static org.keycloak.testsuite.auth.page.AuthRealm.TEST;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.jboss.arquillian.container.test.api.ContainerController;
@ -214,6 +218,61 @@ public class GroupSearchTest extends AbstractGroupTest {
}
}
@Test
public void querySubGroups() {
// create a parent group with a few subgroups.
try (Creator<GroupResource> parentGroupCreator = Creator.create(testRealmResource(), parentGroup)) {
for (int i = 1; i <= 5; i++) {
GroupRepresentation testGroup = new GroupRepresentation();
testGroup.setName("kcgroup-" + i);
testGroup.setAttributes(new HashMap<>() {{
put(ATTR_ORG_NAME, Collections.singletonList("keycloak org"));
put(ATTR_QUOTES_NAME, Collections.singletonList(ATTR_QUOTES_VAL));
}});
parentGroupCreator.resource().subGroup(testGroup);
}
for (int i = 1; i <= 3; i++) {
GroupRepresentation testGroup = new GroupRepresentation();
testGroup.setName("testgroup-" + i);
parentGroupCreator.resource().subGroup(testGroup);
}
// search for subgroups filtering by name - all groups with 'kc' in the name.
List<GroupRepresentation> subGroups = parentGroupCreator.resource().getSubGroups("kc", false, 0, 10, true);
assertThat(subGroups, hasSize(5));
for (int i = 1; i <= 5; i++) {
// subgroups should be ordered by name.
assertThat(subGroups.get(i-1).getName(), is(equalTo("kcgroup-" + i)));
assertThat(subGroups.get(i-1).getAttributes(), is(nullValue())); // brief rep - no attributes should be returned in subgroups.
}
// search for subgroups filtering by name - all groups with 'test' in the name.
subGroups = parentGroupCreator.resource().getSubGroups("test", false, 0, 10, true);
assertThat(subGroups, hasSize(3));
for (int i = 1; i <= 3; i++) {
assertThat(subGroups.get(i-1).getName(), is(equalTo("testgroup-" + i)));
}
// search for subgroups filtering by name - all groups with 'gro' in the name.
subGroups = parentGroupCreator.resource().getSubGroups("gro", false, 0, 10, true);
assertThat(subGroups, hasSize(8));
// search using a string that matches none of the subgroups.
subGroups = parentGroupCreator.resource().getSubGroups("nonexistent", false, 0, 10, false);
assertThat(subGroups, is(empty()));
// exact search with full representation - only one subgroup should be returned.
subGroups = parentGroupCreator.resource().getSubGroups("kcgroup-2", true, 0, 10, false);
assertThat(subGroups, hasSize(1));
assertThat(subGroups.get(0).getName(), is(equalTo("kcgroup-2")));
// attributes should be present in the returned subgroup.
Map<String, List<String>> attributes = subGroups.get(0).getAttributes();
assertThat(attributes, not(anEmptyMap()));
assertThat(attributes.keySet(), hasSize(2));
assertThat(attributes.keySet(), containsInAnyOrder(ATTR_ORG_NAME, ATTR_QUOTES_NAME));
}
}
private void search(String searchQuery, String... expectedGroupIds) {
GroupsResource search = testRealmResource().groups();
List<String> found = search.query(searchQuery).stream()