diff --git a/datahub-frontend/play.gradle b/datahub-frontend/play.gradle index 123cce7eb3..fb08cbddc1 100644 --- a/datahub-frontend/play.gradle +++ b/datahub-frontend/play.gradle @@ -72,7 +72,7 @@ play { platform { playVersion = '2.7.6' scalaVersion = '2.12' - javaVersion = JavaVersion.VERSION_1_8 + javaVersion = JavaVersion.VERSION_11 } injectedRoutesGenerator = true @@ -81,7 +81,7 @@ play { model { components { play { - platform play: '2.7.6', scala: '2.12', java: '1.8' + platform play: '2.7.6', scala: '2.12', java: '11' injectedRoutesGenerator = true binaries.all { 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 db9f999a2b..a2492082f7 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 @@ -3,6 +3,7 @@ package com.linkedin.datahub.graphql; import com.datahub.authentication.AuthenticationConfiguration; import com.datahub.authentication.group.GroupService; import com.datahub.authentication.invite.InviteTokenService; +import com.datahub.authentication.post.PostService; import com.datahub.authentication.token.StatefulTokenService; import com.datahub.authentication.user.NativeUserService; import com.datahub.authorization.AuthorizationConfiguration; @@ -175,6 +176,8 @@ import com.linkedin.datahub.graphql.resolvers.policy.DeletePolicyResolver; import com.linkedin.datahub.graphql.resolvers.policy.GetGrantedPrivilegesResolver; import com.linkedin.datahub.graphql.resolvers.policy.ListPoliciesResolver; import com.linkedin.datahub.graphql.resolvers.policy.UpsertPolicyResolver; +import com.linkedin.datahub.graphql.resolvers.post.CreatePostResolver; +import com.linkedin.datahub.graphql.resolvers.post.ListPostsResolver; import com.linkedin.datahub.graphql.resolvers.recommendation.ListRecommendationsResolver; import com.linkedin.datahub.graphql.resolvers.role.AcceptRoleResolver; import com.linkedin.datahub.graphql.resolvers.role.BatchAssignRoleResolver; @@ -310,6 +313,7 @@ public class GmsGraphQLEngine { private final GroupService groupService; private final RoleService roleService; private final InviteTokenService inviteTokenService; + private final PostService postService; private final FeatureFlags featureFlags; @@ -386,7 +390,7 @@ public class GmsGraphQLEngine { final VisualConfiguration visualConfiguration, final TelemetryConfiguration telemetryConfiguration, final TestsConfiguration testsConfiguration, final DatahubConfiguration datahubConfiguration, final SiblingGraphService siblingGraphService, final GroupService groupService, final RoleService roleService, - final InviteTokenService inviteTokenService, final FeatureFlags featureFlags) { + final InviteTokenService inviteTokenService, final PostService postService, final FeatureFlags featureFlags) { this.entityClient = entityClient; this.graphClient = graphClient; @@ -407,6 +411,7 @@ public class GmsGraphQLEngine { this.groupService = groupService; this.roleService = roleService; this.inviteTokenService = inviteTokenService; + this.postService = postService; this.ingestionConfiguration = Objects.requireNonNull(ingestionConfiguration); this.authenticationConfiguration = Objects.requireNonNull(authenticationConfiguration); @@ -676,6 +681,7 @@ public class GmsGraphQLEngine { .dataFetcher("entities", getEntitiesResolver()) .dataFetcher("listRoles", new ListRolesResolver(this.entityClient)) .dataFetcher("getInviteToken", new GetInviteTokenResolver(this.inviteTokenService)) + .dataFetcher("listPosts", new ListPostsResolver(this.entityClient)) ); } @@ -798,7 +804,7 @@ public class GmsGraphQLEngine { .dataFetcher("batchAssignRole", new BatchAssignRoleResolver(this.roleService)) .dataFetcher("createInviteToken", new CreateInviteTokenResolver(this.inviteTokenService)) .dataFetcher("acceptRole", new AcceptRoleResolver(this.roleService, this.inviteTokenService)) - + .dataFetcher("createPost", new CreatePostResolver(this.postService)) ); } 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 f403476908..be7ab57eb8 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 @@ -107,6 +107,10 @@ public class AuthorizationUtils { groupUrnStr, orPrivilegeGroups); } + public static boolean canCreateGlobalAnnouncements(@Nonnull QueryContext context) { + return isAuthorized(context, Optional.empty(), PoliciesConfig.CREATE_GLOBAL_ANNOUNCEMENTS_PRIVILEGE); + } + public static boolean isAuthorized( @Nonnull QueryContext context, @Nonnull Optional resourceSpec, diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/post/CreatePostResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/post/CreatePostResolver.java new file mode 100644 index 0000000000..524caf14e9 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/post/CreatePostResolver.java @@ -0,0 +1,60 @@ +package com.linkedin.datahub.graphql.resolvers.post; + +import com.datahub.authentication.Authentication; +import com.datahub.authentication.post.PostService; +import com.linkedin.common.Media; +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.CreatePostInput; +import com.linkedin.datahub.graphql.generated.PostContentType; +import com.linkedin.datahub.graphql.generated.PostType; +import com.linkedin.datahub.graphql.generated.UpdateMediaInput; +import com.linkedin.datahub.graphql.generated.UpdatePostContentInput; +import com.linkedin.post.PostContent; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +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 CreatePostResolver implements DataFetcher> { + private final PostService _postService; + + @Override + public CompletableFuture get(final DataFetchingEnvironment environment) throws Exception { + final QueryContext context = environment.getContext(); + + if (!AuthorizationUtils.canCreateGlobalAnnouncements(context)) { + throw new AuthorizationException( + "Unauthorized to create posts. Please contact your DataHub administrator if this needs corrective action."); + } + + final CreatePostInput input = bindArgument(environment.getArgument("input"), CreatePostInput.class); + final PostType type = input.getPostType(); + final UpdatePostContentInput content = input.getContent(); + final PostContentType contentType = content.getContentType(); + final String title = content.getTitle(); + final String link = content.getLink(); + final String description = content.getDescription(); + final UpdateMediaInput updateMediaInput = content.getMedia(); + final Authentication authentication = context.getAuthentication(); + + Media media = updateMediaInput == null ? null + : _postService.mapMedia(updateMediaInput.getType().toString(), updateMediaInput.getLocation()); + PostContent postContent = _postService.mapPostContent(contentType.toString(), title, description, link, media); + + return CompletableFuture.supplyAsync(() -> { + try { + return _postService.createPost(type.toString(), postContent, authentication); + } catch (Exception e) { + throw new RuntimeException("Failed to create a new post", e); + } + }); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/post/ListPostsResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/post/ListPostsResolver.java new file mode 100644 index 0000000000..839c5b5d1a --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/post/ListPostsResolver.java @@ -0,0 +1,72 @@ +package com.linkedin.datahub.graphql.resolvers.post; + +import com.datahub.authentication.Authentication; +import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.ListPostsInput; +import com.linkedin.datahub.graphql.generated.ListPostsResult; +import com.linkedin.datahub.graphql.types.post.PostMapper; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.metadata.query.filter.SortCriterion; +import com.linkedin.metadata.query.filter.SortOrder; +import com.linkedin.metadata.search.SearchEntity; +import com.linkedin.metadata.search.SearchResult; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.HashSet; +import java.util.Map; +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.*; +import static com.linkedin.metadata.Constants.*; + + +@Slf4j +@RequiredArgsConstructor +public class ListPostsResolver implements DataFetcher> { + private static final Integer DEFAULT_START = 0; + private static final Integer DEFAULT_COUNT = 20; + private static final String DEFAULT_QUERY = ""; + + private final EntityClient _entityClient; + + @Override + public CompletableFuture get(final DataFetchingEnvironment environment) throws Exception { + final QueryContext context = environment.getContext(); + final Authentication authentication = context.getAuthentication(); + + final ListPostsInput input = bindArgument(environment.getArgument("input"), ListPostsInput.class); + final Integer start = input.getStart() == null ? DEFAULT_START : input.getStart(); + final Integer count = input.getCount() == null ? DEFAULT_COUNT : input.getCount(); + final String query = input.getQuery() == null ? DEFAULT_QUERY : input.getQuery(); + + return CompletableFuture.supplyAsync(() -> { + try { + final SortCriterion sortCriterion = + new SortCriterion().setField(LAST_MODIFIED_FIELD_NAME).setOrder(SortOrder.DESCENDING); + + // First, get all Post Urns. + final SearchResult gmsResult = _entityClient.search(POST_ENTITY_NAME, query, null, sortCriterion, start, count, + context.getAuthentication()); + + // Then, get and hydrate all Posts. + final Map entities = _entityClient.batchGetV2(POST_ENTITY_NAME, + new HashSet<>(gmsResult.getEntities().stream().map(SearchEntity::getEntity).collect(Collectors.toList())), + null, authentication); + + final ListPostsResult result = new ListPostsResult(); + result.setStart(gmsResult.getFrom()); + result.setCount(gmsResult.getPageSize()); + result.setTotal(gmsResult.getNumEntities()); + result.setPosts(entities.values().stream().map(PostMapper::map).collect(Collectors.toList())); + return result; + } catch (Exception e) { + throw new RuntimeException("Failed to list posts", e); + } + }); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/post/PostMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/post/PostMapper.java new file mode 100644 index 0000000000..791197c7d4 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/post/PostMapper.java @@ -0,0 +1,76 @@ +package com.linkedin.datahub.graphql.types.post; + +import com.linkedin.data.DataMap; +import com.linkedin.datahub.graphql.generated.AuditStamp; +import com.linkedin.datahub.graphql.generated.EntityType; +import com.linkedin.datahub.graphql.generated.Media; +import com.linkedin.datahub.graphql.generated.MediaType; +import com.linkedin.datahub.graphql.generated.Post; +import com.linkedin.datahub.graphql.generated.PostContent; +import com.linkedin.datahub.graphql.generated.PostContentType; +import com.linkedin.datahub.graphql.generated.PostType; +import com.linkedin.datahub.graphql.types.common.mappers.util.MappingHelper; +import com.linkedin.datahub.graphql.types.mappers.ModelMapper; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.EnvelopedAspectMap; +import com.linkedin.post.PostInfo; +import javax.annotation.Nonnull; + +import static com.linkedin.metadata.Constants.*; + + +public class PostMapper implements ModelMapper { + + public static final PostMapper INSTANCE = new PostMapper(); + + public static Post map(@Nonnull final EntityResponse entityResponse) { + return INSTANCE.apply(entityResponse); + } + + @Override + public Post apply(@Nonnull final EntityResponse entityResponse) { + final Post result = new Post(); + + result.setUrn(entityResponse.getUrn().toString()); + result.setType(EntityType.POST); + EnvelopedAspectMap aspectMap = entityResponse.getAspects(); + MappingHelper mappingHelper = new MappingHelper<>(aspectMap, result); + mappingHelper.mapToResult(POST_INFO_ASPECT_NAME, this::mapPostInfo); + return mappingHelper.getResult(); + } + + private void mapPostInfo(@Nonnull Post post, @Nonnull DataMap dataMap) { + PostInfo postInfo = new PostInfo(dataMap); + post.setPostType(PostType.valueOf(postInfo.getType().toString())); + post.setContent(mapPostContent(postInfo.getContent())); + AuditStamp lastModified = new AuditStamp(); + lastModified.setTime(postInfo.getLastModified()); + post.setLastModified(lastModified); + } + + @Nonnull + private com.linkedin.datahub.graphql.generated.PostContent mapPostContent( + @Nonnull com.linkedin.post.PostContent postContent) { + PostContent result = new PostContent(); + result.setContentType(PostContentType.valueOf(postContent.getType().toString())); + result.setTitle(postContent.getTitle()); + if (postContent.hasDescription()) { + result.setDescription(postContent.getDescription()); + } + if (postContent.hasLink()) { + result.setLink(postContent.getLink().toString()); + } + if (postContent.hasMedia()) { + result.setMedia(mapPostMedia(postContent.getMedia())); + } + return result; + } + + @Nonnull + private Media mapPostMedia(@Nonnull com.linkedin.common.Media postMedia) { + Media result = new Media(); + result.setType(MediaType.valueOf(postMedia.getType().toString())); + result.setLocation(postMedia.getLocation().toString()); + return result; + } +} diff --git a/datahub-graphql-core/src/main/resources/entity.graphql b/datahub-graphql-core/src/main/resources/entity.graphql index 58613e0b92..9722b12ea2 100644 --- a/datahub-graphql-core/src/main/resources/entity.graphql +++ b/datahub-graphql-core/src/main/resources/entity.graphql @@ -183,6 +183,11 @@ type Query { Get invite token """ getInviteToken(input: GetInviteTokenInput!): InviteToken + + """ + List all Posts + """ + listPosts(input: ListPostsInput!): ListPostsResult } """ @@ -518,6 +523,11 @@ type Mutation { Create invite token """ createInviteToken(input: CreateInviteTokenInput!): InviteToken + + """ + Create a post + """ + createPost(input: CreatePostInput!): Boolean } """ @@ -693,6 +703,11 @@ enum EntityType { A DataHub Role """ DATAHUB_ROLE + + """ + A DataHub Post + """ + POST } """ @@ -9385,3 +9400,223 @@ input AcceptRoleInput { """ inviteToken: String! } + +""" +The type of post +""" +enum PostType { + """ + Posts on the home page + """ + HOME_PAGE_ANNOUNCEMENT, +} + +""" +The type of post +""" +enum PostContentType { + """ + Text content + """ + TEXT, + + """ + Link content + """ + LINK +} + +""" +The type of media +""" +enum MediaType { + """ + An image + """ + IMAGE +} + +""" +Input provided when creating a Post +""" +input CreatePostInput { + """ + The type of post + """ + postType: PostType! + + """ + The content of the post + """ + content: UpdatePostContentInput! +} + +""" +Input provided for filling in a post content +""" +input UpdatePostContentInput { + """ + The type of post content + """ + contentType: PostContentType! + + """ + The title of the post + """ + title: String! + + """ + Optional content of the post + """ + description: String + + """ + Optional link that the post is associated with + """ + link: String + + """ + Optional media contained in the post + """ + media: UpdateMediaInput +} + +""" +Input provided for filling in a post content +""" +input UpdateMediaInput { + """ + The type of media + """ + type: MediaType! + + """ + The location of the media (a URL) + """ + location: String! +} + +""" +Input provided when listing existing posts +""" +input ListPostsInput { + """ + The starting offset of the result set returned + """ + start: Int + + """ + The maximum number of Roles to be returned in the result set + """ + count: Int + + """ + Optional search query + """ + query: String +} + +""" +The result obtained when listing Posts +""" +type ListPostsResult { + """ + The starting offset of the result set returned + """ + start: Int! + + """ + The number of Roles in the returned result set + """ + count: Int! + + """ + The total number of Roles in the result set + """ + total: Int! + + """ + The Posts themselves + """ + posts: [Post!]! +} + +""" +Input provided when creating a Post +""" +type Post implements Entity { + """ + The primary key of the Post + """ + urn: String! + + """ + The standard Entity Type + """ + type: EntityType! + + """ + Granular API for querying edges extending from the Post + """ + relationships(input: RelationshipsInput!): EntityRelationshipsResult + + """ + The type of post + """ + postType: PostType! + + """ + The content of the post + """ + content: PostContent! + + """ + When the post was last modified + """ + lastModified: AuditStamp! +} + +""" +Post content +""" +type PostContent { + """ + The type of post content + """ + contentType: PostContentType! + + """ + The title of the post + """ + title: String! + + """ + Optional content of the post + """ + description: String + + """ + Optional link that the post is associated with + """ + link: String + + """ + Optional media contained in the post + """ + media: Media +} + +""" +Media content +""" +type Media { + """ + The type of media + """ + type: MediaType! + + """ + The location of the media (a URL) + """ + location: String! +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/post/CreatePostResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/post/CreatePostResolverTest.java new file mode 100644 index 0000000000..b56d897a46 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/post/CreatePostResolverTest.java @@ -0,0 +1,91 @@ +package com.linkedin.datahub.graphql.resolvers.post; + +import com.datahub.authentication.Authentication; +import com.datahub.authentication.post.PostService; +import com.linkedin.common.Media; +import com.linkedin.common.url.Url; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.CreatePostInput; +import com.linkedin.datahub.graphql.generated.MediaType; +import com.linkedin.datahub.graphql.generated.PostContentType; +import com.linkedin.datahub.graphql.generated.PostType; +import com.linkedin.datahub.graphql.generated.UpdateMediaInput; +import com.linkedin.datahub.graphql.generated.UpdatePostContentInput; +import graphql.schema.DataFetchingEnvironment; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + + +public class CreatePostResolverTest { + private static final MediaType POST_MEDIA_TYPE = MediaType.IMAGE; + private static final String POST_MEDIA_LOCATION = + "https://datahubproject.io/img/datahub-logo-color-light-horizontal.svg"; + private static final PostContentType POST_CONTENT_TYPE = PostContentType.LINK; + private static final String POST_TITLE = "title"; + private static final String POST_DESCRIPTION = "description"; + private static final String POST_LINK = "https://datahubproject.io"; + private PostService _postService; + private CreatePostResolver _resolver; + private DataFetchingEnvironment _dataFetchingEnvironment; + private Authentication _authentication; + + @BeforeMethod + public void setupTest() throws Exception { + _postService = mock(PostService.class); + _dataFetchingEnvironment = mock(DataFetchingEnvironment.class); + _authentication = mock(Authentication.class); + + _resolver = new CreatePostResolver(_postService); + } + + @Test + public void testNotAuthorizedFails() { + QueryContext mockContext = getMockDenyContext(); + when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext); + + assertThrows(() -> _resolver.get(_dataFetchingEnvironment).join()); + } + + @Test + public void testCreatePost() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext); + when(mockContext.getAuthentication()).thenReturn(_authentication); + + UpdateMediaInput media = new UpdateMediaInput(); + media.setType(POST_MEDIA_TYPE); + media.setLocation(POST_MEDIA_LOCATION); + Media mediaObj = new Media().setType(com.linkedin.common.MediaType.valueOf(POST_MEDIA_TYPE.toString())) + .setLocation(new Url(POST_MEDIA_LOCATION)); + when(_postService.mapMedia(eq(POST_MEDIA_TYPE.toString()), eq(POST_MEDIA_LOCATION))).thenReturn(mediaObj); + + UpdatePostContentInput content = new UpdatePostContentInput(); + content.setTitle(POST_TITLE); + content.setDescription(POST_DESCRIPTION); + content.setLink(POST_LINK); + content.setContentType(POST_CONTENT_TYPE); + content.setMedia(media); + com.linkedin.post.PostContent postContentObj = new com.linkedin.post.PostContent().setType( + com.linkedin.post.PostContentType.valueOf(POST_CONTENT_TYPE.toString())) + .setTitle(POST_TITLE) + .setDescription(POST_DESCRIPTION) + .setLink(new Url(POST_LINK)) + .setMedia(new Media().setType(com.linkedin.common.MediaType.valueOf(POST_MEDIA_TYPE.toString())) + .setLocation(new Url(POST_MEDIA_LOCATION))); + when(_postService.mapPostContent(eq(POST_CONTENT_TYPE.toString()), eq(POST_TITLE), eq(POST_DESCRIPTION), + eq(POST_LINK), any(Media.class))).thenReturn(postContentObj); + + CreatePostInput input = new CreatePostInput(); + input.setPostType(PostType.HOME_PAGE_ANNOUNCEMENT); + input.setContent(content); + when(_dataFetchingEnvironment.getArgument(eq("input"))).thenReturn(input); + when(_postService.createPost(eq(PostType.HOME_PAGE_ANNOUNCEMENT.toString()), eq(postContentObj), + eq(_authentication))).thenReturn(true); + + assertTrue(_resolver.get(_dataFetchingEnvironment).join()); + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/post/ListPostsResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/post/ListPostsResolverTest.java new file mode 100644 index 0000000000..b4bec3ae9b --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/post/ListPostsResolverTest.java @@ -0,0 +1,120 @@ +package com.linkedin.datahub.graphql.resolvers.post; + +import com.datahub.authentication.Authentication; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.linkedin.common.Media; +import com.linkedin.common.MediaType; +import com.linkedin.common.url.Url; +import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.ListPostsInput; +import com.linkedin.datahub.graphql.generated.ListPostsResult; +import com.linkedin.entity.Aspect; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.EnvelopedAspect; +import com.linkedin.entity.EnvelopedAspectMap; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.metadata.search.SearchEntity; +import com.linkedin.metadata.search.SearchEntityArray; +import com.linkedin.metadata.search.SearchResult; +import com.linkedin.metadata.search.SearchResultMetadata; +import com.linkedin.policy.DataHubRoleInfo; +import com.linkedin.post.PostContent; +import com.linkedin.post.PostContentType; +import com.linkedin.post.PostInfo; +import com.linkedin.post.PostType; +import graphql.schema.DataFetchingEnvironment; +import java.net.URISyntaxException; +import java.util.Map; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import static com.linkedin.datahub.graphql.TestUtils.*; +import static com.linkedin.metadata.Constants.*; +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + + +public class ListPostsResolverTest { + private static Map _entityResponseMap; + private static final String POST_URN_STRING = "urn:li:post:examplePost"; + private static final MediaType POST_MEDIA_TYPE = MediaType.IMAGE; + private static final String POST_MEDIA_LOCATION = + "https://datahubproject.io/img/datahub-logo-color-light-horizontal.svg"; + private static final PostContentType POST_CONTENT_TYPE = PostContentType.LINK; + private static final String POST_TITLE = "title"; + private static final String POST_DESCRIPTION = "description"; + private static final String POST_LINK = "https://datahubproject.io"; + private static final Media MEDIA = new Media().setType(POST_MEDIA_TYPE).setLocation(new Url(POST_MEDIA_LOCATION)); + private static final PostContent POST_CONTENT = new PostContent().setType(POST_CONTENT_TYPE) + .setTitle(POST_TITLE) + .setDescription(POST_DESCRIPTION) + .setLink(new Url(POST_LINK)) + .setMedia(MEDIA); + private static final PostType POST_TYPE = PostType.HOME_PAGE_ANNOUNCEMENT; + + private EntityClient _entityClient; + private ListPostsResolver _resolver; + private DataFetchingEnvironment _dataFetchingEnvironment; + private Authentication _authentication; + + private Map getMockPostsEntityResponse() throws URISyntaxException { + Urn postUrn = Urn.createFromString(POST_URN_STRING); + + EntityResponse entityResponse = new EntityResponse().setUrn(postUrn); + PostInfo postInfo = new PostInfo(); + postInfo.setType(POST_TYPE); + postInfo.setContent(POST_CONTENT); + DataHubRoleInfo dataHubRoleInfo = new DataHubRoleInfo(); + dataHubRoleInfo.setDescription(postUrn.toString()); + dataHubRoleInfo.setName(postUrn.toString()); + entityResponse.setAspects(new EnvelopedAspectMap(ImmutableMap.of(DATAHUB_ROLE_INFO_ASPECT_NAME, + new EnvelopedAspect().setValue(new Aspect(dataHubRoleInfo.data()))))); + + return ImmutableMap.of(postUrn, entityResponse); + } + + @BeforeMethod + public void setupTest() throws Exception { + _entityResponseMap = getMockPostsEntityResponse(); + + _entityClient = mock(EntityClient.class); + _dataFetchingEnvironment = mock(DataFetchingEnvironment.class); + _authentication = mock(Authentication.class); + + _resolver = new ListPostsResolver(_entityClient); + } + + @Test + public void testNotAuthorizedFails() { + QueryContext mockContext = getMockDenyContext(); + when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext); + + assertThrows(() -> _resolver.get(_dataFetchingEnvironment).join()); + } + + @Test + public void testListPosts() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext); + when(mockContext.getAuthentication()).thenReturn(_authentication); + + ListPostsInput input = new ListPostsInput(); + when(_dataFetchingEnvironment.getArgument("input")).thenReturn(input); + final SearchResult roleSearchResult = + new SearchResult().setMetadata(new SearchResultMetadata()).setFrom(0).setPageSize(10).setNumEntities(1); + roleSearchResult.setEntities( + new SearchEntityArray(ImmutableList.of(new SearchEntity().setEntity(Urn.createFromString(POST_URN_STRING))))); + + when(_entityClient.search(eq(POST_ENTITY_NAME), any(), eq(null), any(), anyInt(), anyInt(), + eq(_authentication))).thenReturn(roleSearchResult); + when(_entityClient.batchGetV2(eq(POST_ENTITY_NAME), any(), any(), any())).thenReturn(_entityResponseMap); + + ListPostsResult result = _resolver.get(_dataFetchingEnvironment).join(); + assertEquals(result.getStart(), 0); + assertEquals(result.getCount(), 10); + assertEquals(result.getTotal(), 1); + assertEquals(result.getPosts().size(), 1); + } +} diff --git a/datahub-web-react/src/app/home/HomePagePosts.tsx b/datahub-web-react/src/app/home/HomePagePosts.tsx new file mode 100644 index 0000000000..97ef147f8d --- /dev/null +++ b/datahub-web-react/src/app/home/HomePagePosts.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { Divider, Typography } from 'antd'; +import styled from 'styled-components'; +import { useListPostsQuery } from '../../graphql/post.generated'; +import { Post, PostContentType } from '../../types.generated'; +import { PostTextCard } from '../search/PostTextCard'; +import { PostLinkCard } from '../search/PostLinkCard'; + +const RecommendationContainer = styled.div` + margin-bottom: 32px; + max-width: 1000px; + min-width: 750px; +`; + +const RecommendationTitle = styled(Typography.Title)` + margin-top: 0px; + margin-bottom: 0px; + padding: 0px; +`; + +const ThinDivider = styled(Divider)` + margin-top: 12px; + margin-bottom: 12px; +`; + +const LinkPostsContainer = styled.div` + display: flex; + flex-direction: row; +`; + +export const HomePagePosts = () => { + const { data: postsData } = useListPostsQuery({ + variables: { + input: { + start: 0, + count: 10, + }, + }, + }); + + const textPosts = + postsData?.listPosts?.posts?.filter((post) => post?.content?.contentType === PostContentType.Text) || []; + const linkPosts = + postsData?.listPosts?.posts?.filter((post) => post?.content?.contentType === PostContentType.Link) || []; + const hasPosts = textPosts.length > 0 || linkPosts.length > 0; + return hasPosts ? ( + + Pinned + + {textPosts.map((post) => ( + + ))} + + {linkPosts.map((post, index) => ( + + ))} + + + ) : ( + <> + ); +}; diff --git a/datahub-web-react/src/app/home/HomePageRecommendations.tsx b/datahub-web-react/src/app/home/HomePageRecommendations.tsx index ee65736efc..61e3d9a5b3 100644 --- a/datahub-web-react/src/app/home/HomePageRecommendations.tsx +++ b/datahub-web-react/src/app/home/HomePageRecommendations.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import styled from 'styled-components'; +import styled from 'styled-components/macro'; import { Button, Divider, Empty, Typography } from 'antd'; import { RocketOutlined } from '@ant-design/icons'; import { @@ -16,6 +16,7 @@ import { useGetEntityCountsQuery } from '../../graphql/app.generated'; import { GettingStartedModal } from './GettingStartedModal'; import { ANTD_GRAY } from '../entity/shared/constants'; import { useGetAuthenticatedUser } from '../useGetAuthenticatedUser'; +import { HomePagePosts } from './HomePagePosts'; const RecommendationsContainer = styled.div` margin-top: 32px; @@ -139,6 +140,7 @@ export const HomePageRecommendations = ({ userUrn }: Props) => { return ( + {orderedEntityCounts && orderedEntityCounts.length > 0 && ( {domainRecommendationModule && ( diff --git a/datahub-web-react/src/app/search/PostLinkCard.tsx b/datahub-web-react/src/app/search/PostLinkCard.tsx new file mode 100644 index 0000000000..5e780ccefb --- /dev/null +++ b/datahub-web-react/src/app/search/PostLinkCard.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +// import { Link } from 'react-router-dom'; +import { Button, Image, Typography } from 'antd'; +import { ArrowRightOutlined } from '@ant-design/icons'; +import styled from 'styled-components/macro'; +import { ANTD_GRAY } from '../entity/shared/constants'; +import { Post } from '../../types.generated'; + +const CardContainer = styled(Button)<{ isLastCardInRow?: boolean }>` + display: flex; + flex-direction: row; + justify-content: space-between; + margin-right: ${(props) => (props.isLastCardInRow ? '0%' : '4%')}; + margin-left: 12px; + margin-bottom: 12px; + width: 29%; + height: 100px; + border: 1px solid ${ANTD_GRAY[4]}; + border-radius: 12px; + box-shadow: ${(props) => props.theme.styles['box-shadow']}; + &&:hover { + box-shadow: ${(props) => props.theme.styles['box-shadow-hover']}; + } + white-space: unset; +`; + +const LogoContainer = styled.div` + margin-top: 25px; + margin-left: 25px; + margin-right: 40px; +`; + +const PlatformLogo = styled(Image)` + width: auto; + object-fit: contain; + background-color: transparent; +`; + +const TextContainer = styled.div` + display: flex; + flex: 1; + justify-content: center; + align-items: start; + flex-direction: column; +`; + +const HeaderText = styled(Typography.Text)` + line-height: 10px; + margin-top: 12px; +`; + +const TitleDiv = styled.div` + display: flex; + justify-content: space-evenly; + align-items: center; + gap: 6px; + font-size: 14px; +`; + +const Title = styled(Typography.Title)` + word-break: break-word; +`; + +const NUM_CARDS_PER_ROW = 3; + +type Props = { + linkPost: Post; + index: number; +}; + +export const PostLinkCard = ({ linkPost, index }: Props) => { + const hasMedia = !!linkPost?.content?.media?.location; + const link = linkPost?.content?.link || ''; + const isLastCardInRow = (index + 1) % NUM_CARDS_PER_ROW === 0; + + return ( + + {hasMedia && ( + + + + )} + + Link + + <TitleDiv> + {linkPost?.content?.title} + <ArrowRightOutlined /> + </TitleDiv> + + + + ); +}; diff --git a/datahub-web-react/src/app/search/PostTextCard.tsx b/datahub-web-react/src/app/search/PostTextCard.tsx new file mode 100644 index 0000000000..11079f2a37 --- /dev/null +++ b/datahub-web-react/src/app/search/PostTextCard.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Typography } from 'antd'; +import styled from 'styled-components/macro'; +import { ANTD_GRAY } from '../entity/shared/constants'; +import { Post } from '../../types.generated'; + +const CardContainer = styled.div` + display: flex; + flex-direction: row; + margin-right: 12px; + margin-left: 12px; + margin-bottom: 12px; + height: 140px; + border: 1px solid ${ANTD_GRAY[4]}; + border-radius: 12px; + box-shadow: ${(props) => props.theme.styles['box-shadow']}; + &&:hover { + box-shadow: ${(props) => props.theme.styles['box-shadow-hover']}; + } + white-space: unset; +`; + +const TextContainer = styled.div` + margin-left: 12px; + display: flex; + justify-content: center; + align-items: start; + flex-direction: column; +`; + +const Title = styled(Typography.Title)` + word-break: break-word; +`; + +const HeaderText = styled(Typography.Text)` + margin-top: 12px; +`; + +const AnnouncementText = styled(Typography.Paragraph)` + font-size: 12px; + color: ${ANTD_GRAY[7]}; +`; + +type Props = { + textPost: Post; +}; + +export const PostTextCard = ({ textPost }: Props) => { + return ( + + + Announcement + + {textPost?.content?.title} + + {textPost?.content?.description} + + + ); +}; diff --git a/datahub-web-react/src/graphql/mutations.graphql b/datahub-web-react/src/graphql/mutations.graphql index 105289fe9b..7d1fa0c7cd 100644 --- a/datahub-web-react/src/graphql/mutations.graphql +++ b/datahub-web-react/src/graphql/mutations.graphql @@ -114,4 +114,8 @@ mutation createInviteToken($input: CreateInviteTokenInput!) { mutation acceptRole($input: AcceptRoleInput!) { acceptRole(input: $input) +} + +mutation createPost($input: CreatePostInput!) { + createPost(input: $input) } \ No newline at end of file diff --git a/datahub-web-react/src/graphql/post.graphql b/datahub-web-react/src/graphql/post.graphql new file mode 100644 index 0000000000..c19f38fc77 --- /dev/null +++ b/datahub-web-react/src/graphql/post.graphql @@ -0,0 +1,22 @@ +query listPosts($input: ListPostsInput!) { + listPosts(input: $input) { + start + count + total + posts { + urn + type + postType + content { + contentType + title + description + link + media { + type + location + } + } + } + } +} diff --git a/li-utils/src/main/java/com/linkedin/metadata/Constants.java b/li-utils/src/main/java/com/linkedin/metadata/Constants.java index adee116295..c9e4645dee 100644 --- a/li-utils/src/main/java/com/linkedin/metadata/Constants.java +++ b/li-utils/src/main/java/com/linkedin/metadata/Constants.java @@ -47,6 +47,7 @@ public class Constants { public static final String DATA_HUB_UPGRADE_ENTITY_NAME = "dataHubUpgrade"; public static final String INVITE_TOKEN_ENTITY_NAME = "inviteToken"; public static final String DATAHUB_ROLE_ENTITY_NAME = "dataHubRole"; + public static final String POST_ENTITY_NAME = "post"; /** @@ -243,7 +244,6 @@ public class Constants { public static final String IS_MEMBER_OF_GROUP_RELATIONSHIP_NAME = "IsMemberOfGroup"; public static final String IS_MEMBER_OF_NATIVE_GROUP_RELATIONSHIP_NAME = "IsMemberOfNativeGroup"; - // acryl-main only public static final String CHANGE_EVENT_PLATFORM_EVENT_NAME = "entityChangeEvent"; /** @@ -258,6 +258,10 @@ public class Constants { public static final String DATA_PROCESS_INSTANCE_PROPERTIES_ASPECT_NAME = "dataProcessInstanceProperties"; public static final String DATA_PROCESS_INSTANCE_RUN_EVENT_ASPECT_NAME = "dataProcessInstanceRunEvent"; + // Posts + public static final String POST_INFO_ASPECT_NAME = "postInfo"; + public static final String LAST_MODIFIED_FIELD_NAME = "lastModified"; + private Constants() { } } diff --git a/metadata-models/src/main/pegasus/com/linkedin/common/Media.pdl b/metadata-models/src/main/pegasus/com/linkedin/common/Media.pdl new file mode 100644 index 0000000000..e912c1a670 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/common/Media.pdl @@ -0,0 +1,16 @@ +namespace com.linkedin.common + +/** + * Carries information about which roles a user is assigned to. + */ +record Media { + /** + * Type of content the Media is storing, e.g. image, video, etc. + */ + type: MediaType + + /** + * Where the media content is stored. + */ + location: Url +} \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/common/MediaType.pdl b/metadata-models/src/main/pegasus/com/linkedin/common/MediaType.pdl new file mode 100644 index 0000000000..6d10e28fcc --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/common/MediaType.pdl @@ -0,0 +1,11 @@ +namespace com.linkedin.common + +/** + * Enum defining the type of content a Media object holds. + */ +enum MediaType { + /** + * The Media holds an image. + */ + IMAGE +} \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/metadata/key/PostKey.pdl b/metadata-models/src/main/pegasus/com/linkedin/metadata/key/PostKey.pdl new file mode 100644 index 0000000000..f5a74c64d0 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/metadata/key/PostKey.pdl @@ -0,0 +1,14 @@ +namespace com.linkedin.metadata.key + +/** + * Key for a Post. + */ +@Aspect = { + "name": "postKey" +} +record PostKey { + /** + * A unique id for the DataHub Post record. Generated on the server side at Post creation time. + */ + id: string +} \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/post/PostContent.pdl b/metadata-models/src/main/pegasus/com/linkedin/post/PostContent.pdl new file mode 100644 index 0000000000..0ef537010b --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/post/PostContent.pdl @@ -0,0 +1,37 @@ +namespace com.linkedin.post + +import com.linkedin.common.Media +import com.linkedin.common.Url + +/** + * Content stored inside a Post. + */ +record PostContent { + /** + * Title of the post. + */ + @Searchable = { + "fieldType": "TEXT_PARTIAL" + } + title: string + + /** + * Type of content held in the post. + */ + type: PostContentType + + /** + * Optional description of the post. + */ + description: optional string + + /** + * Optional link that the post is associated with. + */ + link: optional Url + + /** + * Optional media that the post is storing + */ + media: optional Media +} \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/post/PostContentType.pdl b/metadata-models/src/main/pegasus/com/linkedin/post/PostContentType.pdl new file mode 100644 index 0000000000..dbedfbf532 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/post/PostContentType.pdl @@ -0,0 +1,16 @@ +namespace com.linkedin.post + +/** + * Enum defining the type of content held in a Post. + */ +enum PostContentType { + /** + * Text content + */ + TEXT + + /** + * Link content + */ + LINK +} \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/post/PostInfo.pdl b/metadata-models/src/main/pegasus/com/linkedin/post/PostInfo.pdl new file mode 100644 index 0000000000..7eb080653e --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/post/PostInfo.pdl @@ -0,0 +1,35 @@ +namespace com.linkedin.post + +/** + * Information about a DataHub Post. + */ +@Aspect = { + "name": "postInfo" +} +record PostInfo { + /** + * Type of the Post. + */ + type: PostType + + /** + * Content stored in the post. + */ + content: PostContent + + /** + * The time at which the post was initially created + */ + @Searchable = { + "fieldType": "COUNT" + } + created: long + + /** + * The time at which the post was last modified + */ + @Searchable = { + "fieldType": "COUNT" + } + lastModified: long +} \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/post/PostType.pdl b/metadata-models/src/main/pegasus/com/linkedin/post/PostType.pdl new file mode 100644 index 0000000000..43aa851f0e --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/post/PostType.pdl @@ -0,0 +1,11 @@ +namespace com.linkedin.post + +/** + * Enum defining types of Posts. + */ +enum PostType { + /** + * The Post is an Home Page announcement. + */ + HOME_PAGE_ANNOUNCEMENT +} \ No newline at end of file diff --git a/metadata-models/src/main/resources/entity-registry.yml b/metadata-models/src/main/resources/entity-registry.yml index 42ac08270d..842ad8c95b 100644 --- a/metadata-models/src/main/resources/entity-registry.yml +++ b/metadata-models/src/main/resources/entity-registry.yml @@ -259,4 +259,9 @@ entities: keyAspect: dataHubRoleKey aspects: - dataHubRoleInfo + - name: post + category: core + keyAspect: postKey + aspects: + - postInfo events: diff --git a/metadata-service/auth-impl/src/main/java/com/datahub/authentication/post/PostService.java b/metadata-service/auth-impl/src/main/java/com/datahub/authentication/post/PostService.java new file mode 100644 index 0000000000..8295997699 --- /dev/null +++ b/metadata-service/auth-impl/src/main/java/com/datahub/authentication/post/PostService.java @@ -0,0 +1,71 @@ +package com.datahub.authentication.post; + +import com.datahub.authentication.Authentication; +import com.linkedin.common.Media; +import com.linkedin.common.MediaType; +import com.linkedin.common.url.Url; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.metadata.key.PostKey; +import com.linkedin.mxe.MetadataChangeProposal; +import com.linkedin.post.PostContent; +import com.linkedin.post.PostContentType; +import com.linkedin.post.PostInfo; +import com.linkedin.post.PostType; +import com.linkedin.r2.RemoteInvocationException; +import java.time.Instant; +import java.util.UUID; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import static com.linkedin.metadata.Constants.*; +import static com.linkedin.metadata.entity.AspectUtils.*; + + +@Slf4j +@RequiredArgsConstructor +public class PostService { + private final EntityClient _entityClient; + + @Nonnull + public Media mapMedia(@Nonnull String type, @Nonnull String location) { + final Media media = new Media(); + media.setType(MediaType.valueOf(type)); + media.setLocation(new Url(location)); + return media; + } + + @Nonnull + public PostContent mapPostContent(@Nonnull String contentType, @Nonnull String title, @Nullable String description, @Nullable String link, + @Nullable Media media) { + final PostContent postContent = new PostContent().setType(PostContentType.valueOf(contentType)).setTitle(title); + if (description != null) { + postContent.setDescription(description); + } + if (link != null) { + postContent.setLink(new Url(link)); + } + if (media != null) { + postContent.setMedia(media); + } + return postContent; + } + + public boolean createPost(@Nonnull String postType, @Nonnull PostContent postContent, + @Nonnull Authentication authentication) throws RemoteInvocationException { + final String uuid = UUID.randomUUID().toString(); + final PostKey postKey = new PostKey().setId(uuid); + final long currentTimeMillis = Instant.now().toEpochMilli(); + final PostInfo postInfo = new PostInfo().setType(PostType.valueOf(postType)) + .setContent(postContent) + .setCreated(currentTimeMillis) + .setLastModified(currentTimeMillis); + + final MetadataChangeProposal proposal = + buildMetadataChangeProposal(POST_ENTITY_NAME, postKey, POST_INFO_ASPECT_NAME, postInfo); + _entityClient.ingestProposal(proposal, authentication); + + return true; + } +} diff --git a/metadata-service/auth-impl/src/test/java/com/datahub/authentication/post/PostServiceTest.java b/metadata-service/auth-impl/src/test/java/com/datahub/authentication/post/PostServiceTest.java new file mode 100644 index 0000000000..dfc5a2ac51 --- /dev/null +++ b/metadata-service/auth-impl/src/test/java/com/datahub/authentication/post/PostServiceTest.java @@ -0,0 +1,66 @@ +package com.datahub.authentication.post; + +import com.datahub.authentication.Actor; +import com.datahub.authentication.ActorType; +import com.datahub.authentication.Authentication; +import com.linkedin.common.Media; +import com.linkedin.common.MediaType; +import com.linkedin.common.url.Url; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.post.PostContent; +import com.linkedin.post.PostContentType; +import com.linkedin.post.PostType; +import com.linkedin.r2.RemoteInvocationException; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + + +public class PostServiceTest { + private static final MediaType POST_MEDIA_TYPE = MediaType.IMAGE; + private static final String POST_MEDIA_LOCATION = + "https://datahubproject.io/img/datahub-logo-color-light-horizontal.svg"; + private static final PostContentType POST_CONTENT_TYPE = PostContentType.LINK; + private static final String POST_TITLE = "title"; + private static final String POST_DESCRIPTION = "description"; + private static final String POST_LINK = "https://datahubproject.io"; + private static final Media MEDIA = new Media().setType(POST_MEDIA_TYPE).setLocation(new Url(POST_MEDIA_LOCATION)); + private static final PostContent POST_CONTENT = new PostContent().setType(POST_CONTENT_TYPE) + .setTitle(POST_TITLE) + .setDescription(POST_DESCRIPTION) + .setLink(new Url(POST_LINK)) + .setMedia(MEDIA); + private static final PostType POST_TYPE = PostType.HOME_PAGE_ANNOUNCEMENT; + private static final String DATAHUB_SYSTEM_CLIENT_ID = "__datahub_system"; + private static final Authentication SYSTEM_AUTHENTICATION = + new Authentication(new Actor(ActorType.USER, DATAHUB_SYSTEM_CLIENT_ID), ""); + private EntityClient _entityClient; + private PostService _postService; + + @BeforeMethod + public void setupTest() { + _entityClient = mock(EntityClient.class); + _postService = new PostService(_entityClient); + } + + @Test + public void testMapMedia() { + Media media = _postService.mapMedia(POST_MEDIA_TYPE.toString(), POST_MEDIA_LOCATION); + assertEquals(MEDIA, media); + } + + @Test + public void testMapPostContent() { + PostContent postContent = + _postService.mapPostContent(POST_CONTENT_TYPE.toString(), POST_TITLE, POST_DESCRIPTION, POST_LINK, MEDIA); + assertEquals(POST_CONTENT, postContent); + } + + @Test + public void testCreatePost() throws RemoteInvocationException { + _postService.createPost(POST_TYPE.toString(), POST_CONTENT, SYSTEM_AUTHENTICATION); + verify(_entityClient, times(1)).ingestProposal(any(), eq(SYSTEM_AUTHENTICATION)); + } +} diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/PostServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/PostServiceFactory.java new file mode 100644 index 0000000000..8e5e5e5cfc --- /dev/null +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/PostServiceFactory.java @@ -0,0 +1,28 @@ +package com.linkedin.gms.factory.auth; + +import com.datahub.authentication.post.PostService; +import com.linkedin.gms.factory.spring.YamlPropertySourceFactory; +import com.linkedin.metadata.client.JavaEntityClient; +import javax.annotation.Nonnull; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; +import org.springframework.context.annotation.Scope; + + +@Configuration +@PropertySource(value = "classpath:/application.yml", factory = YamlPropertySourceFactory.class) +public class PostServiceFactory { + @Autowired + @Qualifier("javaEntityClient") + private JavaEntityClient _javaEntityClient; + + @Bean(name = "postService") + @Scope("singleton") + @Nonnull + protected PostService getInstance() throws Exception { + return new PostService(this._javaEntityClient); + } +} diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/graphql/GraphQLEngineFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/graphql/GraphQLEngineFactory.java index 5f50233c6a..768f09c784 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/graphql/GraphQLEngineFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/graphql/GraphQLEngineFactory.java @@ -2,6 +2,7 @@ package com.linkedin.gms.factory.graphql; import com.datahub.authentication.group.GroupService; import com.datahub.authentication.invite.InviteTokenService; +import com.datahub.authentication.post.PostService; import com.datahub.authentication.token.StatefulTokenService; import com.datahub.authentication.user.NativeUserService; import com.datahub.authorization.role.RoleService; @@ -123,6 +124,10 @@ public class GraphQLEngineFactory { @Qualifier("inviteTokenService") private InviteTokenService _inviteTokenService; + @Autowired + @Qualifier("postService") + private PostService _postService; + @Value("${platformAnalytics.enabled}") // TODO: Migrate to DATAHUB_ANALYTICS_ENABLED private Boolean isAnalyticsEnabled; @@ -157,6 +162,7 @@ public class GraphQLEngineFactory { _groupService, _roleService, _inviteTokenService, + _postService, _configProvider.getFeatureFlags() ).builder().build(); } @@ -186,6 +192,7 @@ public class GraphQLEngineFactory { _groupService, _roleService, _inviteTokenService, + _postService, _configProvider.getFeatureFlags() ).builder().build(); } 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 28946c5301..fcf2435b3d 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 @@ -93,6 +93,11 @@ public class PoliciesConfig { "Create Domains", "Create new Domains."); + public static final Privilege CREATE_GLOBAL_ANNOUNCEMENTS_PRIVILEGE = Privilege.of( + "CREATE_GLOBAL_ANNOUNCEMENTS", + "Create Global Announcements", + "Create new Global Announcements."); + public static final List PLATFORM_PRIVILEGES = ImmutableList.of( MANAGE_POLICIES_PRIVILEGE, MANAGE_USERS_AND_GROUPS_PRIVILEGE, @@ -107,7 +112,7 @@ public class PoliciesConfig { MANAGE_USER_CREDENTIALS_PRIVILEGE, MANAGE_TAGS_PRIVILEGE, CREATE_TAGS_PRIVILEGE, - CREATE_DOMAINS_PRIVILEGE + CREATE_DOMAINS_PRIVILEGE, CREATE_GLOBAL_ANNOUNCEMENTS_PRIVILEGE ); // Resource Privileges //