Java 3 - 2026

Week 11

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.
Lessons 11-12 - Update and delete existing Location objects.
Lesson 13 - Search, sort, f
ilter records.
Lesson 14 - Email and SMS messaging.

Not getting to - Failed login attempts. Web Sockets. Date and currency formatting. Shopping Cart. Payment processing. Event Registration, project-specific features.

Course Plan

  • http://localhost:8080/schools/xxx
    Doesn't show 404 error.

  •  Add a failed to connect toast if cannot connect to db

  • 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

New Azure Training Certification

Old Azure Training Certification

GitHub Actions with Azure/AWS

APIs

AWS

Google Cloud

  • When NOT logged in:

    • "/schools" displays the list of schools WITHOUT the "Add School" button.

    • Clicking a school's name takes you to "/schools/schoolname" and displays "Join the Action" with a "Login" and "Register" button on the right side.

    • Accessing a school by id is currently not allowed because of this line in the SecurityConfig file:
      .requestMatchers(HttpMethod.GET, "/schools", "/schools/{slug:[a-zA-Z-]+}").permitAll()

    • Accessing "/schools/new" will redirect you to the login page.

    • Accessing "/users/profile" will redirect you to the login page.

    • Accessing "/xxx" will display the custom 404 page defined in the "system.GlobalExceptionHandler" class

    • Accessing "/recipes" will display the recipe JSON data

    • Accessing any route with a slash at the end will redirect without a slash.

Please verify the following:

  • When logged in as a STUDENT:

    • "/schools" displays the list of schools WITHOUT the "Add School" button.

    • Clicking a school's name takes you to "/schools/schoolname" and displays "My Player Card" with some information about the user.

    • Trying to access "/schools/new" will display a "Forbidden" error message.

      • This page can be customized by editing the "system.GlobalExceptionHandler" class and creating an "error/403.html" file.

    • Trying to access "/users/profile" will display the "Edit Profile" page.

Please verify the following:

  • When logged in as a SCHOOL_ADMIN:

    • "/schools" displays the list of schools WITHOUT the "Add School" button.

    • Clicking a school's name takes you to "/schools/schoolname" and displays "My Player Card" with some information about the user.

      • We will change this week to allow the user to C/R/U/D location data.

    • Trying to access "/schools/new" will display a "Forbidden" error message. 

    • Trying to access "/users/profile" will display the "Edit Profile" page.

Please verify the following:

  • When logged in as a SUPER_ADMIN:

    • "/schools" displays the list of schools WITH the "Add School" button.

    • Accessing "/schools/new" will display the form and allow submissions. 

      • Note this line of code in the SecurityConfig file:
        .requestMatchers("/schools/new")
        .hasAuthority("MANAGE_ALL_SCHOOLS")

      • Note the use of .permitAll(), .hasAuthority(), and .authenticated()

    • Trying to access "/users/profile" will display the "Edit Profile" page.

    • 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(...).hasAuthority() rule for it in this config.

Please verify the following:

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

  • Last week 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());
    }

}
  • 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.

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

SchoolControllerTest

import org.springframework.samples.petclinic.user.UserRepository;

// ... your other imports ...

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

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private SchoolRepository schools;

    @MockitoBean
    private UserRepository userRepository;

    private School school;
    
    // ...
  • Also, now that we locked down /schools/new so only users with the MANAGE_SCHOOLS authority can access it, your tests are going to fail because the test runner is acting as an anonymous guest.

  • @WebMvcTest is a "sliced" test that only loads your SchoolController and basic Spring MVC components. It 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.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 {

    // ...
    
    @MockitoBean
    private UserDetailsService userDetailsService;
    
    @MockitoBean
    private AuthenticationConfiguration authenticationConfiguration;
    
    // ...
  • When you run your application normally, Spring Boot's background auto-configuration provides the HttpSecurity builder for you. However, because @WebMvcTest strips that away, 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.

  • Running the SchoolControllerTest.testShowSchoolList method should now pass.

@EnableWebSecurity

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

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // ... your existing beans ...
}
  • Back in SchoolControllerTest, we need to add a Security MockMvc Request Post-Processor to allow 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 SUPER_ADMIN PRIVILEGE DIRECTLY INTO THE REQUEST
               .with(user("super_admin@kirkwood.edu")
                     .authorities(new SimpleGrantedAuthority("MANAGE_ALL_SCHOOLS"))))
          .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("super_admin@kirkwood.edu")
                     .authorities(new SimpleGrantedAuthority("MANAGE_ALL_SCHOOLS"))))
          .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("super_admin@kirkwood.edu")
                     .authorities(new SimpleGrantedAuthority("MANAGE_ALL_SCHOOLS"))))
          .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 {

       // ADD THIS: A dummy security configuration just for this test
       @Bean
       public SecurityFilterChain testFilterChain(HttpSecurity http) throws Exception {
          http.csrf(csrf -> csrf.disable())
              .authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll());
          return http.build();
       }
       
    }

}
  • The CrashControllerIntegrationTests.testTriggerExceptionJson method is failing with an error saying: "Caused by: HttpMessageNotReadableException: JSON parse error: Unrecognized token 'An'.

  • Instead of letting Spring Boot generate the standard JSON error map that the test expects, the GlobalExceptionHandler is catching the exception and returning a plain text string that starts with "An unexpected error occurred...". When Jackson tries to parse that plain string as a JSON Map, it fails on the first word ("An").

  • The cleanest solution is to tell this specific isolated test environment to completely ignore all custom exception handlers. You can do this by adding a @ComponentScan exclusion filter to the nested TestConfiguration class.

CrashControllerIntegrationTests

