mirror of
https://github.com/datahub-project/datahub.git
synced 2025-12-12 02:25:43 +00:00
feat(posts): add posts feature to DataHub (#6110)
This commit is contained in:
parent
3c0f63c50a
commit
ee1a1ef45b
@ -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 {
|
||||
|
||||
@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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!
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
62
datahub-web-react/src/app/home/HomePagePosts.tsx
Normal file
62
datahub-web-react/src/app/home/HomePagePosts.tsx
Normal 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>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
};
|
||||
@ -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 && (
|
||||
|
||||
94
datahub-web-react/src/app/search/PostLinkCard.tsx
Normal file
94
datahub-web-react/src/app/search/PostLinkCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
65
datahub-web-react/src/app/search/PostTextCard.tsx
Normal file
65
datahub-web-react/src/app/search/PostTextCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -114,4 +114,8 @@ mutation createInviteToken($input: CreateInviteTokenInput!) {
|
||||
|
||||
mutation acceptRole($input: AcceptRoleInput!) {
|
||||
acceptRole(input: $input)
|
||||
}
|
||||
|
||||
mutation createPost($input: CreatePostInput!) {
|
||||
createPost(input: $input)
|
||||
}
|
||||
22
datahub-web-react/src/graphql/post.graphql
Normal file
22
datahub-web-react/src/graphql/post.graphql
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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() {
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -259,4 +259,9 @@ entities:
|
||||
keyAspect: dataHubRoleKey
|
||||
aspects:
|
||||
- dataHubRoleInfo
|
||||
- name: post
|
||||
category: core
|
||||
keyAspect: postKey
|
||||
aspects:
|
||||
- postInfo
|
||||
events:
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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 //
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user