Java 3 - 2026

Week 10

Week 1 - Setup Pet Clinic App. Deployment using Docker.
Week 2 - Domains, AWS, ER Diagrams and Databases, Understand the Pet Clinic Setup.

Week 3 - MVC, Repository interfaces, Thymeleaf, Internationalization, Controller Unit Tests,

Week 4 - Use Case Narrative, Sequence Diagram, State Chart Diagram, Create new objects.
Lesson 5 - Users, roles, and permissions setup from Java 2. Unit Tests. Show object details.

Lesson 6 - User Registration, flash messages, Session Cookies

Lesson 7 - Login and logout. Midterm Review. Manage the user profile. More form controls.

Lesson 8 - Midterm exam. Prevent duplicate insert records.

Lesson 9 - Edit and delete user profile. Password reset. GitHub Actions.

Lesson 10 - Recipe API. User permissions. Custom not found page.
Lesson 11 - Update and delete existing objects. Filtering and limiting records by category.
Lesson 12 - Event Registration, project-specific features
Lesson 13 -
Lesson 14 -
Email and SMS messaging. Failed login attempts. Web Sockets. Date and currency formatting. Shopping Cart. Payment processing.

Course Plan

  • Sort and filter the list of schools

  • Add school logo, color

  • If the user enters an incorrect email or password N times, can their account be disabled?

  • Email confirmation
  • Update messages.properties files to include needed translations for the registration form headings, labels, and flash messages
  • Stored procedures
  • Create the database entities for the intramural leagues and teams now?

  • Get the location's lat/lon from LocationIQ

  • Get weather forecast at the location's lat/lon from the OpenWeatherAPI

  • Upcoming Leagues & Events

  • Allow SCHOOL_ADMINS to reuse leagues and events each year

Next

  • XSS Attacks, other OWASP

  • CSRF? Security
  • First name field

  • <img src="http://localhost:9999/city.png" onclick="location='http://packt.com'">

  • <button class="btn btn-danger" onclick="location='http://packt.com'">Click</button>

  • An internal server error occurred.

    could not execute statement [Data truncation: Data too long for column 'first_name' at row 1]

  • SQL injection

  • Juice box app

Next

  • If I make a change to the profile.html file and make a new POST request without refreshing the page, I get a "Whitelabel Error Page" saying:

    ```

    This application has no explicit mapping for /error, so you are seeing this as a fallback.

    Sun Mar 01 23:11:58 CST 2026

    There was an unexpected error (type=Internal Server Error, status=500).

    User not found

    java.lang.RuntimeException: User not found

    at org.springframework.samples.petclinic.user.ProfileController.lambda$processProfileUpdate$1(ProfileController.java:82)

    ```

    Is there a way to customize the Whitelabel Error Page

Bootstrap Styling

  • Update the  resources/db/mysql/schema.sql file to contain this:

CodeSignal Recipes

create table recipes
(
    id bigint unsigned auto_increment primary key,
    recipe_ingredients varchar(255) null,
    instructions       varchar(255) not null,
    type               varchar(50)  null,
    category           varchar(50)  null,
    dietary_preference varchar(50)  null,
    internal_notes     varchar(255) not null,
    constraint id unique (id),
    constraint internal_notes unique (internal_notes)
);

  • Update the  resources/db/mysql/data.sql file to contain this:

INSERT INTO recipes (recipe_ingredients, instructions, type, category, dietary_preference, internal_notes)
VALUES 
('Chickpeas, Tahini, Lemon', 'Blend until smooth.', 'Appetizer', 'Mediterranean', 'Vegan', 'Classic hummus'),
('Pasta, Tomato, Basil', 'Boil pasta, add sauce.', 'Main Course', 'Italian', 'Vegetarian', 'Simple Pomodoro'),
('Beef, Tortilla, Salsa', 'Cook beef, assemble taco.', 'Main Course', 'Mexican', 'Meat-based', 'Street style'),
('Lentils, Carrots, Curry Powder', 'Simmer until soft.', 'Soup', 'Indian', 'Vegan', 'Healthy Dal'),
('Zucchini, Garlic, Olive Oil', 'Sauté zucchini noodles.', 'Main Course', 'Italian', 'Vegan', 'Low carb option');
  • Ensure the recipes database table is created with five records.

  • Create a "codesignal" package. Create a Recipe class, not a record as mentioned on the website.

Recipe Class

package org.springframework.samples.petclinic.codesignal;

import jakarta.persistence.*;
import com.fasterxml.jackson.annotation.*;
import lombok.Getter;
import lombok.Setter;

@Entity
@Table(name = "recipes")
@Getter
@Setter
public class Recipe {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;
	
	@Column(name = "recipe_ingredients") // This tells JPA the database column name is "recipe_ingredients"
	@JsonProperty("recipe_ingredients") // This tells Jackson the JSON key name is "recipe_ingredients"
	private String ingredients;

	private String instructions;

	private String type;

	private String category;

	// Spring/Hibernate automatically maps camelCase to snake_case (dietary_preference)
	private String dietaryPreference;

	private String internalNotes;
}
  • Create a RecipeRepository interface. Use JpaRepository for built-in CRUD and Paging.

  • Implement a findById method.

Recipe Repository and Service

package org.springframework.samples.petclinic.codesignal;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface RecipeRepository extends JpaRepository<Recipe, Long> {
	List<Recipe> findByCategory(String category);

	// Spring generates: SELECT * FROM recipes WHERE type = ?
	List<Recipe> findByTypeIgnoreCase(String type);
}
  • Create a RecipeService interface for other data requests that aren't automatically handled.

package org.springframework.samples.petclinic.codesignal;

import java.util.List;

public interface RecipeService {
	List<Recipe> getRecipesByType(String type);

	// New method for combined filtering
	List<Recipe> findByCategoryAndDietaryPreferenceIgnoreCase(String category, String dietaryPreference);
}
  • Create a class that implements the RecipeService abstract methods.

RecipeServiceImpl

package org.springframework.samples.petclinic.codesignal;

import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

@Service
public class RecipeServiceImpl implements RecipeService {

	private final RecipeRepository recipeRepository;

	public RecipeServiceImpl(RecipeRepository recipeRepository) {
		this.recipeRepository = recipeRepository;
	}

	@Override
	public List<Recipe> getRecipesByType(String type) {
		// If no type is provided, use the built-in findAll()
		if (type == null || type.isEmpty()) {
			return recipeRepository.findAll();
		}
		
		return recipeRepository.findByTypeIgnoreCase(type);
	}

	@Override
	public List<Recipe> findByCategoryAndDietaryPreferenceIgnoreCase(String category, String preference) {
		// 1. Get all recipes in that category from the DB
		List<Recipe> categoryRecipes = recipeRepository.findByCategory(category);

		// 2. If a preference was provided, filter the list in Java
		if (preference != null && !preference.isEmpty()) {
			return categoryRecipes.stream()
				.filter(recipe -> recipe.getDietaryPreference().equalsIgnoreCase(preference))
				.collect(Collectors.toList());
		}

		// 3. Otherwise, return everything in that category
		return categoryRecipes;
	}
}
  • Create a RecipeController class that injects a RecipeService and RecipeRepository object.

