DEV Community

loading...
Cover image for Full Stack Reddit Clone - Spring Boot, React, Electron App - Part 9

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

Aaron C. Beasley
・9 min read

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

Introduction

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

What are we building in this part?

  • Pagination Support
    • We will update our backend to support pagination, this will reduce the amount of loading times for the client as the database begins to scale
  • JWT Invalidation
  • JWT Refreshing

In Part 8 we added the CREATE && READ endpoints for creating and reading comments!!

Important Links

Part 1: Updating Repositories πŸ—„

Let's cover the updating of all of our repositories to implement pagination and sorting support. Inside com.your-name.backend.repository we will update the following classes.

  • CommentRespository: We will convert our existing logic, as well as add a findAllByPost method that still returns a list, as we rely on that for sending back the total amount of comments in our PostService
package com.maxicb.backend.repository;

import com.maxicb.backend.model.Comment;
import com.maxicb.backend.model.Post;
import com.maxicb.backend.model.User;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.PagingAndSortingRepository;

import java.util.List;

public interface CommentRepository extends PagingAndSortingRepository<Comment, Long> {
    Page<Comment> findByPost(Post post, Pageable pageable);
    List<Comment> findAllByPost(Post post);
    Page<Comment> findAllByUser(User user, Pageable pageable);
}
Enter fullscreen mode Exit fullscreen mode
  • PostRepository:
package com.maxicb.backend.repository;

import com.maxicb.backend.model.Post;
import com.maxicb.backend.model.Subreddit;
import com.maxicb.backend.model.User;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.PagingAndSortingRepository;

import java.util.List;

public interface PostRepository extends PagingAndSortingRepository<Post, Long> {
    Page<Post> findAllBySubreddit(Subreddit subreddit, Pageable pageable);
    Page<Post> findByUser(User user, Pageable pageable);
}
Enter fullscreen mode Exit fullscreen mode
  • SubredditRepository:
package com.maxicb.backend.repository;

import com.maxicb.backend.model.Subreddit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.PagingAndSortingRepository;

import java.util.Optional;

public interface SubredditRepository extends PagingAndSortingRepository<Subreddit, Long> {
    Optional<Subreddit> findByName(String subredditName);
    Optional<Page<Subreddit>> findByNameLike(String subredditName, Pageable pageable);
}
Enter fullscreen mode Exit fullscreen mode

Part 2: Updating Services 🌎

Now that we have updated our repositories we will need to update our servcies to refelct these changes. Inside com.your-name.backend.service we will update the following classes. Keep in mind I will not be displaying the whole class in the section, but only the specific methods we will be updating.

  • CommentService: We will update the getCommentsForPost && getCommentsForUser methods to handle pagination properly
    public Page<CommentResponse> getCommentsForPost(Long id, Integer page) {
        Post post = postRepository.findById(id)
                .orElseThrow(() -> new PostNotFoundException("Post not found with id: " + id));
        return commentRepository.findByPost(post, PageRequest.of(page, 100)).map(this::mapToResponse);
    }

    public Page<CommentResponse> getCommentsForUser(Long id, Integer page) {
        User user = userRepository.findById(id)
                .orElseThrow(() -> new UserNotFoundException("User not found with id: " + id));
        return commentRepository.findAllByUser(user, PageRequest.of(page, 100)).map(this::mapToResponse);
    }
Enter fullscreen mode Exit fullscreen mode
  • PostService: We will update the mapToResponse && getAllPosts && getPostsBySubreddit && getPostsByUsername methods to implement pagination, and also retain the existing logic of mapping to DTO's
    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.findAllByPost(post).size())
                .duration(TimeAgo.using(post.getCreationDate().toEpochMilli()))
                .upVote(checkVoteType(post, VoteType.UPVOTE))
                .downVote(checkVoteType(post, VoteType.DOWNVOTE))
                .build();
    }

    public Page<PostResponse> getAllPost(Integer page) {
        return postRepository.findAll(PageRequest.of(page, 100)).map(this::mapToResponse);
    }

    public Page<PostResponse> getPostsBySubreddit(Integer page, Long id) {
        Subreddit subreddit = subredditRepository.findById(id)
                .orElseThrow(() -> new SubredditNotFoundException("Subreddit not found with id: " + id));
        return postRepository
                .findAllBySubreddit(subreddit, PageRequest.of(page, 100))
                .map(this::mapToResponse);
    }

    public Page<PostResponse> getPostsByUsername(String username, Integer page) {
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UserNotFoundException("User not found with username: " + username));
        return postRepository
                .findByUser(user, PageRequest.of(page, 100))
                .map(this::mapToResponse);
    }
