Marc Hauschildt
Web Technologies and Computer Software Development Instructor at Kirkwood Community College in Cedar Rapids, IA.
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.
Update the resources/db/mysql/schema.sql file to contain this:
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.
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.
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.
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.
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.
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
}
}
Create a new Java annotation class named ResourceNotFoundException.java in your system package
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
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.
// 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);
}
Make a GET request to the following:
Create a RecipeController method that returns a single recipe.
// 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
}
Make a GET request to the following:
Create a RecipeController method add a new recipe recipe.
// 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.
// 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"
}Make a GET request to http://localhost:8080/recipes
Create a RecipeController method to delete an existing recipe.
// 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"
}Make a GET request to http://localhost:8080/recipes
Add "/resources/**", to the SecurityConfig file:
Please include any other personal project related routes here as well.
.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.
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.
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.
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.
.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:
<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!
An unexpected error occurred: Cannot invoke "java.security.Principal.getName()" because "principal" is null
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.
<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.
@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.
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.
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.
By Marc Hauschildt
Web Technologies and Computer Software Development Instructor at Kirkwood Community College in Cedar Rapids, IA.