RecipeController

package org.springframework.samples.petclinic.codesignal;

import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/recipes")
public class RecipeController {

	private final RecipeService recipeService;

	private final RecipeRepository recipeRepository;

	public RecipeController(RecipeService recipeService, RecipeRepository recipeRepository) {
		this.recipeRepository = recipeRepository;
		this.recipeService = recipeService;
	}



}
  • Create a RecipeController method that returns a list of recipes along with an HTTP response code.

  • This method includes an option type parameter.

RecipeController.getAllRecipes

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.List;
import java.util.Optional;

// -- Other imports--  

    // 200 OK for successful retrieval
	@GetMapping
	public ResponseEntity<List<Recipe>> getAllRecipes(@RequestParam Optional<String> type) {
		try {
			List<Recipe> recipes;
			if (type.isPresent()) {
				recipes = recipeService.getRecipesByType(type.get());
			}
			else {
				recipes = recipeRepository.findAll();
			}
			return ResponseEntity.ok(recipes);
		}
		catch (Exception e) {
			return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); // 500
		}
	}

RecipeController.getAllRecipes

  • Create a new Java annotation class named ResourceNotFoundException.java in your system package

ResourceNotFoundException

package org.springframework.samples.petclinic.codesignal;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

// This annotation tells Spring to automatically return a 404 if this is thrown
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {

	public ResourceNotFoundException(String message) {
		super(message);
	}

}
  • Create a new Java class named GlobalExceptionHandler.java in your system package

GlobalExceptionHandler

package org.springframework.samples.petclinic.codesignal;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.servlet.resource.NoResourceFoundException;

@ControllerAdvice
public class GlobalExceptionHandler {

	// Catch our specific 404 exception
	@ExceptionHandler(NoResourceFoundException.class)
	public ResponseEntity<String> handleNotFound(NoResourceFoundException ex) {
		return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage());
	}

	// Catch all other unexpected errors (500)
	@ExceptionHandler(Exception.class)
	public ResponseEntity<String> handleGeneralError(Exception ex) {
		return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
			.body("An unexpected error occurred: " + ex.getMessage());
	}

}
  • Create a RecipeController method that returns a list of recipes filtered by category and dietary preference.

getRecipesByCategoryAnd
DietaryPreference

	// 200 OK or 404 if the category returns nothing (depending on preference)
	@GetMapping("/category/{recipeCategory}")
	public ResponseEntity<List<Recipe>> getRecipesByCategoryAndDietaryPreference(
            @PathVariable String recipeCategory,
			@RequestParam Optional<String> dietaryPreference) {

		// We pass the extraction logic to the service
		List<Recipe> recipes = recipeService.findByCategoryAndDietaryPreferenceIgnoreCase(recipeCategory,
				dietaryPreference.orElse(null));

		if (recipes.isEmpty()) {
			return ResponseEntity.notFound().build();
		}

		return ResponseEntity.ok(recipes);
	}

getRecipesByCategoryAnd
DietaryPreference

  • Create a RecipeController method that returns a single recipe.

RecipeController.getRecipe

	// 200 OK or 404 Not Found
	@GetMapping("/{recipeId}")
	public ResponseEntity<Recipe> getRecipe(@PathVariable("recipeId") Long id) {
		return recipeRepository.findById(id)
			.map(recipe -> ResponseEntity.ok(recipe)) // 200
			.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).build()); // 404
	}
  • Create a RecipeController method add a new recipe recipe.

RecipeController.addRecipe

	// 201 Created (You already had this one!)
	@PostMapping("/new")
	public ResponseEntity<Recipe> addRecipe(@RequestBody Recipe recipe) {
		if (recipe == null) {
			return ResponseEntity.badRequest().build(); // 400
		}
		Recipe savedRecipe = recipeRepository.save(recipe);
		return ResponseEntity.status(HttpStatus.CREATED).body(savedRecipe);
	}
  • Add "/recipes/new" to SecurityConfig

  • Use Insomnia or another API testing tool to make a POST request to http://localhost:8080/recipes/new with this JSON body.

{
  "recipe_ingredients":"Bread, Peanut Butter, Jelly",
  "instructions":"Spread peanut butter on one slice of bread. Spread jelly on the other slice of bread. Place the slices together and eat.",
  "type":"Snack",
  "category":"American",
  "dietaryPreference":"Vegetarian",
  "internalNotes":"Peanut Butter and Jelly Sandwich"
}
  • Create a RecipeController method update an existing recipe.

RecipeController.updateRecipe

	// 200 OK or 404 Not Found
	@PutMapping("/{id}")
	public ResponseEntity<Recipe> updateRecipe(@PathVariable Long id, @RequestBody Recipe updatedRecipe) {
		return recipeRepository.findById(id).map(existingRecipe -> {
			existingRecipe.setIngredients(updatedRecipe.getIngredients());
			existingRecipe.setInstructions(updatedRecipe.getInstructions());
			existingRecipe.setType(updatedRecipe.getType());
			existingRecipe.setCategory(updatedRecipe.getCategory());
			existingRecipe.setDietaryPreference(updatedRecipe.getDietaryPreference());
			existingRecipe.setInternalNotes(updatedRecipe.getInternalNotes());
			recipeRepository.save(existingRecipe);
			return ResponseEntity.ok(existingRecipe); // 200
		}).orElseGet(() -> ResponseEntity.notFound().build()); // 404
	}
  • Use Insomnia or another API testing tool to make a PUT request to http://localhost:8080/recipes/6 with this JSON body.

{
  "recipe_ingredients":"Bread, Peanut Butter, Jelly",
  "instructions":"Spread peanut butter on one slice of bread. Spread jelly on the other slice of bread. Place the slices together and eat.",
  "type":"Snack",
  "category":"American",
  "dietaryPreference":"Vegetarian",
  "internalNotes":"Peanut Butter and Jelly Sandwich"
}
  • Create a RecipeController method to delete an existing recipe.

RecipeController.deleteRecipe

	// 204 No Content
	@DeleteMapping("/{id}")
	public ResponseEntity<Void> deleteRecipe(@PathVariable Long id) {
		if (!recipeRepository.existsById(id)) {
			return ResponseEntity.notFound().build(); // 404
		}
		recipeRepository.deleteById(id);
		return ResponseEntity.noContent().build(); // 204
	}
  • Use Insomnia or another API testing tool to make a DELETE request to http://localhost:8080/recipes/6 with this JSON body.

{
  "recipe_ingredients":"Bread, Peanut Butter, Jelly",
  "instructions":"Spread peanut butter on one slice of bread. Spread jelly on the other slice of bread. Place the slices together and eat.",
  "type":"Snack",
  "category":"American",
  "dietaryPreference":"Vegetarian",
  "internalNotes":"Peanut Butter and Jelly Sandwich"
}

Missing CSS

