From 8cea97a3e21cbda9df5769314f1694fb7be8058d Mon Sep 17 00:00:00 2001 From: Matthias Cullmann Date: Wed, 17 Apr 2024 14:50:28 +0200 Subject: [PATCH] use teams API instead of groups --- pom.xml | 359 ++++++------ .../baloise/azure/FunctionalOrgEndpoint.java | 512 ++++++++---------- src/main/java/com/baloise/azure/Graph.java | 239 ++++---- src/main/java/com/baloise/funorg/Role.java | 36 +- src/main/java/com/baloise/funorg/Team.java | 41 -- src/main/java/common/StringTree.java | 146 +++++ .../java/com/baloise/azure/GraphTest.java | 22 + .../java/com/baloise/funorg/RoleTest.java | 31 +- .../java/com/baloise/funorg/TeamTest.java | 46 -- src/test/java/common/StringTreeTest.java | 34 ++ 10 files changed, 767 insertions(+), 699 deletions(-) delete mode 100644 src/main/java/com/baloise/funorg/Team.java create mode 100644 src/main/java/common/StringTree.java create mode 100644 src/test/java/com/baloise/azure/GraphTest.java delete mode 100644 src/test/java/com/baloise/funorg/TeamTest.java create mode 100644 src/test/java/common/StringTreeTest.java diff --git a/pom.xml b/pom.xml index 4c9c1ef..2842d5a 100644 --- a/pom.xml +++ b/pom.xml @@ -1,177 +1,182 @@ - - - 4.0.0 - - com.baloise.azure - functional-org-baloise - 1.0-SNAPSHOT - jar - - Baloise Functional Organisation API - - - Baloise - https://github.com/baloise/ - - - - - Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - repo - A business-friendly OSS license - - - - - Github - https://github.com/baloise/functional-org-baloise/issues - - - - UTF-8 - 17 - 1.27.0 - 3.0.0 - functional-org-baloise - - - - - - org.junit - junit-bom - 5.10.1 - pom - import - - - - - - - - com.microsoft.graph - microsoft-graph - 5.77.0 - - - com.microsoft.azure.functions - azure-functions-java-library - ${azure.functions.java.library.version} - - - com.microsoft.azure - msal4j - 1.14.1 - - - com.azure - azure-security-keyvault-secrets - 4.7.3 - - - com.azure - azure-identity - 1.11.1 - - - com.fasterxml.jackson.core - jackson-databind - 2.16.1 - - - - org.junit.jupiter - junit-jupiter - test - - - - org.mockito - mockito-core - 2.23.4 - test - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.8.1 - - ${java.version} - ${java.version} - ${project.build.sourceEncoding} - - - - com.microsoft.azure - azure-functions-maven-plugin - ${azure.functions.maven.plugin.version} - - - ${functionAppName} - - deop-rg-prod-euw-git-ps-funorg - - - westeurope - - - - - - true - - - linux - 17 - - - - FUNCTIONS_EXTENSION_VERSION - ~4 - - - - - - package-functions - - package - - - - - - maven-surefire-plugin - 3.2.1 - - - - maven-clean-plugin - 3.1.0 - - - - obj - - - - - - - + + + 4.0.0 + + com.baloise.azure + functional-org-baloise + 1.0-SNAPSHOT + jar + + Baloise Functional Organisation API + + + Baloise + https://github.com/baloise/ + + + + + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + A business-friendly OSS license + + + + + Github + https://github.com/baloise/functional-org-baloise/issues + + + + UTF-8 + 17 + 1.27.0 + 3.1.0 + functional-org-baloise + + + + + + org.junit + junit-bom + 5.10.1 + pom + import + + + com.azure + azure-sdk-bom + 1.2.22 + pom + import + + + + + + + + com.microsoft.azure + msal4j + 1.14.3 + + + com.microsoft.graph + microsoft-graph + 6.5.1 + + + com.microsoft.azure.functions + azure-functions-java-library + ${azure.functions.java.library.version} + + + com.azure + azure-security-keyvault-secrets + + + com.azure + azure-identity + + + com.fasterxml.jackson.core + jackson-databind + 2.16.1 + + + + org.junit.jupiter + junit-jupiter + test + + + + org.mockito + mockito-core + 2.23.4 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + ${java.version} + ${java.version} + ${project.build.sourceEncoding} + + + + com.microsoft.azure + azure-functions-maven-plugin + ${azure.functions.maven.plugin.version} + + + ${functionAppName} + + deop-rg-prod-euw-git-ps-funorg + + + westeurope + + + + + + true + + + linux + 17 + + + + FUNCTIONS_EXTENSION_VERSION + ~4 + + + + + + package-functions + + package + + + + + + maven-surefire-plugin + 3.2.1 + + + + maven-clean-plugin + 3.1.0 + + + + obj + + + + + + + diff --git a/src/main/java/com/baloise/azure/FunctionalOrgEndpoint.java b/src/main/java/com/baloise/azure/FunctionalOrgEndpoint.java index 661653c..bbbd3be 100644 --- a/src/main/java/com/baloise/azure/FunctionalOrgEndpoint.java +++ b/src/main/java/com/baloise/azure/FunctionalOrgEndpoint.java @@ -1,297 +1,215 @@ -package com.baloise.azure; - -import static java.lang.String.format; -import static java.util.Arrays.stream; -import static java.util.function.Predicate.not; -import static java.util.stream.Collectors.joining; - -import java.io.IOException; -import java.text.ParseException; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.logging.Level; -import java.util.stream.Collectors; - -import com.azure.identity.ClientSecretCredentialBuilder; -import com.baloise.funorg.Role; -import com.baloise.funorg.Team; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.microsoft.azure.functions.ExecutionContext; -import com.microsoft.azure.functions.HttpMethod; -import com.microsoft.azure.functions.HttpRequestMessage; -import com.microsoft.azure.functions.HttpResponseMessage; -import com.microsoft.azure.functions.HttpResponseMessage.Builder; -import com.microsoft.azure.functions.HttpStatus; -import com.microsoft.azure.functions.annotation.AuthorizationLevel; -import com.microsoft.azure.functions.annotation.BindingName; -import com.microsoft.azure.functions.annotation.FunctionName; -import com.microsoft.azure.functions.annotation.HttpTrigger; -import com.microsoft.graph.authentication.TokenCredentialAuthProvider; -import com.microsoft.graph.models.Group; -import com.microsoft.graph.models.User; - -/** - * Azure Functions with HTTP Trigger. - */ -public class FunctionalOrgEndpoint { - /** - * This function listens at endpoint "/api/Hello". Two ways to invoke it using - * "curl" command in bash: 1. curl -d "HTTP Body" {your host}/api/Hello 2. curl - * "{your host}/api/Hello?name=HTTP%20Query" - */ - - Vault lazyVault = null; - Graph lazygraph = null; - ObjectMapper objectMapper = new ObjectMapper(); - - Vault vault() { - if(lazyVault == null) { - lazyVault = new Vault(); - } - return lazyVault; - } - - Graph graph() { - if(lazygraph == null) { - lazygraph = new Graph(new TokenCredentialAuthProvider( new ClientSecretCredentialBuilder() - .authorityHost(AzureProperties.authority()) - .tenantId(AzureProperties.tenantId()).clientId(AzureProperties.clientId()) - .clientSecret(vault().getSecret(AzureProperties.clientSecretName(), true)).build())); - } - return lazygraph; - } - - @FunctionName("V1") - public HttpResponseMessage v1( - @HttpTrigger( - name = "req", - methods = { HttpMethod.GET, HttpMethod.POST }, - authLevel = AuthorizationLevel.ANONYMOUS, - route = "V1/{unit=null}/{team=null}" - ) - HttpRequestMessage> request, - final ExecutionContext context, - @BindingName("unit") String unit, - @BindingName("team") String team - ) { - try { - - - if(!"null".equals(team) && "avatar".equals(unit)) { - return createAvatarResponse(request, team); - } - if(!"null".equals(team)) { - return createTeamResponse(request, context, unit, team); - } - - if(!"null".equals(unit)) { - return createUnitResponse(request, context, unit); - } - - return createRootResponse(request, context); - - } catch (IOException e) { - context.getLogger().warning(e.getLocalizedMessage()); - return request.createResponseBuilder(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getLocalizedMessage()).build(); - } catch (Throwable t) { - context.getLogger().log(Level.WARNING, t.getLocalizedMessage(), t); - throw t; - } - } - - private HttpResponseMessage createAvatarResponse(HttpRequestMessage> request, String id) throws IOException { - byte[] avatar = graph().avatar(id); - String myETag = String.valueOf(Arrays.hashCode(avatar)); - String theirETag = ignoreKeyCase(request.getHeaders()).get("If-None-Match"); - boolean sameEtag = Objects.equals(myETag, theirETag); - Builder response = request - .createResponseBuilder(sameEtag? HttpStatus.NOT_MODIFIED : HttpStatus.OK) - .header("Content-Type","image/jpeg") - .header("ETag",myETag); - if(!sameEtag) { - response = response - .header("Content-Length",String.valueOf(avatar.length)) - .body(avatar); - } - return response.build(); - } - - private Map ignoreKeyCase(Map mixedMap) { - Map lowMap = new HashMap<>() { - @Override - public String get(Object key) { - return super.get(key.toString().toLowerCase()); - } - }; - mixedMap.entrySet().stream().forEach(e->lowMap.put(e.getKey().toLowerCase(), e.getValue())); - return lowMap; - } - - private HttpResponseMessage createRootResponse(HttpRequestMessage> request, ExecutionContext context) - throws JsonProcessingException { - return request.createResponseBuilder(HttpStatus.OK) - .header("Content-Type","application/json; charset=UTF-8") - .body( - objectMapper.writeValueAsString( - Map.of("units", - graph().getTeams().stream() - .map(g -> Team.parse(context.getLogger(), g.displayName)) - .filter(Objects::nonNull) - .map(t -> - Map.of( - "name", t.unit(), - "url", getCleanUri(request)+"/"+ t.unit() - ) - ) - .distinct() - .collect(Collectors.toList()) - )) - ).build(); - } - - private HttpResponseMessage createUnitResponse(HttpRequestMessage> request, ExecutionContext context, String unit) throws JsonProcessingException { - return request.createResponseBuilder(HttpStatus.OK) - .header("Content-Type","application/json; charset=UTF-8") - .body( - objectMapper.writeValueAsString( - Map.of("teams", - graph().getTeams(unit+"-").stream() - .map(g -> Team.parse(context.getLogger(), g.displayName)) - .filter(Objects::nonNull) - .map(t -> - Map.of( - "name", t.name(), - "unit", t.unit(), - "url", format("%s/%s/%s", getPath(request), t.unit(),t.name()) - ) - ) - .distinct() - .collect(Collectors.toList()) - )) - ).build(); - } - - private HttpResponseMessage createTeamResponse(HttpRequestMessage> request, ExecutionContext context, String unit, String team) throws JsonProcessingException { - Map> name2team = new HashMap<>(); - - for (Group group : graph().getTeams(unit+"-"+team)) { - try { - Team t = Team.parse(group.displayName); - Map tmp = name2team.get(t.name()); - if(tmp== null) { - tmp = Map.of( - "name", t.name(), - "unit", t.unit(), - "url", getPath(request)+"/"+t.name(), - "members" , loadAndMapMembers(group, t.internal(), request.getQueryParameters().get("expand")) - ); - name2team.put(t.name(), tmp); - } else { - @SuppressWarnings("unchecked") - List> members = (List>) tmp.get("members"); - members.addAll(loadAndMapMembers(group, t.internal(), request.getQueryParameters().get("expand"))); - } - } catch (ParseException e) { - context.getLogger().log(Level.WARNING, e.getLocalizedMessage(), e); - } - } - return request.createResponseBuilder(HttpStatus.OK) - .header("Content-Type","application/json; charset=UTF-8") - .body( - objectMapper.writeValueAsString( - Map.of("teams", name2team.values())) - ).build(); - } - - private List> loadAndMapMembers(Group group, boolean internal, String expand) { - return graph().getGroupMembers(group).stream() - .map(User.class::cast) - .map(u-> mapUser(u,internal, graph().getRoles(u),expand)).collect(Collectors.toList()); - } - - private String getPath(HttpRequestMessage request) { - String uri = getCleanUri(request); - return uri.substring(0, uri.lastIndexOf('/')); - } - - private String getCleanUri(HttpRequestMessage request) { - return request.getUri().toString().replaceFirst("/\\z", ""); - } - - @FunctionName("dump") - public HttpResponseMessage dump( - final ExecutionContext context, - @HttpTrigger(name = "req", methods = { HttpMethod.GET, HttpMethod.POST }, authLevel = AuthorizationLevel.ANONYMOUS) - HttpRequestMessage> request - ) { - return request.createResponseBuilder(HttpStatus.OK).body( - listEnv() + listProps() - ).build(); - } - - @FunctionName("hello") - public HttpResponseMessage hello( - final ExecutionContext context, - @HttpTrigger(name = "req", methods = { HttpMethod.GET, HttpMethod.POST }, authLevel = AuthorizationLevel.ANONYMOUS) - HttpRequestMessage> request - ) { - try { - return request.createResponseBuilder(HttpStatus.OK).body( - "Hi there" - ).build(); - } catch (Throwable t) { - context.getLogger().log(Level.WARNING, t.getLocalizedMessage(), t); - throw t; - } - } - - private String listProps() { - return "\nProps\n\n"+ System.getProperties().entrySet().stream().map(e -> format("%s = %s", e.getKey(), e.getValue())).collect(joining("\n")); - } - - private String listEnv() { - return "\nEnv\n\n"+System.getenv().entrySet().stream().map(e -> format("%s = %s", e.getKey(), e.getValue())).collect(joining("\n")); - } - - private Map mapUser(User u, boolean internal, Set roles, String expand) { - Map core = Map.of( - "preferredLanguage", notNull(u.preferredLanguage), - "officeLocation", notNull(u.officeLocation), - "displayName", notNull(u.displayName), - "givenName", notNull(u.givenName), - "surname", notNull(u.surname), - "mail", notNull(u.mail), - "roles", roles, - "internal", internal - ); - return expand == null ? - core : - stream(expand.split(",")) - .map(String::trim) - .filter(Objects::nonNull) - .filter(not(String::isEmpty)) - .reduce( - new HashMap<>(core), (map, element) -> { - try { - map.put(element, u.getClass().getField(element).get(u)); - } catch (Exception e) { - e.printStackTrace(); - } - return map; - }, - (map1, map2) -> { - map1.putAll(map2); - return map1; - } - ); - } - - private String notNull(String mayBeNull) {return mayBeNull == null? "" :mayBeNull;} - -} +package com.baloise.azure; + +import static java.lang.String.format; +import static java.util.Arrays.asList; +import static java.util.stream.Collectors.joining; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.logging.Level; + +import com.azure.identity.ClientSecretCredential; +import com.azure.identity.ClientSecretCredentialBuilder; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.microsoft.azure.functions.ExecutionContext; +import com.microsoft.azure.functions.HttpMethod; +import com.microsoft.azure.functions.HttpRequestMessage; +import com.microsoft.azure.functions.HttpResponseMessage; +import com.microsoft.azure.functions.HttpResponseMessage.Builder; +import com.microsoft.azure.functions.HttpStatus; +import com.microsoft.azure.functions.annotation.AuthorizationLevel; +import com.microsoft.azure.functions.annotation.FunctionName; +import com.microsoft.azure.functions.annotation.HttpTrigger; + +import common.StringTree; + +/** + * Azure Functions with HTTP Trigger. + */ +public class FunctionalOrgEndpoint { + /** + * This function listens at endpoint "/api/Hello". Two ways to invoke it using + * "curl" command in bash: 1. curl -d "HTTP Body" {your host}/api/Hello 2. curl + * "{your host}/api/Hello?name=HTTP%20Query" + */ + + Vault lazyVault = null; + Graph lazygraph = null; + ObjectMapper objectMapper = new ObjectMapper(); + + Vault vault() { + if(lazyVault == null) { + lazyVault = new Vault(); + } + return lazyVault; + } + + Graph graph() { + if(lazygraph == null) { + final String[] scopes = new String[] { AzureProperties.defaultScope() }; + final ClientSecretCredential credential = + new ClientSecretCredentialBuilder() + .clientId(AzureProperties.clientId()). + tenantId(AzureProperties.tenantId()) + .clientSecret( + vault().getSecret(AzureProperties.clientSecretName(), true) + ) + .build(); + + + lazygraph = new Graph(credential, scopes); + } + return lazygraph; + } + + @FunctionName("V1") + public HttpResponseMessage v1( + @HttpTrigger( + name = "req", + methods = { HttpMethod.GET, HttpMethod.POST }, + authLevel = AuthorizationLevel.ANONYMOUS, + route = "V1/{a=null}/{b=null}/{c=null}/{d=null}/{e=null}/{f=null}/{g=null}/{h=null}/{i=null}" + ) + HttpRequestMessage> request, + final ExecutionContext context + ) { + try { + List path = asList(request.getUri().getPath().split("/")); + path = path.subList(path.indexOf("V1")+1, path.size()); + if(path.size() ==2 && "avatar".equals(path.get(0))) { + return createAvatarResponse(request, path.get(1)); + } + + final StringTree child = graph().getOrg().getChild(path.toArray(new String[0])); + + return child.isLeaf() ? createTeamResponse(request, context, child) : createOrganisationResponse(request, context, child); + + } catch (Throwable t) { + context.getLogger().log(Level.WARNING, t.getLocalizedMessage(), t); + return request.createResponseBuilder(HttpStatus.INTERNAL_SERVER_ERROR).body(t.getLocalizedMessage()).build(); + } + } + + private HttpResponseMessage createAvatarResponse(HttpRequestMessage> request, String id) throws IOException { + byte[] avatar = graph().avatar(id); + String myETag = String.valueOf(Arrays.hashCode(avatar)); + String theirETag = ignoreKeyCase(request.getHeaders()).get("If-None-Match"); + boolean sameEtag = Objects.equals(myETag, theirETag); + Builder response = request + .createResponseBuilder(sameEtag? HttpStatus.NOT_MODIFIED : HttpStatus.OK) + .header("Content-Type","image/jpeg") + .header("ETag",myETag); + if(!sameEtag) { + response = response + .header("Content-Length",String.valueOf(avatar.length)) + .body(avatar); + } + return response.build(); + } + + private Map ignoreKeyCase(Map mixedMap) { + Map lowMap = new HashMap<>() { + private static final long serialVersionUID = 5782240485145186162L; + @Override + public String get(Object key) { + return super.get(key.toString().toLowerCase()); + } + }; + mixedMap.entrySet().stream().forEach(e->lowMap.put(e.getKey().toLowerCase(), e.getValue())); + return lowMap; + } + + private HttpResponseMessage createTeamResponse(HttpRequestMessage> request, ExecutionContext context, StringTree team) + throws JsonProcessingException { + + final Map body = graph().loadTeam(team.getProperty("id"), getRoles(request)); + body.put("name", team.getName()); + body.put("url", format("%s/%s", getPath(request),team.getName())); + return createJSONResponse(request, body); + } + + String[] getRoles(HttpRequestMessage> request) { + String roles = request.getQueryParameters().get("roles"); + return (roles == null) ? new String[0] : roles.split("\\s+,\\s+"); + } + + private HttpResponseMessage createOrganisationResponse(HttpRequestMessage> request, ExecutionContext context, StringTree tree) + throws JsonProcessingException { + Map>> response = new HashMap<>(); + response.put("units", new ArrayList>()); + response.put("teams", new ArrayList>()); + for (StringTree child : tree.getChildren()) { + if(child.isLeaf()) { + response.get("teams").add(Map.of( + "name", child.getName(), + "url", format("%s/%s", getPath(request),child.getName()) + )); + } else { + response.get("units").add(Map.of( + "name", child.getName(), + "url", format("%s/%s", getPath(request),child.getName()) + )); + } + } + return createJSONResponse(request, response); + } + + HttpResponseMessage createJSONResponse(HttpRequestMessage> request, + Object body) throws JsonProcessingException { + return request.createResponseBuilder(HttpStatus.OK) + .header("Content-Type","application/json; charset=UTF-8") + .body(objectMapper.writeValueAsString(body)).build(); + } + + private String getPath(HttpRequestMessage request) { + String uri = getCleanUri(request); + return uri.substring(0, uri.lastIndexOf('/')); + } + + private String getCleanUri(HttpRequestMessage request) { + return request.getUri().toString().replaceFirst("/\\z", ""); + } + + @FunctionName("dump") + public HttpResponseMessage dump( + final ExecutionContext context, + @HttpTrigger(name = "req", methods = { HttpMethod.GET, HttpMethod.POST }, authLevel = AuthorizationLevel.ANONYMOUS) + HttpRequestMessage> request + ) { + return request.createResponseBuilder(HttpStatus.OK).body( + listEnv() + listProps() + ).build(); + } + + @FunctionName("hello") + public HttpResponseMessage hello( + final ExecutionContext context, + @HttpTrigger(name = "req", methods = { HttpMethod.GET, HttpMethod.POST }, authLevel = AuthorizationLevel.ANONYMOUS) + HttpRequestMessage> request + ) { + try { + return request.createResponseBuilder(HttpStatus.OK).body( + "Hi there" + ).build(); + } catch (Throwable t) { + context.getLogger().log(Level.WARNING, t.getLocalizedMessage(), t); + throw t; + } + } + + private String listProps() { + return "\nProps\n\n"+ System.getProperties().entrySet().stream().map(e -> format("%s = %s", e.getKey(), e.getValue())).collect(joining("\n")); + } + + private String listEnv() { + return "\nEnv\n\n"+System.getenv().entrySet().stream().map(e -> format("%s = %s", e.getKey(), e.getValue())).collect(joining("\n")); + } + +} diff --git a/src/main/java/com/baloise/azure/Graph.java b/src/main/java/com/baloise/azure/Graph.java index e9f567b..7be578d 100644 --- a/src/main/java/com/baloise/azure/Graph.java +++ b/src/main/java/com/baloise/azure/Graph.java @@ -1,100 +1,139 @@ -package com.baloise.azure; - -import static java.lang.String.format; -import static java.util.Comparator.comparing; -import static java.util.EnumSet.noneOf; - -import java.io.IOException; -import java.io.InputStream; -import java.util.ArrayList; -import java.util.Collections; -import java.util.EnumSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeMap; - -import com.baloise.funorg.Role; -import com.baloise.funorg.Team; -import com.microsoft.graph.authentication.IAuthenticationProvider; -import com.microsoft.graph.http.BaseCollectionPage; -import com.microsoft.graph.http.BaseCollectionRequestBuilder; -import com.microsoft.graph.http.BaseEntityCollectionRequest; -import com.microsoft.graph.http.BaseRequestBuilder; -import com.microsoft.graph.http.ICollectionResponse; -import com.microsoft.graph.models.DirectoryObject; -import com.microsoft.graph.models.Group; -import com.microsoft.graph.models.User; -import com.microsoft.graph.options.QueryOption; -import com.microsoft.graph.requests.GraphServiceClient; - -import okhttp3.Request; - -public class Graph { - final IAuthenticationProvider auth; - final GraphServiceClient graphClient; - Map> lazyUserRoles; - - public Graph(IAuthenticationProvider auth) { - this.auth = auth; - graphClient = GraphServiceClient.builder() - .authenticationProvider(auth) - .buildClient(); - } - - public List getTeams() { - return getTeams(""); - } - public List getTeams(String filter) { - return getGroups(Team.PREFIX.concat(filter)); - } - - private List getGroups(String filter) { - return readAll(graphClient.groups().buildRequest(new QueryOption("$filter", format("startswith(displayName,'%s')", filter)))); - } - - private , T3 extends BaseCollectionPage>> List readAll(BaseEntityCollectionRequest request) { - return readAll(new ArrayList<>(), request); - } - - @SuppressWarnings({ "unchecked", "rawtypes" }) - private , T3 extends BaseCollectionPage>> List readAll(List all, BaseEntityCollectionRequest request) { - T3 page = request.get(); - all.addAll(page.getCurrentPage()); - BaseCollectionRequestBuilder builder = (BaseCollectionRequestBuilder) page.getNextPage(); - if (builder != null) { - readAll(all, (BaseEntityCollectionRequest) builder.buildRequest()); - } - return all; - } - - private List getGroupMembersByDisplayName(String displayName) { - return getGroupMembers(getGroups(displayName).get(0)); - } - - public List getGroupMembers(Group group) { - return readAll(graphClient.groups().byId(group.id).members().buildRequest()); - } - - public List getGroupMembers(Role role) { - return getGroupMembersByDisplayName(role.entitlement); - } - - public byte[] avatar(String id) throws IOException { - try(InputStream is = graphClient.users(id).photo().content().buildRequest().get()){ - return is.readAllBytes(); - } - } - - public Set getRoles(User user) { - if(lazyUserRoles == null) { - lazyUserRoles = new TreeMap>(comparing(u->u.id)); - EnumSet.allOf(Role.class).forEach(role-> { - getGroupMembers(role).forEach(u-> lazyUserRoles.getOrDefault(user, noneOf(Role.class)).add(role)); - }); - } - - return lazyUserRoles.getOrDefault(user,noneOf(Role.class)); - } - -} +package com.baloise.azure; + +import static java.lang.String.format; +import static java.util.Arrays.asList; +import static java.util.Arrays.stream; +import static java.util.Map.of; +import static java.util.function.Function.identity; +import static java.util.stream.Collectors.joining; +import static java.util.stream.Collectors.toList; +import static java.util.stream.Collectors.toMap; +import static java.util.stream.Collectors.toSet; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.azure.identity.ClientSecretCredential; +import com.microsoft.graph.models.Team; +import com.microsoft.graph.models.TeamCollectionResponse; +import com.microsoft.graph.models.TeamworkTag; +import com.microsoft.graph.models.TeamworkTagMember; +import com.microsoft.graph.models.User; +import com.microsoft.graph.serviceclient.GraphServiceClient; + +import common.StringTree; + +public class Graph { + private final String SCRUM_ROLES = "~SCRUM"; + private final Map> rolesSchemes = new HashMap<>(of(SCRUM_ROLES, new TreeSet(asList("Member", "ScrumMaster", "ProductOwner")))); + final String teamMarker = "👨‍👨‍👦‍👦"; + final String orgMarker = "🏢"; + final String orgSeparator = "-"; + final String teamFilter = "startsWith(displayName,'"+teamMarker+"')"; + final Pattern orgPattern = Pattern.compile(orgMarker+"\\s*\\(\\s*([\\w"+orgSeparator+"]+)\\s*\\)"); + StringTree org = new StringTree("Baloise"); + GraphServiceClient graphClient; + + Graph() { + // for testing only + } + + public Graph(ClientSecretCredential credential, String[] scopes) { + graphClient = new GraphServiceClient(credential, scopes); + } + + + public byte[] avatar(String id) throws IOException { + try(InputStream is = graphClient.users().byUserId(id).photo().content().get()){ + return is.readAllBytes(); + } + } + + public StringTree getOrg() { + + TeamCollectionResponse response = graphClient.teams().get(requestConfiguration -> { + requestConfiguration.queryParameters.filter = teamFilter; + }); + + for (Team team : response.getValue()) { + org.merge( + parseOrg(team.getDescription()) + .addChild(new StringTree(parseName(team.getDisplayName())).withProperty("id", team.getId())) + .getRoot() + ); + } + return org; + } + + private String notNull(String mayBeNull) {return mayBeNull == null? "" :mayBeNull;} + + public Map loadTeam(String teamId, String ... roleNames) { + return expandRoles(roleNames).stream().collect(toMap(identity(), (roleName)-> { + final String tagId = getTagId(teamId, roleName); + return tagId == null ? + Collections.emptyList() : + map(graphClient.teams().byTeamId(teamId).tags().byTeamworkTagId(tagId).members().get() + .getValue()); + })); + } + + private List> map(List members) { + return graphClient.users().get((requestConfiguration)->{ + requestConfiguration.queryParameters.filter = format( + "id in (%s)", + members.stream().map(TeamworkTagMember::getUserId).collect(joining("', '", "'", "'")) + ); + requestConfiguration.queryParameters.select = new String []{"displayName", "mail", "officeLocation","preferredLanguage"}; + }).getValue().stream().map(this::mapMember).collect(toList()); + } + + private Map mapMember(User u) { + return of( + "displayName", notNull(u.getDisplayName()), + "mail", notNull(u.getMail()), + "officeLocation", notNull(u.getOfficeLocation()), + "preferredLanguage", notNull(u.getPreferredLanguage()) + ); + + } + + private String getTagId(String teamId, String tagName) { + return getTags(teamId).get(tagName); + } + + Map> tagCache = new HashMap<>(); + private Map getTags(String teamId) { + return tagCache.computeIfAbsent(teamId, + (id)-> graphClient.teams().byTeamId(id).tags().get().getValue().stream().collect(toMap(TeamworkTag::getDisplayName, TeamworkTag::getId)) + ); + } + + + Set expandRoles(String ... rolesNames) { + return rolesNames == null || rolesNames.length == 0 ? + expandRoles(SCRUM_ROLES) : + stream(rolesNames) + .flatMap((name)-> rolesSchemes.computeIfAbsent(name, Collections::singleton).stream()) + .collect(toSet()); + } + + String parseName(String input) { + return input.replaceAll(teamMarker, "").trim(); + } + + + StringTree parseOrg(String description) { + Matcher matcher = orgPattern.matcher(description); + matcher.find(); + return new StringTree(org.getName()).addChild(matcher.group(1).split(orgSeparator)); + } + +} diff --git a/src/main/java/com/baloise/funorg/Role.java b/src/main/java/com/baloise/funorg/Role.java index 5ebae27..8d7bd9b 100644 --- a/src/main/java/com/baloise/funorg/Role.java +++ b/src/main/java/com/baloise/funorg/Role.java @@ -1,22 +1,14 @@ -package com.baloise.funorg; - -import static java.util.Arrays.stream; -import static java.util.stream.Collectors.joining; - -public enum Role { - SCRUM_MASTER, PRODUCT_OWNER; - private final static String PREFIX = "F-AAD-ROLE-"; - final static String SEPERATOR = Team.SEPERATOR; - - public final String entitlement; - private Role() { - entitlement = PREFIX+camelCase(); - } - private String camelCase(String s) { - return s.charAt(0)+s.substring(1).toLowerCase(); - } - private String camelCase() { - return stream(toString().split("_")).map(this::camelCase).collect(joining(SEPERATOR)); - } - -} +package com.baloise.funorg; + +public enum Role { + SCRUM_MASTER, PRODUCT_OWNER; + + public final String entitlement; + private Role() { + entitlement = camelCase(name()); + } + private String camelCase(String s) { + return s.charAt(0)+s.substring(1).toLowerCase(); + } + +} diff --git a/src/main/java/com/baloise/funorg/Team.java b/src/main/java/com/baloise/funorg/Team.java deleted file mode 100644 index beba972..0000000 --- a/src/main/java/com/baloise/funorg/Team.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.baloise.funorg; - -import static java.lang.String.format; -import static java.util.Arrays.stream; -import static java.util.stream.Collectors.joining; - -import java.text.ParseException; -import java.util.Arrays; -import java.util.logging.Level; -import java.util.logging.Logger; -import java.util.regex.Pattern; - -public record Team(String unit, String name,boolean internal) { - public final static String PREFIX = "F-AAD-TEAM-"; - final static String SEPERATOR = "-"; - private final static String INTERNAL = "INTERNAL"; - - /** - * @param logger - * @param entitlement - * @return null if a ParseException occurs, the exception will be logged as WARNING - */ - public static Team parse(Logger logger, String entitlement) { - try { - return parse(entitlement); - } catch (ParseException e) { - logger.log(Level.WARNING, e.getLocalizedMessage(), e); - return null; - } - } - - public static Team parse(String entitlement) throws ParseException { - String[] tokens = entitlement.split(Pattern.quote(SEPERATOR)); - if(tokens.length <6) throw new ParseException(format("%s dos not contains the minimum of 5 seperators: %s",entitlement, SEPERATOR),0); - return new Team( - stream(Arrays.copyOfRange(tokens, 3, tokens.length-2)).collect(joining(SEPERATOR)), - tokens[tokens.length-2], - INTERNAL.equals(tokens[tokens.length-1]) - ); - } -} diff --git a/src/main/java/common/StringTree.java b/src/main/java/common/StringTree.java new file mode 100644 index 0000000..76caf91 --- /dev/null +++ b/src/main/java/common/StringTree.java @@ -0,0 +1,146 @@ +package common; + +import static java.lang.String.format; +import static java.util.stream.Collectors.joining; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; + +public class StringTree implements Comparable{ + private final String name; + private StringTree parent; + private final TreeSet children = new TreeSet<>(); + private Map lazyProperties; + + + private Map getProperties() { + if(lazyProperties == null) lazyProperties = new HashMap<>(); + return lazyProperties; + } + + public StringTree(String[] names) { + this(names[0]); + for (int i = 1; i < names.length; i++) { + addChild(names[i]); + } + } + + public StringTree(String name) { + if(name == null) throw new IllegalArgumentException("name must not be null"); + this.name = name; + } + + public String getName() { + return name; + } + + public StringTree getRoot() { + return parent == null? this : parent.getRoot(); + } + + public StringTree getParent() { + return parent; + } + + public boolean isLeaf() { + return getChildren().isEmpty(); + } + + public Set getChildren() { + return children; + } + + public StringTree addChild(String ... names) { + StringTree parent = this; + for (String name : names) { + parent = parent.addChild(new StringTree(name)); + } + return parent; + } + + public StringTree addChild(StringTree tree) { + if(children.add(tree)) { + tree.parent = this; + return tree; + } else { + return getChild(tree); + } + } + + public StringTree getChild(String ... names) { + StringTree tree = this; + StringTree child; + for (String name : names) { + child = tree.getChild(name); + if(child == null) throw new IllegalArgumentException(format("%s has no child named %s", tree.getName(), name)); + tree = child; + } + return tree; + } + + public StringTree getChild(String name) { + return getChild(new StringTree(name)); + } + + public StringTree getChild(StringTree tree) { + if(tree == null) throw new IllegalArgumentException("tree must not be null"); + StringTree floor = children.floor(tree); + return (floor != null && tree.compareTo(floor) == 0) ? floor : null; + } + + public StringTree merge(StringTree other) { + if(!equals(other)) throw new IllegalArgumentException(format("Can not merge %s into %s - names musts be equal", other, this)); + for (StringTree otherChild : other.children) { + StringTree myChild = getChild(other); + if(myChild == null) + addChild(otherChild); + else + myChild.merge(otherChild); + } + return this; + } + + @Override + public String toString() { + return getName(); + } + + public String dump() { + return format("(%s:%s)", name, children.stream().map(StringTree::dump).collect(joining(","))); + } + + @Override + public int compareTo(StringTree o) { + return getName().compareTo(o.getName()); + } + + @Override + public int hashCode() { + return Objects.hash(name); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + StringTree other = (StringTree) obj; + return compareTo(other) == 0; + } + + public StringTree withProperty(String key, String value) { + getProperties().put(key, value); + return this; + } + + public String getProperty(String key) { + return getProperties().get(key); + } + +} diff --git a/src/test/java/com/baloise/azure/GraphTest.java b/src/test/java/com/baloise/azure/GraphTest.java new file mode 100644 index 0000000..269fe88 --- /dev/null +++ b/src/test/java/com/baloise/azure/GraphTest.java @@ -0,0 +1,22 @@ +package com.baloise.azure; + +import static java.util.Arrays.asList; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.TreeSet; + +import org.junit.jupiter.api.Test; + +class GraphTest { + + @Test + public void testExpandRoles() { + Graph g = new Graph(); + assertEquals(new TreeSet(asList("Member","ProductOwner", "ScrumMaster")), g.expandRoles(null)); + assertEquals(new TreeSet(asList("Member","ProductOwner", "ScrumMaster")), g.expandRoles()); + assertEquals(new TreeSet(asList("Member","ProductOwner", "ScrumMaster")), g.expandRoles("~SCRUM")); + assertEquals(new TreeSet(asList("Member","ProductOwner", "ScrumMaster", "test")), g.expandRoles("~SCRUM", "test")); + assertEquals(new TreeSet(asList("a", "test")), g.expandRoles("a", "test")); + } + +} diff --git a/src/test/java/com/baloise/funorg/RoleTest.java b/src/test/java/com/baloise/funorg/RoleTest.java index a392e91..88efbeb 100644 --- a/src/test/java/com/baloise/funorg/RoleTest.java +++ b/src/test/java/com/baloise/funorg/RoleTest.java @@ -1,16 +1,15 @@ -package com.baloise.funorg; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.util.EnumSet; - -import org.junit.jupiter.api.Test; - -class RoleTest { - - @Test - public void testRole() { - assertEquals("F-AAD-ROLE-Product-Owner", Role.PRODUCT_OWNER.entitlement); - } - -} +package com.baloise.funorg; + + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class RoleTest { + + @Test + public void testRole() { + assertEquals("F-AAD-ROLE-Product-Owner", Role.PRODUCT_OWNER.entitlement); + } + +} diff --git a/src/test/java/com/baloise/funorg/TeamTest.java b/src/test/java/com/baloise/funorg/TeamTest.java deleted file mode 100644 index f9a4f09..0000000 --- a/src/test/java/com/baloise/funorg/TeamTest.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.baloise.funorg; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.text.ParseException; - -import org.junit.jupiter.api.Test; - -class TeamTest { - - @Test - public void parseFAADTEAMBBEDevOpsSystemTeamINTERNAL() throws ParseException { - Team team = Team.parse("F-AAD-TEAM-UNIT-DevOpsSystemTeam-INTERNAL"); - assertEquals("UNIT", team.unit()); - assertEquals("DevOpsSystemTeam", team.name()); - assertTrue(team.internal()); - } - - @Test - public void parseFAADTEAMGITDevOpsSystemEXTERNAL() throws ParseException { - Team team = Team.parse("F-AAD-TEAM-OTHER-ATEAM-EXTERNAL"); - assertEquals("OTHER", team.unit()); - assertEquals("ATEAM", team.name()); - assertFalse(team.internal()); - } - - @Test - public void parseFAADTEAMBCHITDevOpsComplianceINTERNAL() throws ParseException { - Team team = Team.parse("F-AAD-TEAM-A-UNIT-ATEAM-INTERNAL"); - assertEquals("A-UNIT", team.unit()); - assertEquals("ATEAM", team.name()); - assertTrue(team.internal()); - } - - @Test - public void parseFAADTEAMBCHIntegrationSecurity() throws ParseException { - Exception exception = assertThrows(ParseException.class, () -> { - Team.parse("F-AAD-TEAM-BCH-Integration & Security"); - }); - assertEquals("F-AAD-TEAM-BCH-Integration & Security dos not contains the minimum of 5 seperators: -", exception.getLocalizedMessage()); - } - -} diff --git a/src/test/java/common/StringTreeTest.java b/src/test/java/common/StringTreeTest.java new file mode 100644 index 0000000..dc3524e --- /dev/null +++ b/src/test/java/common/StringTreeTest.java @@ -0,0 +1,34 @@ +package common; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.text.ParseException; + +import org.junit.jupiter.api.Test; + +public class StringTreeTest { + @Test + public void merge() throws ParseException { + StringTree tree = new StringTree("fruit"); + StringTree child = tree.addChild("citrus", "orange"); + assertEquals("orange", child.getName()); + assertEquals("citrus", child.getParent().getName()); + tree.addChild("citrus", "mandarine"); + tree.addChild("apple"); + tree.addChild("peach"); + assertEquals("(fruit:(apple:),(citrus:(mandarine:),(orange:)),(peach:))", tree.dump()); + + StringTree other = new StringTree("fruit"); + tree.addChild("tropical", "banana"); + tree.addChild("tropical", "pineapple"); + + tree.merge(other); + assertEquals("(fruit:(apple:),(citrus:(mandarine:),(orange:)),(peach:),(tropical:(banana:),(pineapple:)))", tree.dump()); + } + + @Test + public void testGetRoot() { + assertEquals("(d:)", new StringTree("a").addChild("b", "c", "d").dump()); + assertEquals("(a:(b:(c:(d:))))", new StringTree("a").addChild("b", "c", "d").getRoot().dump()); + } +}