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
- Backend Source: https://github.com/MaxiCB/vox-nobis/tree/master/backend
- Frontend Source: https://github.com/MaxiCB/vox-nobis/tree/master/client
- Live URL: In Progress
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);
}
- 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);
}
- 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);
}
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);
}
- 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);
}
- 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);
}
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);
}
- 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);
}
- 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");
}
}
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;
}
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);
}
- 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);
}
}
- 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;
}
- 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;
}
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;
}
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());
}
- 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");
}
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);}
}
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
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>
- 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();
}
}
- BackendApplication: Inside com.your-name.backend we will inject our Swagger configuration.
@SpringBootApplication
@EnableAsync
@Import(SwaggerConfig.class)
public class BackendApplication {
...
}
- 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()
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
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.
Top comments (2)
Thanks for the brilliant series Maxi. When will be the next part released ?
I'm also looking forward to the next upload.