.requestMatchers(
	"/",
	"/schools/**",
	"/artists/**",
	"/register-student",
	"/register",
	"/resources/**",
	"/recipes"
).permitAll()
  • We have implemented a Role-Based Access Control (RBAC) system that ties permissions to roles and roles to users.

  • If I eventually need to create a "STUDENT_MANAGER", "COACH", or "REFEREE", I can configure their permissions in the database.

  • Register at least two STUDENT users. Manually change the default role of one user to a SCHOOL_Admin user.

  • Create a new Java class named Permission.java in your user package, mapped to your permissions database table.

Permissions

package org.springframework.samples.petclinic.user;

import jakarta.persistence.*;
import lombok.*;
import org.springframework.samples.petclinic.model.BaseEntity;

import java.util.Set;

@Entity
@Table(name = "permissions")
@Data
@NoArgsConstructor
public class Permission extends BaseEntity {

	@Column(nullable = false, unique = true, length = 100)
	private String name; // e.g., "MANAGE_FACILITIES", "VIEW_LEAGUES"

	@Column(length = 255)
	private String description;

	// The reverse mapping back to the Role entity
	@ManyToMany(mappedBy = "permissions")
	@EqualsAndHashCode.Exclude
	private Set<Role> roles;
}
  • Now, open your existing Role.java file. We need to add the @ManyToMany relationship so Hibernate knows to look at your permission_role junction table.

    • I also updated this code to extend BaseEntity.

  • Replace your // We will add the relationship to Permissions later. comment with this block:

    • Setting FetchType.EAGER here means that whenever Spring grabs a Role, it immediately grabs all associated Permissions.

Roles

package org.springframework.samples.petclinic.user;

import jakarta.persistence.*;
import lombok.*;
import org.springframework.samples.petclinic.model.BaseEntity;

import java.util.Set;

@Entity
@Table(name = "roles")
@Data
@NoArgsConstructor
public class Role extends BaseEntity {

	@Column(nullable = false, unique = true, length = 50)
	private String name; // e.g., "ADMIN", "STUDENT"

	@Column(length = 255)
	private String description;

	// Mapped by the 'roles' field in the User entity.
	@ManyToMany(mappedBy = "roles")
	@EqualsAndHashCode.Exclude
	private Set<User> users;

	@ManyToMany(fetch = FetchType.EAGER)
	@JoinTable(
		name = "permission_role", // Your exact DB junction table name
		joinColumns = @JoinColumn(name = "role_id"),
		inverseJoinColumns = @JoinColumn(name = "permission_id")
	)
	@EqualsAndHashCode.Exclude
	private Set<Permission> permissions;

}
  • Update your UserDetailsServiceImpl.java.

  • Currently, our builder uses .roles(...). The tricky thing about the Spring Security User builder is that .roles() only accepts roles, and it strictly auto-prepends ROLE_ to everything you give it. Because we now want a mix of Roles (ROLE_SCHOOL_ADMIN) and Permissions (MANAGE_FACILITIES), we have to switch to using the .authorities() method instead.

UserDetailsService

package org.springframework.samples.petclinic.user;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    public UserDetailsServiceImpl(UserRepository userRepository) {
       this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
       
       User user = userRepository.findByEmail(email)
          .orElseThrow(() -> new UsernameNotFoundException("Invalid email or password."));

       if (user.getDeletedAt() != null) {
          throw new UsernameNotFoundException("Invalid email or password.");
       }

       // NEW AUTHORITIES LOGIC
       List<GrantedAuthority> authorities = new ArrayList<>();

       for (Role role : user.getRoles()) {
           // 1. Add the role (Spring Security requires the "ROLE_" prefix for roles)
           authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getName()));
           
           // 2. Add all permissions attached to this role
           for (Permission permission : role.getPermissions()) {
               authorities.add(new SimpleGrantedAuthority(permission.getName()));
           }
       }

       // UPDATED BUILDER
       return org.springframework.security.core.userdetails.User.builder()
          .username(user.getEmail())
          .password(user.getPassword())
          .authorities(authorities) // Replaced .roles() with .authorities()
          .build();
    }
}
  • Now that Spring Security knows exactly what permissions the logged-in user holds, we can update your SecurityConfig.securityFilterChain method to lock down the URLs.

    • Specify routes that are public to everyone.

    • include .hasAuthority() to lock down protected routes.

Secure the Backend Route

.authorizeHttpRequests(authorize -> authorize
	// 1. Public Pages
	.requestMatchers("/", "/register-student", "/login", "/css/**", "/images/**").permitAll()
    
	// 2. Lock restricted routes
	.requestMatchers("/schools/new").hasAuthority("MANAGE_FACILITIES")
    
	// 3. Allow public routes
	.requestMatchers(HttpMethod.GET, "/schools", "/schools/{slug:[a-zA-Z-]+}").permitAll()
	
	// 4. The rest of your rules ...

	// 5. OPEN CATCH-ALL: Allows unknown URLs to pass through and trigger a 404
	.anyRequest().permitAll()
)
  • Update the schools/schoolList.html Thymeleaf template to read that authority using the sec:authorize attribute on the "Add New School" button:

Hide the Add School Button

<html 
  xmlns:th="https://www.thymeleaf.org" 
  th:replace="~{fragments/layout :: layout (~{::body},'schools')}"
  xmlns:sec="https://www.thymeleaf.org/extras/spring-security">

<body>

<h2 th:text="#{schools}">Schools</h2>

<a th:href="@{/schools/new}"
   sec:authorize="hasAuthority('MANAGE_FACILITIES')"
   class="btn btn-primary mb-4">
  Add New School