Enter fullscreen mode Exit fullscreen mode
  • SubredditService: We will update the getAll method
    @Transactional(readOnly = true)
    public Page<SubredditDTO> getAll(Integer page) {
        return subredditRepository.findAll(PageRequest.of(page, 100))
                .map(this::mapToDTO);
    }
Enter fullscreen mode Exit fullscreen mode

Part 3: Updating Controllers

Now that we have updated our services && repositories we will need to update our controllers to allow client to use pagination. Inside com.your-name.backend.controller we will update the following classes. Keep in mind I will not be displaying the whole class in the section, but only the specific methods we will be updating.

  • CommentController: We will update the getCommentsByPost && getCommentsByUser methods to handle pagination properly
    @GetMapping("/post/{id}")
    public ResponseEntity<Page<CommentResponse>> getCommentsByPost(@PathVariable("id") Long id, @RequestParam Optional<Integer> page) {
        return new ResponseEntity<>(commentService.getCommentsForPost(id, page.orElse(0)), HttpStatus.OK);
    }

    @GetMapping("/user/{id}")
    public ResponseEntity<Page<CommentResponse>> getCommentsByUser(@PathVariable("id") Long id,@RequestParam Optional<Integer> page) {
        return new ResponseEntity<>(commentService.getCommentsForUser(id, page.orElse(0)), HttpStatus.OK);
    }
Enter fullscreen mode Exit fullscreen mode
  • PostController: We will update the addPost method firstly to send the created post back to the client on successful creation, getAllPost && getPostsBySubreddit && getPostsByUsername methods to implement pagination
    @PostMapping
    public ResponseEntity<PostResponse> addPost(@RequestBody PostRequest postRequest) {
        return new ResponseEntity<>(postService.save(postRequest), HttpStatus.CREATED);
    }

    @GetMapping
    public ResponseEntity<Page<PostResponse>> getAllPost(@RequestParam Optional<Integer> page) {
        return new ResponseEntity<>(postService.getAllPost(page.orElse(0)), HttpStatus.OK);
    }

    @GetMapping("/sub/{id}")
    public ResponseEntity<Page<PostResponse>> getPostsBySubreddit(@PathVariable Long id, @RequestParam Optional<Integer> page) {
        return new ResponseEntity<>(postService.getPostsBySubreddit(page.orElse(0), id), HttpStatus.OK);
    }

    @GetMapping("/user/{name}")
    public ResponseEntity<Page<PostResponse>> getPostsByUsername(@PathVariable("name") String username, @RequestParam Optional<Integer> page) {
        return new ResponseEntity<>(postService.getPostsByUsername(username, page.orElse(0)), HttpStatus.OK);
    }
Enter fullscreen mode Exit fullscreen mode
  • SubredditController: We will update all of the methods to implement sending ResponseEntity as well as support pagination
    @GetMapping("/{page}")
    public ResponseEntity<Page<SubredditDTO>> getAllSubreddits (@PathVariable("page") Integer page) {
        return new ResponseEntity<>(subredditService.getAll(page), HttpStatus.OK);
    }

    @GetMapping("/sub/{id}")
    public ResponseEntity<SubredditDTO> getSubreddit(@PathVariable("id") Long id) {
        return new ResponseEntity<>(subredditService.getSubreddit(id), HttpStatus.OK);
    }

    @PostMapping
    public ResponseEntity<SubredditDTO> addSubreddit(@RequestBody @Valid SubredditDTO subredditDTO) throws Exception{
        try {
            return new ResponseEntity<>(subredditService.save(subredditDTO), HttpStatus.OK);
        } catch (Exception e) {
            throw new Exception("Error Creating Subreddit");
        }
    }
Enter fullscreen mode Exit fullscreen mode

Now our appliction fully supports pagination for all resources that could grow and cause slow load time for our front end application!

Part 5: Refresh Token Class ⏳

Now we need to create our RefreshToken class, this class will have a ID, token, and the creationDate associated with it to allow for invalidating tokens after a set amount of time.

  • RefreshToken:
package com.maxicb.backend.model;

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

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import java.time.Instant;

