From 6c39a51b259d2ecc343e92b51df3805340a713b8 Mon Sep 17 00:00:00 2001 From: Chris Collins Date: Tue, 2 Jul 2024 15:22:37 -0400 Subject: [PATCH] feat(forms) Add CRUD endpoints to GraphQL for Form entities (#10825) --- .../datahub/graphql/GmsGraphQLEngine.java | 9 +- .../authorization/AuthorizationUtils.java | 7 + .../resolvers/form/CreateFormResolver.java | 83 ++++++++ .../resolvers/form/DeleteFormResolver.java | 65 ++++++ .../resolvers/form/UpdateFormResolver.java | 98 +++++++++ .../resolvers/mutate/util/FormUtils.java | 114 ++++++++++ .../src/main/resources/forms.graphql | 196 ++++++++++++++++++ .../form/CreateFormResolverTest.java | 116 +++++++++++ .../form/DeleteFormResolverTest.java | 90 ++++++++ .../form/UpdateFormResolverTest.java | 105 ++++++++++ .../metadata/service/FormService.java | 25 +++ .../war/src/main/resources/boot/policies.json | 9 +- .../authorization/PoliciesConfig.java | 9 +- 13 files changed, 921 insertions(+), 5 deletions(-) create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/CreateFormResolver.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/DeleteFormResolver.java create mode 100644 datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/UpdateFormResolver.java create mode 100644 datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/form/CreateFormResolverTest.java create mode 100644 datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/form/DeleteFormResolverTest.java create mode 100644 datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/form/UpdateFormResolverTest.java 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 6f2e250c17c34..4b679b28b629a 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 @@ -172,8 +172,11 @@ import com.linkedin.datahub.graphql.resolvers.form.BatchAssignFormResolver; import com.linkedin.datahub.graphql.resolvers.form.BatchRemoveFormResolver; import com.linkedin.datahub.graphql.resolvers.form.CreateDynamicFormAssignmentResolver; +import com.linkedin.datahub.graphql.resolvers.form.CreateFormResolver; +import com.linkedin.datahub.graphql.resolvers.form.DeleteFormResolver; import com.linkedin.datahub.graphql.resolvers.form.IsFormAssignedToMeResolver; import com.linkedin.datahub.graphql.resolvers.form.SubmitFormPromptResolver; +import com.linkedin.datahub.graphql.resolvers.form.UpdateFormResolver; import com.linkedin.datahub.graphql.resolvers.form.VerifyFormResolver; import com.linkedin.datahub.graphql.resolvers.glossary.AddRelatedTermsResolver; import com.linkedin.datahub.graphql.resolvers.glossary.CreateGlossaryNodeResolver; @@ -1319,7 +1322,11 @@ private void configureMutationResolvers(final RuntimeWiring.Builder builder) { .dataFetcher("raiseIncident", new RaiseIncidentResolver(this.entityClient)) .dataFetcher( "updateIncidentStatus", - new UpdateIncidentStatusResolver(this.entityClient, this.entityService)); + new UpdateIncidentStatusResolver(this.entityClient, this.entityService)) + .dataFetcher( + "createForm", new CreateFormResolver(this.entityClient, this.formService)) + .dataFetcher("deleteForm", new DeleteFormResolver(this.entityClient)) + .dataFetcher("updateForm", new UpdateFormResolver(this.entityClient)); if (featureFlags.isBusinessAttributeEntityEnabled()) { typeWiring .dataFetcher( diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/authorization/AuthorizationUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/authorization/AuthorizationUtils.java index c16e436a7805c..62b197ac5cc13 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/authorization/AuthorizationUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/authorization/AuthorizationUtils.java @@ -381,6 +381,13 @@ public static T restrictEntity(@Nonnull Object entity, Class clazz) { } } + public static boolean canManageForms(@Nonnull QueryContext context) { + return AuthUtil.isAuthorized( + context.getAuthorizer(), + context.getActorUrn(), + PoliciesConfig.MANAGE_DOCUMENTATION_FORMS_PRIVILEGE); + } + public static boolean isAuthorized( @Nonnull Authorizer authorizer, @Nonnull String actor, diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/CreateFormResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/CreateFormResolver.java new file mode 100644 index 0000000000000..e9962464059e6 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/CreateFormResolver.java @@ -0,0 +1,83 @@ +package com.linkedin.datahub.graphql.resolvers.form; + +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument; + +import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.authorization.AuthorizationUtils; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.CreateFormInput; +import com.linkedin.datahub.graphql.generated.CreatePromptInput; +import com.linkedin.datahub.graphql.generated.Form; +import com.linkedin.datahub.graphql.generated.FormPromptType; +import com.linkedin.datahub.graphql.resolvers.mutate.util.FormUtils; +import com.linkedin.datahub.graphql.types.form.FormMapper; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.form.FormInfo; +import com.linkedin.metadata.Constants; +import com.linkedin.metadata.service.FormService; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public class CreateFormResolver implements DataFetcher> { + + private final EntityClient _entityClient; + private final FormService _formService; + + public CreateFormResolver( + @Nonnull final EntityClient entityClient, @Nonnull final FormService formService) { + _entityClient = Objects.requireNonNull(entityClient, "entityClient must not be null"); + _formService = Objects.requireNonNull(formService, "formService must not be null"); + } + + @Override + public CompletableFuture
get(final DataFetchingEnvironment environment) throws Exception { + final QueryContext context = environment.getContext(); + + final CreateFormInput input = + bindArgument(environment.getArgument("input"), CreateFormInput.class); + final FormInfo formInfo = FormUtils.mapFormInfo(input); + + return CompletableFuture.supplyAsync( + () -> { + try { + if (!AuthorizationUtils.canManageForms(context)) { + throw new AuthorizationException("Unable to create form. Please contact your admin."); + } + validatePrompts(input.getPrompts()); + + Urn formUrn = + _formService.createForm(context.getOperationContext(), formInfo, input.getId()); + EntityResponse response = + _entityClient.getV2( + context.getOperationContext(), Constants.FORM_ENTITY_NAME, formUrn, null); + return FormMapper.map(context, response); + } catch (Exception e) { + throw new RuntimeException( + String.format("Failed to perform update against input %s", input), e); + } + }); + } + + private void validatePrompts(@Nullable List prompts) { + if (prompts == null) { + return; + } + prompts.forEach( + prompt -> { + if (prompt.getType().equals(FormPromptType.STRUCTURED_PROPERTY) + || prompt.getType().equals(FormPromptType.FIELDS_STRUCTURED_PROPERTY)) { + if (prompt.getStructuredPropertyParams() == null) { + throw new IllegalArgumentException( + "Provided prompt with type STRUCTURED_PROPERTY or FIELDS_STRUCTURED_PROPERTY and no structured property params"); + } + } + }); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/DeleteFormResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/DeleteFormResolver.java new file mode 100644 index 0000000000000..eec6816042a40 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/DeleteFormResolver.java @@ -0,0 +1,65 @@ +package com.linkedin.datahub.graphql.resolvers.form; + +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument; + +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.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.DeleteFormInput; +import com.linkedin.entity.client.EntityClient; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import javax.annotation.Nonnull; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class DeleteFormResolver implements DataFetcher> { + + private final EntityClient _entityClient; + + public DeleteFormResolver(@Nonnull final EntityClient entityClient) { + _entityClient = Objects.requireNonNull(entityClient, "entityClient must not be null"); + } + + @Override + public CompletableFuture get(final DataFetchingEnvironment environment) + throws Exception { + final QueryContext context = environment.getContext(); + + final DeleteFormInput input = + bindArgument(environment.getArgument("input"), DeleteFormInput.class); + final Urn formUrn = UrnUtils.getUrn(input.getUrn()); + + return CompletableFuture.supplyAsync( + () -> { + try { + if (!AuthorizationUtils.canManageForms(context)) { + throw new AuthorizationException("Unable to delete form. Please contact your admin."); + } + _entityClient.deleteEntity(context.getOperationContext(), formUrn); + // Asynchronously Delete all references to the entity (to return quickly) + CompletableFuture.runAsync( + () -> { + try { + _entityClient.deleteEntityReferences(context.getOperationContext(), formUrn); + } catch (Exception e) { + log.error( + String.format( + "Caught exception while attempting to clear all entity references for Form with urn %s", + formUrn), + e); + } + }); + + return true; + } catch (Exception e) { + throw new RuntimeException( + String.format("Failed to perform update against input %s", input), e); + } + }); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/UpdateFormResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/UpdateFormResolver.java new file mode 100644 index 0000000000000..8b4d1debcd4db --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/form/UpdateFormResolver.java @@ -0,0 +1,98 @@ +package com.linkedin.datahub.graphql.resolvers.form; + +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument; + +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.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.Form; +import com.linkedin.datahub.graphql.generated.UpdateFormInput; +import com.linkedin.datahub.graphql.resolvers.mutate.util.FormUtils; +import com.linkedin.datahub.graphql.types.form.FormMapper; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.form.FormType; +import com.linkedin.metadata.Constants; +import com.linkedin.metadata.aspect.patch.builder.FormInfoPatchBuilder; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import javax.annotation.Nonnull; + +public class UpdateFormResolver implements DataFetcher> { + + private final EntityClient _entityClient; + + public UpdateFormResolver(@Nonnull final EntityClient entityClient) { + _entityClient = Objects.requireNonNull(entityClient, "entityClient must not be null"); + } + + @Override + public CompletableFuture get(final DataFetchingEnvironment environment) throws Exception { + final QueryContext context = environment.getContext(); + + final UpdateFormInput input = + bindArgument(environment.getArgument("input"), UpdateFormInput.class); + final Urn formUrn = UrnUtils.getUrn(input.getUrn()); + + return CompletableFuture.supplyAsync( + () -> { + try { + if (!AuthorizationUtils.canManageForms(context)) { + throw new AuthorizationException("Unable to update form. Please contact your admin."); + } + if (!_entityClient.exists(context.getOperationContext(), formUrn)) { + throw new IllegalArgumentException( + String.format("Form with urn %s does not exist", formUrn)); + } + + FormInfoPatchBuilder patchBuilder = new FormInfoPatchBuilder().urn(formUrn); + if (input.getName() != null) { + patchBuilder.setName(input.getName()); + } + if (input.getDescription() != null) { + patchBuilder.setDescription(input.getDescription()); + } + if (input.getType() != null) { + patchBuilder.setType(FormType.valueOf(input.getType().toString())); + } + if (input.getPromptsToAdd() != null) { + patchBuilder.addPrompts(FormUtils.mapPromptsToAdd(input.getPromptsToAdd())); + } + if (input.getPromptsToRemove() != null) { + patchBuilder.removePrompts(input.getPromptsToRemove()); + } + if (input.getActors() != null) { + if (input.getActors().getOwners() != null) { + patchBuilder.setOwnershipForm(input.getActors().getOwners()); + } + if (input.getActors().getUsersToAdd() != null) { + input.getActors().getUsersToAdd().forEach(patchBuilder::addAssignedUser); + } + if (input.getActors().getUsersToRemove() != null) { + input.getActors().getUsersToRemove().forEach(patchBuilder::removeAssignedUser); + } + if (input.getActors().getGroupsToAdd() != null) { + input.getActors().getGroupsToAdd().forEach(patchBuilder::addAssignedGroup); + } + if (input.getActors().getGroupsToRemove() != null) { + input.getActors().getGroupsToRemove().forEach(patchBuilder::removeAssignedGroup); + } + } + _entityClient.ingestProposal( + context.getOperationContext(), patchBuilder.build(), false); + + EntityResponse response = + _entityClient.getV2( + context.getOperationContext(), Constants.FORM_ENTITY_NAME, formUrn, null); + return FormMapper.map(context, response); + } catch (Exception e) { + throw new RuntimeException( + String.format("Failed to perform update against input %s", input), e); + } + }); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/FormUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/FormUtils.java index 6caa858460c2f..17718f39c1238 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/FormUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/FormUtils.java @@ -1,11 +1,23 @@ package com.linkedin.datahub.graphql.resolvers.mutate.util; +import com.linkedin.common.UrnArray; import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; import com.linkedin.datahub.graphql.generated.CreateDynamicFormAssignmentInput; +import com.linkedin.datahub.graphql.generated.CreateFormInput; +import com.linkedin.datahub.graphql.generated.CreatePromptInput; +import com.linkedin.datahub.graphql.generated.FormActorAssignmentInput; +import com.linkedin.datahub.graphql.generated.StructuredPropertyParamsInput; import com.linkedin.datahub.graphql.generated.SubmitFormPromptInput; import com.linkedin.datahub.graphql.resolvers.ResolverUtils; import com.linkedin.form.DynamicFormAssignment; +import com.linkedin.form.FormActorAssignment; import com.linkedin.form.FormInfo; +import com.linkedin.form.FormPrompt; +import com.linkedin.form.FormPromptArray; +import com.linkedin.form.FormPromptType; +import com.linkedin.form.FormType; +import com.linkedin.form.StructuredPropertyParams; import com.linkedin.metadata.aspect.AspectRetriever; import com.linkedin.metadata.query.filter.Condition; import com.linkedin.metadata.query.filter.ConjunctiveCriterion; @@ -14,7 +26,10 @@ import com.linkedin.metadata.query.filter.CriterionArray; import com.linkedin.metadata.query.filter.Filter; import com.linkedin.structured.PrimitivePropertyValueArray; +import java.util.List; import java.util.Objects; +import java.util.UUID; +import java.util.stream.Collectors; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -99,4 +114,103 @@ private static boolean isActorExplicitlyAssigned( || (formInfo.getActors().getGroups() != null && formInfo.getActors().getGroups().stream().anyMatch(group -> group.equals(actorUrn))); } + + @Nonnull + public static FormInfo mapFormInfo(@Nonnull final CreateFormInput input) { + Objects.requireNonNull(input, "input must not be null"); + + final FormInfo result = new FormInfo(); + result.setName(input.getName()); + if (input.getDescription() != null) { + result.setDescription(input.getDescription()); + } + if (input.getType() != null) { + result.setType(FormType.valueOf(input.getType().toString())); + } + if (input.getPrompts() != null) { + result.setPrompts(mapPrompts(input.getPrompts())); + } + if (input.getActors() != null) { + result.setActors(mapFormActorAssignment(input.getActors())); + } + + return result; + } + + @Nonnull + public static FormPromptArray mapPrompts(@Nonnull final List promptInputs) { + Objects.requireNonNull(promptInputs, "promptInputs must not be null"); + + final FormPromptArray result = new FormPromptArray(); + promptInputs.forEach( + promptInput -> { + result.add(mapPrompt(promptInput)); + }); + return result; + } + + @Nonnull + public static FormPrompt mapPrompt(@Nonnull final CreatePromptInput promptInput) { + Objects.requireNonNull(promptInput, "promptInput must not be null"); + + final FormPrompt result = new FormPrompt(); + String promptId = + promptInput.getId() != null ? promptInput.getId() : UUID.randomUUID().toString(); + result.setId(promptId); + result.setTitle(promptInput.getTitle()); + if (promptInput.getDescription() != null) { + result.setDescription(promptInput.getDescription()); + } + if (promptInput.getType() != null) { + result.setType(FormPromptType.valueOf(promptInput.getType().toString())); + } + if (promptInput.getStructuredPropertyParams() != null) { + result.setStructuredPropertyParams( + mapStructuredPropertyParams(promptInput.getStructuredPropertyParams())); + } + if (promptInput.getRequired() != null) { + result.setRequired(promptInput.getRequired()); + } + + return result; + } + + @Nonnull + public static StructuredPropertyParams mapStructuredPropertyParams( + @Nonnull final StructuredPropertyParamsInput paramsInput) { + Objects.requireNonNull(paramsInput, "paramsInput must not be null"); + + final StructuredPropertyParams result = new StructuredPropertyParams(); + result.setUrn(UrnUtils.getUrn(paramsInput.getUrn())); + return result; + } + + @Nonnull + public static FormActorAssignment mapFormActorAssignment( + @Nonnull final FormActorAssignmentInput input) { + Objects.requireNonNull(input, "input must not be null"); + + final FormActorAssignment result = new FormActorAssignment(); + if (input.getOwners() != null) { + result.setOwners(input.getOwners()); + } + if (input.getUsers() != null) { + UrnArray userUrns = new UrnArray(); + input.getUsers().forEach(user -> userUrns.add(UrnUtils.getUrn(user))); + result.setUsers(userUrns); + } + if (input.getGroups() != null) { + UrnArray groupUrns = new UrnArray(); + input.getGroups().forEach(group -> groupUrns.add(UrnUtils.getUrn(group))); + result.setUsers(groupUrns); + } + + return result; + } + + @Nonnull + public static List mapPromptsToAdd( + @Nonnull final List promptsToAdd) { + return promptsToAdd.stream().map(FormUtils::mapPrompt).collect(Collectors.toList()); + } } diff --git a/datahub-graphql-core/src/main/resources/forms.graphql b/datahub-graphql-core/src/main/resources/forms.graphql index f5e5fa74e3dc9..4a4e270509596 100644 --- a/datahub-graphql-core/src/main/resources/forms.graphql +++ b/datahub-graphql-core/src/main/resources/forms.graphql @@ -3,6 +3,21 @@ extend type Mutation { Remove a form from a given list of entities. """ batchRemoveForm(input: BatchRemoveFormInput!): Boolean! + + """ + Create a new form based on the input + """ + createForm(input: CreateFormInput!): Form! + + """ + Delete a given form + """ + deleteForm(input: DeleteFormInput!): Boolean! + + """ + Update an existing form based on the input + """ + updateForm(input: UpdateFormInput!): Form! } """ @@ -398,3 +413,184 @@ input BatchRemoveFormInput { """ entityUrns: [String!]! } + +""" +Input for batch removing a form from different entities +""" +input CreateFormInput { + """ + Advanced: Optionally provide an ID to create a form urn from + """ + id: String + + """ + The name of the form being created + """ + name: String! + + """ + The optional description of the form being created + """ + description: String + + """ + The type of this form, whether it's verification or completion. Default is completion. + """ + type: FormType + + """ + The type of this form, whether it's verification or completion. Default is completion. + """ + prompts: [CreatePromptInput!] + + """ + Information on how this form should be assigned to users/groups + """ + actors: FormActorAssignmentInput +} + +""" +Input for creating form prompts +""" +input CreatePromptInput { + """ + Advanced: Optionally provide an ID to this prompt. All prompt IDs must be globally unique. + """ + id: String + + """ + The title of the prompt + """ + title: String! + + """ + The optional description of the prompt + """ + description: String + + """ + The type of the prompt. + """ + type: FormPromptType! + + """ + The params required if this prompt type is STRUCTURED_PROPERTY or FIELDS_STRUCTURED_PROPERTY + """ + structuredPropertyParams: StructuredPropertyParamsInput + + """ + Whether this prompt will be required or not. Default is false. + """ + required: Boolean + +} + +""" +Input for assigning a form to actors +""" +input FormActorAssignmentInput { + """ + Whether this form will be applied to owners of associated entities or not. Default is true. + """ + owners: Boolean + + """ + The optional list of user urns to assign this form to + """ + users: [String!] + + """ + The optional list of group urns to assign this form to + """ + groups: [String!] +} + +""" +Input for a structured property type prompt +""" +input StructuredPropertyParamsInput { + """ + The urn of the structured property for a given form prompt + """ + urn: String! +} + +""" +Input for updating a form +""" +input UpdateFormInput { + """ + The urn of the form being updated + """ + urn: String! + + """ + The new name of the form + """ + name: String + + """ + The new description of the form + """ + description: String + + """ + The new type of the form + """ + type: FormType + + """ + The new prompts being added to this form + """ + promptsToAdd: [CreatePromptInput!] + + """ + The IDs of the prompts to remove from this form + """ + promptsToRemove: [String!] + + """ + Information on how this form should be assigned to users/groups + """ + actors: FormActorAssignmentUpdateInput +} + +""" +Update input for assigning a form to actors +""" +input FormActorAssignmentUpdateInput { + """ + Whether this form will be applied to owners of associated entities or not. Default is true. + """ + owners: Boolean + + """ + The optional list of user urns to assign this form to + """ + usersToAdd: [String!] + + """ + The users being removed from being assigned to this form + """ + usersToRemove: [String!] + + """ + The optional list of group urns to assign this form to + """ + groupsToAdd: [String!] + + """ + The groups being removed from being assigned to this form + """ + groupsToRemove: [String!] +} + +""" +Input for deleting a form +""" +input DeleteFormInput { + """ + The urn of the form that is being deleted + """ + urn: String! +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/form/CreateFormResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/form/CreateFormResolverTest.java new file mode 100644 index 0000000000000..65f51830ee148 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/form/CreateFormResolverTest.java @@ -0,0 +1,116 @@ +package com.linkedin.datahub.graphql.resolvers.form; + +import static com.linkedin.datahub.graphql.TestUtils.getMockAllowContext; +import static com.linkedin.datahub.graphql.TestUtils.getMockDenyContext; +import static org.mockito.ArgumentMatchers.any; +import static org.testng.Assert.*; + +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.CreateFormInput; +import com.linkedin.datahub.graphql.generated.Form; +import com.linkedin.datahub.graphql.generated.FormType; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.EnvelopedAspectMap; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.form.FormInfo; +import com.linkedin.metadata.Constants; +import com.linkedin.metadata.service.FormService; +import graphql.schema.DataFetchingEnvironment; +import java.util.ArrayList; +import java.util.concurrent.CompletionException; +import org.mockito.Mockito; +import org.testng.annotations.Test; + +public class CreateFormResolverTest { + private static final String TEST_FORM_URN = "urn:li:form:1"; + + private static final CreateFormInput TEST_INPUT = + new CreateFormInput(null, "test name", null, FormType.VERIFICATION, new ArrayList<>(), null); + + @Test + public void testGetSuccess() throws Exception { + FormService mockFormService = initMockFormService(true); + EntityClient mockEntityClient = initMockEntityClient(); + CreateFormResolver resolver = new CreateFormResolver(mockEntityClient, mockFormService); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + Form form = resolver.get(mockEnv).get(); + + assertEquals(form.getUrn(), TEST_FORM_URN); + + // Validate that we called create on the service + Mockito.verify(mockFormService, Mockito.times(1)) + .createForm(any(), any(FormInfo.class), Mockito.eq(null)); + } + + @Test + public void testGetUnauthorized() throws Exception { + FormService mockFormService = initMockFormService(true); + EntityClient mockEntityClient = initMockEntityClient(); + CreateFormResolver resolver = new CreateFormResolver(mockEntityClient, mockFormService); + + // Execute resolver + QueryContext mockContext = getMockDenyContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + + // Validate that we did NOT call create on the service + Mockito.verify(mockFormService, Mockito.times(0)) + .createForm(any(), any(FormInfo.class), Mockito.eq(null)); + } + + @Test + public void testGetFailure() throws Exception { + FormService mockFormService = initMockFormService(false); + EntityClient mockEntityClient = initMockEntityClient(); + CreateFormResolver resolver = new CreateFormResolver(mockEntityClient, mockFormService); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + + // Validate that we called create on the service + Mockito.verify(mockFormService, Mockito.times(1)) + .createForm(any(), any(FormInfo.class), Mockito.eq(null)); + } + + private FormService initMockFormService(final boolean shouldSucceed) throws Exception { + FormService service = Mockito.mock(FormService.class); + + if (shouldSucceed) { + Mockito.when(service.createForm(any(), Mockito.any(), Mockito.any())) + .thenReturn(UrnUtils.getUrn("urn:li:form:1")); + } else { + Mockito.when(service.createForm(any(), Mockito.any(), Mockito.any())) + .thenThrow(new RuntimeException()); + } + + return service; + } + + private EntityClient initMockEntityClient() throws Exception { + EntityClient client = Mockito.mock(EntityClient.class); + EntityResponse response = new EntityResponse(); + response.setEntityName(Constants.FORM_ENTITY_NAME); + response.setUrn(UrnUtils.getUrn(TEST_FORM_URN)); + response.setAspects(new EnvelopedAspectMap()); + Mockito.when( + client.getV2(any(), Mockito.eq(Constants.FORM_ENTITY_NAME), any(), Mockito.eq(null))) + .thenReturn(response); + + return client; + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/form/DeleteFormResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/form/DeleteFormResolverTest.java new file mode 100644 index 0000000000000..ded79ed9a0018 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/form/DeleteFormResolverTest.java @@ -0,0 +1,90 @@ +package com.linkedin.datahub.graphql.resolvers.form; + +import static com.linkedin.datahub.graphql.TestUtils.getMockAllowContext; +import static com.linkedin.datahub.graphql.TestUtils.getMockDenyContext; +import static org.mockito.ArgumentMatchers.any; +import static org.testng.Assert.assertThrows; +import static org.testng.Assert.assertTrue; + +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.DeleteFormInput; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.r2.RemoteInvocationException; +import graphql.schema.DataFetchingEnvironment; +import java.util.concurrent.CompletionException; +import org.mockito.Mockito; +import org.testng.annotations.Test; + +public class DeleteFormResolverTest { + private static final String TEST_FORM_URN = "urn:li:form:1"; + + private static final DeleteFormInput TEST_INPUT = new DeleteFormInput(TEST_FORM_URN); + + @Test + public void testGetSuccess() throws Exception { + EntityClient mockEntityClient = initMockEntityClient(true); + DeleteFormResolver resolver = new DeleteFormResolver(mockEntityClient); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + Boolean success = resolver.get(mockEnv).get(); + assertTrue(success); + + // Validate that we called delete + Mockito.verify(mockEntityClient, Mockito.times(1)) + .deleteEntity(any(), Mockito.eq(UrnUtils.getUrn(TEST_FORM_URN))); + } + + @Test + public void testGetUnauthorized() throws Exception { + EntityClient mockEntityClient = initMockEntityClient(true); + DeleteFormResolver resolver = new DeleteFormResolver(mockEntityClient); + + // Execute resolver + QueryContext mockContext = getMockDenyContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + + // Validate that we did NOT call delete and delete references + Mockito.verify(mockEntityClient, Mockito.times(0)) + .deleteEntity(any(), Mockito.eq(UrnUtils.getUrn(TEST_FORM_URN))); + Mockito.verify(mockEntityClient, Mockito.times(0)) + .deleteEntityReferences(any(), Mockito.eq(UrnUtils.getUrn(TEST_FORM_URN))); + } + + @Test + public void testGetFailure() throws Exception { + EntityClient mockEntityClient = initMockEntityClient(false); + DeleteFormResolver resolver = new DeleteFormResolver(mockEntityClient); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + + // Validate that deleteEntity was called, but since it failed, delete references was not called + Mockito.verify(mockEntityClient, Mockito.times(1)) + .deleteEntity(any(), Mockito.eq(UrnUtils.getUrn(TEST_FORM_URN))); + Mockito.verify(mockEntityClient, Mockito.times(0)) + .deleteEntityReferences(any(), Mockito.eq(UrnUtils.getUrn(TEST_FORM_URN))); + } + + private EntityClient initMockEntityClient(boolean shouldSucceed) throws Exception { + EntityClient client = Mockito.mock(EntityClient.class); + if (!shouldSucceed) { + Mockito.doThrow(new RemoteInvocationException()).when(client).deleteEntity(any(), any()); + } + return client; + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/form/UpdateFormResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/form/UpdateFormResolverTest.java new file mode 100644 index 0000000000000..6a4b99742f7fd --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/form/UpdateFormResolverTest.java @@ -0,0 +1,105 @@ +package com.linkedin.datahub.graphql.resolvers.form; + +import static com.linkedin.datahub.graphql.TestUtils.getMockAllowContext; +import static com.linkedin.datahub.graphql.TestUtils.getMockDenyContext; +import static org.mockito.ArgumentMatchers.any; +import static org.testng.Assert.*; + +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.Form; +import com.linkedin.datahub.graphql.generated.UpdateFormInput; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.EnvelopedAspectMap; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.metadata.Constants; +import com.linkedin.mxe.MetadataChangeProposal; +import com.linkedin.r2.RemoteInvocationException; +import graphql.schema.DataFetchingEnvironment; +import java.util.concurrent.CompletionException; +import org.mockito.Mockito; +import org.testng.annotations.Test; + +public class UpdateFormResolverTest { + private static final String TEST_FORM_URN = "urn:li:form:1"; + + private static final UpdateFormInput TEST_INPUT = + new UpdateFormInput(TEST_FORM_URN, "new name", null, null, null, null, null); + + @Test + public void testGetSuccess() throws Exception { + EntityClient mockEntityClient = initMockEntityClient(true); + UpdateFormResolver resolver = new UpdateFormResolver(mockEntityClient); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + Form form = resolver.get(mockEnv).get(); + + assertEquals(form.getUrn(), TEST_FORM_URN); + + // Validate that we called ingest + Mockito.verify(mockEntityClient, Mockito.times(1)) + .ingestProposal(any(), any(MetadataChangeProposal.class), Mockito.eq(false)); + } + + @Test + public void testGetUnauthorized() throws Exception { + EntityClient mockEntityClient = initMockEntityClient(true); + UpdateFormResolver resolver = new UpdateFormResolver(mockEntityClient); + + // Execute resolver + QueryContext mockContext = getMockDenyContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + + // Validate that we did NOT call ingest + Mockito.verify(mockEntityClient, Mockito.times(0)) + .ingestProposal(any(), any(MetadataChangeProposal.class), Mockito.eq(false)); + } + + @Test + public void testGetFailure() throws Exception { + EntityClient mockEntityClient = initMockEntityClient(false); + UpdateFormResolver resolver = new UpdateFormResolver(mockEntityClient); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + + // Validate that ingest was called, but that caused a failure + Mockito.verify(mockEntityClient, Mockito.times(1)) + .ingestProposal(any(), any(MetadataChangeProposal.class), Mockito.eq(false)); + } + + private EntityClient initMockEntityClient(boolean shouldSucceed) throws Exception { + EntityClient client = Mockito.mock(EntityClient.class); + EntityResponse response = new EntityResponse(); + response.setEntityName(Constants.FORM_ENTITY_NAME); + response.setUrn(UrnUtils.getUrn(TEST_FORM_URN)); + response.setAspects(new EnvelopedAspectMap()); + if (shouldSucceed) { + Mockito.when( + client.getV2(any(), Mockito.eq(Constants.FORM_ENTITY_NAME), any(), Mockito.eq(null))) + .thenReturn(response); + } else { + Mockito.when( + client.getV2(any(), Mockito.eq(Constants.FORM_ENTITY_NAME), any(), Mockito.eq(null))) + .thenThrow(new RemoteInvocationException()); + } + + Mockito.when(client.exists(any(), Mockito.eq(UrnUtils.getUrn(TEST_FORM_URN)))).thenReturn(true); + + return client; + } +} diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/service/FormService.java b/metadata-service/services/src/main/java/com/linkedin/metadata/service/FormService.java index a75c399300f65..210fba82eb4e2 100644 --- a/metadata-service/services/src/main/java/com/linkedin/metadata/service/FormService.java +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/service/FormService.java @@ -33,7 +33,9 @@ import com.linkedin.metadata.Constants; import com.linkedin.metadata.authorization.OwnershipUtils; import com.linkedin.metadata.entity.AspectUtils; +import com.linkedin.metadata.key.FormKey; import com.linkedin.metadata.service.util.SearchBasedFormAssignmentRunner; +import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.metadata.utils.FormUtils; import com.linkedin.metadata.utils.SchemaFieldUtils; import com.linkedin.mxe.MetadataChangeProposal; @@ -51,6 +53,7 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.UUID; import java.util.stream.Collectors; import java.util.stream.Stream; import javax.annotation.Nonnull; @@ -1050,6 +1053,28 @@ private void verifyEntityExists(@Nonnull OperationContext opContext, @Nonnull fi } } + /** Create a form given the formInfo aspect. */ + public Urn createForm( + @Nonnull OperationContext opContext, + @Nonnull final FormInfo formInfo, + @Nullable final String id) { + + FormKey formKey = new FormKey(); + String formId = id != null ? id : UUID.randomUUID().toString(); + formKey.setId(formId); + Urn formUrn = EntityKeyUtils.convertEntityKeyToUrn(formKey, FORM_ENTITY_NAME); + + try { + this.entityClient.ingestProposal( + opContext, + AspectUtils.buildMetadataChangeProposal(formUrn, FORM_INFO_ASPECT_NAME, formInfo), + false); + return formUrn; + } catch (Exception e) { + throw new RuntimeException("Failed to create form", e); + } + } + private AuditStamp createSystemAuditStamp() { return createAuditStamp(UrnUtils.getUrn(SYSTEM_ACTOR)); } diff --git a/metadata-service/war/src/main/resources/boot/policies.json b/metadata-service/war/src/main/resources/boot/policies.json index 454f0ba7d1163..730556c25766d 100644 --- a/metadata-service/war/src/main/resources/boot/policies.json +++ b/metadata-service/war/src/main/resources/boot/policies.json @@ -34,7 +34,8 @@ "MANAGE_GLOBAL_OWNERSHIP_TYPES", "GET_ANALYTICS_PRIVILEGE", "CREATE_BUSINESS_ATTRIBUTE", - "MANAGE_BUSINESS_ATTRIBUTE" + "MANAGE_BUSINESS_ATTRIBUTE", + "MANAGE_DOCUMENTATION_FORMS" ], "displayName":"Root User - All Platform Privileges", "description":"Grants all platform privileges to root user.", @@ -179,7 +180,8 @@ "MANAGE_GLOBAL_OWNERSHIP_TYPES", "GET_ANALYTICS_PRIVILEGE", "CREATE_BUSINESS_ATTRIBUTE", - "MANAGE_BUSINESS_ATTRIBUTE" + "MANAGE_BUSINESS_ATTRIBUTE", + "MANAGE_DOCUMENTATION_FORMS" ], "displayName":"Admins - Platform Policy", "description":"Admins have all platform privileges.", @@ -265,7 +267,8 @@ "MANAGE_GLOBAL_ANNOUNCEMENTS", "MANAGE_GLOSSARIES", "MANAGE_TAGS", - "MANAGE_BUSINESS_ATTRIBUTE" + "MANAGE_BUSINESS_ATTRIBUTE", + "MANAGE_DOCUMENTATION_FORMS" ], "displayName":"Editors - Platform Policy", "description":"Editors can manage ingestion and view analytics.", diff --git a/metadata-utils/src/main/java/com/linkedin/metadata/authorization/PoliciesConfig.java b/metadata-utils/src/main/java/com/linkedin/metadata/authorization/PoliciesConfig.java index 24fa4ec080cfa..c963925d488eb 100644 --- a/metadata-utils/src/main/java/com/linkedin/metadata/authorization/PoliciesConfig.java +++ b/metadata-utils/src/main/java/com/linkedin/metadata/authorization/PoliciesConfig.java @@ -151,6 +151,12 @@ public class PoliciesConfig { "Manage Connections", "Manage connections to external DataHub platforms."); + public static final Privilege MANAGE_DOCUMENTATION_FORMS_PRIVILEGE = + Privilege.of( + "MANAGE_DOCUMENTATION_FORMS", + "Manage Documentation Forms", + "Manage forms assigned to assets to assist in documentation efforts."); + public static final List PLATFORM_PRIVILEGES = ImmutableList.of( MANAGE_POLICIES_PRIVILEGE, @@ -175,7 +181,8 @@ public class PoliciesConfig { MANAGE_GLOBAL_OWNERSHIP_TYPES, CREATE_BUSINESS_ATTRIBUTE_PRIVILEGE, MANAGE_BUSINESS_ATTRIBUTE_PRIVILEGE, - MANAGE_CONNECTIONS_PRIVILEGE); + MANAGE_CONNECTIONS_PRIVILEGE, + MANAGE_DOCUMENTATION_FORMS_PRIVILEGE); // Resource Privileges //