</a>
  • Guest users and STUDENT users should not see that button.

  • SCHOOL_ADMIN users will see that button.

  • If you log in as a STUDENT, Spring Security sees your authorities are [ROLE_STUDENT, MANAGE_OWN_PROFILE, USE_MESSAGING, VIEW_LEAGUES, ...]. Because MANAGE_FACILITIES is missing, the button disappears,

  • If you try to type /schools/new in the address bar, Spring Security will block you with a 403 Forbidden error and redirect the user to the login page.

  • If you try to type /pizza (a page that doesn't exist)in the address bar, Spring Security will display a 404 File Not Found error.

  • Just remember that moving forward, whenever you build a new private page (like an admin dashboard or a user's team settings), you must remember to add an explicit .requestMatchers(...).authenticated() rule for it in this config!

403 Forbidden, 404 Not Found

  • Running the UserDetailsServiceImplTest.loadUserByUsername 
    method fails saying "the return value of "Role.getPermissions()" is null".

  • When we updated UserDetailsServiceImpl, we told it to look inside every Role and loop through its Permissions.

  • However, in your test file, we are currently creating a mock studentRole but aren't giving it a list of permissions.

  • To fix this, we just need to initialize an empty (or populated) Set of permissions for that role in your @BeforeEach setup method.

UserDetailsServiceImplTest

package org.springframework.samples.petclinic.user;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Optional;
import java.util.Set;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class UserDetailsServiceImplTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UserDetailsServiceImpl userDetailsService;

    private User testUser;

    @BeforeEach
    void setUp() {
       testUser = new User();
       testUser.setEmail("example-student@kirkwood.edu");
       testUser.setPassword("hashedPassword");
       
       Role studentRole = new Role();
       studentRole.setName("STUDENT");
       
       // Initialize a permission and add it to the role
       Permission viewLeaguesPermission = new Permission();
       viewLeaguesPermission.setName("VIEW_LEAGUES");
       studentRole.setPermissions(Set.of(viewLeaguesPermission)); 
       
       testUser.setRoles(Set.of(studentRole));
    }

    @Test
    void loadUserByUsername() {
       // Arrange
       when(userRepository.findByEmail(testUser.getEmail())).thenReturn(Optional.of(testUser));

       // Act
       UserDetails userDetails = userDetailsService.loadUserByUsername(testUser.getEmail());

       // Assert
       assertNotNull(userDetails);
       assertEquals(testUser.getEmail(), userDetails.getUsername());
       assertEquals(testUser.getPassword(), userDetails.getPassword());
       
       // Check that the ROLE was loaded correctly (Requires "ROLE_" prefix)
       assertTrue(userDetails.getAuthorities().stream()
               .anyMatch(a -> a.getAuthority().equals("ROLE_STUDENT")));
               
       // Check that the PERMISSION was loaded correctly (No prefix)
       assertTrue(userDetails.getAuthorities().stream()
               .anyMatch(a -> a.getAuthority().equals("VIEW_LEAGUES")));

       verify(userRepository, times(1)).findByEmail(testUser.getEmail());
    }

}
  • An unexpected error occurred: Cannot invoke "java.security.Principal.getName()" because "principal" is null

User Roles

  • The SchoolControllerTest.testProcessCreationFormSuccess 
    method fails with an error saying "Error creating bean with name 'schoolController'"

  • When we updated your SchoolController to fetch the logged-in student, we added UserRepository to its constructor.

  • The @WebMvcTest annotation is what Spring calls a "sliced test." It purposely does not load your whole application (to keep tests fast). It only loads the SchoolController and whatever you explicitly mock. Since it sees your controller requires a UserRepository but you haven't provided a mock for it, it throws its hands up and crashes.

  • Also, now that we locked down /schools/new so only users with the MANAGE_FACILITIES authority can access it, your tests are going to start failing with 401 Unauthorized or 403 Forbidden errors because the test runner is acting as an anonymous guest!

SchoolControllerTest

  • Add the UserRepository to your class variables and annotate it with @MockitoBean.

  • Because @WebMvcTest is a "sliced" test designed to be fast, it only loads your SchoolController and basic Spring MVC components. It intentionally ignores all of your @Configuration classes. Because your SecurityConfig was left out of the test environment, Thymeleaf has no idea how to process Spring Security tags like sec:authorize, so it throws that TemplateProcessingException.

  • The @Import annotation forces the test to load your custom security rules and the Thymeleaf security dialect.

SchoolControllerTest

import org.springframework.samples.petclinic.user.UserRepository;
import org.springframework.samples.petclinic.user.SecurityConfig;
import org.springframework.context.annotation.Import;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
// ... your other imports ...

@WebMvcTest(SchoolController.class)
@Import(SecurityConfig.class)
class SchoolControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private SchoolRepository schools;

    @MockitoBean
    private UserRepository userRepository;
    
    @MockitoBean
    private UserDetailsService userDetailsService;
    
    @MockitoBean
    private AuthenticationConfiguration authenticationConfiguration;

    private School school;
    
    // ...
  • When you run your application normally, Spring Boot's background auto-configuration provides the HttpSecurity builder for you. However, because @WebMvcTest strips away that background magic to run fast, your SecurityConfig is left standing there asking for an HttpSecurity object that no longer exists.

  • Open your SecurityConfig.java file and add the @EnableWebSecurity annotation right below your @Configuration annotation.

@EnableWebSecurity

import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 
// ... your other imports ...

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // ... your existing beans ...
}
  • We need to add a Security MockMvc Request Post-Processor. This allows you to fake a logged-in user with specific authorities just for the duration of a test. It forces the authentication token directly into the exact HTTP request being performed.

Fake Logged In User

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
// ... your other imports ...

    @Test
    @DisplayName("User clicks \"Add School\" -> GET /schools/new")
    void testInitCreationForm() throws Exception {
       mockMvc.perform(get("/schools/new")
               // 1. INJECT THE VIP BADGE DIRECTLY INTO THE REQUEST
               .with(user("admin@kirkwood.edu")
                     .authorities(new SimpleGrantedAuthority("MANAGE_FACILITIES"))))
          .andExpect(status().isOk())
          .andExpect(view().name("schools/createOrUpdateSchoolForm"))
          .andExpect(model().attributeExists("school"));
    }

    @Test
    @DisplayName("Validation Passed -> verify that the controller tells the repository to save() the school and then redirects us.")
    void testProcessCreationFormSuccess() throws Exception {
       mockMvc.perform(post("/schools/new")
               .param("name", "University of Iowa")
               .param("domain", "uiowa.edu")
               // 2. INJECT IT INTO THE POST REQUEST
               .with(user("admin@kirkwood.edu")
                     .authorities(new SimpleGrantedAuthority("MANAGE_FACILITIES"))))
          .andExpect(status().is3xxRedirection())
          .andExpect(redirectedUrl("/schools"));

       verify(schools).save(any(School.class));
    }

    @Test
    @DisplayName("Validation Failed -> send an empty domain and ensure the controller returns us to the form instead of saving.")
    void testProcessCreationFormHasErrors() throws Exception {
       mockMvc.perform(post("/schools/new")
               .param("name", "Bad School")
               .param("domain", "") 
               // 3. INJECT IT INTO THE FAILED POST REQUEST
               .with(user("admin@kirkwood.edu")
                     .authorities(new SimpleGrantedAuthority("MANAGE_FACILITIES"))))
          .andExpect(status().isOk()) 
          .andExpect(model().attributeHasErrors("school"))
          .andExpect(model().attributeHasFieldErrors("school", "domain"))
          .andExpect(view().name("schools/createOrUpdateSchoolForm"));
    }

CrashControllerIntegrationTests

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
// ... your other imports ...


class CrashControllerIntegrationTests {

	// code omitted 
	@Test
	void testTriggerExceptionHtml() {
		// code omitted 
	}

	@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class,
		DataSourceTransactionManagerAutoConfiguration.class, HibernateJpaAutoConfiguration.class })
	static class TestConfiguration {

		@Bean
		public SecurityFilterChain testFilterChain(HttpSecurity http) throws Exception {
			http.csrf(csrf -> csrf.disable())
				.authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll());
			return http.build();
		}

	}

}
  • When a user requests a URL that does not exist, the framework throws a NoResourceFoundException. Because this exception is not explicitly handled by a view resolver, the server outputs that plain text error message.

  • Inside your src/main/resources/templates/ folder, create a new directory named error.  

  • Create a file named 404.html inside that folder.

  • You can use your existing Thymeleaf layout fragment so the error page matches the rest of your site's design. 

Custom 404

<html xmlns:th="https://www.thymeleaf.org"
      th:replace="~{fragments/layout :: layout (~{::body},'error')}">
