diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java index fde0dd26b6daec..2c864f2859090c 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java @@ -104,8 +104,11 @@ import com.linkedin.datahub.graphql.resolvers.load.UsageTypeResolver; import com.linkedin.datahub.graphql.resolvers.mutate.AddLinkResolver; import com.linkedin.datahub.graphql.resolvers.mutate.AddOwnerResolver; +import com.linkedin.datahub.graphql.resolvers.mutate.AddOwnersResolver; import com.linkedin.datahub.graphql.resolvers.mutate.AddTagResolver; +import com.linkedin.datahub.graphql.resolvers.mutate.AddTagsResolver; import com.linkedin.datahub.graphql.resolvers.mutate.AddTermResolver; +import com.linkedin.datahub.graphql.resolvers.mutate.AddTermsResolver; import com.linkedin.datahub.graphql.resolvers.mutate.MutableTypeResolver; import com.linkedin.datahub.graphql.resolvers.mutate.RemoveLinkResolver; import com.linkedin.datahub.graphql.resolvers.mutate.RemoveOwnerResolver; @@ -584,14 +587,17 @@ private void configureMutationResolvers(final RuntimeWiring.Builder builder) { .dataFetcher("updateCorpUserProperties", new MutableTypeResolver<>(corpUserType)) .dataFetcher("updateCorpGroupProperties", new MutableTypeResolver<>(corpGroupType)) .dataFetcher("addTag", new AddTagResolver(entityService)) + .dataFetcher("addTags", new AddTagsResolver(entityService)) .dataFetcher("removeTag", new RemoveTagResolver(entityService)) .dataFetcher("addTerm", new AddTermResolver(entityService)) + .dataFetcher("addTerms", new AddTermsResolver(entityService)) .dataFetcher("removeTerm", new RemoveTermResolver(entityService)) .dataFetcher("createPolicy", new UpsertPolicyResolver(this.entityClient)) .dataFetcher("updatePolicy", new UpsertPolicyResolver(this.entityClient)) .dataFetcher("deletePolicy", new DeletePolicyResolver(this.entityClient)) .dataFetcher("updateDescription", new UpdateDescriptionResolver(entityService)) .dataFetcher("addOwner", new AddOwnerResolver(entityService)) + .dataFetcher("addOwners", new AddOwnersResolver(entityService)) .dataFetcher("removeOwner", new RemoveOwnerResolver(entityService)) .dataFetcher("addLink", new AddLinkResolver(entityService)) .dataFetcher("removeLink", new RemoveLinkResolver(entityService)) diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/AddOwnerResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/AddOwnerResolver.java index 2e5ebbd3c6f6cd..faddf984f7bbf0 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/AddOwnerResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/AddOwnerResolver.java @@ -47,7 +47,7 @@ public CompletableFuture get(DataFetchingEnvironment environment) throw ); try { - log.debug("Adding Link. input: {}", input.toString()); + log.debug("Adding Owner. input: {}", input.toString()); Urn actor = CorpuserUrn.createFromString(((QueryContext) environment.getContext()).getActorUrn()); OwnerUtils.addOwner( diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/AddOwnersResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/AddOwnersResolver.java new file mode 100644 index 00000000000000..8d9630f08ad4ed --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/AddOwnersResolver.java @@ -0,0 +1,63 @@ +package com.linkedin.datahub.graphql.resolvers.mutate; + +import com.linkedin.common.urn.CorpuserUrn; + +import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.AddOwnersInput; +import com.linkedin.datahub.graphql.generated.OwnerInput; +import com.linkedin.datahub.graphql.resolvers.mutate.util.OwnerUtils; +import com.linkedin.metadata.entity.EntityService; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*; + + +@Slf4j +@RequiredArgsConstructor +public class AddOwnersResolver implements DataFetcher> { + + private final EntityService _entityService; + + @Override + public CompletableFuture get(DataFetchingEnvironment environment) throws Exception { + final AddOwnersInput input = bindArgument(environment.getArgument("input"), AddOwnersInput.class); + List owners = input.getOwners(); + Urn targetUrn = Urn.createFromString(input.getResourceUrn()); + + return CompletableFuture.supplyAsync(() -> { + + if (!OwnerUtils.isAuthorizedToUpdateOwners(environment.getContext(), targetUrn)) { + throw new AuthorizationException("Unauthorized to perform this action. Please contact your DataHub administrator."); + } + + OwnerUtils.validateAddInput( + owners, + targetUrn, + _entityService + ); + try { + + log.debug("Adding Owners. input: {}", input.toString()); + + Urn actor = CorpuserUrn.createFromString(((QueryContext) environment.getContext()).getActorUrn()); + OwnerUtils.addOwners( + owners, + targetUrn, + actor, + _entityService + ); + return true; + } catch (Exception e) { + log.error("Failed to add owners to resource with input {}, {}", input.toString(), e.getMessage()); + throw new RuntimeException(String.format("Failed to add owners to resource with input %s", input.toString()), e); + } + }); + } +} \ No newline at end of file diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/AddTagResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/AddTagResolver.java index 16c3cd367cadb0..b2420f8a446d2f 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/AddTagResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/AddTagResolver.java @@ -1,5 +1,6 @@ package com.linkedin.datahub.graphql.resolvers.mutate; +import com.google.common.collect.ImmutableList; import com.linkedin.common.urn.CorpuserUrn; import com.linkedin.common.urn.Urn; @@ -51,8 +52,8 @@ public CompletableFuture get(DataFetchingEnvironment environment) throw log.info("Adding Tag. input: {}", input.toString()); Urn actor = CorpuserUrn.createFromString(((QueryContext) environment.getContext()).getActorUrn()); - LabelUtils.addTagToTarget( - tagUrn, + LabelUtils.addTagsToTarget( + ImmutableList.of(tagUrn), targetUrn, input.getSubResource(), actor, diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/AddTagsResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/AddTagsResolver.java new file mode 100644 index 00000000000000..b8791b91732181 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/AddTagsResolver.java @@ -0,0 +1,69 @@ +package com.linkedin.datahub.graphql.resolvers.mutate; + +import com.linkedin.common.urn.CorpuserUrn; + +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.AddTagsInput; +import com.linkedin.datahub.graphql.resolvers.mutate.util.LabelUtils; +import com.linkedin.metadata.entity.EntityService; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*; + + +@Slf4j +@RequiredArgsConstructor +public class AddTagsResolver implements DataFetcher> { + + private final EntityService _entityService; + + @Override + public CompletableFuture get(DataFetchingEnvironment environment) throws Exception { + final AddTagsInput input = bindArgument(environment.getArgument("input"), AddTagsInput.class); + List tagUrns = input.getTagUrns().stream() + .map(UrnUtils::getUrn) + .collect(Collectors.toList()); + Urn targetUrn = Urn.createFromString(input.getResourceUrn()); + + return CompletableFuture.supplyAsync(() -> { + + if (!LabelUtils.isAuthorizedToUpdateTags(environment.getContext(), targetUrn, input.getSubResource())) { + throw new AuthorizationException("Unauthorized to perform this action. Please contact your DataHub administrator."); + } + + LabelUtils.validateInput( + tagUrns, + targetUrn, + input.getSubResource(), + input.getSubResourceType(), + "tag", + _entityService, + false + ); + try { + log.info("Adding Tags. input: {}", input.toString()); + Urn actor = CorpuserUrn.createFromString(((QueryContext) environment.getContext()).getActorUrn()); + LabelUtils.addTagsToTarget( + tagUrns, + targetUrn, + input.getSubResource(), + actor, + _entityService + ); + return true; + } catch (Exception e) { + log.error("Failed to perform update against input {}, {}", input.toString(), e.getMessage()); + throw new RuntimeException(String.format("Failed to perform update against input %s", input.toString()), e); + } + }); + } +} \ No newline at end of file diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/AddTermResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/AddTermResolver.java index 83c4a5ddee88b1..f69a23bc5763bd 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/AddTermResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/AddTermResolver.java @@ -1,5 +1,6 @@ package com.linkedin.datahub.graphql.resolvers.mutate; +import com.google.common.collect.ImmutableList; import com.linkedin.common.urn.CorpuserUrn; import com.linkedin.common.urn.Urn; import com.linkedin.datahub.graphql.QueryContext; @@ -44,8 +45,8 @@ public CompletableFuture get(DataFetchingEnvironment environment) throw try { log.info("Adding Term. input: {}", input); Urn actor = CorpuserUrn.createFromString(((QueryContext) environment.getContext()).getActorUrn()); - LabelUtils.addTermToTarget( - termUrn, + LabelUtils.addTermsToTarget( + ImmutableList.of(termUrn), targetUrn, input.getSubResource(), actor, diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/AddTermsResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/AddTermsResolver.java new file mode 100644 index 00000000000000..3676c589c3c648 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/AddTermsResolver.java @@ -0,0 +1,67 @@ +package com.linkedin.datahub.graphql.resolvers.mutate; + +import com.linkedin.common.urn.CorpuserUrn; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.AddTermsInput; +import com.linkedin.datahub.graphql.resolvers.mutate.util.LabelUtils; +import com.linkedin.metadata.entity.EntityService; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*; + +@Slf4j +@RequiredArgsConstructor +public class AddTermsResolver implements DataFetcher> { + private final EntityService _entityService; + + @Override + public CompletableFuture get(DataFetchingEnvironment environment) throws Exception { + final AddTermsInput input = bindArgument(environment.getArgument("input"), AddTermsInput.class); + List termUrns = input.getTermUrns().stream() + .map(UrnUtils::getUrn) + .collect(Collectors.toList()); + Urn targetUrn = Urn.createFromString(input.getResourceUrn()); + + return CompletableFuture.supplyAsync(() -> { + + if (!LabelUtils.isAuthorizedToUpdateTerms(environment.getContext(), targetUrn, input.getSubResource())) { + throw new AuthorizationException("Unauthorized to perform this action. Please contact your DataHub administrator."); + } + + LabelUtils.validateInput( + termUrns, + targetUrn, + input.getSubResource(), + input.getSubResourceType(), + "glossaryTerm", + _entityService, + false + ); + + try { + log.info("Adding Term. input: {}", input); + Urn actor = CorpuserUrn.createFromString(((QueryContext) environment.getContext()).getActorUrn()); + LabelUtils.addTermsToTarget( + termUrns, + targetUrn, + input.getSubResource(), + actor, + _entityService + ); + return true; + } catch (Exception e) { + log.error("Failed to perform update against input {}, {}", input.toString(), e.getMessage()); + throw new RuntimeException(String.format("Failed to perform update against input %s", input.toString()), e); + } + }); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/LabelUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/LabelUtils.java index 36776d9eaca0bf..0213bddece60f8 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/LabelUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/LabelUtils.java @@ -23,6 +23,8 @@ import com.linkedin.schema.EditableSchemaFieldInfo; import com.linkedin.schema.EditableSchemaMetadata; import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; import javax.annotation.Nonnull; import lombok.extern.slf4j.Slf4j; @@ -100,8 +102,8 @@ public static void removeTagFromTarget( } } - public static void addTagToTarget( - Urn labelUrn, + public static void addTagsToTarget( + List labelUrns, Urn targetUrn, String subResource, Urn actor, @@ -114,7 +116,7 @@ public static void addTagToTarget( if (!tags.hasTags()) { tags.setTags(new TagAssociationArray()); } - addTagIfNotExists(tags, labelUrn); + addTagsIfNotExists(tags, labelUrns); persistAspect(targetUrn, TAGS_ASPECT_NAME, tags, actor, entityService); } else { com.linkedin.schema.EditableSchemaMetadata editableSchemaMetadata = @@ -126,13 +128,13 @@ public static void addTagToTarget( editableFieldInfo.setGlobalTags(new GlobalTags()); } - addTagIfNotExists(editableFieldInfo.getGlobalTags(), labelUrn); + addTagsIfNotExists(editableFieldInfo.getGlobalTags(), labelUrns); persistAspect(targetUrn, EDITABLE_SCHEMA_METADATA, editableSchemaMetadata, actor, entityService); } } - public static void addTermToTarget( - Urn labelUrn, + public static void addTermsToTarget( + List labelUrns, Urn targetUrn, String subResource, Urn actor, @@ -147,7 +149,8 @@ public static void addTermToTarget( terms.setTerms(new GlossaryTermAssociationArray()); } - addTermIfNotExistsToEntity(terms, labelUrn); + addTermsIfNotExistsToEntity(terms, labelUrns); + System.out.println(String.format("Persisting terms! %s", terms.toString())); persistAspect(targetUrn, GLOSSARY_TERM_ASPECT_NAME, terms, actor, entityService); } else { com.linkedin.schema.EditableSchemaMetadata editableSchemaMetadata = @@ -161,12 +164,12 @@ public static void addTermToTarget( editableFieldInfo.getGlossaryTerms().setAuditStamp(getAuditStamp(actor)); - addTermIfNotExistsToEntity(editableFieldInfo.getGlossaryTerms(), labelUrn); + addTermsIfNotExistsToEntity(editableFieldInfo.getGlossaryTerms(), labelUrns); persistAspect(targetUrn, EDITABLE_SCHEMA_METADATA, editableSchemaMetadata, actor, entityService); } } - private static void addTermIfNotExistsToEntity(GlossaryTerms terms, Urn termUrn) + private static void addTermsIfNotExistsToEntity(GlossaryTerms terms, List termUrns) throws URISyntaxException { if (!terms.hasTerms()) { terms.setTerms(new GlossaryTermAssociationArray()); @@ -174,14 +177,24 @@ private static void addTermIfNotExistsToEntity(GlossaryTerms terms, Urn termUrn) GlossaryTermAssociationArray termArray = terms.getTerms(); - // if term exists, do not add it again - if (termArray.stream().anyMatch(association -> association.getUrn().equals(termUrn))) { + List termsToAdd = new ArrayList<>(); + for (Urn termUrn : termUrns) { + if (termArray.stream().anyMatch(association -> association.getUrn().equals(termUrn))) { + continue; + } + termsToAdd.add(termUrn); + } + + // Check for no terms to add + if (termsToAdd.size() == 0) { return; } - GlossaryTermAssociation newAssociation = new GlossaryTermAssociation(); - newAssociation.setUrn(GlossaryTermUrn.createFromUrn(termUrn)); - termArray.add(newAssociation); + for (Urn termUrn : termsToAdd) { + GlossaryTermAssociation newAssociation = new GlossaryTermAssociation(); + newAssociation.setUrn(GlossaryTermUrn.createFromUrn(termUrn)); + termArray.add(newAssociation); + } } private static TagAssociationArray removeTagIfExists(GlobalTags tags, Urn tagUrn) { @@ -206,21 +219,31 @@ private static GlossaryTermAssociationArray removeTermIfExists(GlossaryTerms ter return termArray; } - private static void addTagIfNotExists(GlobalTags tags, Urn tagUrn) throws URISyntaxException { + private static void addTagsIfNotExists(GlobalTags tags, List tagUrns) throws URISyntaxException { if (!tags.hasTags()) { tags.setTags(new TagAssociationArray()); } TagAssociationArray tagAssociationArray = tags.getTags(); - // if tag exists, do not add it again - if (tagAssociationArray.stream().anyMatch(association -> association.getTag().equals(tagUrn))) { + List tagsToAdd = new ArrayList<>(); + for (Urn tagUrn : tagUrns) { + if (tagAssociationArray.stream().anyMatch(association -> association.getTag().equals(tagUrn))) { + continue; + } + tagsToAdd.add(tagUrn); + } + + // Check for no tags to add + if (tagsToAdd.size() == 0) { return; } - TagAssociation newAssociation = new TagAssociation(); - newAssociation.setTag(TagUrn.createFromUrn(tagUrn)); - tagAssociationArray.add(newAssociation); + for (Urn tagUrn : tagsToAdd) { + TagAssociation newAssociation = new TagAssociation(); + newAssociation.setTag(TagUrn.createFromUrn(tagUrn)); + tagAssociationArray.add(newAssociation); + } } public static boolean isAuthorizedToUpdateTags(@Nonnull QueryContext context, Urn targetUrn, String subResource) { @@ -265,6 +288,24 @@ public static boolean isAuthorizedToUpdateTerms(@Nonnull QueryContext context, U orPrivilegeGroups); } + public static Boolean validateInput( + List labelUrns, + Urn targetUrn, + String subResource, + SubResourceType subResourceType, + String labelEntityType, + EntityService entityService, + Boolean isRemoving + ) { + for (Urn urn : labelUrns) { + boolean labelResult = validateInput(urn, targetUrn, subResource, subResourceType, labelEntityType, entityService, isRemoving); + if (!labelResult) { + return false; + } + } + return true; + } + public static Boolean validateInput( Urn labelUrn, Urn targetUrn, diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/OwnerUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/OwnerUtils.java index d7b9a3ec370e8a..a0770dd32e4568 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/OwnerUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/OwnerUtils.java @@ -9,15 +9,18 @@ import com.linkedin.common.OwnershipSourceType; import com.linkedin.common.OwnershipType; import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.authorization.AuthorizationUtils; import com.linkedin.datahub.graphql.authorization.ConjunctivePrivilegeGroup; import com.linkedin.datahub.graphql.authorization.DisjunctivePrivilegeGroup; import com.linkedin.datahub.graphql.generated.OwnerEntityType; +import com.linkedin.datahub.graphql.generated.OwnerInput; import com.linkedin.datahub.graphql.resolvers.mutate.MutationUtils; import com.linkedin.metadata.Constants; import com.linkedin.metadata.authorization.PoliciesConfig; import com.linkedin.metadata.entity.EntityService; +import java.util.List; import java.util.stream.Collectors; import javax.annotation.Nonnull; import lombok.extern.slf4j.Slf4j; @@ -49,6 +52,23 @@ public static void addOwner( persistAspect(resourceUrn, Constants.OWNERSHIP_ASPECT_NAME, ownershipAspect, actor, entityService); } + public static void addOwners( + List owners, + Urn resourceUrn, + Urn actor, + EntityService entityService + ) { + Ownership ownershipAspect = (Ownership) getAspectFromEntity( + resourceUrn.toString(), + Constants.OWNERSHIP_ASPECT_NAME, + entityService, + new Ownership()); + for (OwnerInput input : owners) { + addOwner(ownershipAspect, UrnUtils.getUrn(input.getOwnerUrn()), OwnershipType.valueOf(input.getType().toString())); + } + persistAspect(resourceUrn, Constants.OWNERSHIP_ASPECT_NAME, ownershipAspect, actor, entityService); + } + public static void removeOwner( Urn ownerUrn, Urn resourceUrn, @@ -106,6 +126,24 @@ public static boolean isAuthorizedToUpdateOwners(@Nonnull QueryContext context, orPrivilegeGroups); } + public static Boolean validateAddInput( + List owners, + Urn resourceUrn, + EntityService entityService + ) { + for (OwnerInput owner : owners) { + boolean result = validateAddInput( + UrnUtils.getUrn(owner.getOwnerUrn()), + owner.getOwnerEntityType(), + resourceUrn, + entityService); + if (!result) { + return false; + } + } + return true; + } + public static Boolean validateAddInput( Urn ownerUrn, OwnerEntityType ownerEntityType, @@ -125,6 +163,10 @@ public static Boolean validateAddInput( throw new IllegalArgumentException(String.format("Failed to change ownership for resource %s. Resource does not exist.", resourceUrn)); } + if (!entityService.exists(ownerUrn)) { + throw new IllegalArgumentException(String.format("Failed to change ownership for resource %s. Owner does not exist.", resourceUrn)); + } + return true; } diff --git a/datahub-graphql-core/src/main/resources/entity.graphql b/datahub-graphql-core/src/main/resources/entity.graphql index fae40bf078dc1d..97c6263b2c535e 100644 --- a/datahub-graphql-core/src/main/resources/entity.graphql +++ b/datahub-graphql-core/src/main/resources/entity.graphql @@ -210,6 +210,11 @@ type Mutation { """ addTag(input: TagAssociationInput!): Boolean + """ + Add multiple tags to a particular Entity or subresource + """ + addTags(input: AddTagsInput!): Boolean + """ Remove a tag from a particular Entity or subresource """ @@ -220,6 +225,11 @@ type Mutation { """ addTerm(input: TermAssociationInput!): Boolean + """ + Add multiple glossary terms to a particular Entity or subresource + """ + addTerms(input: AddTermsInput!): Boolean + """ Remove a glossary term from a particular Entity or subresource """ @@ -230,6 +240,11 @@ type Mutation { """ addOwner(input: AddOwnerInput!): Boolean + """ + Add multiple owners to a particular Entity + """ + addOwners(input: AddOwnersInput!): Boolean + """ Remove an owner from a particular Entity """ @@ -5782,6 +5797,31 @@ input TermAssociationInput { subResource: String } +""" +Input provided when adding Terms to an asset +""" +input AddTermsInput { + """ + The primary key of the Glossary Term to add or remove + """ + termUrns: [String!]! + + """ + The target Metadata Entity to add or remove the Glossary Term from + """ + resourceUrn: String! + + """ + An optional type of a sub resource to attach the Glossary Term to + """ + subResourceType: SubResourceType + + """ + An optional sub resource identifier to attach the Glossary Term to + """ + subResource: String +} + """ A type of Metadata Entity sub resource """ @@ -5817,6 +5857,31 @@ input TagAssociationInput { subResource: String } +""" +Input provided when adding tags to an asset +""" +input AddTagsInput { + """ + The primary key of the Tags + """ + tagUrns: [String!]! + + """ + The target Metadata Entity to add or remove the Tag to + """ + resourceUrn: String! + + """ + An optional type of a sub resource to attach the Tag to + """ + subResourceType: SubResourceType + + """ + An optional sub resource identifier to attach the Tag to + """ + subResource: String +} + """ Entities that are able to own other entities """ @@ -5857,6 +5922,41 @@ input AddOwnerInput { resourceUrn: String! } +""" +Input provided when adding an owner to an asset +""" +input OwnerInput { + """ + The primary key of the Owner to add or remove + """ + ownerUrn: String! + + """ + The owner type, either a user or group + """ + ownerEntityType: OwnerEntityType! + + """ + The ownership type for the new owner. If none is provided, then a new NONE will be added. + """ + type: OwnershipType +} + +""" +Input provided when adding multiple associations between a Metadata Entity and an user or group owner +""" +input AddOwnersInput { + """ + The primary key of the Owner to add or remove + """ + owners: [OwnerInput!]! + + """ + The urn of the resource or entity to attach or remove the owner from, for example a dataset urn + """ + resourceUrn: String! +} + """ Input provided when removing the association between a Metadata Entity and an user or group owner """ diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/owner/AddOwnersResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/owner/AddOwnersResolverTest.java new file mode 100644 index 00000000000000..16a8e27b7559ab --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/owner/AddOwnersResolverTest.java @@ -0,0 +1,213 @@ +package com.linkedin.datahub.graphql.resolvers.owner; + +import com.google.common.collect.ImmutableList; +import com.linkedin.common.AuditStamp; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.AddOwnersInput; +import com.linkedin.datahub.graphql.generated.OwnerEntityType; +import com.linkedin.datahub.graphql.generated.OwnerInput; +import com.linkedin.datahub.graphql.generated.OwnershipType; +import com.linkedin.datahub.graphql.resolvers.mutate.AddOwnersResolver; +import com.linkedin.metadata.Constants; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.mxe.MetadataChangeProposal; +import graphql.schema.DataFetchingEnvironment; +import java.util.concurrent.CompletionException; +import org.mockito.Mockito; +import org.testng.annotations.Test; + +import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.testng.Assert.*; + + +public class AddOwnersResolverTest { + + private static final String TEST_ENTITY_URN = "urn:li:dataset:(urn:li:dataPlatform:mysql,my-test,PROD)"; + private static final String TEST_OWNER_1_URN = "urn:li:corpuser:test-id-1"; + private static final String TEST_OWNER_2_URN = "urn:li:corpuser:test-id-2"; + + @Test + public void testGetSuccessNoExistingOwners() throws Exception { + EntityService mockService = Mockito.mock(EntityService.class); + + Mockito.when(mockService.getAspect( + Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN)), + Mockito.eq(Constants.OWNERSHIP_ASPECT_NAME), + Mockito.eq(0L))) + .thenReturn(null); + + Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); + Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_1_URN))).thenReturn(true); + Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_2_URN))).thenReturn(true); + + AddOwnersResolver resolver = new AddOwnersResolver(mockService); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + AddOwnersInput input = new AddOwnersInput(ImmutableList.of( + new OwnerInput(TEST_OWNER_1_URN, OwnerEntityType.CORP_USER, OwnershipType.TECHNICAL_OWNER), + new OwnerInput(TEST_OWNER_2_URN, OwnerEntityType.CORP_USER, OwnershipType.TECHNICAL_OWNER) + ), TEST_ENTITY_URN); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + assertTrue(resolver.get(mockEnv).get()); + + // Unable to easily validate exact payload due to the injected timestamp + Mockito.verify(mockService, Mockito.times(1)).ingestProposal( + Mockito.any(MetadataChangeProposal.class), + Mockito.any(AuditStamp.class) + ); + + Mockito.verify(mockService, Mockito.times(1)).exists( + Mockito.eq(Urn.createFromString(TEST_OWNER_1_URN)) + ); + + Mockito.verify(mockService, Mockito.times(1)).exists( + Mockito.eq(Urn.createFromString(TEST_OWNER_2_URN)) + ); + } + + @Test + public void testGetSuccessExistingOwners() throws Exception { + EntityService mockService = Mockito.mock(EntityService.class); + + Mockito.when(mockService.getAspect( + Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN)), + Mockito.eq(Constants.OWNERSHIP_ASPECT_NAME), + Mockito.eq(0L))) + .thenReturn(null); + + Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); + Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_1_URN))).thenReturn(true); + Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_2_URN))).thenReturn(true); + + AddOwnersResolver resolver = new AddOwnersResolver(mockService); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + AddOwnersInput input = new AddOwnersInput(ImmutableList.of( + new OwnerInput(TEST_OWNER_1_URN, OwnerEntityType.CORP_USER, OwnershipType.TECHNICAL_OWNER), + new OwnerInput(TEST_OWNER_2_URN, OwnerEntityType.CORP_USER, OwnershipType.TECHNICAL_OWNER) + ), TEST_ENTITY_URN); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + assertTrue(resolver.get(mockEnv).get()); + + // Unable to easily validate exact payload due to the injected timestamp + Mockito.verify(mockService, Mockito.times(1)).ingestProposal( + Mockito.any(MetadataChangeProposal.class), + Mockito.any(AuditStamp.class) + ); + + Mockito.verify(mockService, Mockito.times(1)).exists( + Mockito.eq(Urn.createFromString(TEST_OWNER_1_URN)) + ); + + Mockito.verify(mockService, Mockito.times(1)).exists( + Mockito.eq(Urn.createFromString(TEST_OWNER_2_URN)) + ); + } + + @Test + public void testGetFailureOwnerDoesNotExist() throws Exception { + EntityService mockService = Mockito.mock(EntityService.class); + + Mockito.when(mockService.getAspect( + Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN)), + Mockito.eq(Constants.OWNERSHIP_ASPECT_NAME), + Mockito.eq(0L))) + .thenReturn(null); + + Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); + Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_1_URN))).thenReturn(false); + + AddOwnersResolver resolver = new AddOwnersResolver(mockService); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + AddOwnersInput input = new AddOwnersInput(ImmutableList.of( + new OwnerInput(TEST_OWNER_1_URN, OwnerEntityType.CORP_USER, OwnershipType.TECHNICAL_OWNER)), TEST_ENTITY_URN); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + Mockito.verify(mockService, Mockito.times(0)).ingestProposal( + Mockito.any(), + Mockito.any(AuditStamp.class)); + } + + @Test + public void testGetFailureResourceDoesNotExist() throws Exception { + EntityService mockService = Mockito.mock(EntityService.class); + + Mockito.when(mockService.getAspect( + Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN)), + Mockito.eq(Constants.OWNERSHIP_ASPECT_NAME), + Mockito.eq(0L))) + .thenReturn(null); + + Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(false); + Mockito.when(mockService.exists(Urn.createFromString(TEST_OWNER_1_URN))).thenReturn(true); + + AddOwnersResolver resolver = new AddOwnersResolver(mockService); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + AddOwnersInput input = new AddOwnersInput(ImmutableList.of( + new OwnerInput(TEST_OWNER_1_URN, OwnerEntityType.CORP_USER, OwnershipType.TECHNICAL_OWNER)), TEST_ENTITY_URN); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + Mockito.verify(mockService, Mockito.times(0)).ingestProposal( + Mockito.any(), + Mockito.any(AuditStamp.class)); + } + + @Test + public void testGetUnauthorized() throws Exception { + EntityService mockService = Mockito.mock(EntityService.class); + + AddOwnersResolver resolver = new AddOwnersResolver(mockService); + + // Execute resolver + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + AddOwnersInput input = new AddOwnersInput(ImmutableList.of( + new OwnerInput(TEST_OWNER_1_URN, OwnerEntityType.CORP_USER, OwnershipType.TECHNICAL_OWNER)), TEST_ENTITY_URN); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + QueryContext mockContext = getMockDenyContext(); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + Mockito.verify(mockService, Mockito.times(0)).ingestProposal( + Mockito.any(), + Mockito.any(AuditStamp.class)); + } + + @Test + public void testGetEntityClientException() throws Exception { + EntityService mockService = Mockito.mock(EntityService.class); + + Mockito.doThrow(RuntimeException.class).when(mockService).ingestProposal( + Mockito.any(), + Mockito.any(AuditStamp.class)); + + AddOwnersResolver resolver = new AddOwnersResolver(Mockito.mock(EntityService.class)); + + // Execute resolver + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + QueryContext mockContext = getMockAllowContext(); + AddOwnersInput input = new AddOwnersInput(ImmutableList.of( + new OwnerInput(TEST_OWNER_1_URN, OwnerEntityType.CORP_USER, OwnershipType.TECHNICAL_OWNER)), TEST_ENTITY_URN); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + } +} \ No newline at end of file diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/AddTagsResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/AddTagsResolverTest.java new file mode 100644 index 00000000000000..1b1ead881574d5 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/AddTagsResolverTest.java @@ -0,0 +1,246 @@ +package com.linkedin.datahub.graphql.resolvers.tag; + +import com.google.common.collect.ImmutableList; +import com.linkedin.common.AuditStamp; +import com.linkedin.common.GlobalTags; +import com.linkedin.common.TagAssociation; +import com.linkedin.common.TagAssociationArray; +import com.linkedin.common.urn.TagUrn; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.AddTagsInput; +import com.linkedin.datahub.graphql.resolvers.mutate.AddTagsResolver; +import com.linkedin.events.metadata.ChangeType; +import com.linkedin.metadata.Constants; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.metadata.utils.GenericRecordUtils; +import com.linkedin.mxe.MetadataChangeProposal; +import graphql.schema.DataFetchingEnvironment; +import java.util.concurrent.CompletionException; +import org.mockito.Mockito; +import org.testng.annotations.Test; + +import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.testng.Assert.*; + + +public class AddTagsResolverTest { + + private static final String TEST_ENTITY_URN = "urn:li:dataset:(urn:li:dataPlatform:mysql,my-test,PROD)"; + private static final String TEST_TAG_1_URN = "urn:li:tag:test-id-1"; + private static final String TEST_TAG_2_URN = "urn:li:tag:test-id-2"; + + @Test + public void testGetSuccessNoExistingTags() throws Exception { + EntityService mockService = Mockito.mock(EntityService.class); + + Mockito.when(mockService.getAspect( + Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN)), + Mockito.eq(Constants.GLOBAL_TAGS_ASPECT_NAME), + Mockito.eq(0L))) + .thenReturn(null); + + Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); + Mockito.when(mockService.exists(Urn.createFromString(TEST_TAG_1_URN))).thenReturn(true); + Mockito.when(mockService.exists(Urn.createFromString(TEST_TAG_2_URN))).thenReturn(true); + + AddTagsResolver resolver = new AddTagsResolver(mockService); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + AddTagsInput input = new AddTagsInput(ImmutableList.of( + TEST_TAG_1_URN, + TEST_TAG_2_URN + ), TEST_ENTITY_URN, null, null); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + assertTrue(resolver.get(mockEnv).get()); + + final GlobalTags newTags = new GlobalTags().setTags(new TagAssociationArray(ImmutableList.of( + new TagAssociation().setTag(TagUrn.createFromString(TEST_TAG_1_URN)), + new TagAssociation().setTag(TagUrn.createFromString(TEST_TAG_2_URN)) + ))); + + final MetadataChangeProposal proposal = new MetadataChangeProposal(); + proposal.setEntityUrn(Urn.createFromString(TEST_ENTITY_URN)); + proposal.setEntityType(Constants.DATASET_ENTITY_NAME); + proposal.setAspectName(Constants.GLOBAL_TAGS_ASPECT_NAME); + proposal.setAspect(GenericRecordUtils.serializeAspect(newTags)); + proposal.setChangeType(ChangeType.UPSERT); + + Mockito.verify(mockService, Mockito.times(1)).ingestProposal( + Mockito.eq(proposal), + Mockito.any(AuditStamp.class) + ); + + Mockito.verify(mockService, Mockito.times(1)).exists( + Mockito.eq(Urn.createFromString(TEST_TAG_1_URN)) + ); + + Mockito.verify(mockService, Mockito.times(1)).exists( + Mockito.eq(Urn.createFromString(TEST_TAG_2_URN)) + ); + } + + @Test + public void testGetSuccessExistingTags() throws Exception { + GlobalTags originalTags = new GlobalTags().setTags(new TagAssociationArray(ImmutableList.of( + new TagAssociation().setTag(TagUrn.createFromString(TEST_TAG_1_URN)))) + ); + + EntityService mockService = Mockito.mock(EntityService.class); + + Mockito.when(mockService.getAspect( + Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN)), + Mockito.eq(Constants.GLOBAL_TAGS_ASPECT_NAME), + Mockito.eq(0L))) + .thenReturn(originalTags); + + Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); + Mockito.when(mockService.exists(Urn.createFromString(TEST_TAG_1_URN))).thenReturn(true); + Mockito.when(mockService.exists(Urn.createFromString(TEST_TAG_2_URN))).thenReturn(true); + + AddTagsResolver resolver = new AddTagsResolver(mockService); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + AddTagsInput input = new AddTagsInput(ImmutableList.of( + TEST_TAG_1_URN, + TEST_TAG_2_URN + ), TEST_ENTITY_URN, null, null); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + assertTrue(resolver.get(mockEnv).get()); + + final GlobalTags newTags = new GlobalTags().setTags(new TagAssociationArray(ImmutableList.of( + new TagAssociation().setTag(TagUrn.createFromString(TEST_TAG_1_URN)), + new TagAssociation().setTag(TagUrn.createFromString(TEST_TAG_2_URN)) + ))); + + final MetadataChangeProposal proposal = new MetadataChangeProposal(); + proposal.setEntityUrn(Urn.createFromString(TEST_ENTITY_URN)); + proposal.setEntityType(Constants.DATASET_ENTITY_NAME); + proposal.setAspectName(Constants.GLOBAL_TAGS_ASPECT_NAME); + proposal.setAspect(GenericRecordUtils.serializeAspect(newTags)); + proposal.setChangeType(ChangeType.UPSERT); + + Mockito.verify(mockService, Mockito.times(1)).ingestProposal( + Mockito.eq(proposal), + Mockito.any(AuditStamp.class) + ); + + Mockito.verify(mockService, Mockito.times(1)).exists( + Mockito.eq(Urn.createFromString(TEST_TAG_1_URN)) + ); + + Mockito.verify(mockService, Mockito.times(1)).exists( + Mockito.eq(Urn.createFromString(TEST_TAG_2_URN)) + ); + } + + @Test + public void testGetFailureTagDoesNotExist() throws Exception { + EntityService mockService = Mockito.mock(EntityService.class); + + Mockito.when(mockService.getAspect( + Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN)), + Mockito.eq(Constants.GLOBAL_TAGS_ASPECT_NAME), + Mockito.eq(0L))) + .thenReturn(null); + + Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); + Mockito.when(mockService.exists(Urn.createFromString(TEST_TAG_1_URN))).thenReturn(false); + + AddTagsResolver resolver = new AddTagsResolver(mockService); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + AddTagsInput input = new AddTagsInput(ImmutableList.of( + TEST_TAG_1_URN + ), TEST_ENTITY_URN, null, null); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + Mockito.verify(mockService, Mockito.times(0)).ingestProposal( + Mockito.any(), + Mockito.any(AuditStamp.class)); + } + + @Test + public void testGetFailureResourceDoesNotExist() throws Exception { + EntityService mockService = Mockito.mock(EntityService.class); + + Mockito.when(mockService.getAspect( + Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN)), + Mockito.eq(Constants.GLOBAL_TAGS_ASPECT_NAME), + Mockito.eq(0L))) + .thenReturn(null); + + Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(false); + Mockito.when(mockService.exists(Urn.createFromString(TEST_TAG_1_URN))).thenReturn(true); + + AddTagsResolver resolver = new AddTagsResolver(mockService); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + AddTagsInput input = new AddTagsInput(ImmutableList.of( + TEST_TAG_1_URN + ), TEST_ENTITY_URN, null, null); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + Mockito.verify(mockService, Mockito.times(0)).ingestProposal( + Mockito.any(), + Mockito.any(AuditStamp.class)); + } + + @Test + public void testGetUnauthorized() throws Exception { + EntityService mockService = Mockito.mock(EntityService.class); + + AddTagsResolver resolver = new AddTagsResolver(mockService); + + // Execute resolver + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + AddTagsInput input = new AddTagsInput(ImmutableList.of( + TEST_TAG_1_URN + ), TEST_ENTITY_URN, null, null); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + QueryContext mockContext = getMockDenyContext(); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + Mockito.verify(mockService, Mockito.times(0)).ingestProposal( + Mockito.any(), + Mockito.any(AuditStamp.class)); + } + + @Test + public void testGetEntityClientException() throws Exception { + EntityService mockService = Mockito.mock(EntityService.class); + + Mockito.doThrow(RuntimeException.class).when(mockService).ingestProposal( + Mockito.any(), + Mockito.any(AuditStamp.class)); + + AddTagsResolver resolver = new AddTagsResolver(Mockito.mock(EntityService.class)); + + // Execute resolver + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + QueryContext mockContext = getMockAllowContext(); + AddTagsInput input = new AddTagsInput(ImmutableList.of( + TEST_TAG_1_URN + ), TEST_ENTITY_URN, null, null); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + } +} \ No newline at end of file diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/term/AddTermsResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/term/AddTermsResolverTest.java new file mode 100644 index 00000000000000..2ac8842d9590e1 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/term/AddTermsResolverTest.java @@ -0,0 +1,222 @@ +package com.linkedin.datahub.graphql.resolvers.term; + +import com.google.common.collect.ImmutableList; +import com.linkedin.common.AuditStamp; +import com.linkedin.common.GlossaryTermAssociation; +import com.linkedin.common.GlossaryTermAssociationArray; +import com.linkedin.common.GlossaryTerms; +import com.linkedin.common.urn.GlossaryTermUrn; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.AddTermsInput; +import com.linkedin.datahub.graphql.resolvers.mutate.AddTermsResolver; +import com.linkedin.metadata.Constants; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.mxe.MetadataChangeProposal; +import graphql.schema.DataFetchingEnvironment; +import java.util.concurrent.CompletionException; +import org.mockito.Mockito; +import org.testng.annotations.Test; + +import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.testng.Assert.*; + + +public class AddTermsResolverTest { + + private static final String TEST_ENTITY_URN = "urn:li:dataset:(urn:li:dataPlatform:mysql,my-test,PROD)"; + private static final String TEST_TERM_1_URN = "urn:li:glossaryTerm:test-id-1"; + private static final String TEST_TERM_2_URN = "urn:li:glossaryTerm:test-id-2"; + + @Test + public void testGetSuccessNoExistingTerms() throws Exception { + EntityService mockService = Mockito.mock(EntityService.class); + + Mockito.when(mockService.getAspect( + Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN)), + Mockito.eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), + Mockito.eq(0L))) + .thenReturn(null); + + Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); + Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_1_URN))).thenReturn(true); + Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_2_URN))).thenReturn(true); + + AddTermsResolver resolver = new AddTermsResolver(mockService); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + AddTermsInput input = new AddTermsInput(ImmutableList.of( + TEST_TERM_1_URN, + TEST_TERM_2_URN + ), TEST_ENTITY_URN, null, null); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + assertTrue(resolver.get(mockEnv).get()); + + // Unable to easily validate exact payload due to the injected timestamp + Mockito.verify(mockService, Mockito.times(1)).ingestProposal( + Mockito.any(MetadataChangeProposal.class), + Mockito.any(AuditStamp.class) + ); + + Mockito.verify(mockService, Mockito.times(1)).exists( + Mockito.eq(Urn.createFromString(TEST_TERM_1_URN)) + ); + + Mockito.verify(mockService, Mockito.times(1)).exists( + Mockito.eq(Urn.createFromString(TEST_TERM_2_URN)) + ); + } + + @Test + public void testGetSuccessExistingTerms() throws Exception { + GlossaryTerms originalTerms = new GlossaryTerms().setTerms(new GlossaryTermAssociationArray(ImmutableList.of( + new GlossaryTermAssociation().setUrn(GlossaryTermUrn.createFromString(TEST_TERM_1_URN)))) + ); + + EntityService mockService = Mockito.mock(EntityService.class); + + Mockito.when(mockService.getAspect( + Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN)), + Mockito.eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), + Mockito.eq(0L))) + .thenReturn(originalTerms); + + Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); + Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_1_URN))).thenReturn(true); + Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_2_URN))).thenReturn(true); + + AddTermsResolver resolver = new AddTermsResolver(mockService); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + AddTermsInput input = new AddTermsInput(ImmutableList.of( + TEST_TERM_1_URN, + TEST_TERM_2_URN + ), TEST_ENTITY_URN, null, null); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + assertTrue(resolver.get(mockEnv).get()); + + // Unable to easily validate exact payload due to the injected timestamp + Mockito.verify(mockService, Mockito.times(1)).ingestProposal( + Mockito.any(MetadataChangeProposal.class), + Mockito.any(AuditStamp.class) + ); + + Mockito.verify(mockService, Mockito.times(1)).exists( + Mockito.eq(Urn.createFromString(TEST_TERM_1_URN)) + ); + + Mockito.verify(mockService, Mockito.times(1)).exists( + Mockito.eq(Urn.createFromString(TEST_TERM_2_URN)) + ); + } + + @Test + public void testGetFailureTermDoesNotExist() throws Exception { + EntityService mockService = Mockito.mock(EntityService.class); + + Mockito.when(mockService.getAspect( + Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN)), + Mockito.eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), + Mockito.eq(0L))) + .thenReturn(null); + + Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true); + Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_1_URN))).thenReturn(false); + + AddTermsResolver resolver = new AddTermsResolver(mockService); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + AddTermsInput input = new AddTermsInput(ImmutableList.of( + TEST_TERM_1_URN + ), TEST_ENTITY_URN, null, null); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + Mockito.verify(mockService, Mockito.times(0)).ingestProposal( + Mockito.any(), + Mockito.any(AuditStamp.class)); + } + + @Test + public void testGetFailureResourceDoesNotExist() throws Exception { + EntityService mockService = Mockito.mock(EntityService.class); + + Mockito.when(mockService.getAspect( + Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN)), + Mockito.eq(Constants.GLOSSARY_TERMS_ASPECT_NAME), + Mockito.eq(0L))) + .thenReturn(null); + + Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(false); + Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_1_URN))).thenReturn(true); + + AddTermsResolver resolver = new AddTermsResolver(mockService); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + AddTermsInput input = new AddTermsInput(ImmutableList.of( + TEST_TERM_1_URN + ), TEST_ENTITY_URN, null, null); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + Mockito.verify(mockService, Mockito.times(0)).ingestProposal( + Mockito.any(), + Mockito.any(AuditStamp.class)); + } + + @Test + public void testGetUnauthorized() throws Exception { + EntityService mockService = Mockito.mock(EntityService.class); + + AddTermsResolver resolver = new AddTermsResolver(mockService); + + // Execute resolver + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + AddTermsInput input = new AddTermsInput(ImmutableList.of( + TEST_TERM_1_URN + ), TEST_ENTITY_URN, null, null); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + QueryContext mockContext = getMockDenyContext(); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + Mockito.verify(mockService, Mockito.times(0)).ingestProposal( + Mockito.any(), + Mockito.any(AuditStamp.class)); + } + + @Test + public void testGetEntityClientException() throws Exception { + EntityService mockService = Mockito.mock(EntityService.class); + + Mockito.doThrow(RuntimeException.class).when(mockService).ingestProposal( + Mockito.any(), + Mockito.any(AuditStamp.class)); + + AddTermsResolver resolver = new AddTermsResolver(Mockito.mock(EntityService.class)); + + // Execute resolver + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + QueryContext mockContext = getMockAllowContext(); + AddTermsInput input = new AddTermsInput(ImmutableList.of( + TEST_TERM_1_URN + ), TEST_ENTITY_URN, null, null); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + } +} \ No newline at end of file diff --git a/smoke-test/tests/containers/data.json b/smoke-test/tests/containers/data.json index 461b2a7f3b080a..e1801d0df2aee1 100644 --- a/smoke-test/tests/containers/data.json +++ b/smoke-test/tests/containers/data.json @@ -130,5 +130,37 @@ "contentType":"application/json" }, "systemMetadata":null + }, + { + "auditHeader": null, + "proposedSnapshot": { + "com.linkedin.pegasus2avro.metadata.snapshot.CorpUserSnapshot": { + "urn": "urn:li:corpuser:jdoe", + "aspects": [ + { + "com.linkedin.pegasus2avro.identity.CorpUserInfo": { + "active": true, + "displayName": { + "string": "John Doe" + }, + "email": "jdoe@linkedin.com", + "title": { + "string": "Software Engineer" + }, + "managerUrn": null, + "departmentId": null, + "departmentName": null, + "firstName": null, + "lastName": null, + "fullName": { + "string": "John Doe" + }, + "countryCode": null + } + } + ] + } + }, + "proposedDelta": null } ] \ No newline at end of file