DEV Community

Cover image for Full Stack Reddit Clone - Spring Boot, React, Electron App - Part 6
MaxiCB
MaxiCB

Posted on

Full Stack Reddit Clone - Spring Boot, React, Electron App - Part 6

Full Stack Reddit Clone - Spring Boot, React, Electron App - Part 6

Introduction

Welcome to Part 6 of creating a Reddit clone using Spring Boot, and React.

What are we building in this part?

  • Post Request DTO
  • Post Response DTO
  • Custom Exceptions
  • Updated Auth Service
  • Post Service
  • READ Post Endpoint's
  • Create Post Endpoint
  • Updated application.properties

In Part 5 we created the logic needed for JWT filtering, updated our authentication service, and made our subreddit endpoint's!

Important Links

Part 1: Post DTO's πŸ“¨

Let's cover our the various DTO's we will need. Inside com.your-name.backend.dto we will create the following classes.

  • PostRequest: Handles creation of the data that will be sent from the client to the API.
package com.maxicb.backend.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class PostRequest {
    private Long postId;
    private String postTitle;
    private String url;
    private String description;
    private String subredditName;

}
Enter fullscreen mode Exit fullscreen mode
  • PostResponse: Handles creation of the data that will be sent to the client from the API.
package com.maxicb.backend.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class PostResponse {
    private Long postId;
    private String postTitle;
    private String url;
    private String description;
    private String userName;
    private String subredditName;
    private Integer voteCount;
    private Integer commentCount;
    private String duration;
    private boolean upVote;
    private boolean downVote;
}
Enter fullscreen mode Exit fullscreen mode

Part 2: Custom Exceptions 🚫

Let's cover our custom exceptions we will need. Inside com.your-name.backend.exception we will create the following classes.

  • UserNotFoundException: Handles exceptions related to looking for a invalid user.
package com.maxicb.backend.exception;

public class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(String message) {
        super(message);
    }
}
Enter fullscreen mode Exit fullscreen mode
  • PostNotFoundException: Handles exceptions related to looking for a invalid posting.
package com.maxicb.backend.exception;

public class PostNotFoundException extends RuntimeException {
    public PostNotFoundException(String message) {
        super(message);
    }
}
Enter fullscreen mode Exit fullscreen mode

Part 3: Updated Auth Service πŸ’‚β€β™€οΈ

Let's cover our JWT validation logic we will need. Inside com.your-name.backend.service we will update the following class.

  • AuthService: We need to implement logic to check if the user is currently logged in.
package com.maxicb.backend.service;

import com.maxicb.backend.dto.AuthResponse;
import com.maxicb.backend.dto.LoginRequest;
import com.maxicb.backend.dto.RegisterRequest;
import com.maxicb.backend.exception.ActivationException;
import com.maxicb.backend.model.AccountVerificationToken;
import com.maxicb.backend.model.NotificationEmail;
import com.maxicb.backend.model.User;
import com.maxicb.backend.repository.TokenRepository;
import com.maxicb.backend.repository.UserRepository;
import com.maxicb.backend.security.JWTProvider;
import lombok.AllArgsConstructor;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.util.Optional;
import java.util.UUID;

import static com.maxicb.backend.config.Constants.EMAIL_ACTIVATION;

@Service
@AllArgsConstructor

public class AuthService {

    UserRepository userRepository;
    PasswordEncoder passwordEncoder;
    TokenRepository tokenRepository;
    MailService mailService;
    MailBuilder mailBuilder;
    AuthenticationManager authenticationManager;
    JWTProvider jwtProvider;

    @Transactional
    public void register(RegisterRequest registerRequest) {
        User user = new User();
        user.setUsername(registerRequest.getUsername());
        user.setEmail(registerRequest.getEmail());
        user.setPassword(encodePassword(registerRequest.getPassword()));
        user.setCreationDate(Instant.now());
        user.setAccountStatus(false);

        userRepository.save(user);

        String token = generateToken(user);
        String message = mailBuilder.build("Welcome to React-Spring-Reddit Clone. " +
                "Please visit the link below to activate you account : " + EMAIL_ACTIVATION + "/" + token);
        mailService.sendEmail(new NotificationEmail("Please Activate Your Account", user.getEmail(), message));
    }

    @Transactional(readOnly = true)
    public User getCurrentUser() {
        org.springframework.security.core.userdetails.User principal = (org.springframework.security.core.userdetails.User) SecurityContextHolder.
                getContext().getAuthentication().getPrincipal();
        return userRepository.findByUsername(principal.getUsername())
                .orElseThrow(() -> new UsernameNotFoundException("User not found with username: " + principal.getUsername()));
    }