<body>
    <div class="container text-center mt-5">
        <h1 class="display-1 text-danger">404</h1>
        <h2 class="mb-4">Page Not Found</h2>
        <p class="lead">We couldn't find the page you were looking for.</p>
        <p>It might have been removed, had its name changed, or is temporarily unavailable.</p>
        
        <a th:href="@{/}" class="btn btn-primary mt-4">
            <i class="fa fa-home me-1"></i> Return to Homepage
        </a>
    </div>
</body>
</html>
  • Create/edit a global exception handler class that listens for 404 errors across the entire application and returns a custom view.

  • We created a Java class named NotFoundExceptionHandler.java 
    in your system package during the Recipe lesson.

  • Edit the handleNotFound method.

Custom 404

@ControllerAdvice
public class GlobalExceptionHandler {

	// Catch our specific 404 exception
//	@ExceptionHandler(NoResourceFoundException.class)
//	public ResponseEntity<String> handleNotFound(ResourceNotFoundException ex) {
//		return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage());
//	}

	@ExceptionHandler(NoResourceFoundException.class)
	@ResponseStatus(HttpStatus.NOT_FOUND)
	public String handleNotFound(NoResourceFoundException ex) {
		return "error/404";
	}

	// ... Catch all other unexpected errors (500)

}
  • @ControllerAdvice: This annotation makes the class act as a global interceptor for all controllers in your application.

  • @ExceptionHandler: This specifies that the method should only run when a NoResourceFoundException is thrown.

  • @ResponseStatus: This ensures the server sends a standard HTTP 404 status code to the browser, which is important for SEO and accurate network logging.

Custom 404

  • If I visit "/schools", I get my list of schools. If I visit "/schools/" with an extra / at the end, I get a 404 because Spring does not treat URLs with and without a trailing slash as the same route.

  • To fix this, you need to create a web filter that will intercept every incoming request, and if a URL ends with a slash, it will issue an HTTP 301 Permanent Redirect to the same URL without the /

  • Create a new Java class named TrailingSlashRedirectFilter.java in your system package.

Handling a trailing slash

package org.springframework.samples.petclinic.system;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TrailingSlashRedirectFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        String requestUri = request.getRequestURI();

        // If the URL ends with a slash (and isn't just the root "/" page)
        if (requestUri.endsWith("/") && requestUri.length() > 1) {
            String newUrl = requestUri.substring(0, requestUri.length() - 1);

            // Keep any query parameters if they exist (e.g., ?page=2)
            String queryString = request.getQueryString();
            if (queryString != null) {
                newUrl += "?" + queryString;
            }

            // Send a 301 redirect to the URL without the slash
            response.setStatus(HttpStatus.MOVED_PERMANENTLY.value());
            response.setHeader(HttpHeaders.LOCATION, newUrl);
            return;
        }

        filterChain.doFilter(request, response);
    }
}
  • @Component: Registers the filter with Spring Boot so it runs automatically.

  • @Order(Ordered.HIGHEST_PRECEDENCE): This guarantees this filter runs before Spring Security. This prevents Spring Security from throwing a 403 Forbidden or kicking the user to the login screen before the trailing slash can be removed.

  • Query String Logic: It preserves parameters, so a request to /schools/?page=2 correctly redirects to /schools?page=2.

Handling a trailing slash

  • If I am logged in with a "kirkwood.edu" email address, I want to see my player card on "/schools/kirkwood", but not "/schools/uiowa".

  • If the user's email address doesn't match the current school, we can update the schoolDetails.html file have an alternate block that says something like "You must have an active uiowa.edu email address to register for University of Iowa intramurals."

  • We can use Thymeleaf's built-in #strings.endsWith() utility method to compare the end of the user's email address against the specific school's domain dynamically.

  • Update the right column in your schools/schoolDetails.html file to look like this:

Not My School

<div class="col-lg-3">

  <div th:if="${currentUser == null}" class="card shadow-sm">
    <div class="card-body text-center">
      <h5 class="card-title">Join the Action</h5>
      <p class="card-text text-sm">Log in or register to join teams and view your player stats.</p>
      <a th:href="@{/login}" class="btn btn-primary w-100 mb-2">Log In</a>
      <a th:href="@{/register-student}" class="btn btn-outline-secondary w-100">Register</a>
    </div>
  </div>

  <div th:if="${currentUser != null and !#strings.endsWith(currentUser.email, '@' + school.domain) and !#strings.endsWith(currentUser.email, '.' + school.domain)}" class="card shadow-sm border-warning">
    <div class="card-header bg-warning text-dark">
      <h5 class="mb-0">Access Restricted</h5>
    </div>
    <div class="card-body text-center">
      <i class="fa fa-exclamation-triangle text-warning mb-2" style="font-size: 2rem;"></i>
      <p class="card-text text-sm" 
         th:text="'You must have an active ' + ${school.domain} + ' email address to register for ' + ${school.name} + ' intramurals.'">
      </p>
    </div>
  </div>

  <div th:if="${currentUser != null and (#strings.endsWith(currentUser.email, '@' + school.domain) or #strings.endsWith(currentUser.email, '.' + school.domain))}" class="card shadow-sm border-primary">
    <div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
      <h5 class="mb-0">My Player Card</h5>
      <a th:href="@{/users/profile}" class="btn btn-sm btn-light">Edit</a>
    </div>

    <div class="card-body">
      <th:block th:if="${!#strings.isEmpty(currentUser.firstName) or !#strings.isEmpty(currentUser.nickname)}">
        <h5 class="card-title text-primary">
          <span th:if="${!#strings.isEmpty(currentUser.nickname)}" 
                th:text="${currentUser.nickname}">Nickname</span>

          <span th:if="${#strings.isEmpty(currentUser.nickname)}" 
                th:text="${currentUser.firstName + (!#strings.isEmpty(currentUser.lastName) ? ' ' + currentUser.lastName : '')}">First Last</span>
        </h5>
        <hr>
      </th:block>

      <div class="mb-3 text-sm">
        <strong>Email:</strong> <br>
        <span th:text="${currentUser.email}">student@school.edu</span>
        <span th:if="${currentUser.publicEmail}" class="badge bg-success float-end">Public</span>
      </div>

      <div class="mb-3 text-sm" th:if="${currentUser.phone}">
        <strong>Phone:</strong> <br>
        <span th:text="${currentUser.phone}">(319) 555-0199</span>
        <span th:if="${currentUser.publicPhone}" class="badge bg-success float-end">Public</span>
      </div>

      <div class="text-sm">
        <strong>Language:</strong>
        <span th:switch="${currentUser.preferredLanguage}">
            <span th:case="'EN'">English</span>
            <span th:case="'ES'">Spanish</span>
            <span th:case="'KO'">Korean</span>
            <span th:case="*">Not specified</span>
        </span>
      </div>
    </div>
  </div>

</div>
  • The string concatenation '@' + school.domain ensures that a user with the email admin@uiowa.edu is not accidentally granted access to a school domain like iowa.edu.

  • the logic needs to check for two valid conditions:

    • The email ends with @kirkwood.edu (usually faculty or staff).

    • The email ends with .kirkwood.edu (usually students on a subdomain).

    • Adding the . check accurately catches the subdomain without accidentally granting access to spoofed domains (like hacker@badkirkwood.edu).