import org.springframework.context.annotation.ComponentScan;
// -- Other imports --

    @SpringBootApplication(exclude = { DataSourceAutoConfiguration.class,
          DataSourceTransactionManagerAutoConfiguration.class, HibernateJpaAutoConfiguration.class })
    // ADD THIS: Instructs the test context to ignore all custom global exception handlers
    @ComponentScan(excludeFilters = @ComponentScan.Filter(
          type = org.springframework.context.annotation.FilterType.ANNOTATION, 
          classes = org.springframework.web.bind.annotation.ControllerAdvice.class))
    static class TestConfiguration {
  • The MySqlIntegrationTests.testSchoolDetails method fails saying

    java.lang.AssertionError: 

    Expecting actual:

      "<!DOCTYPE html>...to contain:  "Kirkwood Community College"

  • Either change RequestEntity.get("/schools/1") to RequestEntity.get("/schools/kirkwood")
    or

  • You need to add the numeric ID pattern to your requestMatchers list in your SecurityConfig.java file . Additionally, you should update the slug pattern in this configuration to match an updated regex to be applied soon to the SchoolController, otherwise schools with numbers in their name (like "kirkwood2") will also be blocked by Spring Security.

.requestMatchers(HttpMethod.GET, 
      "/schools", 
      "/schools/{schoolId:\\d+}", 
      "/schools/{slug:[a-zA-Z0-9-]*[a-zA-Z-][a-zA-Z0-9-]*}").permitAll()

MySqlIntegrationTests

  • The PetControllerTests currently fail because our SecurityConfig doesn't allow those requests.

PetControllerTests

.requestMatchers(
    "/",
    "/register-student",
    "/resources/**",
    "/recipes/**",
    "/recipes/new",
    "/owners/**",
    "/pets/**",
    "/vets/**",
    "/vets.html"
).permitAll()
  • The routes will work, but the tests will still fail because of how the @WebMvcTest annotation behaves. Remember, it creates a "sliced" application context, meaning it only loads the specified controller (PetController) and ignores your custom @Configuration classes—including your SecurityConfig.

  • Because your SecurityConfig is missing from the test environment, Spring Boot applies its strict default security settings blocking all endpoints (returning 401 Unauthorized for your GET requests) and requires a CSRF token for all data submissions (returning 403 Forbidden for your POST requests).

  • The most direct solution is to instruct the mock environment to disable its web filters. This bypasses the default security layer and allows the tests to evaluate the controller logic directly.

  • Add the @AutoConfigureMockMvc(addFilters = false) annotation to your class header:

PetControllerTests

import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
// -- Other imports --

@WebMvcTest(value = PetController.class,
       includeFilters = @ComponentScan.Filter(value = PetTypeFormatter.class, type = FilterType.ASSIGNABLE_TYPE))
@AutoConfigureMockMvc(addFilters = false) // <-- Add this line
@DisabledInNativeImage
@DisabledInAotMode
class PetControllerTests {
    // ... all existing setup and test methods remain unchanged ...
}
  • The testInitCreationForm method fails saying: `TemplateProcessingException: Exception evaluating SpringEL expression: "opt.key" (template: "fragments/selectField" - line 13, col 15)`.

  • The original PetClinic fragment was built assuming every select menu would be populated by a List of custom objects (like PetType). However, your user profile controller is passing a Map for the languages. 

  • To resolve this, we can use Thymeleaf's th:if and th:unless attributes to create two separate, isolated blocks of HTML. This guarantees Thymeleaf will never attempt to read a .key property unless it is certain the object is a Map.

PetControllerTests

    <select class="form-select"
            th:classappend="${valid ? '' : 'is-invalid'}"
            th:field="*{__${name}__}"
            th:id="${name}">
      
      <th:block th:if="${options instanceof T(java.util.Map)}">
        <option th:each="opt : ${options}"
                th:value="${opt.key}"
                th:text="${opt.value}">Option</option>
      </th:block>

      <th:block th:unless="${options instanceof T(java.util.Map)}">
        <option th:each="opt : ${options}"
                th:value="${opt}"
                th:text="${opt}">Option</option>
      </th:block>
    </select>
  • 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

  • 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.

  • Add two new methods to the SchoolController to determine if the current 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 methods

    private boolean checkEditPermissions(School school) {
       Authentication auth = SecurityContextHolder.getContext().getAuthentication();
       
       // Handle unauthenticated users safely
       if (auth == null || !auth.isAuthenticated() || auth.getName().equals("anonymousUser")) {
          return false;
       }

       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());

       return isSuperAdmin || (isSchoolAdmin && belongsToSchool);
    }

    private void verifyEditPermissions(School school) {
       if (!checkEditPermissions(school)) {
          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";
    }
  • Update your two showSchool endpoints to evaluate the school against the logged-in user and pass the canEdit boolean to the model.

SchoolController showSchoolById

    @GetMapping("/{schoolId:\\d+}")
    public ModelAndView showSchoolById(@PathVariable("schoolId") int schoolId) {
       ModelAndView mav = new ModelAndView("schools/schoolDetails");
       School school = schoolRepository.findById(schoolId)
          .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "School not found"));
       mav.addObject(school);
       
       // ADD THIS LINE
       mav.addObject("canEdit", checkEditPermissions(school));
       
       return mav;
    }
  • Update your two showSchool endpoints to evaluate the school against the logged-in user and pass the canEdit boolean to the model.

SchoolController showSchoolBySlug

	@GetMapping("/{slug:[a-zA-Z-]+}")
	public ModelAndView showSchoolBySlug(@PathVariable("slug") String slug, Principal principal) {
       String fullDomain = slug + ".edu";
       ModelAndView mav = new ModelAndView("schools/schoolDetails");

       School school = schoolRepository.findByDomain(fullDomain)
          .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "School not found"));
       mav.addObject(school);

       // ADD THIS LINE
       mav.addObject("canEdit", checkEditPermissions(school));

       if (principal != null) {
       // ... remainder of your existing method ...
  • 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|}" 
     th:if="${canEdit}" 
     class="btn btn-outline-primary">
     Edit School
  </a>
