From 44a70270c88e33597a63fa9c58452eaf39bce0b2 Mon Sep 17 00:00:00 2001 From: xushanchuan Date: Fri, 19 Mar 2021 15:24:10 +0800 Subject: [PATCH] feat: add graphql --- .../graphql/AuthenticationException.java | 3 + .../graphql/users/ArticleDatafetcher.java | 386 ++++++++++++++++++ .../spring/graphql/users/ArticleMutation.java | 124 ++++++ .../graphql/users/CommentDatafetcher.java | 118 ++++++ .../spring/graphql/users/CommentMutation.java | 77 ++++ .../spring/graphql/users/MeDatafetcher.java | 67 +++ .../graphql/users/ProfileDatafetcher.java | 76 ++++ .../graphql/users/RelationMutation.java | 70 ++++ .../io/spring/graphql/users/SecurityUtil.java | 19 + .../io/spring/graphql/users/UserMutation.java | 90 ++++ src/main/resources/schema/schema.graphqls | 164 ++++++++ 11 files changed, 1194 insertions(+) create mode 100644 src/main/java/io/spring/graphql/AuthenticationException.java create mode 100644 src/main/java/io/spring/graphql/users/ArticleDatafetcher.java create mode 100644 src/main/java/io/spring/graphql/users/ArticleMutation.java create mode 100644 src/main/java/io/spring/graphql/users/CommentDatafetcher.java create mode 100644 src/main/java/io/spring/graphql/users/CommentMutation.java create mode 100644 src/main/java/io/spring/graphql/users/MeDatafetcher.java create mode 100644 src/main/java/io/spring/graphql/users/ProfileDatafetcher.java create mode 100644 src/main/java/io/spring/graphql/users/RelationMutation.java create mode 100644 src/main/java/io/spring/graphql/users/SecurityUtil.java create mode 100644 src/main/java/io/spring/graphql/users/UserMutation.java create mode 100644 src/main/resources/schema/schema.graphqls diff --git a/src/main/java/io/spring/graphql/AuthenticationException.java b/src/main/java/io/spring/graphql/AuthenticationException.java new file mode 100644 index 0000000..3adab49 --- /dev/null +++ b/src/main/java/io/spring/graphql/AuthenticationException.java @@ -0,0 +1,3 @@ +package io.spring.graphql; + +public class AuthenticationException extends RuntimeException {} diff --git a/src/main/java/io/spring/graphql/users/ArticleDatafetcher.java b/src/main/java/io/spring/graphql/users/ArticleDatafetcher.java new file mode 100644 index 0000000..17c0372 --- /dev/null +++ b/src/main/java/io/spring/graphql/users/ArticleDatafetcher.java @@ -0,0 +1,386 @@ +package io.spring.graphql.users; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsData; +import com.netflix.graphql.dgs.DgsDataFetchingEnvironment; +import com.netflix.graphql.dgs.InputArgument; +import graphql.execution.DataFetcherResult; +import graphql.relay.DefaultConnectionCursor; +import graphql.relay.DefaultPageInfo; +import graphql.schema.DataFetchingEnvironment; +import io.spring.Util; +import io.spring.api.exception.ResourceNotFoundException; +import io.spring.application.ArticleQueryService; +import io.spring.application.CursorPageParameter; +import io.spring.application.CursorPager; +import io.spring.application.CursorPager.Direction; +import io.spring.application.data.ArticleData; +import io.spring.application.data.CommentData; +import io.spring.core.user.User; +import io.spring.core.user.UserRepository; +import io.spring.graphql.DgsConstants; +import io.spring.graphql.DgsConstants.ARTICLEPAYLOAD; +import io.spring.graphql.DgsConstants.COMMENT; +import io.spring.graphql.DgsConstants.PROFILE; +import io.spring.graphql.DgsConstants.QUERY; +import io.spring.graphql.types.Article; +import io.spring.graphql.types.ArticleEdge; +import io.spring.graphql.types.ArticlesConnection; +import io.spring.graphql.types.Profile; +import java.util.HashMap; +import java.util.stream.Collectors; +import org.joda.time.format.ISODateTimeFormat; +import org.springframework.beans.factory.annotation.Autowired; + +@DgsComponent +public class ArticleDatafetcher { + + private ArticleQueryService articleQueryService; + private UserRepository userRepository; + + @Autowired + public ArticleDatafetcher( + ArticleQueryService articleQueryService, UserRepository userRepository) { + this.articleQueryService = articleQueryService; + this.userRepository = userRepository; + } + + @DgsData(parentType = DgsConstants.QUERY_TYPE, field = QUERY.Feed) + public DataFetcherResult getFeed( + @InputArgument("first") Integer first, + @InputArgument("after") String after, + @InputArgument("last") Integer last, + @InputArgument("before") String before, + DgsDataFetchingEnvironment dfe) { + if (first == null && last == null) { + throw new IllegalArgumentException("first 和 last 必须只存在一个"); + } + + User current = SecurityUtil.getCurrentUser().orElse(null); + + CursorPager articles; + if (first != null) { + articles = + articleQueryService.findUserFeedWithCursor( + current, new CursorPageParameter(after, first, Direction.NEXT)); + } else { + articles = + articleQueryService.findUserFeedWithCursor( + current, new CursorPageParameter(before, last, Direction.PREV)); + } + graphql.relay.PageInfo pageInfo = buildArticlePageInfo(articles); + ArticlesConnection articlesConnection = + ArticlesConnection.newBuilder() + .pageInfo(pageInfo) + .edges( + articles.getData().stream() + .map( + a -> + ArticleEdge.newBuilder() + .cursor(a.getCursor()) + .node(buildArticleResult(a)) + .build()) + .collect(Collectors.toList())) + .build(); + return DataFetcherResult.newResult() + .data(articlesConnection) + .localContext( + articles.getData().stream().collect(Collectors.toMap(ArticleData::getSlug, a -> a))) + .build(); + } + + @DgsData(parentType = PROFILE.TYPE_NAME, field = PROFILE.Feed) + public DataFetcherResult userFeed( + @InputArgument("first") Integer first, + @InputArgument("after") String after, + @InputArgument("last") Integer last, + @InputArgument("before") String before, + DgsDataFetchingEnvironment dfe) { + if (first == null && last == null) { + throw new IllegalArgumentException("first 和 last 必须只存在一个"); + } + + Profile profile = dfe.getSource(); + User target = + userRepository + .findByUsername(profile.getUsername()) + .orElseThrow(ResourceNotFoundException::new); + + CursorPager articles; + if (first != null) { + articles = + articleQueryService.findUserFeedWithCursor( + target, new CursorPageParameter(after, first, Direction.NEXT)); + } else { + articles = + articleQueryService.findUserFeedWithCursor( + target, new CursorPageParameter(before, last, Direction.PREV)); + } + graphql.relay.PageInfo pageInfo = buildArticlePageInfo(articles); + ArticlesConnection articlesConnection = + ArticlesConnection.newBuilder() + .pageInfo(pageInfo) + .edges( + articles.getData().stream() + .map( + a -> + ArticleEdge.newBuilder() + .cursor(a.getCursor()) + .node(buildArticleResult(a)) + .build()) + .collect(Collectors.toList())) + .build(); + return DataFetcherResult.newResult() + .data(articlesConnection) + .localContext( + articles.getData().stream().collect(Collectors.toMap(ArticleData::getSlug, a -> a))) + .build(); + } + + @DgsData(parentType = PROFILE.TYPE_NAME, field = PROFILE.Favorites) + public DataFetcherResult userFavorites( + @InputArgument("first") Integer first, + @InputArgument("after") String after, + @InputArgument("last") Integer last, + @InputArgument("before") String before, + DgsDataFetchingEnvironment dfe) { + if (first == null && last == null) { + throw new IllegalArgumentException("first 和 last 必须只存在一个"); + } + + User current = SecurityUtil.getCurrentUser().orElse(null); + Profile profile = dfe.getSource(); + + CursorPager articles; + if (first != null) { + articles = + articleQueryService.findRecentArticlesWithCursor( + null, + null, + profile.getUsername(), + new CursorPageParameter(after, first, Direction.NEXT), + current); + } else { + articles = + articleQueryService.findRecentArticlesWithCursor( + null, + null, + profile.getUsername(), + new CursorPageParameter(before, last, Direction.PREV), + current); + } + graphql.relay.PageInfo pageInfo = buildArticlePageInfo(articles); + + ArticlesConnection articlesConnection = + ArticlesConnection.newBuilder() + .pageInfo(pageInfo) + .edges( + articles.getData().stream() + .map( + a -> + ArticleEdge.newBuilder() + .cursor(a.getCursor()) + .node(buildArticleResult(a)) + .build()) + .collect(Collectors.toList())) + .build(); + return DataFetcherResult.newResult() + .data(articlesConnection) + .localContext( + articles.getData().stream().collect(Collectors.toMap(ArticleData::getSlug, a -> a))) + .build(); + } + + @DgsData(parentType = PROFILE.TYPE_NAME, field = PROFILE.Articles) + public DataFetcherResult userArticles( + @InputArgument("first") Integer first, + @InputArgument("after") String after, + @InputArgument("last") Integer last, + @InputArgument("before") String before, + DgsDataFetchingEnvironment dfe) { + if (first == null && last == null) { + throw new IllegalArgumentException("first 和 last 必须只存在一个"); + } + + User current = SecurityUtil.getCurrentUser().orElse(null); + Profile profile = dfe.getSource(); + + CursorPager articles; + if (first != null) { + articles = + articleQueryService.findRecentArticlesWithCursor( + null, + profile.getUsername(), + null, + new CursorPageParameter(after, first, Direction.NEXT), + current); + } else { + articles = + articleQueryService.findRecentArticlesWithCursor( + null, + profile.getUsername(), + null, + new CursorPageParameter(before, last, Direction.PREV), + current); + } + graphql.relay.PageInfo pageInfo = buildArticlePageInfo(articles); + ArticlesConnection articlesConnection = + ArticlesConnection.newBuilder() + .pageInfo(pageInfo) + .edges( + articles.getData().stream() + .map( + a -> + ArticleEdge.newBuilder() + .cursor(a.getCursor()) + .node(buildArticleResult(a)) + .build()) + .collect(Collectors.toList())) + .build(); + return DataFetcherResult.newResult() + .data(articlesConnection) + .localContext( + articles.getData().stream().collect(Collectors.toMap(ArticleData::getSlug, a -> a))) + .build(); + } + + @DgsData(parentType = DgsConstants.QUERY_TYPE, field = QUERY.Articles) + public DataFetcherResult getArticles( + @InputArgument("first") Integer first, + @InputArgument("after") String after, + @InputArgument("last") Integer last, + @InputArgument("before") String before, + @InputArgument("authoredBy") String authoredBy, + @InputArgument("favoritedBy") String favoritedBy, + @InputArgument("withTag") String withTag, + DgsDataFetchingEnvironment dfe) { + if (first == null && last == null) { + throw new IllegalArgumentException("first 和 last 必须只存在一个"); + } + + User current = SecurityUtil.getCurrentUser().orElse(null); + + CursorPager articles; + if (first != null) { + articles = + articleQueryService.findRecentArticlesWithCursor( + withTag, + authoredBy, + favoritedBy, + new CursorPageParameter(after, first, Direction.NEXT), + current); + } else { + articles = + articleQueryService.findRecentArticlesWithCursor( + withTag, + authoredBy, + favoritedBy, + new CursorPageParameter(before, last, Direction.PREV), + current); + } + graphql.relay.PageInfo pageInfo = buildArticlePageInfo(articles); + ArticlesConnection articlesConnection = + ArticlesConnection.newBuilder() + .pageInfo(pageInfo) + .edges( + articles.getData().stream() + .map( + a -> + ArticleEdge.newBuilder() + .cursor(a.getCursor()) + .node(buildArticleResult(a)) + .build()) + .collect(Collectors.toList())) + .build(); + return DataFetcherResult.newResult() + .data(articlesConnection) + .localContext( + articles.getData().stream().collect(Collectors.toMap(ArticleData::getSlug, a -> a))) + .build(); + } + + @DgsData(parentType = ARTICLEPAYLOAD.TYPE_NAME, field = ARTICLEPAYLOAD.Article) + public DataFetcherResult
getArticle(DataFetchingEnvironment dfe) { + io.spring.core.article.Article article = dfe.getLocalContext(); + + User current = SecurityUtil.getCurrentUser().orElse(null); + ArticleData articleData = + articleQueryService + .findById(article.getId(), current) + .orElseThrow(ResourceNotFoundException::new); + Article articleResult = buildArticleResult(articleData); + return DataFetcherResult.
newResult() + .localContext( + new HashMap() { + { + put(articleData.getSlug(), articleData); + } + }) + .data(articleResult) + .build(); + } + + @DgsData(parentType = COMMENT.TYPE_NAME, field = COMMENT.Article) + public DataFetcherResult
getCommentArticle( + DataFetchingEnvironment dataFetchingEnvironment) { + CommentData comment = dataFetchingEnvironment.getLocalContext(); + User current = SecurityUtil.getCurrentUser().orElse(null); + ArticleData articleData = + articleQueryService + .findById(comment.getArticleId(), current) + .orElseThrow(ResourceNotFoundException::new); + Article articleResult = buildArticleResult(articleData); + return DataFetcherResult.
newResult() + .localContext( + new HashMap() { + { + put(articleData.getSlug(), articleData); + } + }) + .data(articleResult) + .build(); + } + + @DgsData(parentType = DgsConstants.QUERY_TYPE, field = QUERY.Article) + public DataFetcherResult
findArticleBySlug(@InputArgument("slug") String slug) { + User current = SecurityUtil.getCurrentUser().orElse(null); + ArticleData articleData = + articleQueryService.findBySlug(slug, current).orElseThrow(ResourceNotFoundException::new); + Article articleResult = buildArticleResult(articleData); + return DataFetcherResult.
newResult() + .localContext( + new HashMap() { + { + put(articleData.getSlug(), articleData); + } + }) + .data(articleResult) + .build(); + } + + private DefaultPageInfo buildArticlePageInfo(CursorPager articles) { + return new DefaultPageInfo( + Util.isEmpty(articles.getStartCursor()) + ? null + : new DefaultConnectionCursor(articles.getStartCursor()), + Util.isEmpty(articles.getEndCursor()) + ? null + : new DefaultConnectionCursor(articles.getEndCursor()), + articles.hasPrevious(), + articles.hasNext()); + } + + private Article buildArticleResult(ArticleData articleData) { + return Article.newBuilder() + .body(articleData.getBody()) + .createdAt(ISODateTimeFormat.dateTime().withZoneUTC().print(articleData.getCreatedAt())) + .description(articleData.getDescription()) + .favorited(articleData.isFavorited()) + .favoritesCount(articleData.getFavoritesCount()) + .slug(articleData.getSlug()) + .tagList(articleData.getTagList()) + .title(articleData.getTitle()) + .updatedAt(ISODateTimeFormat.dateTime().withZoneUTC().print(articleData.getUpdatedAt())) + .build(); + } +} diff --git a/src/main/java/io/spring/graphql/users/ArticleMutation.java b/src/main/java/io/spring/graphql/users/ArticleMutation.java new file mode 100644 index 0000000..903a658 --- /dev/null +++ b/src/main/java/io/spring/graphql/users/ArticleMutation.java @@ -0,0 +1,124 @@ +package io.spring.graphql.users; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsData; +import com.netflix.graphql.dgs.InputArgument; +import graphql.execution.DataFetcherResult; +import io.spring.api.exception.NoAuthorizationException; +import io.spring.api.exception.ResourceNotFoundException; +import io.spring.application.article.ArticleCommandService; +import io.spring.application.article.NewArticleParam; +import io.spring.application.article.UpdateArticleParam; +import io.spring.core.article.Article; +import io.spring.core.article.ArticleRepository; +import io.spring.core.favorite.ArticleFavorite; +import io.spring.core.favorite.ArticleFavoriteRepository; +import io.spring.core.service.AuthorizationService; +import io.spring.core.user.User; +import io.spring.graphql.AuthenticationException; +import io.spring.graphql.DgsConstants.MUTATION; +import io.spring.graphql.types.ArticlePayload; +import io.spring.graphql.types.CreateArticleInput; +import io.spring.graphql.types.DeletionStatus; +import io.spring.graphql.types.UpdateArticleInput; +import java.util.Collections; +import org.springframework.beans.factory.annotation.Autowired; + +@DgsComponent +public class ArticleMutation { + + private ArticleCommandService articleCommandService; + private ArticleFavoriteRepository articleFavoriteRepository; + private ArticleRepository articleRepository; + + @Autowired + public ArticleMutation( + ArticleCommandService articleCommandService, + ArticleFavoriteRepository articleFavoriteRepository, + ArticleRepository articleRepository) { + this.articleCommandService = articleCommandService; + this.articleFavoriteRepository = articleFavoriteRepository; + this.articleRepository = articleRepository; + } + + @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.CreateArticle) + public DataFetcherResult createArticle( + @InputArgument("input") CreateArticleInput input) { + User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); + NewArticleParam newArticleParam = + NewArticleParam.builder() + .title(input.getTitle()) + .description(input.getDescription()) + .body(input.getBody()) + .tagList(input.getTagList() == null ? Collections.emptyList() : input.getTagList()) + .build(); + Article article = articleCommandService.createArticle(newArticleParam, user); + return DataFetcherResult.newResult() + .data(ArticlePayload.newBuilder().build()) + .localContext(article) + .build(); + } + + @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.UpdateArticle) + public DataFetcherResult updateArticle( + @InputArgument("slug") String slug, @InputArgument("changes") UpdateArticleInput params) { + Article article = + articleRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); + if (!AuthorizationService.canWriteArticle(user, article)) { + throw new NoAuthorizationException(); + } + article = + articleCommandService.updateArticle( + article, + new UpdateArticleParam(params.getTitle(), params.getBody(), params.getDescription())); + return DataFetcherResult.newResult() + .data(ArticlePayload.newBuilder().build()) + .localContext(article) + .build(); + } + + @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.FavoriteArticle) + public DataFetcherResult favoriteArticle(@InputArgument("slug") String slug) { + User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); + Article article = + articleRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + ArticleFavorite articleFavorite = new ArticleFavorite(article.getId(), user.getId()); + articleFavoriteRepository.save(articleFavorite); + return DataFetcherResult.newResult() + .data(ArticlePayload.newBuilder().build()) + .localContext(article) + .build(); + } + + @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.UnfavoriteArticle) + public DataFetcherResult unfavoriteArticle(@InputArgument("slug") String slug) { + User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); + Article article = + articleRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + articleFavoriteRepository + .find(article.getId(), user.getId()) + .ifPresent( + favorite -> { + articleFavoriteRepository.remove(favorite); + }); + return DataFetcherResult.newResult() + .data(ArticlePayload.newBuilder().build()) + .localContext(article) + .build(); + } + + @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.DeleteArticle) + public DeletionStatus deleteArticle(@InputArgument("slug") String slug) { + User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); + Article article = + articleRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + + if (!AuthorizationService.canWriteArticle(user, article)) { + throw new NoAuthorizationException(); + } + + articleRepository.remove(article); + return DeletionStatus.newBuilder().success(true).build(); + } +} diff --git a/src/main/java/io/spring/graphql/users/CommentDatafetcher.java b/src/main/java/io/spring/graphql/users/CommentDatafetcher.java new file mode 100644 index 0000000..0752bf8 --- /dev/null +++ b/src/main/java/io/spring/graphql/users/CommentDatafetcher.java @@ -0,0 +1,118 @@ +package io.spring.graphql.users; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsData; +import com.netflix.graphql.dgs.DgsDataFetchingEnvironment; +import com.netflix.graphql.dgs.InputArgument; +import graphql.execution.DataFetcherResult; +import graphql.relay.DefaultConnectionCursor; +import graphql.relay.DefaultPageInfo; +import io.spring.Util; +import io.spring.application.CommentQueryService; +import io.spring.application.CursorPageParameter; +import io.spring.application.CursorPager; +import io.spring.application.CursorPager.Direction; +import io.spring.application.data.ArticleData; +import io.spring.application.data.CommentData; +import io.spring.core.user.User; +import io.spring.graphql.DgsConstants.ARTICLE; +import io.spring.graphql.DgsConstants.COMMENTPAYLOAD; +import io.spring.graphql.types.Comment; +import io.spring.graphql.types.CommentEdge; +import io.spring.graphql.types.CommentsConnection; +import java.util.HashMap; +import java.util.stream.Collectors; +import org.joda.time.format.ISODateTimeFormat; +import org.springframework.beans.factory.annotation.Autowired; + +@DgsComponent +public class CommentDatafetcher { + private CommentQueryService commentQueryService; + + @Autowired + public CommentDatafetcher(CommentQueryService commentQueryService) { + this.commentQueryService = commentQueryService; + } + + @DgsData(parentType = COMMENTPAYLOAD.TYPE_NAME, field = COMMENTPAYLOAD.Comment) + public DataFetcherResult getComment(DgsDataFetchingEnvironment dfe) { + CommentData comment = dfe.getLocalContext(); + Comment commentResult = buildCommentResult(comment); + return DataFetcherResult.newResult() + .data(commentResult) + .localContext( + new HashMap() { + { + put(comment.getId(), comment); + } + }) + .build(); + } + + @DgsData(parentType = ARTICLE.TYPE_NAME, field = ARTICLE.Comments) + public DataFetcherResult articleComments( + @InputArgument("first") Integer first, + @InputArgument("after") String after, + @InputArgument("last") Integer last, + @InputArgument("before") String before, + DgsDataFetchingEnvironment dfe) { + + if (first == null && last == null) { + throw new IllegalArgumentException("first 和 last 必须只存在一个"); + } + + User current = SecurityUtil.getCurrentUser().orElse(null); + ArticleData articleData = dfe.getLocalContext(); + + CursorPager comments; + if (first != null) { + comments = + commentQueryService.findByArticleIdWithCursor( + articleData.getId(), current, new CursorPageParameter(after, first, Direction.NEXT)); + } else { + comments = + commentQueryService.findByArticleIdWithCursor( + articleData.getId(), current, new CursorPageParameter(before, last, Direction.PREV)); + } + graphql.relay.PageInfo pageInfo = buildCommentPageInfo(comments); + CommentsConnection result = + CommentsConnection.newBuilder() + .pageInfo(pageInfo) + .edges( + comments.getData().stream() + .map( + a -> + CommentEdge.newBuilder() + .cursor(a.getCursor()) + .node(buildCommentResult(a)) + .build()) + .collect(Collectors.toList())) + .build(); + return DataFetcherResult.newResult() + .data(result) + .localContext( + comments.getData().stream().collect(Collectors.toMap(CommentData::getId, c -> c))) + .build(); + } + + private DefaultPageInfo buildCommentPageInfo(CursorPager comments) { + return new DefaultPageInfo( + Util.isEmpty(comments.getStartCursor()) + ? null + : new DefaultConnectionCursor(comments.getStartCursor()), + Util.isEmpty(comments.getEndCursor()) + ? null + : new DefaultConnectionCursor(comments.getEndCursor()), + comments.hasPrevious(), + comments.hasNext()); + } + + private Comment buildCommentResult(CommentData comment) { + return Comment.newBuilder() + .id(comment.getId()) + .body(comment.getBody()) + .updatedAt(ISODateTimeFormat.dateTime().withZoneUTC().print(comment.getCreatedAt())) + .createdAt(ISODateTimeFormat.dateTime().withZoneUTC().print(comment.getCreatedAt())) + .build(); + } +} diff --git a/src/main/java/io/spring/graphql/users/CommentMutation.java b/src/main/java/io/spring/graphql/users/CommentMutation.java new file mode 100644 index 0000000..8a37a0f --- /dev/null +++ b/src/main/java/io/spring/graphql/users/CommentMutation.java @@ -0,0 +1,77 @@ +package io.spring.graphql.users; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsData; +import com.netflix.graphql.dgs.InputArgument; +import graphql.execution.DataFetcherResult; +import io.spring.api.exception.NoAuthorizationException; +import io.spring.api.exception.ResourceNotFoundException; +import io.spring.application.CommentQueryService; +import io.spring.application.data.CommentData; +import io.spring.core.article.Article; +import io.spring.core.article.ArticleRepository; +import io.spring.core.comment.Comment; +import io.spring.core.comment.CommentRepository; +import io.spring.core.service.AuthorizationService; +import io.spring.core.user.User; +import io.spring.graphql.AuthenticationException; +import io.spring.graphql.DgsConstants.MUTATION; +import io.spring.graphql.types.CommentPayload; +import io.spring.graphql.types.DeletionStatus; +import org.springframework.beans.factory.annotation.Autowired; + +@DgsComponent +public class CommentMutation { + + private ArticleRepository articleRepository; + private CommentRepository commentRepository; + private CommentQueryService commentQueryService; + + @Autowired + public CommentMutation( + ArticleRepository articleRepository, + CommentRepository commentRepository, + CommentQueryService commentQueryService) { + this.articleRepository = articleRepository; + this.commentRepository = commentRepository; + this.commentQueryService = commentQueryService; + } + + @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.AddComment) + public DataFetcherResult createComment( + @InputArgument("slug") String slug, @InputArgument("body") String body) { + User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); + Article article = + articleRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + Comment comment = new Comment(body, user.getId(), article.getId()); + commentRepository.save(comment); + CommentData commentData = + commentQueryService + .findById(comment.getId(), user) + .orElseThrow(ResourceNotFoundException::new); + return DataFetcherResult.newResult() + .localContext(commentData) + .data(CommentPayload.newBuilder().build()) + .build(); + } + + @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.DeleteComment) + public DeletionStatus removeComment( + @InputArgument("slug") String slug, @InputArgument("id") String commentId) { + User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); + + Article article = + articleRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + return commentRepository + .findById(article.getId(), commentId) + .map( + comment -> { + if (!AuthorizationService.canWriteComment(user, article, comment)) { + throw new NoAuthorizationException(); + } + commentRepository.remove(comment); + return DeletionStatus.newBuilder().success(true).build(); + }) + .orElseThrow(ResourceNotFoundException::new); + } +} diff --git a/src/main/java/io/spring/graphql/users/MeDatafetcher.java b/src/main/java/io/spring/graphql/users/MeDatafetcher.java new file mode 100644 index 0000000..23a971b --- /dev/null +++ b/src/main/java/io/spring/graphql/users/MeDatafetcher.java @@ -0,0 +1,67 @@ +package io.spring.graphql.users; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsData; +import graphql.execution.DataFetcherResult; +import graphql.schema.DataFetchingEnvironment; +import io.spring.api.exception.ResourceNotFoundException; +import io.spring.application.UserQueryService; +import io.spring.application.data.UserData; +import io.spring.application.data.UserWithToken; +import io.spring.core.service.JwtService; +import io.spring.graphql.DgsConstants; +import io.spring.graphql.DgsConstants.QUERY; +import io.spring.graphql.DgsConstants.USERPAYLOAD; +import io.spring.graphql.types.User; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.RequestHeader; + +@DgsComponent +public class MeDatafetcher { + private UserQueryService userQueryService; + private JwtService jwtService; + + @Autowired + public MeDatafetcher(UserQueryService userQueryService, JwtService jwtService) { + this.userQueryService = userQueryService; + this.jwtService = jwtService; + } + + @DgsData(parentType = DgsConstants.QUERY_TYPE, field = QUERY.Me) + public DataFetcherResult getMe( + @RequestHeader(value = "Authorization") String authorization, + DataFetchingEnvironment dataFetchingEnvironment) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication instanceof AnonymousAuthenticationToken + || authentication.getPrincipal() == null) { + return null; + } + io.spring.core.user.User user = (io.spring.core.user.User) authentication.getPrincipal(); + UserData userData = + userQueryService.findById(user.getId()).orElseThrow(ResourceNotFoundException::new); + UserWithToken userWithToken = new UserWithToken(userData, authorization.split(" ")[1]); + User result = + User.newBuilder() + .email(userWithToken.getEmail()) + .username(userWithToken.getUsername()) + .token(userWithToken.getToken()) + .build(); + return DataFetcherResult.newResult().data(result).localContext(user).build(); + } + + @DgsData(parentType = USERPAYLOAD.TYPE_NAME, field = USERPAYLOAD.User) + public DataFetcherResult getUserPayloadUser( + DataFetchingEnvironment dataFetchingEnvironment) { + io.spring.core.user.User user = dataFetchingEnvironment.getLocalContext(); + User result = + User.newBuilder() + .email(user.getEmail()) + .username(user.getUsername()) + .token(jwtService.toToken(user)) + .build(); + return DataFetcherResult.newResult().data(result).localContext(user).build(); + } +} diff --git a/src/main/java/io/spring/graphql/users/ProfileDatafetcher.java b/src/main/java/io/spring/graphql/users/ProfileDatafetcher.java new file mode 100644 index 0000000..a202a36 --- /dev/null +++ b/src/main/java/io/spring/graphql/users/ProfileDatafetcher.java @@ -0,0 +1,76 @@ +package io.spring.graphql.users; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsData; +import com.netflix.graphql.dgs.InputArgument; +import graphql.schema.DataFetchingEnvironment; +import io.spring.api.exception.ResourceNotFoundException; +import io.spring.application.ProfileQueryService; +import io.spring.application.data.ArticleData; +import io.spring.application.data.CommentData; +import io.spring.application.data.ProfileData; +import io.spring.core.user.User; +import io.spring.graphql.DgsConstants; +import io.spring.graphql.DgsConstants.ARTICLE; +import io.spring.graphql.DgsConstants.COMMENT; +import io.spring.graphql.DgsConstants.QUERY; +import io.spring.graphql.DgsConstants.USER; +import io.spring.graphql.types.Article; +import io.spring.graphql.types.Comment; +import io.spring.graphql.types.Profile; +import io.spring.graphql.types.ProfilePayload; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; + +@DgsComponent +public class ProfileDatafetcher { + + private ProfileQueryService profileQueryService; + + @Autowired + public ProfileDatafetcher(ProfileQueryService profileQueryService) { + this.profileQueryService = profileQueryService; + } + + @DgsData(parentType = USER.TYPE_NAME, field = USER.Profile) + public Profile getUserProfile(DataFetchingEnvironment dataFetchingEnvironment) { + User user = dataFetchingEnvironment.getLocalContext(); + String username = user.getUsername(); + return queryProfile(username); + } + + @DgsData(parentType = ARTICLE.TYPE_NAME, field = ARTICLE.Author) + public Profile getAuthor(DataFetchingEnvironment dataFetchingEnvironment) { + Map map = dataFetchingEnvironment.getLocalContext(); + Article article = dataFetchingEnvironment.getSource(); + return queryProfile(map.get(article.getSlug()).getProfileData().getUsername()); + } + + @DgsData(parentType = COMMENT.TYPE_NAME, field = COMMENT.Author) + public Profile getCommentAuthor(DataFetchingEnvironment dataFetchingEnvironment) { + Comment comment = dataFetchingEnvironment.getSource(); + Map map = dataFetchingEnvironment.getLocalContext(); + return queryProfile(map.get(comment.getId()).getProfileData().getUsername()); + } + + @DgsData(parentType = DgsConstants.QUERY_TYPE, field = QUERY.Profile) + public ProfilePayload queryProfile( + @InputArgument("username") String username, DataFetchingEnvironment dataFetchingEnvironment) { + Profile profile = queryProfile(dataFetchingEnvironment.getArgument("username")); + return ProfilePayload.newBuilder().profile(profile).build(); + } + + private Profile queryProfile(String username) { + User current = SecurityUtil.getCurrentUser().orElse(null); + ProfileData profileData = + profileQueryService + .findByUsername(username, current) + .orElseThrow(ResourceNotFoundException::new); + return Profile.newBuilder() + .username(profileData.getUsername()) + .bio(profileData.getBio()) + .image(profileData.getImage()) + .following(profileData.isFollowing()) + .build(); + } +} diff --git a/src/main/java/io/spring/graphql/users/RelationMutation.java b/src/main/java/io/spring/graphql/users/RelationMutation.java new file mode 100644 index 0000000..c3702da --- /dev/null +++ b/src/main/java/io/spring/graphql/users/RelationMutation.java @@ -0,0 +1,70 @@ +package io.spring.graphql.users; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsData; +import com.netflix.graphql.dgs.InputArgument; +import io.spring.api.exception.ResourceNotFoundException; +import io.spring.application.ProfileQueryService; +import io.spring.application.data.ProfileData; +import io.spring.core.user.FollowRelation; +import io.spring.core.user.User; +import io.spring.core.user.UserRepository; +import io.spring.graphql.AuthenticationException; +import io.spring.graphql.DgsConstants.MUTATION; +import io.spring.graphql.types.Profile; +import io.spring.graphql.types.ProfilePayload; +import org.springframework.beans.factory.annotation.Autowired; + +@DgsComponent +public class RelationMutation { + + private UserRepository userRepository; + private ProfileQueryService profileQueryService; + + @Autowired + public RelationMutation(UserRepository userRepository, ProfileQueryService profileQueryService) { + this.userRepository = userRepository; + this.profileQueryService = profileQueryService; + } + + @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.FollowUser) + public ProfilePayload follow(@InputArgument("username") String username) { + User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); + return userRepository + .findByUsername(username) + .map( + target -> { + FollowRelation followRelation = new FollowRelation(user.getId(), target.getId()); + userRepository.saveRelation(followRelation); + Profile profile = buildProfile(username, user); + return ProfilePayload.newBuilder().profile(profile).build(); + }) + .orElseThrow(ResourceNotFoundException::new); + } + + @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.UnfollowUser) + public ProfilePayload unfollow(@InputArgument("username") String username) { + User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); + User target = + userRepository.findByUsername(username).orElseThrow(ResourceNotFoundException::new); + return userRepository + .findRelation(user.getId(), target.getId()) + .map( + relation -> { + userRepository.removeRelation(relation); + Profile profile = buildProfile(username, user); + return ProfilePayload.newBuilder().profile(profile).build(); + }) + .orElseThrow(ResourceNotFoundException::new); + } + + private Profile buildProfile(@InputArgument("username") String username, User current) { + ProfileData profileData = profileQueryService.findByUsername(username, current).get(); + return Profile.newBuilder() + .username(profileData.getUsername()) + .bio(profileData.getBio()) + .image(profileData.getImage()) + .following(profileData.isFollowing()) + .build(); + } +} diff --git a/src/main/java/io/spring/graphql/users/SecurityUtil.java b/src/main/java/io/spring/graphql/users/SecurityUtil.java new file mode 100644 index 0000000..14e45a4 --- /dev/null +++ b/src/main/java/io/spring/graphql/users/SecurityUtil.java @@ -0,0 +1,19 @@ +package io.spring.graphql.users; + +import io.spring.core.user.User; +import java.util.Optional; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +public class SecurityUtil { + public static Optional getCurrentUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication instanceof AnonymousAuthenticationToken + || authentication.getPrincipal() == null) { + return Optional.empty(); + } + io.spring.core.user.User currentUser = (io.spring.core.user.User) authentication.getPrincipal(); + return Optional.of(currentUser); + } +} diff --git a/src/main/java/io/spring/graphql/users/UserMutation.java b/src/main/java/io/spring/graphql/users/UserMutation.java new file mode 100644 index 0000000..64924da --- /dev/null +++ b/src/main/java/io/spring/graphql/users/UserMutation.java @@ -0,0 +1,90 @@ +package io.spring.graphql.users; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsData; +import com.netflix.graphql.dgs.InputArgument; +import graphql.execution.DataFetcherResult; +import io.spring.api.exception.InvalidAuthenticationException; +import io.spring.application.user.RegisterParam; +import io.spring.application.user.UpdateUserCommand; +import io.spring.application.user.UpdateUserParam; +import io.spring.application.user.UserService; +import io.spring.core.user.EncryptService; +import io.spring.core.user.User; +import io.spring.core.user.UserRepository; +import io.spring.graphql.DgsConstants.MUTATION; +import io.spring.graphql.types.CreateUserInput; +import io.spring.graphql.types.UpdateUserInput; +import io.spring.graphql.types.UserPayload; +import java.util.Optional; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +@DgsComponent +public class UserMutation { + + private UserRepository userRepository; + private EncryptService encryptService; + private UserService userService; + + @Autowired + public UserMutation( + UserRepository userRepository, EncryptService encryptService, UserService userService) { + this.userRepository = userRepository; + this.encryptService = encryptService; + this.userService = userService; + } + + @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.CreateUser) + public DataFetcherResult createUser(@InputArgument("input") CreateUserInput input) { + RegisterParam registerParam = + new RegisterParam(input.getEmail(), input.getUsername(), input.getPassword()); + User user = userService.createUser(registerParam); + + return DataFetcherResult.newResult() + .data(UserPayload.newBuilder().build()) + .localContext(user) + .build(); + } + + @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.Login) + public DataFetcherResult login( + @InputArgument("password") String password, @InputArgument("email") String email) { + Optional optional = userRepository.findByEmail(email); + if (optional.isPresent() && encryptService.check(password, optional.get().getPassword())) { + return DataFetcherResult.newResult() + .data(UserPayload.newBuilder().build()) + .localContext(optional.get()) + .build(); + } else { + throw new InvalidAuthenticationException(); + } + } + + @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.UpdateUser) + public DataFetcherResult updateUser( + @InputArgument("changes") UpdateUserInput updateUserInput) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication instanceof AnonymousAuthenticationToken + || authentication.getPrincipal() == null) { + return null; + } + io.spring.core.user.User currentUser = (io.spring.core.user.User) authentication.getPrincipal(); + UpdateUserParam param = + UpdateUserParam.builder() + .username(updateUserInput.getUsername()) + .email(updateUserInput.getEmail()) + .bio(updateUserInput.getBio()) + .password(updateUserInput.getPassword()) + .image(updateUserInput.getImage()) + .build(); + + userService.updateUser(new UpdateUserCommand(currentUser, param)); + return DataFetcherResult.newResult() + .data(UserPayload.newBuilder().build()) + .localContext(currentUser) + .build(); + } +} diff --git a/src/main/resources/schema/schema.graphqls b/src/main/resources/schema/schema.graphqls new file mode 100644 index 0000000..cbea169 --- /dev/null +++ b/src/main/resources/schema/schema.graphqls @@ -0,0 +1,164 @@ +# Build the schema. +type Query { + article(slug: String!): Article + articles( + first: Int, + after: String, + last: Int, + before: String, + authoredBy: String + favoritedBy: String + withTag: String + ): ArticlesConnection + me: User + feed(first: Int, after: String, last: Int, before: String): ArticlesConnection + profile(username: String!): ProfilePayload + tags: [String] +} + +type Mutation { + ### User & Profile + createUser(input: CreateUserInput): UserPayload + login(password: String!, email: String!): UserPayload + updateUser(changes: UpdateUserInput!): UserPayload + followUser(username: String!): ProfilePayload + unfollowUser(username: String!): ProfilePayload + + ### Article + createArticle(input: CreateArticleInput!): ArticlePayload + updateArticle(slug: String!, changes: UpdateArticleInput!): ArticlePayload + favoriteArticle(slug: String!): ArticlePayload + unfavoriteArticle(slug: String!): ArticlePayload + deleteArticle(slug: String!): DeletionStatus + + ### Comment + addComment(slug: String!, body: String!): CommentPayload + deleteComment(slug: String!, id: ID!): DeletionStatus +} + +schema { + query: Query + mutation: Mutation +} + +### Articles +type Article { + author: Profile! + body: String! + comments(first: Int, after: String, last: Int, before: String): CommentsConnection + createdAt: String! + description: String! + favorited: Boolean! + favoritesCount: Int! + slug: String! + tagList: [String], + title: String! + updatedAt: String! +} + +type ArticleEdge { + cursor: String! + node: Article +} + +type ArticlesConnection { + edges: [ArticleEdge] + pageInfo: PageInfo! +} + +### Comments +type Comment { + id: ID! + author: Profile! + article: Article! + body: String! + createdAt: String! + updatedAt: String! +} + +type CommentEdge { + cursor: String! + node: Comment +} + +type CommentsConnection { + edges: [CommentEdge] + pageInfo: PageInfo! +} + +type DeletionStatus { + success: Boolean! +} + +type PageInfo { + endCursor: String + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String +} + +### Profile +type Profile { + username: String! + bio: String + following: Boolean! + image: String + articles(first: Int, after: String, last: Int, before: String): ArticlesConnection + favorites(first: Int, after: String, last: Int, before: String): ArticlesConnection + feed(first: Int, after: String, last: Int, before: String): ArticlesConnection +} + +### User +type User { + email: String! + profile: Profile! + token: String! + username: String! +} + +## Mutations + +# Input types. +input UpdateArticleInput { + body: String + description: String + title: String +} + +input CreateArticleInput { + body: String! + description: String! + tagList: [String] + title: String! +} + +type ArticlePayload { + article: Article +} + +type CommentPayload { + comment: Comment +} + +input CreateUserInput { + email: String! + username: String! + password: String! +} + +input UpdateUserInput { + email: String + username: String + password: String + image: String + bio: String +} + +type UserPayload { + user: User +} + +type ProfilePayload { + profile: Profile +} +