Not My School

  • Insert the new role and a specific permission into your database so Spring Security can recognize them.

Database Setup for Super Admin

INSERT INTO roles (name, description) VALUES ('SUPER_ADMIN', 'System Administrator: Full access.');
INSERT INTO permissions (name, description) VALUES ('MANAGE_ALL_SCHOOLS', 'Allows user to C/R/U/D any school and change statuses.');
.requestMatchers("/schools/new").hasAuthority("MANAGE_ALL_SCHOOLS")
  • Update your SecurityConfig.java to restrict the creation route to this new permission instead of MANAGE_FACILITIES.
INSERT IGNORE INTO permission_role (role_id, permission_id)
SELECT (SELECT id FROM roles WHERE name = 'SUPER_ADMIN'), id
FROM permissions;
  • You can run a SQL command like this to quickly assign all existing permissions to the Super Admin role.
    • The IGNORE keyword ensures your database doesn't throw an error if the Super Admin already has some of those permissions assigned

Database Setup for Super Admin

<a th:href="@{/schools/new}" 
   sec:authorize="hasAuthority('MANAGE_ALL_SCHOOLS')" 
   class="btn btn-primary">
   Add New School
</a>
  • Update the "Add New School" button is schoolList.html to only be visible and accessible by SUPER_ADMIN users.

  • Register a new user and manually give them a SUPER_ADMIN role.

  • Since all mappings in the SchoolController starts with "/schools", update it to use a @RequestMapping annotation.

SchoolController @RequestMapping

@Controller
@RequestMapping("/schools")
public class SchoolController {

	// Code omitted

	@GetMapping
	public String showSchoolList(@RequestParam(defaultValue = "1") int page, Model model) {
		// Code omitted
	}

	@GetMapping("/new")
	public String initCreationForm(Map<String, Object> model) {
		// Code omitted
	}

	@PostMapping("/new")
	public String processCreationForm(@Valid School school, BindingResult result) {
		// Code omitted
	}

	// Matches ONLY numbers (e.g., /schools/1)
	@GetMapping("/{schoolId:\\d+}")
	public ModelAndView showSchoolById(@PathVariable("schoolId") int schoolId) {
		// Code omitted
	}

	// Matches text (e.g., /schools/kirkwood)
	@GetMapping("/{slug:[a-zA-Z-]+}")
	public ModelAndView showSchoolBySlug(@PathVariable("slug") String slug, Principal principal) {
		// Code omitted
	}


}
  • SCHOOL_ADMIN users with "MANAGE_FACILITIES" permissions should be able to update only their own school's info.

  • SUPER_ADMIN users with "MANAGE_ALL_SCHOOLS" permissions will be able to update any school's info.

  • Only SUPER_ADMIN users will have the ability to change a school's status from active to inactive or suspended.

  • Add a method to the SchoolController to determine if thecurrent user has edit privileges. 

SchoolController CRUD

// New imports
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;

// New method

    private void verifyEditPermissions(School school) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        String userEmail = auth.getName();
        
        boolean isSuperAdmin = auth.getAuthorities().stream()
                .anyMatch(a -> a.getAuthority().equals("MANAGE_ALL_SCHOOLS"));
        boolean isSchoolAdmin = auth.getAuthorities().stream()
                .anyMatch(a -> a.getAuthority().equals("MANAGE_FACILITIES"));
        
        boolean belongsToSchool = userEmail.endsWith("@" + school.getDomain()) || 
                                  userEmail.endsWith("." + school.getDomain());

        if (!isSuperAdmin && !(isSchoolAdmin && belongsToSchool)) {
            throw new AccessDeniedException("You do not have permission to edit this school.");
        }
    }
  • SCHOOL_ADMIN users with "MANAGE_FACILITIES" permissions should be able to update only their own school's info.

  • SUPER_ADMIN users with "MANAGE_ALL_SCHOOLS" permissions will be able to update any school's info.

  • Add a method to the SchoolController to determine if thecurrent user has edit privileges. 

SchoolController verifyEditPermissions

// New imports
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;

// New method

    private void verifyEditPermissions(School school) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        String userEmail = auth.getName();
        
        boolean isSuperAdmin = auth.getAuthorities().stream()
                .anyMatch(a -> a.getAuthority().equals("MANAGE_ALL_SCHOOLS"));
        boolean isSchoolAdmin = auth.getAuthorities().stream()
                .anyMatch(a -> a.getAuthority().equals("MANAGE_FACILITIES"));
        
        boolean belongsToSchool = userEmail.endsWith("@" + school.getDomain()) || 
                                  userEmail.endsWith("." + school.getDomain());

        if (!isSuperAdmin && !(isSchoolAdmin && belongsToSchool)) {
            throw new AccessDeniedException("You do not have permission to edit this school.");
        }
    }
  • In your SchoolController.java, add an initUpdateForm method. They will extract the logged-in user's email and authorities to verify they have the right to edit the requested school.

SchoolController initUpdateForm

    @GetMapping("/{id}/edit")
    public String initUpdateForm(@PathVariable("id") int id, Model model) {
        School school = schoolRepository.findById(id)
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "School not found"));

        verifyEditPermissions(school);

        model.addAttribute("school", school);
        return "schools/createOrUpdateSchoolForm";
    }
  • Here is the updated HTML block in the schoolDetails.html page.

  • By adding Bootstrap's flexbox utility classes to the container, the heading and the button will automatically align to opposite sides of the screen.

schoolDetails

<html xmlns:th="https://www.thymeleaf.org"
      xmlns:sec="https://www.thymeleaf.org/extras/spring-security"
      th:replace="~{fragments/layout :: layout (~{::body},'schools')}">
  
  
<!-- Code omitted -->

<div class="mb-4 pb-2 border-bottom d-flex justify-content-between align-items-center">
  <h1 class="mb-0"><span th:text="${school.name}">School Name</span> Intramurals</h1>
  
  <a th:href="@{|/schools/${school.id}/edit|}" 
     sec:authorize="hasAuthority('MANAGE_FACILITIES')" 
     class="btn btn-outline-primary">
     Edit School
  </a>
</div>
  • Only SUPER_ADMIN users will have the ability to change a school's status from active to inactive or suspended.

  • In createOrUpdateSchoolForm.html: Wrap the following status dropdown field so it only renders for Super Admins.

  • Your database and Java entity use an Enum for the school status. In Java, enum constants are strictly case-sensitive and are almost always defined in uppercase (e.g., ACTIVE, INACTIVE, SUSPENDED).

createOrUpdateSchoolForm

<html xmlns:th="https://www.thymeleaf.org"
      xmlns:sec="https://www.thymeleaf.org/extras/spring-security"
      th:replace="~{fragments/layout :: layout (~{::body},'schools')}

<!-- Code omitted -->

<div sec:authorize="hasAuthority('MANAGE_ALL_SCHOOLS')" class="mb-3">
    <label for="status" class="form-label">School Status</label>
    <select class="form-select" id="status" th:field="*{status}">
        <option value="ACTIVE">Active</option>
        <option value="INACTIVE">Inactive</option>
        <option value="SUSPENDED">Suspended</option>
    </select>
