feat(posts): add posts feature to DataHub (#6110)

This commit is contained in:
Aditya Radhakrishnan 2022-10-04 15:37:28 -07:00 committed by GitHub
parent 3c0f63c50a
commit ee1a1ef45b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1246 additions and 7 deletions

View File

@ -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 {

View File

@ -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))
);
}

View File

@ -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> resourceSpec,

View File

@ -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<CompletableFuture<Boolean>> {
private final PostService _postService;
@Override
public CompletableFuture<Boolean> 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);
}
});
}
}

View File

@ -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<CompletableFuture<ListPostsResult>> {
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<ListPostsResult> 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<Urn, EntityResponse> 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);
}
});
}
}

View File

@ -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<EntityResponse, Post> {
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<Post> 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;
}
}

View File

@ -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!
}

View File

@ -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());
}
}

View File

@ -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<Urn, EntityResponse> _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<Urn, EntityResponse> 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);
}
}

View File

@ -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 ? (
<RecommendationContainer>
<RecommendationTitle level={4}>Pinned</RecommendationTitle>
<ThinDivider />
{textPosts.map((post) => (
<PostTextCard textPost={post as Post} />
))}
<LinkPostsContainer>
{linkPosts.map((post, index) => (
<PostLinkCard linkPost={post as Post} index={index} />
))}
</LinkPostsContainer>
</RecommendationContainer>
) : (
<></>
);
};

View File

@ -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 (
<RecommendationsContainer>
<HomePagePosts />
{orderedEntityCounts && orderedEntityCounts.length > 0 && (
<RecommendationContainer>
{domainRecommendationModule && (

View File

@ -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 (
<CardContainer type="link" href={link} isLastCardInRow={isLastCardInRow}>
{hasMedia && (
<LogoContainer>
<PlatformLogo width={50} height={50} preview={false} src={linkPost?.content?.media?.location} />
</LogoContainer>
)}
<TextContainer>
<HeaderText type="secondary">Link</HeaderText>
<Title level={5}>
<TitleDiv>
{linkPost?.content?.title}
<ArrowRightOutlined />
</TitleDiv>
</Title>
</TextContainer>
</CardContainer>
);
};

View File

@ -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 (
<CardContainer>
<TextContainer>
<HeaderText type="secondary">Announcement</HeaderText>
<Title
ellipsis={{
rows: 1,
}}
level={5}
>
{textPost?.content?.title}
</Title>
<AnnouncementText>{textPost?.content?.description}</AnnouncementText>
</TextContainer>
</CardContainer>
);
};

View File

@ -114,4 +114,8 @@ mutation createInviteToken($input: CreateInviteTokenInput!) {
mutation acceptRole($input: AcceptRoleInput!) {
acceptRole(input: $input)
}
mutation createPost($input: CreatePostInput!) {
createPost(input: $input)
}

View File

@ -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
}
}
}
}
}

View File

@ -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() {
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -259,4 +259,9 @@ entities:
keyAspect: dataHubRoleKey
aspects:
- dataHubRoleInfo
- name: post
category: core
keyAspect: postKey
aspects:
- postInfo
events:

View File

@ -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;
}
}

View File

@ -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));
}
}

View File

@ -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);
}
}

View File

@ -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();
}

View File

@ -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<Privilege> 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 //