@Data
@Entity
@AllArgsConstructor
@NoArgsConstructor
public class RefreshToken {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String token;
    private Instant creationDate;
}
Enter fullscreen mode Exit fullscreen mode

Part 5: Refresh Token Service and DTO🌎

Now that we have our RefreshToken, we will get everything in place to begin updating our Authentication system. Inside the project we will add, and update the following classes.

  • RefreshTokenRepository:
package com.maxicb.backend.repository;

import com.maxicb.backend.model.RefreshToken;
import org.springframework.data.repository.PagingAndSortingRepository;

import java.util.Optional;

public interface RefreshTokenRepository extends PagingAndSortingRepository<RefreshToken, Long> {
    Optional<RefreshToken> findByToken(String token);

    void deleteByToken(String token);
}
Enter fullscreen mode Exit fullscreen mode
  • RefreshTokenService: This service will allow us to generate tokens, validate tokens, and delete tokens.
package com.maxicb.backend.service;

import com.maxicb.backend.exception.VoxNobisException;
import com.maxicb.backend.model.RefreshToken;
import com.maxicb.backend.repository.RefreshTokenRepository;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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

@Service
@AllArgsConstructor
@Transactional
public class RefreshTokenService {
    private RefreshTokenRepository refreshTokenRepository;

    RefreshToken generateRefreshToken () {
        RefreshToken refreshToken = new RefreshToken();
        refreshToken.setToken(UUID.randomUUID().toString());
        refreshToken.setCreationDate(Instant.now());
        return refreshTokenRepository.save(refreshToken);
    }

    void validateToken(String token) {
        refreshTokenRepository.findByToken(token)
                .orElseThrow(() -> new VoxNobisException("Invalid Refresh Token"));
    }