</div>
  • The <form> tag is missing the th:action attribute. Without it, Thymeleaf does not inject the hidden CSRF (Cross-Site Request Forgery) security token. When you click submit, Spring Security sees a POST request without a token, assumes it is a malicious attack, and silently blocks the submission.

  • The "Add School" text (heading and button)is static. We need to add Thymeleaf conditional logic to check if the school has an ID.

createOrUpdateSchoolForm

<h2 th:text="${school.id == null} ? 'Add School' : 'Update School'">Add School</h2>

<form th:object="${school}" class="form-horizontal" id="add-school-form" method="post"
      th:action="${school.id == null} ? @{/schools/new} : @{|/schools/${school.id}/edit|}">

  <!-- code omitted -->

  <div class="form-group">
    <div class="col-sm-offset-2 col-sm-10">
      <button class="btn btn-primary" type="submit" 
              th:text="${school.id == null} ? 'Add School' : 'Update School'">Add School</button>
    </div>
  </div>

</form>

Thymeleaf th:field

  • th:field="*{status}" is a Thymeleaf attribute that binds an HTML form input directly to a specific property on your backend Java object.
  • When you use th:field, Thymeleaf generates three standard HTML attributes for that element when the page renders:

    • id="status": Assigns an ID so your <label for="status"> can connect to the input.

    • name="status": Tells the browser what key to use when sending the submitted form data back to your Spring controller.

    • value="...": Automatically pre-fills the input with the current value of the object. If you are editing an existing school, it selects the correct dropdown option based on what is in the database.

The Asterisk Syntax (*{...})

  • The asterisk is a "selection variable expression." It is designed to be used inside a parent tag that has a th:object attribute defined.

  • In your createOrUpdateSchoolForm.html file, your main <form> tag likely looks like this:
    <form th:object="${school}" method="post">

  • Because the form has already "selected" the ${school} object, any input inside that form can use the asterisk shorthand. Writing *{status} is simply a shorter, cleaner way of writing ${school.status}.

  • In your SchoolController.java, add a processUpdateForm method to process the form for logged-in user's that have the right to edit the requested school.

SchoolController processUpdateForm

    @PostMapping("/{id}/edit")
    public String processUpdateForm(@Valid School school, BindingResult result, @PathVariable("id") int id) {
        School existingSchool = schoolRepository.findById(id)
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "School not found"));

        verifyEditPermissions(existingSchool);

        if (result.hasErrors()) {
            return "schools/createOrUpdateSchoolForm";
        }

        // Prevent standard admins from modifying the status via form tampering
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        boolean isSuperAdmin = auth.getAuthorities().stream()
                .anyMatch(a -> a.getAuthority().equals("MANAGE_ALL_SCHOOLS"));

        if (!isSuperAdmin) {
            school.setStatus(existingSchool.getStatus());
        }

        school.setId(id);
        schoolRepository.save(school);

        // Strip ".edu" for the redirect to match your slug regex [a-zA-Z-]+
        String slug = school.getDomain().replace(".edu", "");
        return "redirect:/schools/" + slug;
    }
  • You should also update the redirect at the end of your existing processCreationForm method to use the slug format:

SchoolController processCreationForm

    @PostMapping("/new")
    public String processCreationForm(@Valid School school, BindingResult result) {
       if (result.hasErrors()) {
          return "schools/createOrUpdateSchoolForm";
       }
       schoolRepository.save(school);
       
       // Update redirect to use the slug
       String slug = school.getDomain().replace(".edu", "");
       return "redirect:/schools/" + slug;
    }
  • Assume, you didn't capitalize the values in the select block:
    <option value="active">Active</option>
    <option value="inactive">Inactive</option>
    <option value="suspended">Suspended</option>

  • The form would no longer submit without displaying errors.

  • Add this inside the form tag of the createOrUpdateSchoolForm file to display the errors.

Displaying Errors

  <div th:if="${#fields.hasAnyErrors()}" class="alert alert-danger">
    <p class="mb-0">Please correct the highlighted errors before submitting.</p>
    <ul>
      <li th:each="err : ${#fields.errors('*')}" th:text="${err}">Error message</li>
    </ul>
  </div>
  • When I add a new school named "Kirkwood2" with "kirkwood2.edu" as the domain, the program redirects me to "/schools/kirkwood2" and displays a 404 error.

  • Your current mapping looks like this:
    @GetMapping("/{slug:[a-zA-Z-]+}")

  • This regex explicitly tells Spring Boot to only accept URLs that contain letters (a-z, A-Z) and hyphens (-). Because "kirkwood2" contains a number, Spring rejects it as an invalid path and throws a 404. 

  • To resolve this, you need to add numbers (0-9) to the allowed characters in your regex pattern.
    @GetMapping("/{slug:[a-zA-Z0-9-]*[a-zA-Z-][a-zA-Z0-9-]*}")

    • See an explanation on the next slide.

Domains with Numbers

  • To keep your clean URLs without causing conflicts between /schools/2 and /schools/kirkwood2, we need to make the slug regex slightly more specific.

  • We can tell Spring Boot that a slug can contain letters, numbers, and hyphens, but it must contain at least one letter or hyphen. This guarantees it will never match a purely numeric ID.

    • [a-zA-Z0-9-]*: Allows zero or more alphanumeric characters or hyphens at the start.

    • [a-zA-Z-]: Requires exactly one letter or hyphen in the middle.

    • [a-zA-Z0-9-]*: Allows zero or more alphanumeric characters or hyphens at the end

  • With this change, /schools/1 will route to the ID method, and /schools/kirkwood2 will route to the slug method.

Domains with Numbers

  • When editing a school, SCHOOL_ADMIN users should be able to manage locations.

  • A school that is inactive or suspended will have their upcoming leagues and events on the schoolDetails page replaced with a message saying their status.

SchoolController CRUD

  • X

More Create Table Statements

DROP TABLE IF EXISTS leagues;
CREATE TABLE leagues (
    id INT AUTO_INCREMENT PRIMARY KEY,
    school_id INT NOT NULL,
    location_id INT, -- Default location
    user_id INT, -- League Manager/Contact
    name VARCHAR(255) NOT NULL,
    description TEXT,
    registration_start DATETIME,
    registration_end DATETIME,
    league_start DATETIME,
    league_end DATETIME,
    is_public TINYINT DEFAULT 1,
    type ENUM('male', 'female', 'coed') NOT NULL,
    capacity INT,
    capacity_type ENUM('team', 'individual') NOT NULL,
    fee DECIMAL(6,2),
    status_id ENUM('draft', 'active', 'inactive', 'postponed', 'cancelled', 'past') DEFAULT 'draft',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

    CONSTRAINT fk_leagues_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE,
    CONSTRAINT fk_leagues_location FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE SET NULL,
    CONSTRAINT fk_leagues_manager FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,

    -- Performance: Quickly find active leagues for a specific school
    INDEX idx_leagues_school_status (school_id, status_id)
);