</div>
  • The | characters in Thymeleaf represent Literal Substitution.

  • They act exactly like template literals in JavaScript (the backtick ` syntax) or f-strings in Python. They allow you to seamlessly mix plain text and variables without needing to use clunky string concatenation operators.

  • Without the pipes, you would have to use plus signs and single quotes to build the URL piece by piece, which is harder to read and prone to syntax errors:

    @{'/schools/' + ${school.id} + '/edit'}

  • By wrapping the entire path in pipes, Thymeleaf knows to treat the whole block as a single string while still evaluating the ${school.id} variable inside it:

    @{|/schools/${school.id}/edit|}

The | characters

  • A student trying to access "/schools/1/edit" will get an error from the verifyEditPermissions method saying "You do not have permission to edit this school." School and Super Admins will not

  • Spring Boot provides a built-in mechanism to handle error pages dynamically based on your application's configuration properties.

  • This means you can display detailed error attributes (like the message and stack trace) during local development and hide them when deployed to production.

  • Spring Boot uses a feature called "Profiles" to manage different configurations for different environments. 

  • By default, Spring Boot looks for a file named application.properties in your src/main/resources directory. This acts as your base configuration file. Any settings placed here apply to your application universally.

Profiles

  • Create an application-dev.properties
    # Display detailed error information on localhost
    spring.web.error.include-message=always
    spring.web.error.include-stacktrace=always

  • Create an application-prod.properties 
    # Hide detailed error information in production
    spring.web.error.include-message=never
    spring.web.error.include-stacktrace=never

  • When you create files named application-{profile}.properties, Spring Boot loads them alongside the base file when that specific profile is activated.

  • The profile-specific file takes precedence. If a property exists in both files, the value in the active profile file will override the value in the base file. 

Dev and Prod Profiles

  • Spring Boot needs to be instructed on which profile is currently active. 

  • You can set the active profile directly in your application.properties file.
    # Set to dev locally, and prod on a cloud server
    spring.profiles.active=${ACTIVE_PROFILE:dev}

  • Using ${ACTIVE_PROFILE} tells Spring Boot to look for an environment variable with that exact name at runtime.

  • This configuration tells Spring Boot to use the dev profile if the ACTIVE_PROFILE environment variable is missing. This prevents the application from failing to start during local development if you forget to set the variable on your machine.

     

Activating a Profile

  • To inject an environment variable into your Azure Container App, append the --set-env-vars parameter to your existing command.
    az containerapp update `
        --name <your-container-app-name> `
        --resource-group <your-resource-group-name> `
        --image <your-docker-username>/<your-imagename>:<your-version> `
        --set-env-vars ACTIVE_PROFILE=prod

  • If you need to add multiple environment variables in the future, you can pass them as a space-separated list to the same parameter:
    --set-env-vars ACTIVE_PROFILE=prod DB_PASSWORD=your_password API_KEY=your_key

Update the Azure CLI Command

  • Spring Boot's BasicErrorController automatically passes error attributes to views located in the src/main/resources/templates/error/ directory.

  • Create a new file named 500.html in your templates/error/ directory:

500 Error Template

<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-4 text-danger">500</h1>
    <h2>Internal Server Error</h2>
    <p class="lead">The server encountered an unexpected condition that prevented it from fulfilling the request.</p>
    <a th:href="@{/}" class="btn btn-outline-primary mt-3">Return to Home</a>

    <div th:if="${trace != null}" class="alert alert-warning mt-5 text-start shadow-sm">
      <h4 class="alert-heading">Developer Details</h4>
      <hr>
      <p class="mb-1"><strong>Path:</strong> <span th:text="${path}">/path</span></p>
      <p class="mb-1"><strong>Error:</strong> <span th:text="${error}">Exception Type</span></p>
      <p class="mb-3"><strong>Message:</strong> <span th:text="${message}">Exception message goes here</span></p>
      
      <div class="bg-light p-3 border rounded overflow-auto" style="max-height: 400px;">
        <pre th:text="${trace}" class="mb-0" style="font-size: 0.85rem;"></pre>
      </div>
    </div>
  </div>
</body>

</html>
  • When your verifyEditPermissions method throws an AccessDeniedException, Spring Security automatically intercepts it and translates it into an HTTP 403 (Forbidden) status code, rather than an HTTP 500 (Internal Server Error).

  • Create a new file named 403.html in your src/main/resources/templates/error/ directory:

403 Forbidden Template

<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-4 text-warning">403</h1>
    <h2>Access Denied</h2>
    <p class="lead">You do not have the required permissions to view or modify this resource.</p>
    <a th:href="@{/}" class="btn btn-outline-primary mt-3">Return to Home</a>

    <div th:if="${trace != null}" class="alert alert-warning mt-5 text-start shadow-sm">
      <h4 class="alert-heading">Developer Details</h4>
      <hr>
      <p class="mb-1"><strong>Path:</strong> <span th:text="${path}">/path</span></p>
      <p class="mb-1"><strong>Error:</strong> <span th:text="${error}">Exception Type</span></p>
      <p class="mb-3"><strong>Message:</strong> <span th:text="${message}">Exception message goes here</span></p>
      
      <div class="bg-light p-3 border rounded overflow-auto" style="max-height: 400px;">
        <pre th:text="${trace}" class="mb-0" style="font-size: 0.85rem;"></pre>
      </div>
    </div>
  </div>
</body>

</html>
  • Delete the handleGeneralError method from your GlobalExceptionHandler.
  • Spring Boot's BasicErrorController is programmed to automatically look for a file named 500.html inside the templates/error/ directory whenever an unhandled server exception occurs.

  • By removing your manual interceptor, the default controller will take over, read your spring.web.error.include-stacktrace=always property, and automatically inject the stack trace into the Thymeleaf model.

  • Login as a student and try to access "/schools/1/edit". You will see the new error page.

GlobalExceptionHandler

  • To simulate a true 500 Internal Server Error, you can inject a hard crash into one of your existing routes.

  • Open your SchoolController and add a throw new RuntimeException statement in a new route:

Simulate 500 Error

@GetMapping("/bad")
public void badSchoolRequest() {
		if (true) {
			throw new RuntimeException("This is a simulated database failure to test the 500 page stack trace.");
		}
}
  • Visit "/schools/bad" to see this error.

  • The changes you just made will cause the testTriggerExceptionHtml method to fail. 

CrashControllerIntegrationTests

@GetMapping("/bad")
public void badSchoolRequest() {
		if (true) {
			throw new RuntimeException("This is a simulated database failure to test the 500 page stack trace.");
		}
}
  • The original Pet Clinic test was written to look for the generic <h2>Something happened...</h2> text from the default error.html template. When the application was updated to use a custom 500.html template with the developer details block, the response body no longer matches the expected subsequence.

  • To fix this, update the containsSubsequence assertion to match the text and layout of your new 500 error page.

CrashControllerIntegrationTests

@Test
void testTriggerExceptionHtml() {
    HttpHeaders headers = new HttpHeaders();
    headers.setAccept(List.of(MediaType.TEXT_HTML));
    ResponseEntity<String> resp = rest.exchange("http://localhost:" + port + "/oups", HttpMethod.GET,
          new HttpEntity<>(headers), String.class);
    
    assertThat(resp).isNotNull();
    assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
    assertThat(resp.getBody()).isNotNull();
    
    // Updated to match the custom 500.html structure
    assertThat(resp.getBody()).containsSubsequence(
          "<h2>", "Internal Server Error", "</h2>", 
          "Message:", "Expected:", "controller", "used", "to", "showcase", "what", "happens", "when", "an", "exception", "is", "thrown"
    );
    
    // Not the whitelabel error page:
    assertThat(resp.getBody()).doesNotContain("Whitelabel Error Page",
          "This application has no explicit mapping for");
}
  • When a super admin creates a new school, we are now redirecting them to the school's page instead a list of all schools.

  • Update the testProcessCreationFormSuccess form accordingly.

SchoolControllerTest

@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")
			.with(user("super_admin@kirkwood.edu")
				.authorities(new SimpleGrantedAuthority("MANAGE_ALL_SCHOOLS"))))
		.andExpect(status().is3xxRedirection())
		.andExpect(redirectedUrl("/schools/uiowa"));

	// Verify that the repository.save() method was actually called
	verify(schools).save(any(School.class));
}
  • 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>

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}.

  • 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>
  • 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.

    • Placing this before the form tag will result in a TemplateInputException.

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>
  • 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.

    • Placing this before the form tag will result in a TemplateInputException.

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

  • When guest user accesses "/users/profile" it correctly redirects the user to the login page but doesn't display message.

  • It would be nice if it displayed a warning message saying something like "You must be logged in to edit your profile"

  • Spring Security intercepts the guest user and secretly saves the URL they were trying to visit in the session so it knows where to send them after they successfully log in.

  • We can tap into this "Saved Request" directly inside the Auth Controller to figure out where they came from and display a custom warning message.

Guest User Profile

  • Find the controller method that serves your custom login page (@GetMapping("/login")).

  • Inject the HttpSession and check for that saved request:

AuthController

import org.springframework.security.web.savedrequest.SavedRequest;
import jakarta.servlet.http.HttpSession;

// ... inside your controller ...

    @GetMapping("/login")
    public String initLoginForm(Model model, HttpSession session) {
        
        // 1. Ask the session if Spring Security saved an intercepted request
        SavedRequest savedRequest = (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST");

        // 2. If a request was saved, inspect the URL
        if (savedRequest != null) {
            String attemptedUrl = savedRequest.getRedirectUrl();
            
            // 3. Customize the message based on where they were trying to go
            if (attemptedUrl.contains("/users/profile")) {
                model.addAttribute("messageWarning", "You must be logged in to edit your profile.");
            } else {
                model.addAttribute("messageWarning", "Please log in to access that page.");
            }
        }
        
        User user = new User();

		String lastEmail = (String)session.getAttribute("LAST_EMAIL");
		if(lastEmail != null) {
			user.setEmail(lastEmail);
			session.removeAttribute("LAST_EMAIL");
		}
		model.addAttribute("user", user);

        return "auth/loginForm";
    }
  • Now, if a user clicks the standard "Login" button in your navbar, they will just see the normal login form. But if they try to sneak into /users/profile, the bouncer will kick them to the login page and display your custom warning.

  • If your layout.html file doesn't contain a messageWarning alert box element, you can add one to your loginForm.html template and display that messageWarning attribute.

  • Add this somewhere near the top of your login form container:

Add Alert to your Login HTML

    <div th:if="${messageWarning}" class="alert alert-warning alert-dismissible fade show" role="alert">
        <span th:text="${messageWarning}">Please log in.</span>
        <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
    </div>
  • The "You must be logged in to edit your profile." warning message displays when you visit "/users/profile".

  • The "Please log in to access that page." warning displays when I visit "/schools/new".

  • However, when I visit "/login" by clicking the login button, the last warning message re-appears.

  • When Spring Security intercepts an unauthenticated user, it places that SPRING_SECURITY_SAVED_REQUEST object into their session memory. That object stays in their session until they successfully log in. If a user gets redirected to the login page, sees the warning, decides not to log in, goes back to the homepage, and then later clicks the standard "Login" button... your controller looks into the session, sees the ghost of that old saved request still sitting there, and displays the warning again!

Warning Message

  • We can drop a second, temporary string into the session to keep track of which URL we just warned them about.

  • If the attempted URL matches the one we already warned them about, we just skip adding the message to the model.

  • Update your initLoginForm method to look like this:

The "Already Warned" Tracker

    @GetMapping("/login")
    public String initLoginForm(Model model, HttpSession session) {
        // Ask the session if Spring Security saved an intercepted request
        SavedRequest savedRequest = (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST");

        if (savedRequest != null) {
            String attemptedUrl = savedRequest.getRedirectUrl();
            
            // 1. Check if we have already warned them about this specific URL
            String warnedUrl = (String) session.getAttribute("WARNED_URL");

            // 2. Only show the message if this is a fresh interception
            if (!attemptedUrl.equals(warnedUrl)) {
                
                if (attemptedUrl.contains("/users/profile")) {
                    model.addAttribute("messageWarning", "You must be logged in to edit your profile.");
                } else {
                    model.addAttribute("messageWarning", "Please log in to access that page.");
                }
                
                // 3. Mark this URL as "warned" so we don't show the message again
                // if they navigate away and come back to the login page manually.
                session.setAttribute("WARNED_URL", attemptedUrl);
            }
        }

        User user = new User();

        // Grab the failed email attempt from Spring Security's session memory
        String lastEmail = (String) session.getAttribute("LAST_EMAIL");
        if (lastEmail != null) {
            user.setEmail(lastEmail);
            session.removeAttribute("LAST_EMAIL"); 
        }

        model.addAttribute("user", user);
        return "auth/loginForm";
    }
  • The guest user visits /users/profile. Spring Security intercepts them and saves the request.

  • They arrive at /login. The attemptedUrl is /users/profile. warnedUrl is null.

  • The controller shows the warning and sets WARNED_URL to /users/profile.

  • The user clicks "Home", then clicks "Login".

  • They arrive at /login. The attemptedUrl is /users/profile. warnedUrl is /users/profile.

  • They match! The controller skips the warning, and the user just sees a clean login form.

  • 2027 To Fix - When visiting http://localhost:8080/users/profile twice, the warning shows one time, but not the second time.

How this flow works now

  • To finish building the relationship of Schools and Locations, we will mirror the architectural pattern used for Owners and Pets. The School acts as the parent (like Owner), and the Location acts as the child (like Pet).

  • With the data layer configured (School.java, Location.java), the next step is to create the LocationRepository interface.

  • Because we will need to fetch locations specific to the school being viewed, add a custom finder method that filters by the school_id.

  • This uses a SQL Stored procedure, rather than a SELECT statement.

  • Using nativeQuery = true maps a stored procedure's result set directly into a List of Java Persistence API (JPA) entities.

LocationRepository

package org.springframework.samples.petclinic.school;

import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;

import java.util.List;

public interface LocationRepository extends CrudRepository<Location, Integer> {

    // Call the MySQL stored procedure natively
    @Query(value = "CALL get_locations_by_school(:schoolId)", nativeQuery = true)
    List<Location> findBySchoolId(@Param("schoolId") int schoolId);

}
  • Spring Data JPA fully supports calling database procedures and mapping the result sets back to your Java entities.

  • However, please know that native queries and stored procedures completely bypass Hibernate's @SQLRestriction annotation (See line 18 of Location.java and line 24 of School.java). 

  • If you use a stored procedure, you must manually enforce the soft-delete logic inside the SQL, otherwise, your application will start fetching deleted locations.

  • Add this procedure definition to your MySQL database. Notice that we must explicitly include the deleted_at IS NULL check.

Locations Stored Procedure

DELIMITER //

CREATE PROCEDURE get_locations_by_school(IN p_school_id INT)
BEGIN
    SELECT * FROM locations 
    WHERE school_id = p_school_id 
      AND deleted_at IS NULL;
END //

DELIMITER ;
  • It is generally considered a best practice to avoid stored procedures for simple SELECT statements in a Spring Boot application.

  • When using this code instead...
    @Query("SELECT l FROM Location l WHERE l.school.id = :schoolId")
    List<Location> findBySchoolId(@Param("schoolId") int schoolId);

    • ...Spring's built-in method naming conventions will...

      • ...keep your business logic inside the Java codebase.

      • ...keep your database agnostic (allowing you to switch to PostgreSQL or others without rewriting SQL)

      • ...ensure annotations like @SQLRestriction function automatically.

  • Stored procedures are typically reserved for complex, multi-table data processing operations where the database engine can execute the logic faster than the Java application.

Locations Stored Procedure

  • The next step is to create the LocationController to handle the routing and the MANAGE_FACILITIES permission checks.

  • By using Spring's @ModelAttribute annotation at the method level, we can intercept every request to this controller, fetch the parent School entity, and verify the user's permissions before any route-specific logic is executed. This prevents you from having to copy and paste the security check into every method.

LocationController

package org.springframework.samples.petclinic.school;

import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;

@Controller
@RequestMapping("/schools/{schoolId}/locations")
public class LocationController {

    private final SchoolRepository schoolRepository;
    private final LocationRepository locationRepository;

    public LocationController(SchoolRepository schoolRepository, LocationRepository locationRepository) {
        this.schoolRepository = schoolRepository;
        this.locationRepository = locationRepository;
    }

    // This executes before every route in this controller to populate the school and check permissions
    @ModelAttribute("school")
    public School findSchoolAndVerifyPermissions(@PathVariable("schoolId") int schoolId) {
        School school = schoolRepository.findById(schoolId)
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "School not found"));

        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth == null || !auth.isAuthenticated() || auth.getName().equals("anonymousUser")) {
            throw new AccessDeniedException("Authentication required.");
        }

        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 manage facilities for this school.");
        }

        return school;
    }


}
  • Inside the "src/main/resources/templates/schools" directory, create the createOrUpdateLocationForm.html file.

  • We will write a standard <select> block for the parent location rather than using your selectField fragment. This gives us direct control to map the <option value> to the location's ID, which Spring Data will automatically convert back into a Location entity.

  • For latitude and longitude, we will change the input type from 'number' to 'text'. Spring Boot is smart enough to parse the text into your BigDecimal variables in Location.java.

createOrUpdateLocationForm

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

<body>

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

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

  <div th:if="${#fields.hasAnyErrors()}" class="alert alert-danger">
    <p class="mb-0">Please correct the highlighted errors before submitting.</p>
  </div>

  <input th:replace="~{fragments/inputField :: input ('Name', 'name', 'text')}" />
  
  <div class="mb-3">
    <label for="parentLocation" class="form-label">Parent Location (Optional)</label>
    <select class="form-select" id="parentLocation" th:field="*{parentLocation}">
      <option value="">-- None --</option>
      <option th:each="loc : ${parentLocations}" 
              th:value="${loc.id}" 
              th:text="${loc.name}">Parent Name</option>
    </select>
  </div>

  <input th:replace="~{fragments/inputField :: input ('Address', 'address', 'text')}" />
  <input th:replace="~{fragments/inputField :: input ('Description', 'description', 'text')}" />
  
  <div class="row">
    <div class="col-md-6">
      <input th:replace="~{fragments/inputField :: input ('Latitude', 'latitude', 'text')}" />
    </div>
    <div class="col-md-6">
      <input th:replace="~{fragments/inputField :: input ('Longitude', 'longitude', 'text')}" />
    </div>
  </div>

  <div class="mb-3">
    <label for="status" class="form-label">Status</label>
    <select class="form-select" id="status" th:field="*{status}">
      <option value="ACTIVE">Active</option>
      <option value="DRAFT">Draft</option>
      <option value="COMING_SOON">Coming Soon</option>
      <option value="CLOSED">Closed</option>
    </select>
  </div>

  <div class="form-group mt-4">
    <button class="btn btn-primary mt-0" type="submit" 
            th:text="${location.id == null} ? 'Add Location' : 'Update Location'">Add Location</button>
            
    <a class="btn btn-outline-secondary ms-2" th:href="@{|/schools/${school.id}|}">Cancel</a>
  </div>

</form>

</body>
</html>
  • Create methods in the LocationController to display and process the form to create new Location records.

  • To support the parent location dropdown, we need to pass a list of available locations to the view.

  • We can use a @ModelAttribute method to automatically inject this list into every request for this controller.

  • We need to filter out any unsaved locations from the dropdown list.

  • We also need to filter out the current location so a building cannot be set as its own parent.

LocationController "/new"

import java.util.List;
// -- Other import statements
    
    // Injects the list of potential parent locations into the Thymeleaf model
    @ModelAttribute("parentLocations")
    public List<Location> populateParentLocations(School school, @PathVariable(required = false) Integer locationId) {
        return school.getLocations().stream()
                // 1. Filter out the blank, unsaved location used for form binding
                .filter(loc -> loc.getId() != null)
                // 2. Filter out the current location being edited to prevent infinite loops
                .filter(loc -> locationId == null || !loc.getId().equals(locationId))
                .toList();
    }
    
    @GetMapping("/new")
    public String initCreationForm(School school, ModelMap model) {
        Location location = new Location();
        school.addLocation(location);
        model.put("location", location);
        return "schools/createOrUpdateLocationForm";
    }

    @PostMapping("/new")
    public String processCreationForm(School school, @Valid Location location, BindingResult result, ModelMap model) {
        if (result.hasErrors()) {
            model.put("location", location);
            return "schools/createOrUpdateLocationForm";
        }
        school.addLocation(location);
        locationRepository.save(location);
        
        String slug = school.getDomain().replace(".edu", "");
        return "redirect:/schools/" + slug;
    }
  • To display the locations, we need to add a new section to your schoolDetails.html file that will include the "Add Location" button, and a table to list the existing locations.

  • Add this code inside of, at the bottom of the existing container.

  • Log in as a School Admin user, and navigate to their assigned school to test adding locations.

schoolDetails.html Add Location

  <div class="mt-5">
    <div class="d-flex justify-content-between align-items-center mb-3">
      <h2>Locations</h2>
      
      <a th:href="@{|/schools/${school.id}/locations/new|}" 
         th:if="${canEdit}" 
         class="btn btn-success">
        Add Location
      </a>
    </div>

    <div th:if="${school.locations.isEmpty()}" class="alert alert-info">
      No locations have been added to this school yet.
    </div>

    <table th:unless="${school.locations.isEmpty()}" class="table table-striped table-hover mt-3">
      <thead class="table-dark">
        <tr>
          <th>Name</th>
          <th>Address</th>
          <th>Description</th>
          <th th:if="${canEdit}">Actions</th>
        </tr>
      </thead>
      <tbody>
        <tr th:each="location : ${school.locations}">
          <td th:text="${location.name}">Building Name</td>
          <td th:text="${location.address}">123 Main St</td>
          <td th:text="${location.description}">Main campus gym</td>
          
          <td th:if="${canEdit}">
            <a th:href="@{|/schools/${school.id}/locations/${location.id}/edit|}" 
               class="btn btn-sm btn-outline-primary">Edit</a>
            
            <form th:action="@{|/schools/${school.id}/locations/${location.id}/delete|}" 
                  method="post" class="d-inline"
                  onsubmit="return confirm('Are you sure you want to delete this location?');">
              <button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
            </form>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
  • The reason only the name field triggers an error is because your Location class inherits from NamedEntity, which already has a @NotBlank annotation on its name string. 

  • To enforce remaining validation, you need to apply Bean Validation annotations directly to the variables in your Location.java file.

  • Because your LocationController already has the @Valid annotation and the BindingResult parameter in its POST methods, Spring Boot will automatically intercept these new rules, check the data, and send the custom messages back to the template.

Location.java

import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDateTime;

// ... (your existing class annotations)
public class Location extends NamedEntity {

    @ManyToOne
    @JoinColumn(name = "school_id")
    private School school;

    @ManyToOne
    @JoinColumn(name = "parent_location_id")
    private Location parentLocation;

    @Column(name = "description")
    private String description;

    @Column(name = "address")
    @NotBlank(message = "Please provide a street address.")
    private String address;

    @Column(name = "latitude")
    @NotNull(message = "Latitude is required.")
    @DecimalMin(value = "-90.0", message = "Latitude must be a valid number between -90 and 90.")
    @DecimalMax(value = "90.0", message = "Latitude must be a valid number between -90 and 90.")
    private BigDecimal latitude;

    @Column(name = "longitude")
    @NotNull(message = "Longitude is required.")
    @DecimalMin(value = "-180.0", message = "Longitude must be a valid number between -180 and 180.")
    @DecimalMax(value = "180.0", message = "Longitude must be a valid number between -180 and 180.")
    private BigDecimal longitude;

    @Enumerated(EnumType.STRING)
    @Column(name = "status_id")
    @NotNull(message = "Please select a valid status.")
    private LocationStatus status = LocationStatus.ACTIVE;

    // ... (rest of your class)
}
  • @NotBlank: Used for Strings. It ensures the value is not null and is not just empty spaces.

  • @NotNull: Used for objects like BigDecimal or Enums. It ensures the value exists, but does not check for empty strings.

  • @DecimalMin / @DecimalMax: Used for numeric types. It ensures the user inputs a valid GPS coordinate range.

Annotation Breakdown

  • You cannot directly add a custom message to the name field inside Location.java because that variable belongs to the parent NamedEntity class. Modifying NamedEntity directly would change the error message for every other entity in the Pet Clinic project (like Owners and Pets).

  • To customize the message for locations only, Spring Boot allows you to override validation text using a properties file.

  • Open your src/main/resources/messages.properties file, add this line at the bottom:
    NotBlank.location.name=Please enter a name for this location.

  • Spring Boot follows a specific naming convention: AnnotationName.objectName.fieldName. When the validation fails on a Location object's name field, Spring will look in this file first and use your custom message instead of the default text.

Name Validation Message

  • If a parent location is selected, we want to pre-populate the address, latitude, and longitude fields.

  • For database design, pre-populating the fields is generally better than hiding them. If a child location (Basketball Court) shares the address of its parent (Gymnasium), saving that address directly on the child record prevents you from having to write complex SQL JOIN statements later just to find out where the court is located. It also allows the user to tweak the GPS coordinates slightly for the specific entrance to the court.

  • Because Thymeleaf renders on the server before the page is sent to the browser, it cannot react to a user clicking a dropdown menu in real time. To achieve this, you need to use a small amount of client-side JavaScript.

  • Add latitude/longitude values to the first two locations in your db:

    • Kirkwood Main Campus: 41.9183949219706, -91.6556028184276

    • Carver-Hawkeye Arena: 41.6640690473, -91.554612631832

Parent Location Selected

  • To seamlessly pre-populate those fields without requiring an extra call to the server, we can use custom HTML5 data-* attributes to attach the parent's address and coordinates directly to the dropdown options.

  • Open your createOrUpdateLocationForm.html file and update the <option> tag inside the parent location <select> block.

  • We need to use the Thymeleaf Elvis Operator (?: ''). This tells the server: "If the database value is null, print an empty string instead of deleting the HTML attribute."

Embed Data in HTML Options

  <div class="mb-3">
    <label for="parentLocation" class="form-label">Parent Location (Optional)</label>
    <select class="form-select" id="parentLocation" th:field="*{parentLocation}">
      <option value="">-- None --</option>
      <option th:each="loc : ${parentLocations}"
              th:value="${loc.id}"
              th:text="${loc.name}"
              th:data-address="${loc.address ?: ''}"
              th:data-lat="${loc.latitude ?: ''}"
              th:data-lng="${loc.longitude ?: ''}">Parent Name</option>
    </select>
  </div>
  • Add a <script> block at the very bottom of your createOrUpdateLocationForm.html file, just above the closing </body> tag.

  • This script listens for a change in the dropdown menu. When a parent is selected, it extracts the data from those attributes and injects it into the text inputs.

Add the JavaScript

<script>
  document.addEventListener('DOMContentLoaded', function() {
    const parentSelect = document.getElementById('parentLocation');
    const addressInput = document.getElementById('address');
    const latInput = document.getElementById('latitude');
    const lngInput = document.getElementById('longitude');

    parentSelect.addEventListener('change', function() {
      const selectedOption = parentSelect.options[parentSelect.selectedIndex];

      if (selectedOption.value !== "") {
        const pulledAddress = selectedOption.getAttribute('data-address');
        const pulledLat = selectedOption.getAttribute('data-lat');
        const pulledLng = selectedOption.getAttribute('data-lng');
        
        // Only overwrite if the text box is currently empty
        if (!addressInput.value && pulledAddress) addressInput.value = pulledAddress;
        if (!latInput.value && pulledLat) latInput.value = pulledLat;
        if (!lngInput.value && pulledLng) lngInput.value = pulledLng;
      }
    });
  });
</script>
  • Make sure the "Parent Location" you are selecting from the dropdown has an address and coordinates saved to it in the database. If the parent location's data is blank, it will correctly pass blank data down to the child.
  • Next, we need to add the "Edit" and "Delete" buttons for each row in your schoolDetails.html file .

  • We will use the ${canEdit} boolean flag to ensure these administrative actions are only visible to authorized users.

schoolDetails.html Edit/Delete

    <table th:unless="${school.locations.isEmpty()}" class="table table-striped table-hover mt-3">
      <thead class="table-dark">
        <tr>
          <th>Name</th>
          <th>Address</th>
          <th>Description</th>
          <th th:if="${canEdit}">Actions</th>
        </tr>
      </thead>
      <tbody>
        <tr th:each="location : ${school.locations}">
          <td th:text="${location.name}">Building Name</td>
          <td th:text="${location.address}">123 Main St</td>
          <td th:text="${location.description}">Main campus gym</td>
          
          <td th:if="${canEdit}">
            <a th:href="@{|/schools/${school.id}/locations/${location.id}/edit|}" 
               class="btn btn-sm btn-outline-primary">Edit</a>
            
            <form th:action="@{|/schools/${school.id}/locations/${location.id}/delete|}" 
                  method="post" class="d-inline"
                  onsubmit="return confirm('Are you sure you want to delete this location?');">
              <button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
            </form>
          </td>
        </tr>
      </tbody>
    </table>
  • The th:if="${canEdit}" tags ensure that standard users (like students) only see the data, while administrators see the action buttons.

  • Because the LocationController will use a @PostMapping for the delete route (which is a secure best practice to prevent accidental deletions via pre-fetched links), the "Delete" button cannot be a standard <a> link. It must be a mini <form> that submits a POST request.

  • The onsubmit="return confirm(...);" on the delete form intercepts the click and asks the user to confirm before sending the POST request to the server.

schoolDetails.html

  • Pre-fetching is a performance optimization technique used by modern web browsers (like Chrome, Safari, and Edge) to make websites feel faster.

  • Browsers try to predict what you are going to do next. If you are reading a webpage, the browser might look at the links on that page and quietly download those linked pages in the background before you even click them.

  • It can be triggered in a few ways:

    • Hovering: When you rest your mouse over a link for a fraction of a second, the browser assumes you are about to click it and starts downloading the destination URL.

    • HTML Tags: A website can explicitly tell the browser to pre-fetch specific pages using tags like <link rel="prefetch" href="/next-page">.

    • Browser Prediction: Some browsers learn your habits or use algorithms to guess the most likely link you will click next.

Pre-fetching links

  • In web development, a standard <a> hyperlink always executes an HTTP GET request. GET requests are strictly supposed to be "safe" operations—meaning they only retrieve data, they never alter or destroy it.

  • If you build a delete button as a simple link like this:

    <a href="/schools/1/locations/5/delete">Delete Location</a>
    ...you have created a dangerous situation.
  • If a user simply hovers their mouse over that "Delete" link while reading the table, their browser might eagerly fire off a GET request to /schools/1/locations/5/delete in the background to pre-fetch the result. Your server receives that request, sees the URL, and deletes the location from the database—even though the user never actually clicked the mouse.

Pre-fetching delete links

  • Browsers are programmed to understand that HTTP POST requests are meant for submitting data and changing the state of the server.

  • Therefore, browsers will never automatically pre-fetch a form submission or a POST request. By changing your delete button into a mini <form method="post">, you guarantee that the browser will only send that destructive request to the server when the user intentionally clicks the button and confirms the action.

  • This also protects your app from search engine web crawlers that "click" every <a> link they see to index your site, but they do not submit forms. If your delete routes are GET links, a search engine bot could accidentally wipe out your entire database just by indexing the page.

The Form / POST Solution

    @GetMapping("/{locationId}/edit")
    public String initUpdateForm(@PathVariable("locationId") int locationId, ModelMap model) {
        Location location = locationRepository.findById(locationId)
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Location not found"));
        model.put("location", location);
        return "schools/createOrUpdateLocationForm";
    }

    @PostMapping("/{locationId}/edit")
    public String processUpdateForm(@Valid Location location, BindingResult result, School school,
                                    @PathVariable("locationId") int locationId, ModelMap model) {
        if (result.hasErrors()) {
            location.setId(locationId);
            model.put("location", location);
            return "schools/createOrUpdateLocationForm";
        }
        location.setId(locationId);
        school.addLocation(location);
        locationRepository.save(location);
        
        String slug = school.getDomain().replace(".edu", "");
        return "redirect:/schools/" + slug;
    }
  • Create methods in the LocationController to display and process the form to update existing Location records.

LocationController /edit

import org.springframework.transaction.annotation.Transactional;
// -- Other import statements

@PostMapping("/{locationId}/delete")
@Transactional
public String processDeleteForm(@PathVariable("schoolId") int schoolId, @PathVariable("locationId") int locationId) {
    Location location = locationRepository.findById(locationId)
       .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Location not found"));

    School school = location.getSchool();

    // 1. Sever the parent-to-child link
    if (school != null) {
       school.getLocations().removeIf(loc -> loc.getId() != null && loc.getId().equals(locationId));
    }
    
    // 2. Sever the child-to-parent link
    location.setSchool(null);

    // 3. Execute the soft-delete within the transaction
    locationRepository.delete(location);

    return "redirect:/schools/" + schoolId;
}
  • Create a method in the LocationController to process the the delete location forms.

  • Because Location.java has the @SQLDelete annotation, this will execute an UPDATE query setting the deleted_at column to NOW(), rather than executing a hard DELETE.

LocationController /delete

  • Your School entity has a @OneToMany(cascade = CascadeType.ALL) relationship with its locations. When the @ModelAttribute method in your controller fetches the School, it loads all the locations into the current Hibernate session. 

  • If you call locationRepository.delete(location), you are telling Hibernate to delete the child. However, before the transaction commits, Hibernate checks the parent School entity. Because the Location still exists inside the school's in-memory list, the cascade rules override your delete command. Hibernate assumes the entity should still exist and cancels the DELETE SQL statement. Because no DELETE is issued, your @SQLDelete annotation is never triggered.

  • You must remove the location from the parent school's list before deleting it.

Deleting a Location

  • You must wrap the entire method in a single transaction so the unlinking and the deletion commit together.

  • Furthermore, when dealing with bi-directional relationships, it is best practice to sever the link on both sides of the entity (removing the location from the school, and nullifying the school on the location).

@Transactional Annotation

Java 3 - Week 11

By Marc Hauschildt

Java 3 - Week 11

  • 161