From 49a52f41b36165fc3f28947aaf08f4c972be097e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Ainsworth?= Date: Mon, 24 Nov 2025 21:20:01 +0100 Subject: [PATCH 1/3] Migrate from ObjectMapper to JsonMapper --- .../java/org/openpodcastapi/opa/OpenPodcastAPI.java | 7 ------- .../SubscriptionEntityRestControllerTest.java | 10 +++++----- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/openpodcastapi/opa/OpenPodcastAPI.java b/src/main/java/org/openpodcastapi/opa/OpenPodcastAPI.java index 8b578e3..5796f3b 100644 --- a/src/main/java/org/openpodcastapi/opa/OpenPodcastAPI.java +++ b/src/main/java/org/openpodcastapi/opa/OpenPodcastAPI.java @@ -1,9 +1,7 @@ package org.openpodcastapi.opa; -import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.context.annotation.Bean; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @@ -13,9 +11,4 @@ public class OpenPodcastAPI { static void main(String[] args) { SpringApplication.run(OpenPodcastAPI.class, args); } - - @Bean - public ObjectMapper objectMapper() { - return new ObjectMapper(); - } } diff --git a/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java b/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java index bf0326a..16a031e 100644 --- a/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java +++ b/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java @@ -1,6 +1,5 @@ package org.openpodcastapi.opa.subscriptions; -import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.persistence.EntityNotFoundException; import lombok.NonNull; import org.junit.jupiter.api.BeforeEach; @@ -24,6 +23,7 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import tools.jackson.databind.json.JsonMapper; import java.time.Instant; import java.util.List; @@ -53,7 +53,7 @@ class SubscriptionEntityRestControllerTest { private MockMvc mockMvc; @Autowired - private ObjectMapper objectMapper; + private JsonMapper jsonMapper; @Autowired private TokenService tokenService; @@ -247,7 +247,7 @@ void createUserSubscriptions_shouldReturnMixedResponse() throws Exception { mockMvc.perform(post("/api/v1/subscriptions") .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(List.of(dto1, dto2)))) + .content(jsonMapper.writeValueAsString(List.of(dto1, dto2)))) .andExpect(status().isMultiStatus()) .andDo(document("subscriptions-bulk-create-mixed", preprocessRequest(prettyPrint()), @@ -293,7 +293,7 @@ void createUserSubscription_shouldReturnSuccess() throws Exception { mockMvc.perform(post("/api/v1/subscriptions") .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(List.of(dto)))) + .content(jsonMapper.writeValueAsString(List.of(dto)))) .andExpect(status().is2xxSuccessful()) .andDo(document("subscriptions-bulk-create-success", preprocessRequest(prettyPrint()), @@ -333,7 +333,7 @@ void createUserSubscription_shouldReturnFailure() throws Exception { mockMvc.perform(post("/api/v1/subscriptions") .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(List.of(dto)))) + .content(jsonMapper.writeValueAsString(List.of(dto)))) .andExpect(status().isBadRequest()) .andDo(document("subscriptions-bulk-create-failure", preprocessRequest(prettyPrint()), From fed5615c9939ec010de32b692e360184ad57f2f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Ainsworth?= Date: Wed, 26 Nov 2025 15:08:17 +0100 Subject: [PATCH 2/3] Add docstrings for all functions, classes, and entity fields --- .../opa/advice/GlobalExceptionHandler.java | 37 +++++++++++++++---- .../advice/GlobalModelAttributeAdvice.java | 13 +++++++ ...ApiBearerTokenAuthenticationConverter.java | 6 ++- .../opa/auth/ApiSecurityHandlers.java | 5 +++ .../opa/auth/JwtAuthenticationProvider.java | 14 ++++++- .../opa/config/SecurityConfig.java | 30 +++++++++++++++ .../openpodcastapi/opa/config/WebConfig.java | 9 +++++ .../opa/controllers/api/AuthController.java | 21 +++++++++-- .../opa/controllers/web/DocsController.java | 10 ++++- .../opa/controllers/web/HomeController.java | 11 +++++- .../controllers/web/WebAuthController.java | 27 +++++++++++--- .../exceptions/ValidationErrorResponse.java | 4 ++ .../opa/security/RefreshTokenEntity.java | 7 ++++ .../opa/security/RefreshTokenRepository.java | 9 +++++ .../opa/security/TokenService.java | 3 ++ .../opa/service/CustomUserDetails.java | 6 +++ .../opa/service/CustomUserDetailsService.java | 2 +- .../opa/subscription/SubscriptionDTO.java | 5 +++ .../opa/subscription/SubscriptionEntity.java | 10 +++++ .../opa/subscription/SubscriptionMapper.java | 9 +++++ .../subscription/SubscriptionRepository.java | 5 +++ .../SubscriptionRestController.java | 4 ++ .../opa/subscription/SubscriptionService.java | 7 +++- .../subscription/UserSubscriptionEntity.java | 10 +++++ .../subscription/UserSubscriptionMapper.java | 6 +++ .../UserSubscriptionRepository.java | 18 +++++++++ .../org/openpodcastapi/opa/user/UserDTO.java | 5 +++ .../openpodcastapi/opa/user/UserEntity.java | 12 ++++++ .../openpodcastapi/opa/user/UserMapper.java | 11 ++++++ .../opa/user/UserRepository.java | 20 ++++++++-- .../opa/user/UserRestController.java | 3 +- .../openpodcastapi/opa/user/UserRoles.java | 5 ++- .../openpodcastapi/opa/user/UserService.java | 8 +++- .../opa/util/AdminUserInitializer.java | 2 +- .../opa/util/RefreshTokenCleanup.java | 1 + .../openpodcastapi/opa/auth/AuthApiTest.java | 2 +- .../SubscriptionEntityRestControllerTest.java | 2 +- .../opa/user/UserRestControllerTest.java | 4 +- 38 files changed, 324 insertions(+), 39 deletions(-) diff --git a/src/main/java/org/openpodcastapi/opa/advice/GlobalExceptionHandler.java b/src/main/java/org/openpodcastapi/opa/advice/GlobalExceptionHandler.java index 056e37e..64d1b8d 100644 --- a/src/main/java/org/openpodcastapi/opa/advice/GlobalExceptionHandler.java +++ b/src/main/java/org/openpodcastapi/opa/advice/GlobalExceptionHandler.java @@ -16,32 +16,53 @@ import java.time.Instant; import java.util.List; +/// A global handler for common exceptions thrown by the application. +/// +/// Where possible, controllers should throw their own exceptions. +/// However, for common exceptions such as invalid parameters and +/// not found entities, a global exception handler can be added. @RestControllerAdvice @RequiredArgsConstructor @Log4j2 public class GlobalExceptionHandler { + /// Returns a 404 if a database entity is not found + /// + /// @param exception the thrown [EntityNotFoundException] + /// @return a [ResponseEntity] containing the error message @ExceptionHandler(EntityNotFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) - public ResponseEntity<@NonNull String> handleEntityNotFoundException(EntityNotFoundException error) { - log.debug("{}", error.getMessage()); + public ResponseEntity<@NonNull String> handleEntityNotFoundException(EntityNotFoundException exception) { + log.debug("{}", exception.getMessage()); return ResponseEntity.notFound().build(); } + /// Returns a 400 error when conflicting data is entered + /// + /// @param exception the thrown [DataIntegrityViolationException] + /// @return a [ResponseEntity] containing the error message @ExceptionHandler(DataIntegrityViolationException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) - public ResponseEntity<@NonNull String> handleDataIntegrityViolationException(DataIntegrityViolationException e) { - return ResponseEntity.badRequest().body(e.getMessage()); + public ResponseEntity<@NonNull String> handleDataIntegrityViolationException(DataIntegrityViolationException exception) { + return ResponseEntity.badRequest().body(exception.getMessage()); } + /// Returns a 400 error when illegal arguments are passed + /// + /// @param exception the thrown [IllegalArgumentException] + /// @return a [ResponseEntity] containing the error message @ExceptionHandler(IllegalArgumentException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) - public ResponseEntity<@NonNull String> handleIllegalArgumentException(IllegalArgumentException e) { - return ResponseEntity.badRequest().body(e.getMessage()); + public ResponseEntity<@NonNull String> handleIllegalArgumentException(IllegalArgumentException exception) { + return ResponseEntity.badRequest().body(exception.getMessage()); } + /// Returns a 400 error when invalid arguments are passed to an endpoint + /// + /// @param exception the thrown [MethodArgumentNotValidException] + /// @return a [ResponseEntity] containing the error message @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity<@NonNull ValidationErrorResponse> handleValidationException(MethodArgumentNotValidException ex) { - List errors = ex.getBindingResult().getFieldErrors().stream() + public ResponseEntity<@NonNull ValidationErrorResponse> handleValidationException(MethodArgumentNotValidException exception) { + List errors = exception.getBindingResult().getFieldErrors().stream() .map(fe -> new ValidationErrorResponse.FieldError(fe.getField(), fe.getDefaultMessage())) .toList(); diff --git a/src/main/java/org/openpodcastapi/opa/advice/GlobalModelAttributeAdvice.java b/src/main/java/org/openpodcastapi/opa/advice/GlobalModelAttributeAdvice.java index 9ce94d8..221b670 100644 --- a/src/main/java/org/openpodcastapi/opa/advice/GlobalModelAttributeAdvice.java +++ b/src/main/java/org/openpodcastapi/opa/advice/GlobalModelAttributeAdvice.java @@ -9,10 +9,19 @@ import java.security.Principal; +/// A helper class for adding user information to requests. +/// +/// This class is used to populate user details in templates +/// and to ensure that a user is authenticated when viewing +/// web pages. @Log4j2 @ControllerAdvice public class GlobalModelAttributeAdvice { + /// Adds a boolean `isAuthenticated` property to the request model based on + /// whether the user is logged-in. + /// + /// @param model the [Model] attached to the request @ModelAttribute public void addAuthenticationFlag(Model model) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); @@ -21,6 +30,10 @@ public void addAuthenticationFlag(Model model) { model.addAttribute("isAuthenticated", isAuthenticated); } + /// Adds user details to the request model. + /// + /// @param principal the [Principal] representing the user + /// @param model the [Model] attached to the request @ModelAttribute public void addUserDetails(Principal principal, Model model) { var username = principal != null ? principal.getName() : "Guest"; diff --git a/src/main/java/org/openpodcastapi/opa/auth/ApiBearerTokenAuthenticationConverter.java b/src/main/java/org/openpodcastapi/opa/auth/ApiBearerTokenAuthenticationConverter.java index b52794d..e30e5b1 100644 --- a/src/main/java/org/openpodcastapi/opa/auth/ApiBearerTokenAuthenticationConverter.java +++ b/src/main/java/org/openpodcastapi/opa/auth/ApiBearerTokenAuthenticationConverter.java @@ -6,6 +6,10 @@ import org.springframework.security.web.authentication.AuthenticationConverter; import org.springframework.stereotype.Component; +/// A converter that handles JWT-based auth for API requests. +/// +/// This converter targets only the API endpoints at `/api`. +/// Auth for the frontend is handled by Spring's form login. @Component public class ApiBearerTokenAuthenticationConverter implements AuthenticationConverter { @@ -15,7 +19,7 @@ public class ApiBearerTokenAuthenticationConverter implements AuthenticationConv @Override public Authentication convert(HttpServletRequest request) { - String path = request.getRequestURI(); + final var path = request.getRequestURI(); // Don't authenticate the auth endpoints if (path.startsWith("/api/auth/")) { diff --git a/src/main/java/org/openpodcastapi/opa/auth/ApiSecurityHandlers.java b/src/main/java/org/openpodcastapi/opa/auth/ApiSecurityHandlers.java index 6c92d03..1dc4a54 100644 --- a/src/main/java/org/openpodcastapi/opa/auth/ApiSecurityHandlers.java +++ b/src/main/java/org/openpodcastapi/opa/auth/ApiSecurityHandlers.java @@ -6,9 +6,12 @@ import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.stereotype.Component; +/// Contains handlers for token-related errors on API endpoints @Component public class ApiSecurityHandlers { /// Returns an unauthorized response for unauthenticate API queries + /// + /// @return an unauthorized response with a JSON-formatted error message @Bean public AuthenticationEntryPoint apiAuthenticationEntryPoint() { return (_, response, authException) -> { @@ -21,6 +24,8 @@ public AuthenticationEntryPoint apiAuthenticationEntryPoint() { } /// Returns a forbidden response for API queries + /// + /// @return a forbidden response with a JSON-formatted error message @Bean public AccessDeniedHandler apiAccessDeniedHandler() { return (_, response, exception) -> { diff --git a/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationProvider.java b/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationProvider.java index 9598f4d..5a5b3b4 100644 --- a/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationProvider.java +++ b/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationProvider.java @@ -18,12 +18,18 @@ import java.nio.charset.StandardCharsets; import java.util.UUID; +/// Handles provisioning and authenticating JWTs for API requests @Component public class JwtAuthenticationProvider implements AuthenticationProvider { private final UserRepository repository; private final SecretKey key; + /// Constructor with secret value provided in `.env` file + /// or environment variables. + /// + /// @param repository the [UserRepository] interface for user entities + /// @param secret the secret value used to generate JWT values public JwtAuthenticationProvider( UserRepository repository, @Value("${jwt.secret}") String secret) { @@ -36,25 +42,31 @@ public JwtAuthenticationProvider( public Authentication authenticate(Authentication authentication) throws AuthenticationException { + // Get the JWT token from the authentication header final var token = (String) authentication.getCredentials(); try { + // Parse the JWT claims final var claims = Jwts.parser() .verifyWith(key) .build() .parseSignedClaims(token) .getPayload(); + // Get the user's UUID from the claims subject final var uuid = UUID.fromString(claims.getSubject()); - final var user = repository.getUserByUuid(uuid) + // Find the user entity + final var user = repository.findUserByUuid(uuid) .orElseThrow(() -> new BadCredentialsException("User not found")); + // Configure the user details for the authenticated user final var details = new CustomUserDetails( user.getId(), user.getUuid(), user.getUsername(), user.getPassword(), user.getUserRoles() ); + // Return the parsed token return new UsernamePasswordAuthenticationToken( details, token, details.getAuthorities()); } catch (Exception ex) { diff --git a/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java b/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java index 8db28d3..32a6ce8 100644 --- a/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java +++ b/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java @@ -22,6 +22,7 @@ import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +/// Security configuration for the Spring application @Configuration @EnableWebSecurity @RequiredArgsConstructor @@ -41,6 +42,14 @@ public class SecurityConfig { "/favicon.ico", }; + /// API-related security configuration + /// + /// @param http the [HttpSecurity] object to be configured + /// @param jwtAuthenticationProvider the [JwtAuthenticationProvider] used to handle JWT auth + /// @param entryPoint the entrypoint that commences the JWT auth + /// @param deniedHandler the [AccessDeniedHandler] that handles auth failures + /// @param converter the [ApiBearerTokenAuthenticationConverter] that manages JWT validation + /// @return the configured [HttpSecurity] object @Bean @Order(1) public SecurityFilterChain apiSecurity( @@ -79,6 +88,10 @@ public SecurityFilterChain apiSecurity( return http.build(); } + /// Web-related security configuration + /// + /// @param http the [HttpSecurity] object to be configured + /// @return the configured [HttpSecurity] object @Bean @Order(2) public SecurityFilterChain webSecurity(HttpSecurity http) { @@ -104,11 +117,19 @@ public SecurityFilterChain webSecurity(HttpSecurity http) { .build(); } + /// The default password encoder used for hashing and encoding user passwords and JWTs + /// + /// @return a configured [BCryptPasswordEncoder] @Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + /// An authentication provider for password-based authentication + /// + /// @param userDetailsService the [UserDetailsService] for loading user data + /// @param passwordEncoder the default password encoder + /// @return the configured [DaoAuthenticationProvider] @Bean public DaoAuthenticationProvider daoAuthenticationProvider(UserDetailsService userDetailsService, BCryptPasswordEncoder passwordEncoder) { @@ -117,11 +138,20 @@ public DaoAuthenticationProvider daoAuthenticationProvider(UserDetailsService us return provider; } + /// An authentication provider for JWT-based authentication + /// + /// @param provider a configured [JwtAuthenticationProvider] + /// @return a configured [ProviderManager] that uses the JWT auth provider + /// @see JwtAuthenticationProvider for provider details @Bean(name = "jwtAuthManager") public AuthenticationManager jwtAuthenticationManager(JwtAuthenticationProvider provider) { return new ProviderManager(provider); } + /// An authentication provider for API POST login + /// + /// @param daoProvider a configured [DaoAuthenticationProvider] + /// @return a configured [ProviderManager] that uses basic username/password auth @Bean(name = "apiLoginManager", defaultCandidate = false) public AuthenticationManager apiLoginAuthenticationManager( DaoAuthenticationProvider daoProvider) { diff --git a/src/main/java/org/openpodcastapi/opa/config/WebConfig.java b/src/main/java/org/openpodcastapi/opa/config/WebConfig.java index 69e75b8..1c41eef 100644 --- a/src/main/java/org/openpodcastapi/opa/config/WebConfig.java +++ b/src/main/java/org/openpodcastapi/opa/config/WebConfig.java @@ -6,23 +6,32 @@ import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +/// Configuration for the web interface @Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { + // Additional CSS storage registry .addResourceHandler("/css/**") .addResourceLocations("classpath:/static/css/"); + // Additional JavaScript storage registry .addResourceHandler("/js/**") .addResourceLocations("classpath:/static/js/"); + // The hosted documentation registry .addResourceHandler("/docs/**") .addResourceLocations("classpath:/static/docs/"); } + /// Informs Spring to use Thymeleaf Layout Dialect for composing Thymeleaf templates + /// + /// See [Thymeleaf Layout Dialect](https://ultraq.github.io/thymeleaf-layout-dialect/) for more information + /// + /// @return the configured [LayoutDialect] @Bean public LayoutDialect layoutDialect() { return new LayoutDialect(); diff --git a/src/main/java/org/openpodcastapi/opa/controllers/api/AuthController.java b/src/main/java/org/openpodcastapi/opa/controllers/api/AuthController.java index ace8260..daec4f1 100644 --- a/src/main/java/org/openpodcastapi/opa/controllers/api/AuthController.java +++ b/src/main/java/org/openpodcastapi/opa/controllers/api/AuthController.java @@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +/// Controllers for API-based authentication @RestController @Log4j2 public class AuthController { @@ -25,6 +26,12 @@ public class AuthController { private final UserRepository userRepository; private final AuthenticationManager authenticationManager; + /// Constructs the controller with the correct [AuthenticationManager] + /// + /// @param tokenService the [TokenService] used to manage auth tokens + /// @param userRepository the [UserRepository] used to manage user entity interaction + /// @param authenticationManager the [AuthenticationManager] used to handle auth + /// @see org.openpodcastapi.opa.config.SecurityConfig#apiLoginAuthenticationManager public AuthController( TokenService tokenService, UserRepository userRepository, @@ -35,7 +42,10 @@ public AuthController( this.authenticationManager = authenticationManager; } - // === Login endpoint === + /// The API login endpoint. Accepts a basic username/password combination to authenticate. + /// + /// @param loginRequest the [AuthDTO.LoginRequest] containing the user's credentials + /// @return a [ResponseEntity] containing a [AuthDTO.LoginSuccessResponse] @PostMapping("/api/auth/login") public ResponseEntity login(@RequestBody @NotNull AuthDTO.LoginRequest loginRequest) { // Set the authentication using the provided details @@ -47,7 +57,7 @@ public AuthController( SecurityContextHolder.getContext().setAuthentication(authentication); // Fetch the user record from the database - final var userEntity = userRepository.findByUsername(loginRequest.username()).orElseThrow(() -> new EntityNotFoundException("No userEntity with username " + loginRequest.username() + " found")); + final var userEntity = userRepository.findUserByUsername(loginRequest.username()).orElseThrow(() -> new EntityNotFoundException("No userEntity with username " + loginRequest.username() + " found")); // Generate the access and refresh tokens for the user final String accessToken = tokenService.generateAccessToken(userEntity); @@ -59,10 +69,13 @@ public AuthController( return ResponseEntity.ok(response); } - // === Refresh token endpoint === + /// The token refresh endpoint. Validates refresh tokens and returns new access tokens. + /// + /// @param refreshTokenRequest the [AuthDTO.RefreshTokenRequest] request body + /// @return a [ResponseEntity] containing a [AuthDTO.RefreshTokenResponse] @PostMapping("/api/auth/refresh") public ResponseEntity getRefreshToken(@RequestBody @NotNull AuthDTO.RefreshTokenRequest refreshTokenRequest) { - final var targetUserEntity = userRepository.findByUsername(refreshTokenRequest.username()).orElseThrow(() -> new EntityNotFoundException("No user with username " + refreshTokenRequest.username() + " found")); + final var targetUserEntity = userRepository.findUserByUsername(refreshTokenRequest.username()).orElseThrow(() -> new EntityNotFoundException("No user with username " + refreshTokenRequest.username() + " found")); // Validate the existing refresh token final UserEntity userEntity = tokenService.validateRefreshToken(refreshTokenRequest.refreshToken(), targetUserEntity); diff --git a/src/main/java/org/openpodcastapi/opa/controllers/web/DocsController.java b/src/main/java/org/openpodcastapi/opa/controllers/web/DocsController.java index c4360b0..ea35256 100644 --- a/src/main/java/org/openpodcastapi/opa/controllers/web/DocsController.java +++ b/src/main/java/org/openpodcastapi/opa/controllers/web/DocsController.java @@ -4,17 +4,23 @@ import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; +/// Controller for the hosted documentation endpoints @Controller @Log4j2 public class DocsController { - // === Docs index page === + /// The hosted documentation endpoint. Redirects users to the index page. + /// + /// @return a redirect to the documentation @GetMapping("/docs") public String docs() { return "forward:/docs/index.html"; } - // === Docs page with trailing slash === + /// The hosted documentation endpoint (with a trailing slash). + /// Redirects users to the index page. + /// + /// @return a redirect to the documentation @GetMapping("/docs/") public String docsWithSlash() { return "forward:/docs/index.html"; diff --git a/src/main/java/org/openpodcastapi/opa/controllers/web/HomeController.java b/src/main/java/org/openpodcastapi/opa/controllers/web/HomeController.java index ad4f83b..3580fee 100644 --- a/src/main/java/org/openpodcastapi/opa/controllers/web/HomeController.java +++ b/src/main/java/org/openpodcastapi/opa/controllers/web/HomeController.java @@ -6,18 +6,25 @@ import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; +/// Controller for the home and landing page controllers @Controller @RequiredArgsConstructor @Log4j2 public class HomeController { - // === Landing page === + /// Controller for the landing page. + /// + /// @return the landing page @GetMapping("/") public String getLandingPage() { return "landing"; } - // === Authenticated homepage === + /// Controller for an authenticated user's homepage. + /// Redirects users to the login page if they're not authenticated. + /// + /// @param auth the [Authentication] object for the user + /// @return the home page @GetMapping("/home") public String getHomePage(Authentication auth) { if (auth != null && !auth.isAuthenticated()) { diff --git a/src/main/java/org/openpodcastapi/opa/controllers/web/WebAuthController.java b/src/main/java/org/openpodcastapi/opa/controllers/web/WebAuthController.java index 4aeccef..1a2322e 100644 --- a/src/main/java/org/openpodcastapi/opa/controllers/web/WebAuthController.java +++ b/src/main/java/org/openpodcastapi/opa/controllers/web/WebAuthController.java @@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; +/// Controller for the web authentication endpoints @Controller @Log4j2 @RequiredArgsConstructor @@ -22,7 +23,12 @@ public class WebAuthController { private static final String REGISTER_TEMPLATE = "auth/register"; private final UserService userService; - // === Login page === + /// Controller for the login page. + /// Displays an error message if a previous login was unsuccessful. + /// + /// @param error an optional error message + /// @param model a placeholder for additional data to be passed to Thymeleaf + /// @return the login page @GetMapping("/login") public String loginPage(@RequestParam(value = "error", required = false) String error, Model model) { @@ -32,20 +38,31 @@ public String loginPage(@RequestParam(value = "error", required = false) String return "auth/login"; } - // === Logout confirmation page === + /// Controller for the logout confirmation page. + /// Logouts are handled by Spring Security, this page displays a confirmation only. + /// + /// @return the logout confirmation page @GetMapping("/logout-confirm") public String logoutPage() { return "auth/logout"; } - // === Registration page === + /// Controller for the account registration page. + /// + /// @param model a placeholder for additional data to be passed to Thymeleaf + /// @return the account registration page template @GetMapping("/register") public String getRegister(Model model) { model.addAttribute(USER_REQUEST_ATTRIBUTE, new UserDTO.CreateUserDTO("", "", "")); return REGISTER_TEMPLATE; } - // === Registration POST handler === + /// Controller for the account registration form. + /// + /// @param createUserRequest the [UserDTO.CreateUserDTO] containing the new account details + /// @param result the [BindingResult] for displaying data validation errors + /// @param model a placeholder for additional data to be passed to Thymeleaf + /// @return a redirect to the login page, if successful @PostMapping("/register") public String processRegistration( @Valid @ModelAttribute UserDTO.CreateUserDTO createUserRequest, @@ -60,7 +77,7 @@ public String processRegistration( try { userService.createAndPersistUser(createUserRequest); } catch (DataIntegrityViolationException _) { - result.rejectValue("username", "", "Username or email already exists"); + result.rejectValue("username", "", "Username or email already in use"); model.addAttribute(USER_REQUEST_ATTRIBUTE, createUserRequest); return REGISTER_TEMPLATE; } diff --git a/src/main/java/org/openpodcastapi/opa/exceptions/ValidationErrorResponse.java b/src/main/java/org/openpodcastapi/opa/exceptions/ValidationErrorResponse.java index a5046bf..96d192c 100644 --- a/src/main/java/org/openpodcastapi/opa/exceptions/ValidationErrorResponse.java +++ b/src/main/java/org/openpodcastapi/opa/exceptions/ValidationErrorResponse.java @@ -13,6 +13,10 @@ public record ValidationErrorResponse( int status, List errors ) { + /// Represents an error message for field validation errors + /// + /// @param field the field containing the error + /// @param message the error message to display public record FieldError(String field, String message) { } } diff --git a/src/main/java/org/openpodcastapi/opa/security/RefreshTokenEntity.java b/src/main/java/org/openpodcastapi/opa/security/RefreshTokenEntity.java index 3407d21..c0fc33e 100644 --- a/src/main/java/org/openpodcastapi/opa/security/RefreshTokenEntity.java +++ b/src/main/java/org/openpodcastapi/opa/security/RefreshTokenEntity.java @@ -6,6 +6,7 @@ import java.time.Instant; +/// Entity for refresh tokens @Entity @Getter @Setter @@ -14,23 +15,29 @@ @Builder @Table(name = "refresh_tokens") public class RefreshTokenEntity { + /// The token ID @Id @Generated @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /// The hashed representation of the token @Column(nullable = false, unique = true) private String tokenHash; + /// The user that owns the token @ManyToOne(optional = false, fetch = FetchType.LAZY) private UserEntity user; + /// The date at which the token expires @Column(nullable = false) private Instant expiresAt; + /// The date at which the token was created @Column(nullable = false) private Instant createdAt; + /// Performs actions on initial save @PrePersist public void prePersist() { this.setCreatedAt(Instant.now()); diff --git a/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java b/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java index 8ec12aa..11d1d96 100644 --- a/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java +++ b/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java @@ -8,9 +8,18 @@ import java.time.Instant; import java.util.List; +/// Repository for refresh token interactions @Repository public interface RefreshTokenRepository extends JpaRepository<@NonNull RefreshTokenEntity, @NonNull Long> { + /// Fetches a list of refresh tokens associated with a user + /// + /// @param userEntity the [UserEntity] to search for + /// @return a list of [RefreshTokenEntity] for the user List findAllByUser(UserEntity userEntity); + /// Deletes all tokens that expire before a given date + /// + /// @param timestamp the cut-off date + /// @return a count of deleted items int deleteAllByExpiresAtBefore(Instant timestamp); } diff --git a/src/main/java/org/openpodcastapi/opa/security/TokenService.java b/src/main/java/org/openpodcastapi/opa/security/TokenService.java index c2ef523..f70623b 100644 --- a/src/main/java/org/openpodcastapi/opa/security/TokenService.java +++ b/src/main/java/org/openpodcastapi/opa/security/TokenService.java @@ -14,6 +14,7 @@ import java.util.Date; import java.util.UUID; +/// Service for refresh token and JWT-related actions @Service @RequiredArgsConstructor public class TokenService { @@ -42,6 +43,8 @@ private SecretKey key() { } /// Returns the expiration time for JWTs + /// + /// @return a number representing the user-defined TTL of JWT tokens public long getExpirationTime() { return Long.parseLong(jwtExpiration); } diff --git a/src/main/java/org/openpodcastapi/opa/service/CustomUserDetails.java b/src/main/java/org/openpodcastapi/opa/service/CustomUserDetails.java index 6a61cbd..e0e75b7 100644 --- a/src/main/java/org/openpodcastapi/opa/service/CustomUserDetails.java +++ b/src/main/java/org/openpodcastapi/opa/service/CustomUserDetails.java @@ -12,6 +12,12 @@ import java.util.stream.Collectors; /// Implements a custom user details service to expose UUID information +/// +/// @param id the user ID +/// @param uuid the user UUID +/// @param username the user's username +/// @param password the user's hashed password +/// @param roles the user's assigned roles public record CustomUserDetails(Long id, UUID uuid, String username, String password, Set roles) implements UserDetails { diff --git a/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java b/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java index 33d6996..61947dd 100644 --- a/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java +++ b/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java @@ -21,7 +21,7 @@ public class CustomUserDetailsService implements UserDetailsService { /// @throws UsernameNotFoundException if user is not matched by username @Override public @NonNull UserDetails loadUserByUsername(@NonNull String username) { - return userRepository.getUserByUsername(username) + return userRepository.findUserByUsername(username) .map(this::mapToUserDetails) .orElseThrow(() -> new UsernameNotFoundException("UserEntity not found")); } diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java index 937228f..18162f7 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java @@ -11,6 +11,7 @@ import java.time.Instant; import java.util.List; +/// Container for all subscription-related data transfer objects public class SubscriptionDTO { /// A DTO representing a new subscription /// @@ -80,6 +81,10 @@ public record SubscriptionPageDTO( int numberOfElements, int size ) { + /// Returns a paginated response with details from a [Page] of user subscriptions + /// + /// @param page the [Page] of [UserSubscriptionDTO] items + /// @return a [SubscriptionPageDTO] with pagination details filled out public static SubscriptionPageDTO fromPage(Page<@NonNull UserSubscriptionDTO> page) { return new SubscriptionPageDTO( page.getContent(), diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionEntity.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionEntity.java index 6109490..e12b7ac 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionEntity.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionEntity.java @@ -7,6 +7,7 @@ import java.util.Set; import java.util.UUID; +/// An entity representing a subscription wrapper @Entity @NoArgsConstructor @AllArgsConstructor @@ -15,26 +16,34 @@ @Setter @Table(name = "subscriptions") public class SubscriptionEntity { + /// The subscription ID @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Generated private Long id; + /// The UUID of the subscription. + /// This should be calculated by the client based on the feed URL @Column(unique = true, nullable = false, updatable = false, columnDefinition = "uuid") private UUID uuid; + /// The URL of the subscription feed @Column(nullable = false) private String feedUrl; + /// A list of [UserSubscriptionEntity] associated with the subscription @OneToMany(mappedBy = "subscription") private Set subscribers; + /// The date at which the subscription was created @Column(updatable = false, nullable = false) private Instant createdAt; + /// The date at which the subscription was last updated @Column(nullable = false) private Instant updatedAt; + /// Performs actions when the entity is initially saved @PrePersist public void prePersist() { final Instant timestamp = Instant.now(); @@ -43,6 +52,7 @@ public void prePersist() { this.setUpdatedAt(timestamp); } + /// Performs actions when an entity is updated @PreUpdate public void preUpdate() { // Store the timestamp of the update diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionMapper.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionMapper.java index 369c795..87c19e5 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionMapper.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionMapper.java @@ -5,8 +5,13 @@ import java.util.UUID; +/// Mapper for subscription items @Mapper(componentModel = "spring") public interface SubscriptionMapper { + /// Maps a [SubscriptionDTO.SubscriptionCreateDTO] to a [SubscriptionEntity] + /// + /// @param dto the [SubscriptionDTO.SubscriptionCreateDTO] to map + /// @return a mapped [SubscriptionEntity] @Mapping(target = "id", ignore = true) @Mapping(target = "uuid", source = "uuid") @Mapping(target = "subscribers", ignore = true) @@ -14,6 +19,10 @@ public interface SubscriptionMapper { @Mapping(target = "updatedAt", ignore = true) SubscriptionEntity toEntity(SubscriptionDTO.SubscriptionCreateDTO dto); + /// Maps a string UUID to a UUID instance + /// + /// @param feedUUID the string UUID to map + /// @return the mapped [UUID] instance default UUID mapStringToUUID(String feedUUID) { return UUID.fromString(feedUUID); } diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRepository.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRepository.java index 5df66b8..79f7c99 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRepository.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRepository.java @@ -7,7 +7,12 @@ import java.util.Optional; import java.util.UUID; +/// Repository for subscription interactions @Repository public interface SubscriptionRepository extends JpaRepository<@NonNull SubscriptionEntity, @NonNull Long> { + /// Finds a single subscription by UUID. Returns `null` if no value exists. + /// + /// @param uuid the [UUID] to match + /// @return the matching [SubscriptionEntity] Optional findByUuid(UUID uuid); } diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java index 0862178..4f397e3 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java @@ -16,6 +16,7 @@ import java.util.List; import java.util.UUID; +/// Controller for API subscription requests @RestController @RequiredArgsConstructor @Log4j2 @@ -50,6 +51,7 @@ public class SubscriptionRestController { /// Returns a single subscription entry by UUID /// /// @param uuid the UUID value to query for + /// @param user the [CustomUserDetails] for the user /// @return a [ResponseEntity] containing a [SubscriptionDTO.UserSubscriptionDTO] object /// @throws EntityNotFoundException if no entry is found /// @throws IllegalArgumentException if the UUID is improperly formatted @@ -71,6 +73,7 @@ public class SubscriptionRestController { /// Updates the subscription status of a subscription for a given user /// /// @param uuid the UUID of the subscription to update + /// @param user the [CustomUserDetails] for the user /// @return a [ResponseEntity] containing a [SubscriptionDTO.UserSubscriptionDTO] object /// @throws EntityNotFoundException if no entry is found /// @throws IllegalArgumentException if the UUID is improperly formatted @@ -90,6 +93,7 @@ public class SubscriptionRestController { /// Bulk creates [UserSubscriptionEntity] objects for a user. Creates new [SubscriptionEntity] objects if not already present /// /// @param request a list of [SubscriptionDTO.SubscriptionCreateDTO] objects + /// @param user the [CustomUserDetails] for the user /// @return a [ResponseEntity] containing a [SubscriptionDTO.BulkSubscriptionResponseDTO] object @PostMapping @PreAuthorize("hasRole('USER')") diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java index 001fca9..ffedb36 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java @@ -15,6 +15,7 @@ import java.util.List; import java.util.UUID; +/// Service for subscription-related actions @Service @RequiredArgsConstructor @Log4j2 @@ -57,7 +58,8 @@ public SubscriptionDTO.UserSubscriptionDTO getUserSubscriptionBySubscriptionUuid /// Gets all subscriptions for the authenticated userEntity /// - /// @param userId the database ID of the authenticated userEntity + /// @param userId the database ID of the authenticated userEntity + /// @param pageable the [Pageable] object containing pagination options /// @return a paginated set of [SubscriptionDTO.UserSubscriptionDTO] objects @Transactional(readOnly = true) public Page getAllSubscriptionsForUser(Long userId, Pageable pageable) { @@ -69,7 +71,8 @@ public SubscriptionDTO.UserSubscriptionDTO getUserSubscriptionBySubscriptionUuid /// Gets all active subscriptions for the authenticated user /// - /// @param userId the database ID of the authenticated user + /// @param userId the database ID of the authenticated user + /// @param pageable the [Pageable] object containing pagination options /// @return a paginated set of [SubscriptionDTO.UserSubscriptionDTO] objects @Transactional(readOnly = true) public Page getAllActiveSubscriptionsForUser(Long userId, Pageable pageable) { diff --git a/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionEntity.java b/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionEntity.java index a99203a..8d08ae7 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionEntity.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionEntity.java @@ -7,6 +7,7 @@ import java.time.Instant; import java.util.UUID; +/// Entity representing the relationship between a user and a subscription @Entity @NoArgsConstructor @AllArgsConstructor @@ -15,31 +16,39 @@ @Builder @Table(name = "user_subscription") public class UserSubscriptionEntity { + /// The entity ID @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Generated private Long id; + /// The UUID of the entity @Column(unique = true, nullable = false, updatable = false, columnDefinition = "uuid") private UUID uuid; + /// The associated [UserEntity] @ManyToOne @JoinColumn(name = "user_id") private UserEntity user; + /// The associated [SubscriptionEntity] @ManyToOne @JoinColumn(name = "subscription_id") private SubscriptionEntity subscription; + /// The date at which the user subscription was created @Column(nullable = false, updatable = false) private Instant createdAt; + /// The date at which the user subscription was last updated @Column(nullable = false) private Instant updatedAt; + /// The timestamp representing when the user unsubscribed from the feed @Column private Instant unsubscribedAt; + /// Performs actions on initial save @PrePersist public void prePersist() { this.setUuid(UUID.randomUUID()); @@ -49,6 +58,7 @@ public void prePersist() { this.setUpdatedAt(timestamp); } + /// Performs actions when an entity is updated @PreUpdate public void preUpdate() { // Store the timestamp of the update diff --git a/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionMapper.java b/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionMapper.java index 281d1c3..c85d303 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionMapper.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionMapper.java @@ -3,8 +3,14 @@ import org.mapstruct.Mapper; import org.mapstruct.Mapping; +/// Mapper for user subscription items @Mapper(componentModel = "spring") public interface UserSubscriptionMapper { + /// Maps a [UserSubscriptionEntity] to a [SubscriptionDTO.UserSubscriptionDTO]. + /// Returns the [SubscriptionEntity#uuid] and [SubscriptionEntity#feedUrl] of the associated subscription. + /// + /// @param userSubscriptionEntity the [UserSubscriptionEntity] to map + /// @return the mapped [SubscriptionDTO.UserSubscriptionDTO] @Mapping(target = "uuid", source = "userSubscriptionEntity.subscription.uuid") @Mapping(target = "feedUrl", source = "userSubscriptionEntity.subscription.feedUrl") SubscriptionDTO.UserSubscriptionDTO toDto(UserSubscriptionEntity userSubscriptionEntity); diff --git a/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionRepository.java b/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionRepository.java index 33cb396..b71730d 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionRepository.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionRepository.java @@ -9,11 +9,29 @@ import java.util.Optional; import java.util.UUID; +/// Repository for user subscription interactions @Repository public interface UserSubscriptionRepository extends JpaRepository<@NonNull UserSubscriptionEntity, @NonNull Long> { + /// Finds an individual [UserSubscriptionEntity] by user ID and feed UUID. + /// Returns `null` if no matching value is found. + /// + /// @param userId the ID of the user + /// @param subscriptionUuid the UUID of the subscription + /// @return a [UserSubscriptionEntity], if one matches Optional findByUserIdAndSubscriptionUuid(Long userId, UUID subscriptionUuid); + /// Returns a paginated list of [UserSubscriptionEntity] objects associated with a user. + /// + /// @param userId the ID of the associated user + /// @param pageable the [Pageable] object containing pagination information + /// @return a [Page] of [UserSubscriptionEntity] associated with the user Page<@NonNull UserSubscriptionEntity> findAllByUserId(Long userId, Pageable pageable); + /// Returns a paginated list of [UserSubscriptionEntity] for a user where the [UserSubscriptionEntity#unsubscribedAt] + /// field is not empty. + /// + /// @param userId the ID of the associated user + /// @param pageable the [Pageable] object containing pagination information + /// @return a [Page] of [UserSubscriptionEntity] associated with the user Page<@NonNull UserSubscriptionEntity> findAllByUserIdAndUnsubscribedAtNotEmpty(Long userId, Pageable pageable); } diff --git a/src/main/java/org/openpodcastapi/opa/user/UserDTO.java b/src/main/java/org/openpodcastapi/opa/user/UserDTO.java index a23874c..5ad824f 100644 --- a/src/main/java/org/openpodcastapi/opa/user/UserDTO.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserDTO.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.UUID; +/// Container for all user-related data transfer objects public class UserDTO { /// A DTO representing a user response over the api /// @@ -47,6 +48,10 @@ public record UserPageDTO( int numberOfElements, int size ) { + /// Returns a paginated response with details from a [Page] of users + /// + /// @param page the [Page] of [UserResponseDTO] items + /// @return a [UserPageDTO] with pagination details filled out public static UserPageDTO fromPage(Page<@NonNull UserResponseDTO> page) { return new UserPageDTO( page.getContent(), diff --git a/src/main/java/org/openpodcastapi/opa/user/UserEntity.java b/src/main/java/org/openpodcastapi/opa/user/UserEntity.java index b4bf184..a47aa3f 100644 --- a/src/main/java/org/openpodcastapi/opa/user/UserEntity.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserEntity.java @@ -11,6 +11,7 @@ import java.util.Set; import java.util.UUID; +/// An entity representing a user @Entity @Table(name = "users") @Builder @@ -20,37 +21,47 @@ @AllArgsConstructor public class UserEntity implements Serializable { + /// The user ID @Id @Generated @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /// The user UUID @Column(unique = true, nullable = false, updatable = false, columnDefinition = "uuid") private UUID uuid; + /// The user's username @Column(nullable = false, unique = true) private String username; + /// The user's hashed password @Column(nullable = false) private String password; + /// The user's email address @Column(nullable = false, unique = true) private String email; + /// A list of [UserSubscriptionEntity] associated with the user @OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE) private transient Set subscriptions; + /// The user's associated roles @ElementCollection(fetch = FetchType.EAGER) @Builder.Default @Enumerated(EnumType.STRING) @CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id")) private Set userRoles = new HashSet<>(Collections.singletonList(UserRoles.USER)); + /// The date at which the entity was created @Column(updatable = false) private Instant createdAt; + /// The date at which the entity was last updated private Instant updatedAt; + /// Performs actions when the entity is initially saved @PrePersist public void prePersist() { this.setUuid(UUID.randomUUID()); @@ -60,6 +71,7 @@ public void prePersist() { this.setUpdatedAt(timestamp); } + /// Performs actions when the entity is updated @PreUpdate public void preUpdate() { // Store the timestamp of the update diff --git a/src/main/java/org/openpodcastapi/opa/user/UserMapper.java b/src/main/java/org/openpodcastapi/opa/user/UserMapper.java index 8a3b2db..63d0413 100644 --- a/src/main/java/org/openpodcastapi/opa/user/UserMapper.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserMapper.java @@ -3,10 +3,21 @@ import org.mapstruct.Mapper; import org.mapstruct.Mapping; +/// Mapper for user items @Mapper(componentModel = "spring") public interface UserMapper { + /// Maps a [UserEntity] to a [UserDTO.UserResponseDTO] + /// + /// @param userEntity the [UserEntity] to map + /// @return the mapped [UserDTO.UserResponseDTO] UserDTO.UserResponseDTO toDto(UserEntity userEntity); + /// Maps a [UserDTO.CreateUserDTO] to a [UserEntity]. + /// This mapper ignores all fields other than the username and email address. + /// Other items are populated prior to persistence. + /// + /// @param dto the [UserDTO.CreateUserDTO] to map + /// @return the mapped [UserEntity] @Mapping(target = "uuid", ignore = true) @Mapping(target = "id", ignore = true) @Mapping(target = "subscriptions", ignore = true) diff --git a/src/main/java/org/openpodcastapi/opa/user/UserRepository.java b/src/main/java/org/openpodcastapi/opa/user/UserRepository.java index 247983e..087d3e9 100644 --- a/src/main/java/org/openpodcastapi/opa/user/UserRepository.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserRepository.java @@ -7,13 +7,25 @@ import java.util.Optional; import java.util.UUID; +/// A repository for user interactions @Repository public interface UserRepository extends JpaRepository<@NonNull UserEntity, @NonNull Long> { - Optional getUserByUuid(UUID uuid); + /// Finds a single user by UUID. Returns `null` if no entity is found. + /// + /// @param uuid the UUID of the user + /// @return the found [UserEntity] + Optional findUserByUuid(UUID uuid); - Optional getUserByUsername(String username); + /// Finds a single user by username. Returns `null` if no entity is found. + /// + /// @param username the username of the user + /// @return the found [UserEntity] + Optional findUserByUsername(String username); + /// Performs a check to see if there is an existing entity with the same username or email address + /// + /// @param email the email address to check + /// @param username the username to check + /// @return a boolean value representing whether an existing user was found boolean existsUserByEmailOrUsername(String email, String username); - - Optional findByUsername(String username); } diff --git a/src/main/java/org/openpodcastapi/opa/user/UserRestController.java b/src/main/java/org/openpodcastapi/opa/user/UserRestController.java index 0a655ed..ac87019 100644 --- a/src/main/java/org/openpodcastapi/opa/user/UserRestController.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserRestController.java @@ -11,13 +11,14 @@ import java.util.UUID; +/// Controller for user-related API requests @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/users") public class UserRestController { private final UserService service; - /// Returns all users + /// Returns all users. Only accessible to admins. /// /// @param pageable the [Pageable] options used for pagination /// @return a [ResponseEntity] containing [UserDTO.UserPageDTO] objects diff --git a/src/main/java/org/openpodcastapi/opa/user/UserRoles.java b/src/main/java/org/openpodcastapi/opa/user/UserRoles.java index b4efef5..66beb21 100644 --- a/src/main/java/org/openpodcastapi/opa/user/UserRoles.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserRoles.java @@ -1,8 +1,9 @@ package org.openpodcastapi.opa.user; -/// The roles associated with users. All users have `USER` permissions. -/// Admins require the `ADMIN` role to perform administrative functions. +/// An enum of roles for user accounts public enum UserRoles { + /// Base permission for all registered users USER, + /// Identifies a user as an administrator and enables them to perform additional functions ADMIN, } diff --git a/src/main/java/org/openpodcastapi/opa/user/UserService.java b/src/main/java/org/openpodcastapi/opa/user/UserService.java index 6e792b8..1cf9086 100644 --- a/src/main/java/org/openpodcastapi/opa/user/UserService.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserService.java @@ -13,6 +13,7 @@ import java.util.UUID; +/// Service class for user-related actions @Service @RequiredArgsConstructor @Log4j2 @@ -45,6 +46,11 @@ public UserDTO.UserResponseDTO createAndPersistUser(UserDTO.CreateUserDTO dto) t return mapper.toDto(persistedUserEntity); } + /// Fetches a list of all users in the system. + /// Intended for use by admins only. + /// + /// @param pageable the [Pageable] object containing pagination options + /// @return a [Page] iterable of [UserDTO.UserResponseDTO] objects @Transactional(readOnly = true) public Page getAllUsers(Pageable pageable) { final var paginatedUserDTO = repository.findAll(pageable); @@ -61,7 +67,7 @@ public UserDTO.UserResponseDTO createAndPersistUser(UserDTO.CreateUserDTO dto) t /// @throws EntityNotFoundException if no matching record is found @Transactional public String deleteUserAndReturnMessage(UUID uuid) throws EntityNotFoundException { - final var userEntity = repository.getUserByUuid(uuid).orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND)); + final var userEntity = repository.findUserByUuid(uuid).orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND)); repository.delete(userEntity); diff --git a/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java b/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java index 5a3d5c8..1beb27e 100644 --- a/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java +++ b/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java @@ -33,7 +33,7 @@ public class AdminUserInitializer implements ApplicationRunner { /// @param args the application arguments @Override public void run(@NonNull ApplicationArguments args) { - if (userRepository.getUserByUsername(username).isEmpty()) { + if (userRepository.findUserByUsername(username).isEmpty()) { final var adminUserEntity = new UserEntity(); adminUserEntity.setUsername(username); adminUserEntity.setEmail(email); diff --git a/src/main/java/org/openpodcastapi/opa/util/RefreshTokenCleanup.java b/src/main/java/org/openpodcastapi/opa/util/RefreshTokenCleanup.java index d1dc368..94c9ebe 100644 --- a/src/main/java/org/openpodcastapi/opa/util/RefreshTokenCleanup.java +++ b/src/main/java/org/openpodcastapi/opa/util/RefreshTokenCleanup.java @@ -9,6 +9,7 @@ import java.time.Instant; +/// A scheduled task to clean up expired refresh tokens @Component @RequiredArgsConstructor @Log4j2 diff --git a/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java b/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java index 87ebf10..af0f7e3 100644 --- a/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java +++ b/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java @@ -74,7 +74,7 @@ void setup() { .build(); // Mock repository behavior for finding user by username - when(userRepository.findByUsername("test_user")).thenReturn(Optional.of(mockUserEntity)); + when(userRepository.findUserByUsername("test_user")).thenReturn(Optional.of(mockUserEntity)); // Mock the refresh token validation to return the mock user when(tokenService.validateRefreshToken(anyString(), any(UserEntity.class))) diff --git a/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java b/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java index 16a031e..c488801 100644 --- a/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java +++ b/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java @@ -81,7 +81,7 @@ void setup() { .updatedAt(Instant.now()) .build(); - when(userRepository.getUserByUuid(any(UUID.class))).thenReturn(Optional.of(mockUser)); + when(userRepository.findUserByUuid(any(UUID.class))).thenReturn(Optional.of(mockUser)); accessToken = tokenService.generateAccessToken(mockUser); } diff --git a/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java b/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java index e25e06a..c3d5b29 100644 --- a/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java +++ b/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java @@ -76,7 +76,7 @@ void getAllUsers_shouldReturn200_andList() throws Exception { .updatedAt(Instant.now()) .build(); - when(userRepository.getUserByUuid(any(UUID.class))).thenReturn(Optional.of(mockUser)); + when(userRepository.findUserByUuid(any(UUID.class))).thenReturn(Optional.of(mockUser)); String accessToken = tokenService.generateAccessToken(mockUser); @@ -150,7 +150,7 @@ void getAllUsers_shouldReturn403_forUserRole() throws Exception { .updatedAt(Instant.now()) .build(); - when(userRepository.getUserByUuid(any(UUID.class))).thenReturn(Optional.of(mockUser)); + when(userRepository.findUserByUuid(any(UUID.class))).thenReturn(Optional.of(mockUser)); String accessToken = tokenService.generateAccessToken(mockUser); From f4319e237af8613ccafaec44157f02e1bc8a055d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Ainsworth?= Date: Wed, 26 Nov 2025 15:09:39 +0100 Subject: [PATCH 3/3] Fix subscription documentation --- src/docs/subscriptions.adoc | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/docs/subscriptions.adoc b/src/docs/subscriptions.adoc index c240ca6..632d250 100644 --- a/src/docs/subscriptions.adoc +++ b/src/docs/subscriptions.adoc @@ -3,7 +3,7 @@ :sectlinks: The `subscriptions` endpoint exposes operations taken on subscriptions. -A subscriptionEntity represents two things: +A subscription represents two things: 1. A podcast feed 2. The relationship between a user and a podcast feed @@ -16,8 +16,8 @@ POST /api/v1/users [[actions-subscriptions-create]] == Create subscriptions -When a user adds a subscription to the system, a corresponding `subscriptionEntity` object is fetched or created depending on whether a matching subscriptionEntity is present. -A link is then created between the user and the subscriptionEntity. +When a user adds a subscription to the system, a corresponding `subscription` object is fetched or created depending on whether a matching subscription is present. +A link is then created between the user and the subscription. operation::subscriptions-bulk-create-mixed[snippets='request-headers,request-fields,curl-request,response-fields,http-response'] @@ -38,8 +38,7 @@ include::{snippets}/subscriptions-bulk-create-mixed/http-response.adoc[] [[actions-subscriptions-list]] == List subscriptions -When a user fetches a list of subscriptions, their own subscriptions are returned. -The subscriptions of other users are not returned. +When a user fetches a list of subscriptions, only their own subscriptions are returned. operation::subscriptions-list[snippets='request-headers,query-parameters,curl-request,response-fields,http-response'] @@ -47,19 +46,19 @@ operation::subscriptions-list[snippets='request-headers,query-parameters,curl-re operation::subscriptions-list-with-unsubscribed[snippets='curl-request,http-response'] -[[actions-subscriptionEntity-fetch]] -== Fetch a single subscriptionEntity +[[actions-subscription-fetch]] +== Fetch a single subscription -Returns the details of a single subscriptionEntity for the authenticated user. -Returns `404` if the user has no subscriptionEntity entry for the feed in question. +Returns the details of a single subscription for the authenticated user. +Returns `404` if the user has no subscription entry for the feed in question. operation::subscription-get[snippets='request-headers,path-parameters,curl-request,response-fields,http-response'] -[[actions-subscriptionEntity-update]] +[[actions-subscription-update]] == Unsubscribe from a feed Unsubscribes the authenticated user from a feed. -This action updates the user subscriptionEntity record to mark the subscriptionEntity as inactive. -It does not delete the subscriptionEntity record. +This action updates the user subscription record to mark the subscription as inactive. +It does not delete the subscription record. operation::subscription-unsubscribe[snippets='request-headers,path-parameters,curl-request,response-fields,http-response']