DROP TABLE IF EXISTS teams;
CREATE TABLE teams (
    id INT AUTO_INCREMENT PRIMARY KEY,
    league_id INT NOT NULL,
    captain_user_id INT NOT NULL,
    name VARCHAR(255) NOT NULL,
    logo_url VARCHAR(255),
    status_id ENUM('active', 'inactive', 'suspended') DEFAULT 'active',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

    CONSTRAINT fk_teams_league FOREIGN KEY (league_id) REFERENCES leagues(id) ON DELETE CASCADE,
    CONSTRAINT fk_teams_captain FOREIGN KEY (captain_user_id) REFERENCES users(id) ON DELETE CASCADE,

    -- Performance: List all teams in a league
    INDEX idx_teams_league (league_id)
);

DROP TABLE IF EXISTS team_users;
CREATE TABLE team_users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    team_id INT NOT NULL,
    user_id INT NOT NULL,
    role ENUM('member', 'captain') DEFAULT 'member',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

    CONSTRAINT fk_team_users_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
    CONSTRAINT fk_team_users_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,

    -- Integrity: A user cannot join the same team twice
    UNIQUE INDEX idx_team_users_unique (team_id, user_id)
);
  • X

More Create Table Statements

DROP TABLE IF EXISTS events;
CREATE TABLE events (
    id INT AUTO_INCREMENT PRIMARY KEY,
    league_id INT NOT NULL,
    location_id INT,
    user_id INT, -- Event organizer/referee
    name VARCHAR(255) NOT NULL,
    description TEXT,
    event_start DATETIME,
    event_end DATETIME,
    status_id ENUM('draft', 'active', 'ongoing', 'postponed', 'cancelled', 'final') DEFAULT 'draft',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

    CONSTRAINT fk_events_league FOREIGN KEY (league_id) REFERENCES leagues(id) ON DELETE CASCADE,
    CONSTRAINT fk_events_location FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE SET NULL,
    CONSTRAINT fk_events_contact FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,

    -- Integrity & Performance: Check for double-bookings at a location
    INDEX idx_events_location_time (location_id, event_start)
);

DROP TABLE IF EXISTS matches;
CREATE TABLE matches (
    id INT AUTO_INCREMENT PRIMARY KEY,
    event_id INT NOT NULL,
    home_team_id INT,
    away_team_id INT,
    winner_team_id INT,
    home_score INT DEFAULT 0,
    away_score INT DEFAULT 0,
    status ENUM('scheduled', 'in_progress', 'final', 'forfeit') DEFAULT 'scheduled',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

    CONSTRAINT fk_matches_event FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
    CONSTRAINT fk_matches_home FOREIGN KEY (home_team_id) REFERENCES teams(id) ON DELETE SET NULL,
    CONSTRAINT fk_matches_away FOREIGN KEY (away_team_id) REFERENCES teams(id) ON DELETE SET NULL,
    CONSTRAINT fk_matches_winner FOREIGN KEY (winner_team_id) REFERENCES teams(id) ON DELETE SET NULL,

    -- Performance: Essential for calculating standings (W/L records)
    INDEX idx_matches_home (home_team_id),
    INDEX idx_matches_away (away_team_id)
);
  • X

More Create Table Statements

DROP TABLE IF EXISTS messages;
CREATE TABLE messages (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id_from INT NOT NULL,
    user_id_to INT,
    league_id INT,
    event_id INT,
    parent_message_id INT, -- For threaded replies
    message VARCHAR(255) NOT NULL,
    is_flagged TINYINT DEFAULT 0,
    status_id ENUM('draft', 'active', 'hidden') DEFAULT 'active',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

    CONSTRAINT fk_messages_from FOREIGN KEY (user_id_from) REFERENCES users(id) ON DELETE CASCADE,
    CONSTRAINT fk_messages_to FOREIGN KEY (user_id_to) REFERENCES users(id) ON DELETE SET NULL,
    CONSTRAINT fk_messages_league FOREIGN KEY (league_id) REFERENCES leagues(id) ON DELETE CASCADE,
    CONSTRAINT fk_messages_event FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
    CONSTRAINT fk_messages_parent FOREIGN KEY (parent_message_id) REFERENCES messages(id) ON DELETE CASCADE,

    -- Performance: Quickly load chat history for a league or event
    INDEX idx_messages_context (league_id, event_id)
);

DROP TABLE IF EXISTS message_reactions;
CREATE TABLE message_reactions (
    id INT AUTO_INCREMENT PRIMARY KEY,
    message_id INT NOT NULL,
    user_id INT NOT NULL,
    reaction ENUM('like', 'dislike', 'love', 'hug', 'sad', 'angry'),
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    
    CONSTRAINT fk_reactions_message FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
    CONSTRAINT fk_reactions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,

    -- Integrity: One reaction per user per message
    UNIQUE INDEX idx_reactions_unique (message_id, user_id)
);
  • It is possible to replace Thymeleaf with a frontend JavaScript framework like React, Angular, or Vue.

  • This involves shifting your application from a Server-Side Rendered (SSR) architecture to a Client-Side Rendered (CSR) or Single Page Application (SPA) architecture.

  • Here is how that transition works:

    • Thymeleaf: The Spring Boot server generates the full HTML page. It merges the data (e.g., a list of Vets) with the template (vetList.html) and sends the finished HTML to the browser.

    • React/Angular/Vue: The Spring Boot server becomes a REST API. It sends only the raw data (usually in JSON format). The JavaScript framework running in the browser receives that JSON and builds the HTML dynamically.

Frontend JavaScript Frameworks

  • A benefit of the layered architecture (Controller > Service > Repository) is that you do not need to change your Database or Repository layers—you only need to modify the Controller layer.

  • If you look at VetController.java, the application already has an endpoint ready for a JavaScript framework to use:

Frontend JavaScript Frameworks

// This method is for Thymeleaf (Returns a View)
@GetMapping("/vets.html")
public String showVetList(...) { ... }

// This method is for External Clients/JS Frameworks (Returns JSON/XML)
@GetMapping({ "/vets" })
public @ResponseBody Vets showResourcesVetList() {
    Vets vets = new Vets();
    vets.getVetList().addAll(this.vetRepository.findAll());
    return vets;
}
  • The @ResponseBody annotation tells Spring: "Do not look for a Thymeleaf template. Just take this Java object, convert it to JSON, and send it back."

  • A React or Angular app would make a fetch('/vets') call to this URL, receive the list of doctors, and render the table itself.

  • To fully replace Thymeleaf, you would:

  • Update Controllers: Change your @Controller classes to @RestController (which automatically applies @ResponseBody to every method).

  • Return Data, Not Strings: Instead of returning strings like "owners/createOrUpdateOwnerForm", your methods would return Owner objects or ResponseEntity objects.

  • Delete Templates: You would eventually delete the src/main/resources/templates folder since the Java app no longer generates HTML.

  • Frontend Build: You would build your React/Angular app separately. You can then either run it on a separate server (like Node.js) that talks to your Spring Boot API, or package the built JavaScript files into the Spring Boot static folder to serve them together.

Frontend JavaScript Frameworks

Java 3 - Week 10

By Marc Hauschildt

Java 3 - Week 10

  • 64