    public AuthResponse login (LoginRequest loginRequest) {
        Authentication authenticate = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        loginRequest.getUsername(), loginRequest.getPassword()));
        SecurityContextHolder.getContext().setAuthentication(authenticate);
        String authToken = jwtProvider.generateToken(authenticate);
        return new AuthResponse(authToken, loginRequest.getUsername());
    }

    private String encodePassword(String password) {
        return passwordEncoder.encode(password);
    }

    private String generateToken(User user) {
        String token = UUID.randomUUID().toString();
        AccountVerificationToken verificationToken = new AccountVerificationToken();
        verificationToken.setToken(token);
        verificationToken.setUser(user);
        tokenRepository.save(verificationToken);
        return token;
    }

    public void verifyToken(String token) {
        Optional<AccountVerificationToken> verificationToken = tokenRepository.findByToken(token);
        verificationToken.orElseThrow(() -> new ActivationException("Invalid Activation Token"));
        enableAccount(verificationToken.get());
    }

    public void enableAccount(AccountVerificationToken token) {
        String username = token.getUser().getUsername();
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new ActivationException("User not found with username: " + username));
        user.setAccountStatus(true);
        userRepository.save(user);
    }

    public boolean isLoggedIn() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        return !(authentication instanceof AnonymousAuthenticationToken) && authentication.isAuthenticated();
    }
}
Enter fullscreen mode Exit fullscreen mode

Part 4: Post Service 🌎

Let's cover the subreddit service our application will have. Inside com.your-name.backend.services add the following class.

  • PostService: Hold the logic for mapping data to and from DTO's, getting all post's, getting specific posts's, and adding post's.
package com.maxicb.backend.service;

import com.github.marlonlom.utilities.timeago.TimeAgo;
import com.maxicb.backend.dto.PostRequest;
import com.maxicb.backend.dto.PostResponse;
import com.maxicb.backend.exception.PostNotFoundException;
import com.maxicb.backend.exception.SubredditNotFoundException;
import com.maxicb.backend.exception.UserNotFoundException;
import com.maxicb.backend.model.*;
import com.maxicb.backend.repository.*;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;

@Service
@AllArgsConstructor
@Transactional
public class PostService {

    private final PostRepository postRepository;
    private final SubredditRepository subredditRepository;
    private final UserRepository userRepository;
    private final CommentRepository commentRepository;
    private final AuthService authService;
    private final VoteRepository voteRepository;

    private boolean checkVoteType(Post post, VoteType voteType) {
        if(authService.isLoggedIn()) {
            Optional<Vote> voteForPostForUser = voteRepository.findTopByPostAndUserOrderByVoteIdDesc(post, authService.getCurrentUser());
            return voteForPostForUser.filter(vote -> vote.getVoteType().equals(voteType)).isPresent();
        }
        return false;
    }

    private PostResponse mapToResponse(Post post) {
        return PostResponse.builder()
                .postId(post.getPostId())
                .postTitle(post.getPostTitle())
                .url(post.getUrl())
                .description(post.getDescription())
                .userName(post.getUser().getUsername())
                .subredditName(post.getSubreddit().getName())
                .voteCount(post.getVoteCount())
                .commentCount(commentRepository.findByPost(post).size())
                .duration(TimeAgo.using(post.getCreationDate().toEpochMilli()))
                .upVote(checkVoteType(post, VoteType.UPVOTE))
                .downVote(checkVoteType(post, VoteType.DOWNVOTE))
                .build();
    }

    private Post mapToPost(PostRequest postRequest) {
        Subreddit subreddit = subredditRepository.findByName(postRequest.getSubredditName())
                .orElseThrow(() -> new SubredditNotFoundException(postRequest.getSubredditName()));
        Post newPost = Post.builder()
                .postTitle(postRequest.getPostTitle())
                .url(postRequest.getUrl())
                .description(postRequest.getDescription())
                .voteCount(0)
                .user(authService.getCurrentUser())
                .creationDate(Instant.now())
                .subreddit(subreddit)
                .build();
        subreddit.getPosts().add(newPost);
        return newPost;
    }

    public PostResponse save(PostRequest postRequest) {
        return mapToResponse(postRepository.save(mapToPost(postRequest)));
    }