    public void deleteRefreshToken(String token) {
        refreshTokenRepository.deleteByToken(token);
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Updated AuthResponse: We will update the AuthResponse to include our newly generated token.
import lombok.AllArgsConstructor;
import lombok.Data;

import java.time.Instant;

@Data
@AllArgsConstructor
public class AuthResponse {
        private String authenticationToken;
        private String refreshToken;
        private Instant expiresAt;
        private String username;
}
Enter fullscreen mode Exit fullscreen mode
  • RefreshTokenRequest: This DTO will handle requests from the client to refresh their token, before it expires in the system
package com.maxicb.backend.dto;

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

import javax.validation.constraints.NotBlank;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class RefreshTokenRequest {
    @NotBlank
    private String refreshToken;
    private String username;
}
Enter fullscreen mode Exit fullscreen mode

Part 6: JWTProvider Update πŸ”

Now that we have everything in place, we will begin updating our JWT system. Inside com.your-name.backend.service we will update the following classes. Keep in mind I will not be displaying the whole class in the section, but only the specific methods we will be updating.

  • JWTProvider: We will update our JWT implementation to include a issuedAt date, and also set a expiration date when we create a new token.
@Service
public class JWTProvider {
    private KeyStore keystore;
    @Value("${jwt.expiration.time}")
    private Long jwtExpirationMillis;

    ...
    ....
    public String generateToken(Authentication authentication) {
        org.springframework.security.core.userdetails.User princ = (User) authentication.getPrincipal();
        return Jwts.builder()
                .setSubject(princ.getUsername())
                .setIssuedAt(from(Instant.now()))
                .signWith(getPrivKey())
                .setExpiration(from(Instant.now().plusMillis(jwtExpirationMillis)))
                .compact();
    }

    public String generateTokenWithUsername(String username) {
        return Jwts.builder()
                .setSubject(username)
                .setIssuedAt(from(Instant.now()))
                .signWith(getPrivKey())
                .setExpiration(from(Instant.now().plusMillis(jwtExpirationMillis)))
                .compact();
    }
    ....
    ...
    public Long getJwtExpirationMillis() {
        return jwtExpirationMillis;
    }
Enter fullscreen mode Exit fullscreen mode

Part 7: Updated Authentication πŸ’‚β€β™€οΈ

Now that we implemented pagination, we will begin updating our Authentication system. Inside our project we will update the following classes. Keep in mind I will not be displaying the whole class in the section, but only the specific methods we will be updating.

  • AuthService: We will update our AuthService to handle sending refreshTokens, and add the logic for refreshing existing tokens.
public AuthResponse refreshToken(RefreshTokenRequest refreshTokenRequest) {
        refreshTokenService.validateToken(refreshTokenRequest.getRefreshToken());
        String token = jwtProvider.generateTokenWithUsername(refreshTokenRequest.getUsername());
        return new AuthResponse(token, refreshTokenService.generateRefreshToken().getToken(), Instant.now().plusMillis(jwtProvider.getJwtExpirationMillis()), refreshTokenRequest.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);
        String refreshToken = refreshTokenService.generateRefreshToken().getToken();
        return new AuthResponse(authToken, refreshToken, Instant.now().plusMillis(jwtProvider.getJwtExpirationMillis()), loginRequest.getUsername());
    }
Enter fullscreen mode Exit fullscreen mode
  • AuthController: We will now implement the new endpoints to allow the client to use the newly added logic.
@PostMapping("/refresh/token")
    public AuthResponse refreshToken(@Valid @RequestBody RefreshTokenRequest refreshTokenRequest) {
        return authService.refreshToken(refreshTokenRequest);
    }

    @PostMapping("/logout")
    public ResponseEntity<String> logout(@Valid @RequestBody RefreshTokenRequest refreshTokenRequest) {
        refreshTokenService.deleteRefreshToken(refreshTokenRequest.getRefreshToken());
        return ResponseEntity.status(HttpStatus.OK).body("Refresh Token Deleted");
    }
Enter fullscreen mode Exit fullscreen mode

Part 8: Custom Exception 🚫

  • VoxNobisException: We will create a general purpose custom exception that can be used repeatedly throughout our application as we extend it.
package com.maxicb.backend.exception;

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

Part 9: Updated application.properties

We will need to add the expiration time that we would like our application to use when it comes to generating tokens, and setting their expiration dates accordingly. I have chose to set it to 15 minutes, but in the future will increase the duration.

# JWT Properties
jwt.expiration.time=900000
Enter fullscreen mode Exit fullscreen mode

Part 10: Implementing Swagger UI πŸ“ƒ

Now that we are at the end of our MVP backend, we will add Swagger UI. If you have never used Swagger before, it is a great way to automatically generate documentation for your API. You can learn more here!

  • pom.xml: We will need to include the swagger dependencies inside our project's pom.xml file.
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
            <version>2.9.2</version>
        </dependency>
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
            <version>2.9.2</version>
        </dependency>
Enter fullscreen mode Exit fullscreen mode
  • SwaggerConfig: Inside com.your-name.backend.config we will create the following class.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

@Configuration
@EnableSwagger2
public class SwaggerConfig {
    @Bean
    public Docket voxNobisAPI() {
        return new Docket(DocumentationType.SWAGGER_2)
                .select()
                .apis(RequestHandlerSelectors.any())
                .paths(PathSelectors.any())
                .build()
                .apiInfo(getAPIInfo());
    }

    private ApiInfo getAPIInfo(){
        return new ApiInfoBuilder()
                .title("Vox-Nobis API")
                .version("1.0")
                .description("API for Vox-Nobis reddit clone")
                .build();
    }
}
Enter fullscreen mode Exit fullscreen mode
  • BackendApplication: Inside com.your-name.backend we will inject our Swagger configuration.
@SpringBootApplication
@EnableAsync
@Import(SwaggerConfig.class)
public class BackendApplication {
    ...
}
Enter fullscreen mode Exit fullscreen mode
  • Security: If you run the application now, and try to navigate to http://localhost:8080/swagger-ui.html#/, you will likely get a 403 forbidden error. Inside com.your-name.backend.config we will need to update our security configuration to allow access without authorization by adding the following matchers underneath our existing one.
.antMatchers(HttpMethod.GET, "/api/subreddit")
.permitAll()
.antMatchers("/v2/api-docs",
            "/configuration/ui",
            "/swagger-resources/**",
            "/configuration/security",
            "/swagger-ui.html",
            "/webjars/**")
.permitAll()
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 the new logic by sending a post request to http://localhost:8080/api/auth/login with the correct data, upon successful login you should receive the refreshToken, and username back now!

  • You can also navigate to http://localhost:8080/swagger-ui.html#/, and view the documentation for all of the endpoints we have created, as well as the information they need, and return.

  • In this article we added pagination, and token expiration times.

Next

Follow to get informed when part ten is released, where we will begin working on the Front End of the application!

Discussion (0)