    public List<PostResponse> getAllPost() {
        return StreamSupport
                .stream(postRepository.findAll().spliterator(), false)
                .map(this::mapToResponse)
                .collect(Collectors.toList());
    }

    public PostResponse findByID (Long id) {
        Post post = postRepository.findById(id)
                .orElseThrow(() -> new PostNotFoundException("Post not found with id: " + id));
        return mapToResponse(post);
    }

    public List<PostResponse> getPostsBySubreddit(Long id) {
        Subreddit subreddit = subredditRepository.findById(id)
                .orElseThrow(() -> new SubredditNotFoundException("Subreddit not found with id: " + id));
        return subreddit.getPosts().stream()
                .map(this::mapToResponse)
                .collect(Collectors.toList());
    }

    public List<PostResponse> getPostsByUsername(String username) {
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UserNotFoundException("User not found with username: " + username));
        return postRepository.findByUser(user).stream()
                .map(this::mapToResponse)
                .collect(Collectors.toList());
    }
}
Enter fullscreen mode Exit fullscreen mode

Part 5: READ && CREATE Post Endpoint's 🌐

Let's cover the post controller our application will have. Inside com.your-name.backend.controller add the following class.

  • PostController: Hold the logic for fetching creating post's, fetching all post's, and specific post's based on user, and subreddit.
package com.maxicb.backend.controller;

import com.maxicb.backend.dto.PostRequest;
import com.maxicb.backend.dto.PostResponse;
import com.maxicb.backend.service.PostService;
import lombok.AllArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/posts")
@AllArgsConstructor
public class PostController {
    private final PostService postService;

    @PostMapping
    public ResponseEntity<Void> addPost(@RequestBody PostRequest postRequest) {
        postService.save(postRequest);
        return new ResponseEntity<>(HttpStatus.CREATED);
    }

    @GetMapping
    public ResponseEntity<List<PostResponse>> getAllPost() {
        return new ResponseEntity<>(postService.getAllPost(), HttpStatus.OK);
    }

    @GetMapping("{id}")
    public ResponseEntity<PostResponse> getPostByID(@PathVariable Long id) {
        return new ResponseEntity<>(postService.findByID(id), HttpStatus.OK);
    }

    @GetMapping("/sub/{id}")
    public ResponseEntity<List<PostResponse>> getPostsBySubreddit(@PathVariable Long id) {
        return new ResponseEntity<>(postService.getPostsBySubreddit(id), HttpStatus.OK);
    }

    @GetMapping("/user/{name}")
    public ResponseEntity<List<PostResponse>> getPostsByUsername(@PathVariable String username) {
        return new ResponseEntity<>(postService.getPostsByUsername(username), HttpStatus.OK);
    }
}

Enter fullscreen mode Exit fullscreen mode

Part 5: Updated application.properties βš™

To alleviate having to create a new user, and walking through the registration, and creating of subreddit to test the newly added logic we are going to update the application.properties to persist our data. Inside main.resources update your application.properties file to match below.

# Database Properties
spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.url=jdbc:postgresql://localhost:5432/postgres
spring.datasource.username=postgres
spring.datasource.password=admin
spring.datasource.initialization-mode=always
# Changing this from create-drop to update
# Allows us to persist the database rather than
# Dropping it each time the application is ran
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.generate-ddl=true
# Redis Properties
spring.cache.type=redis
spring.redis.host=localhost
spring.redis.port=6379
# Mail Properties
spring.mail.host=smtp.mailtrap.io
spring.mail.port=25
spring.mail.username=a08f0bfd316af9
spring.mail.password=ce1b93c770fc96
spring.mail.protocol=smtp
Enter fullscreen mode Exit fullscreen mode

Conclusion πŸ”

  • To ensure everything is configured correctly you can run the application, and ensure there are no error in the console. Towards the bottom of the console you should see output similar to below

Alt Text

  • If there are no error's in the console you can test you post creation logic by sending a post request to http://localhost:8080/api/posts with the following data. You will still have to follow the same steps covered in the previous parts to login to an account to make post's, as well as create a subreddit and provide a valid name.
{
    "postTitle": "Testing Post",
    "url": "HEREEEE",
    "description": "HEREEEE",
    "subredditName": "/r/NAME"
}
Enter fullscreen mode Exit fullscreen mode
  • In this article we added the CREATE && READ endpoints for post's, updated our application properties and added new exception's.

Next

Follow to get informed when part seven is released, where we will cover the Create/Read operations for comments! If you have any questions be sure to leave a comment!

Top comments (0)