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. Update and delete existing objects.
Lesson 7 - Homepage with content from the database. Custom not found and error pages.
Lesson 8 - Login and logout. Cookies. User permission access.
Lesson 9 - Edit and delete user profile. Password reset.
Lesson 10 - Filtering and limiting records by category.
Lesson 11 - Web Sockets. Date and currency formatting.
Lesson 12 - Email and SMS messaging. Failed login attempts.
Lesson 13 - Shopping Cart. Payment processing.
Sort and filter the list of schools
update and delete a school
language toggle
Use local storage to remember language toggle.
Access schools by visiting "/schools/kirkwood" instead of "/schools/1"
Cannot snapshot C:\Users\mlhau\OneDrive\Kirkwood\Grading\spring-athleagues-lp\build\resources\main\db\h2\data.sql: not a regular file
Cannot snapshot C:\Users\mlhau\OneDrive\Kirkwood\Grading\spring-athleagues-lp\build\classes\java\main\org\springframework\samples\petclinic\model\BaseEntity.class: not a regular file
Title: Register New Student with Auto-School Detection
Actor: Anonymous User (to become Student by default)
Precondition: User is not logged in.
Main Success Scenario:
User clicks "Register" in the top navigation bar.
System displays the Registration Form (Fields: Email, Password).
User enters valid data (e.g., alex.student@kirkwood.edu, StrongPass1!).
System validates:
Email format is valid.
Email is not already registered.
Password meets strength requirements (8+ chars, mix of types). (continued)
Main Success Scenario (continued):
System parses the email domain (kirkwood.edu) to find the associated School.
System saves the new User with the STUDENT role.
System automatically logs the user in (establishing a session).
System redirects the user to the School Details Page for that domain (e.g., /schools/{id}).
Alternative Flows:
School Not Found: If the email domain (e.g., @gmail.com) does not match any registered school, the system saves the user but redirects them to a generic "Welcome" or "Select School" page.
Validation Fail: System re-displays the form with error messages.
This diagram includes logic specific for my Athleagues project: Domain Parsing.
When a user enters their email address, we need to extract the part after the @ symbol and look it up in the SchoolRepository.
sequenceDiagram
actor User
participant Controller as AuthController
participant Service as UserService
participant Security as HttpServletRequest
participant Repo as SchoolRepository
participant DB as Database
User->>Controller: POST /register (email, password)
%% Phase 1: Validation
Controller->>Controller: Validate Input (@Valid)
alt Validation Failed
Controller-->>User: Return "auth/registerForm" (Display Errors)
else Validation Passed
%% Phase 2: Registration
Note right of Controller: 1. Create Account
Controller->>Service: registerNewUser(user)
Service->>Service: Hash Password (BCrypt)
Service->>DB: Save User & Roles
DB-->>Service: Saved User
Service-->>Controller: Return User
%% Phase 3: Auto-Login
Note right of Controller: 2. Auto-Login
Controller->>Security: login(email, rawPassword)
Security->>Security: Authenticate & Create Session
Security-->>Controller: Session Established (JSESSIONID)
%% Phase 4: School Detection
Note right of Controller: 3. Domain Resolution
loop Recursive Domain Check
Controller->>Repo: findByDomain(currentDomain)
Repo-->>Controller: Optional<School>
opt School Not Found
Controller->>Controller: Strip subdomain (student.kirkwood.edu -> kirkwood.edu)
end
end
%% Phase 5: Redirect
alt School Found
Controller->>Controller: Add Flash Attribute (messageSuccess)
Controller-->>User: Redirect to /schools/{id}
else No School Found
Controller->>Controller: Add Flash Attribute (messageWarning)
Controller-->>User: Redirect to /
end
endWe will create additional unit tests after creating an AuthController class.
Create a "user" package.
Add the following to the dependencies block in your build.gradle file:
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
Create UserServiceImplTest and UserDetailsServiceImplTest classes.
The tests should pass when run.
Use a tool like Insomnia, a simple browser extension, or Postman to send POST requests with a JSON body.
Make a new HTTP request. Change GET to POST.
Enter "http://localhost:8080/api/auth/register" as the URL.
Enter the following data as the body. Change the firstName and email as necessary.
{ "firstName": "Alex",
"lastName": "Student",
"email": "alex.student@kirkwood.edu",
"password": "StrongPassword123" }
Go to the Headers tab. Ensure that a header named "Content-Type" with the value "application/json" is present.
Click Send. If a 201 status displays.
Check your database. A new user and user_role are created.
If you send a POST request with valid credentials to http://localhost:8080/api/auth/login, you will get a 200 OK response.
Use this as the JSON body.
{ "email": "alex.student@kirkwood.edu",
"password": "StrongPassword123" }
If the password is wrong, Spring Security will throw a BadCredentialsException.
When we run the unit tests, it currently ignores MySqlIntegrationTests and PostgresIntegrationTests. Since we're using MySQL, let's enable MySqlIntegrationTests.
Open MySqlIntegrationTests.java.
Replace these lines:
@Testcontainers(disabledWithoutDocker = true)
@DisabledInNativeImage
@DisabledInAotMode
With this: @Testcontainers
Ensure Docker Desktop is running on your machine. The tests use "Testcontainers" to spin up a real MySQL instance on the fly.
Add an assertion to the testFindAll method to verify it worked
assertThat(vets.findAll()).isNotEmpty();
Add additional assertions to the MySqlIntegrationTests.findAll method.
The MySqlIntegrationTests class uses the @ActiveProfiles("mysql") annotation.
In application-mysql.properties, the configuration points specifically to schema.sql and data.sql
@Autowired
private VetRepository vets;
@Autowired
private UserRepository users;
@Autowired
private SchoolRepository schools;
@Test
void testFindAll() {
vets.findAll();
vets.findAll(); // served from cache
assertThat(vets.findAll()).isNotEmpty();
users.findAll();
users.findAll(); // served from cache
assertThat(users.findAll()).isNotEmpty();
schools.findAll();
schools.findAll(); // served from cache
assertThat(schools.findAll()).isNotEmpty();
}CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
first_name VARCHAR(50),
last_name VARCHAR(50),
nickname VARCHAR(50),
nickname_is_flagged TINYINT DEFAULT 0,
email VARCHAR(255) NOT NULL,
public_email TINYINT DEFAULT 0,
phone VARCHAR(255),
public_phone TINYINT DEFAULT 0,
password_hash VARCHAR(255),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME,
UNIQUE INDEX idx_users_email (email),
INDEX idx_users_name (last_name, first_name)
);
CREATE TABLE IF NOT EXISTS roles (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
description VARCHAR(255)
);
CREATE TABLE IF NOT EXISTS permissions (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description VARCHAR(255)
);
CREATE TABLE IF NOT EXISTS user_roles (
user_id INT NOT NULL,
role_id INT NOT NULL,
PRIMARY KEY (user_id, role_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS permission_role (
permission_id INT NOT NULL,
role_id INT NOT NULL,
PRIMARY KEY (permission_id, role_id),
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS schools (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
domain VARCHAR(255) NOT NULL,
status_id ENUM('ACTIVE', 'INACTIVE', 'SUSPENDED') DEFAULT 'ACTIVE',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME DEFAULT NULL,
UNIQUE INDEX idx_schools_domain (domain)
);
CREATE TABLE IF NOT EXISTS locations (
id INT AUTO_INCREMENT PRIMARY KEY,
school_id INT NOT NULL,
parent_location_id INT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
address VARCHAR(255),
latitude DECIMAL(8,4),
longitude DECIMAL(8,4),
status_id ENUM('DRAFT', 'ACTIVE', 'CLOSED', 'COMING_SOON') DEFAULT 'ACTIVE',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME DEFAULT NULL,
CONSTRAINT fk_locations_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE,
CONSTRAINT fk_locations_parent FOREIGN KEY (parent_location_id) REFERENCES locations(id) ON DELETE SET NULL
);mysql profile relies strictly on SQL files found in src/main/resources/db/mysql/.schema.sql and add table definitions for your entities at the bottom.INSERT IGNORE INTO roles (name, description) VALUES
('SCHOOL_ADMIN', 'Rec Center Admin: Can manage facilities, leagues, scores, and users.'),
('STUDENT', 'Student: Can join leagues, create teams, and view schedules.');
INSERT IGNORE INTO permissions (name, description) VALUES
('MANAGE_OWN_PROFILE', 'Allows user to update their personal info and password.'),
('USE_MESSAGING', 'Allows user to send/receive messages with other participants.'),
('VIEW_LEAGUES', 'Allows user to browse and search available leagues and activities.'),
('REGISTER_FOR_LEAGUE', 'Allows user to register as an individual for a league.'),
('CREATE_TEAM', 'Allows user to create a new team as a captain.'),
('MANAGE_TEAM_INVITATIONS', 'Allows user to accept or decline invitations to a team.'),
('VIEW_OWN_SCHEDULE', 'Allows user to view their personal and team game schedule.'),
('VIEW_STANDINGS', 'Allows user to view league standings and team statistics.'),
('MANAGE_FACILITIES', 'Allows user to C/R/U/D locations, fields, and courts.'),
('MANAGE_SCHEDULES', 'Allows user to C/R/U/D leagues, activities, and games.'),
('MANAGE_REGISTRATIONS', 'Allows user to view and approve team registrations.'),
('MANAGE_SCORES', 'Allows user to enter and confirm game scores.'),
('SEND_ANNOUNCEMENTS', 'Allows user to send messages to individuals, teams, and leagues.');
INSERT IGNORE INTO permission_role (role_id, permission_id) VALUES
((SELECT id FROM roles WHERE name = 'STUDENT'), (SELECT id FROM permissions WHERE name = 'MANAGE_OWN_PROFILE')),
((SELECT id FROM roles WHERE name = 'STUDENT'), (SELECT id FROM permissions WHERE name = 'USE_MESSAGING')),
((SELECT id FROM roles WHERE name = 'STUDENT'), (SELECT id FROM permissions WHERE name = 'VIEW_LEAGUES')),
((SELECT id FROM roles WHERE name = 'STUDENT'), (SELECT id FROM permissions WHERE name = 'REGISTER_FOR_LEAGUE')),
((SELECT id FROM roles WHERE name = 'STUDENT'), (SELECT id FROM permissions WHERE name = 'CREATE_TEAM')),
((SELECT id FROM roles WHERE name = 'STUDENT'), (SELECT id FROM permissions WHERE name = 'MANAGE_TEAM_INVITATIONS')),
((SELECT id FROM roles WHERE name = 'STUDENT'), (SELECT id FROM permissions WHERE name = 'VIEW_OWN_SCHEDULE')),
((SELECT id FROM roles WHERE name = 'STUDENT'), (SELECT id FROM permissions WHERE name = 'VIEW_STANDINGS'));
INSERT IGNORE INTO permission_role (role_id, permission_id) VALUES
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'MANAGE_OWN_PROFILE')),
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'USE_MESSAGING')),
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'VIEW_LEAGUES')),
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'REGISTER_FOR_LEAGUE')),
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'CREATE_TEAM')),
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'MANAGE_TEAM_INVITATIONS')),
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'VIEW_OWN_SCHEDULE')),
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'VIEW_STANDINGS')),
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'MANAGE_FACILITIES')),
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'MANAGE_SCHEDULES')),
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'MANAGE_REGISTRATIONS')),
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'MANAGE_SCORES')),
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'SEND_ANNOUNCEMENTS'));
INSERT IGNORE INTO users (first_name, last_name, email, password_hash) VALUES
('Brett', 'School Admin', 'brett.baumgart@kirkwood.edu', 'hashed_password_for_brett'),
('Alex', 'Student', 'alex.student@student.kirkwood.edu', 'hashed_password_for_alex');
INSERT IGNORE INTO user_roles (user_id, role_id) VALUES
((SELECT id FROM users WHERE email = 'brett.baumgart@kirkwood.edu'), (SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN')),
((SELECT id FROM users WHERE email = 'alex.student@student.kirkwood.edu'), (SELECT id FROM roles WHERE name = 'STUDENT'));
INSERT IGNORE INTO schools (name, domain, status_id) VALUES
('Kirkwood Community College', 'kirkwood.edu', 'active'),
('University of Iowa', 'uiowa.edu', 'active'),
('Iowa State University', 'iastate.edu', 'active'),
('University of Northern Iowa', 'uni.edu', 'active'),
('Coe College', 'coe.edu', 'active'),
('Mount Mercy University', 'mtmercy.edu', 'active'),
('Drake University', 'drake.edu', 'active'),
('Grinnell College', 'grinnell.edu', 'active'),
('Luther College', 'luther.edu', 'active'),
('Simpson College', 'simpson.edu', 'inactive'), -- Testing status
('Wartburg College', 'wartburg.edu', 'active'),
('Cornell College', 'cornellcollege.edu', 'active'),
('Loras College', 'loras.edu', 'active'),
('Clarke University', 'clarke.edu', 'suspended'), -- Testing status
('St. Ambrose University', 'sau.edu', 'active');
INSERT IGNORE INTO locations (school_id, name, description, address, status_id) VALUES
(1, 'Main Campus', 'The primary campus in Cedar Rapids', '6301 Kirkwood Blvd SW, Cedar Rapids, IA', 'active');
INSERT IGNORE INTO locations (school_id, name, description, address, status_id) VALUES
(2, 'Carver-Hawkeye Arena', 'Main sports arena', '1 Elliott Dr, Iowa City, IA', 'active');
INSERT IGNORE INTO locations (school_id, parent_location_id, name, description, status_id) VALUES
(1, 1, 'Michael J Gould Rec Center', 'Student recreation facility', 'active'),
(1, 1, 'Johnson Hall', 'Athletics building and gymnasium', 'active');
INSERT IGNORE INTO locations (school_id, parent_location_id, name, description, status_id) VALUES
(2, 2, 'Main Court', 'The primary basketball court', 'active'),
(2, 2, 'Weight Room', 'Athlete training facility', 'coming_soon');
INSERT IGNORE INTO locations (school_id, parent_location_id, name, description, status_id) VALUES
(1, 3, 'Basketball Court 1', 'North court', 'active'),
(1, 3, 'Basketball Court 2', 'South court', 'active');
data.sql and add this to the bottom.INSERT IGNORE is a MySQL-specific command that tells the database, "Try to insert this row. If a unique key (like the role name) already exists, just ignore this command and move on without throwing an error."
When you run the tests, the Docker container used by your test will initialize with the correct tables and data. Your tests will pass.
@SpringBootTest(webEnvironment = RANDOM_PORT)
This tells Spring to start the Tomcat server, but instead of using the default port 8080 (which might be busy), it picks a random available port (e.g., 60982).
@LocalServerPort int port;
Spring injects that random port number into this variable so your test knows where to send requests.
builder.rootUri("http://localhost:" + port):
This constructs the full URL (e.g., http://localhost:60982).
template.exchange(...)
This sends a GET request to the path /owners/1 and waits for the server to process the request (query DB, render HTML)
assertThat(...):
It verifies that the server responded with "200 OK". The test fails if the ID didn't exist (404) or the server crashed (500).
Add this method inside your MySqlIntegrationTests class:
@Test
void testSchoolDetails() {
RestTemplate template = builder.rootUri("http://localhost:" + port).build();
ResponseEntity<String> result = template.exchange(RequestEntity.get("/schools/1").build(), String.class);
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(result.getBody()).contains("Kirkwood Community College");
}/schools/{id}" mapping in the SchoolController class.Instead of returning a plain School object (which might be null), have the findById method return the following:
@Transactional(readOnly = true)
Optional<School> findById(Integer id);
Add a @GetMapping method to handle requests to "/schools/{schoolId}"
The @PathVariable annotation tells Spring to look at the URL itself to find a value after /schools/.
{schoolId} defines a "wildcard" in your URL pattern
How it works: When a user visits /schools/1, Spring sees the {schoolId} placeholder, grabs the value 1, and forces it into your method parameter int schoolId.
@GetMapping("/schools/{schoolId}")
public ModelAndView showSchool(@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);
return mav;
}In Spring MVC, you need to return two things to the user:
The View: Which HTML file to display (schoolDetails.html).
The Model: The actual data to populate that file (The "School" object).
ModelAndView is a container that holds both of these in one object.
You instantiate the object with the view.
You call the .addObject() method to add the model.
The .orElseThrow() method only exists on Optional objects.
You can use Spring's built-in ResponseStatusException to handle requests for schoolId's that don't exist.
Create a new view file at: templates/schools/schoolDetails.html
It uses the standard PetClinic layout, so it will have the navigation bar and footer automatically. I have added a dynamic header that pulls the school name from your database.
th:text="${school.name}": This line ensures that when your test runs assertThat(result.getBody()).contains("Kirkwood Community College"), it will pass because the HTML renders the actual name from the DB.
layout: Keeping the th:replace at the top ensures all your CSS/JS assets load, preventing 404s on static resources during browser testing.
<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org"
th:replace="~{fragments/layout :: layout (~{::body},'schools')}">
<body>
<h2 class="text-center">Welcome to <span th:text="${school.name}">School Name</span></h2>
<div class="container mt-4">
<div class="alert alert-info">
<h4 class="alert-heading">Coming Soon!</h4>
<p>Upcoming leagues, intramural events, and registration for
<strong th:text="${school.name}">this school</strong> will appear here shortly.
</p>
</div>
</div>
</body>
</html>
Hopefully you understand the importance of internationalization.
But if you are in a prototyping phase or just want to move forward without translating every single string immediately, disabling the test is a valid strategy.
Open your I18nPropertiesSyncTest.java and add the annotation to disable the whole class.
@Disabled("Temporarily disabled until MVP is complete")
If only one specific test method is annoying you, you can disable just that one.
@Test
@Disabled
void testToDisable() { /* ... */ }
Turn on Docker Desktop before running unit tests.
When we run unit tests, CrashControllerIntegrationTests fails because of a conflict between the original test design and our new Security implementation.
Our new SecurityConfig (and AuthController) requires a database connection to load (to verify users). This conflict can cause the test context to fail or behave unpredictably.
We need to remove the "No-Database" optimization. Since our application now has complex security dependencies (UserService, Roles, Database).
Remove lines 95-99—the inner TestConfiguration class and the @SpringBootApplication annotation entirely.
It is possible for a URL like `localhost:8080/schools/kirkwood` to be mapped to `localhost:8080/schools/1`.
Schools can be looked up by their id, or by the domain, minus the `.edu` part
Not recommended:
If you map `/{slug}` at the root level, `localhost:8080/{slug}`, Spring will try to match everything against it.
For example, if a user goes to /login, Spring thinks "login" is a school slug and checks the DB for login.edu it, which returns 404.
If a user goes to `/css/style.css`, Spring thinks "css" is a school slug and returns 404.
If you absolutely require root URLs (like linkedin.com/username), you have to write complex exclusion rules.
Using `/schools/kirkwood` is much safer and cleaner.
Here is how to implement "Smart URLs" that accept both IDs (/schools/1) and Slugs (/schools/kirkwood) in the same Controller.
Update the @GetMapping to use Regular Expressions to tell Spring: "If the URL is a number, call the ID method. If it is text, call the Slug method."
// Matches ONLY numbers (e.g., /schools/1)
@GetMapping("/schools/{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);
return mav;
}
// Matches text (e.g., /schools/kirkwood)
@GetMapping("/schools/{slug:[a-zA-Z-]+}")
public ModelAndView showSchoolBySlug(@PathVariable("slug") String slug) {
// Reconstruct the domain (User asked to assume ".edu")
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);
return mav;
}The current set up is suitable for building a REST API that communicates with a JavaScript frontend like React, Angular, or Vue.
The Pet Clinic project uses an MVC structure with Thymeleaf to handle the frontend workload.
For this project, we need to refactor the AuthController to be a Spring MVC Controller that serves HTML forms and handles the domain redirection logic.
package org.springframework.samples.petclinic.user;
import jakarta.validation.Valid;
import org.springframework.samples.petclinic.school.School;
import org.springframework.samples.petclinic.school.SchoolRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import java.util.Optional;
@Controller
public class AuthController {
private final UserService userService;
private final SchoolRepository schoolRepository;
public AuthController(UserService userService, SchoolRepository schoolRepository) {
this.userService = userService;
this.schoolRepository = schoolRepository;
}
@GetMapping("/register")
public String initRegisterForm(Model model) {
model.addAttribute("user", new User());
return "auth/registerForm";
}
@PostMapping("/register")
public String processRegisterForm(@Valid User user, BindingResult result) {
if (result.hasErrors()) {
return "auth/registerForm";
}
// 1. Save the User (UserService handles password hashing)
try {
userService.registerNewUser(user);
} catch (RuntimeException ex) {
// Handle duplicate email or other service errors
result.rejectValue("email", "duplicate", "This email is already registered");
return "auth/registerForm";
}
// 2. Parse Domain and Redirect
String email = user.getEmail();
String domain = email.substring(email.indexOf("@") + 1);
Optional<School> school = schoolRepository.findByDomain(domain);
if (school.isPresent()) {
return "redirect:/schools/" + school.get().getId();
} else {
// Fallback if no school matches the email domain
return "redirect:/";
}
}
@GetMapping("/login")
public String initLoginForm() {
return "auth/loginForm";
}
}Key Changes:
@Controller instead of @RestController (returns Views, not JSON).
initRegisterForm: Prepares the blank User object.
processRegisterForm: Handles validation, saves the user via UserService, parses the email domain, and redirects to the specific School page.
Your SecurityConfig.java is still pointing to the old API endpoints. We need to open the new /register URL so users can access it without logging in.
Replace the two existing .requestMatchers with this:
.requestMatchers("/register","/login").permitAll()
In SchoolRepository, add a method to look up a school by its domain.
Using Optional allows us to handle the "School Not Found" scenario (like when a user registers with @gmail.com) explicitly in the Controller using .isPresent() or .orElse(), preventing NullPointerExceptions.
package org.springframework.samples.petclinic.school;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.Optional;
public interface SchoolRepository extends Repository<School, Integer> {
/**
* Get a School by its domain.
*/
@Transactional(readOnly = true)
Optional<School> findByDomain(String domain);
/**
* Retrieve all Schools from the data store.
*/
@Transactional(readOnly = true)
Collection<School> findAll();
/**
* Retrieve Schools by page (for pagination in the UI)
*/
@Transactional(readOnly = true)
Page<School> findAll(Pageable pageable);
/**
* Save a School to the data store, either inserting or updating it.
*/
void save(School school);
/**
* Retrieve a School by its id.
*/
@Transactional(readOnly = true)
School findById(Integer id);
}
Create registerForm.html in a "src/main/resources/templates/auth/" folder.
This uses the same inputField fragment you previously updated to ensure consistent styling and validation messages.
th:replace="~{fragments/layout ...}": This means "I USE the layout." as defined in layout.html.
<html xmlns:th="https://www.thymeleaf.org"
th:replace="~{fragments/layout :: layout (~{::body},'register')}">
<body>
<h2 th:text="#{title.register.new}">Register New Student</h2>
<form th:object="${user}" class="form-horizontal" method="post">
<input th:replace="~{fragments/inputField :: input ('firstName', 'First Name', 'text')}" />
<input th:replace="~{fragments/inputField :: input ('lastName', 'Last Name', 'text')}" />
<input th:replace="~{fragments/inputField :: input ('email', 'Email', 'text')}" />
<input th:replace="~{fragments/inputField :: input ('password', 'Password', 'password')}" />
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button class="btn btn-primary" type="submit" th:text="#{button.register}">Register</button>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<p>
<span th:text="#{text.auth.existing}">Already have an account?</span>
<a th:href="@{/login}" th:text="#{link.auth.login}">Login here</a>
</p>
</div>
</div>
</form>
</body>
</html>Add English translations.
You can also add these to your Spanish/Korean files if you wish, but it's not strictly required to pass unit tests.
# Auth / Registration
title.register.new=Register New Student
button.register=Register
text.auth.existing=Already have an account?
link.auth.login=Login hereOur inputField.html fragment currently has logic for 'text' and 'date', but it has no instruction for what to do if the type is 'password', so it will render nothing.
Open inputField.html and add a new th:case="'password'" to the switch statement.
<input th:case="'password'"
th:class="${#fields.hasErrors(name)} ? 'form-control is-invalid' : 'form-control'"
type="password" th:field="*{__${name}__}" />
We want to include a "Register" and "Login" button that changes to "Edit Profile" and "Logout" when the user is signed in?
To implement this, we need to use Spring Security Dialect for Thymeleaf (sec:authorize) to conditionally show buttons based on the user's login status.
First, ensure your layout.html (at the very top of the file) includes the security namespace, or the sec: tags will be ignored.
<html th:fragment="layout (template, menu)" xmlns:th="https://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
Thymeleaf does not understand sec:authorize out of the box. Without the specific plugin library, it ignores the attribute and renders the buttons by default.
Open build.gradle.
Add this line to your dependencies block:
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
Reload Gradle (Click the Elephant icon).
On desktop view, the Home and Find Schools links should be next to the branding logo, and the new buttons should be on the right.
On mobile view, the "Home" and "Find Schools" buttons should be stacked vertically with the new buttons underneath.
Place this code after the last ul tag in the nav section. It must go inside the div#main-navbar.
<ul class="navbar-nav ms-auto">
<li class="nav-item" sec:authorize="!isAuthenticated()">
<a class="nav-link" th:href="@{/register}"
th:classappend="${menu == 'register' ? 'active' : ''}">
<span class="fa fa-user-plus"></span> Register
</a>
</li>
<li class="nav-item" sec:authorize="!isAuthenticated()">
<a class="nav-link" th:href="@{/login}"
th:classappend="${menu == 'login' ? 'active' : ''}">
<span class="fa fa-sign-in"></span> Login
</a>
</li>
<li class="nav-item" sec:authorize="isAuthenticated()">
<a class="nav-link" th:href="@{/users/profile}"
th:classappend="${menu == 'profile' ? 'active' : ''}">
<span class="fa fa-user"></span> Edit Profile
</a>
</li>
<li class="nav-item" sec:authorize="isAuthenticated()">
<form th:action="@{/logout}" method="post" class="d-flex" style="height: 100%; margin: 0;">
<button type="submit" class="nav-link btn btn-link"
style="border: none; background: transparent; cursor: pointer; display: flex; align-items: center;">
<span class="fa fa-sign-out" style="margin-right: 5px;"></span> Logout
</button>
</form>
</li>
</ul>sec:authorize="!isAuthenticated()": Shows the content only if the user is Anonymous (Guest).
sec:authorize="isAuthenticated()": Shows the content only if the user is Logged In.
The Left <ul> has me-auto (Margin End Auto). This consumes all available empty space to its right, effectively pushing the next element (the Right <ul>) to the edge.
Logout Form: I wrapped the logout button in a <form method="post">.
th:classappend) to check if the variable menu (passed from the page) matches the specific name for that button. This styles the "active" link.Temporarily change the Logout button from "isAuthenticated()" to "!isAuthenticated()"
The Logout item is a <button> inside a <form>. Browsers apply a default "User Agent Stylesheet" to buttons (gray background, borders, padding) that overrides the Bootstrap .nav-link class.
Add this code at the bottom of the layout.html <head> section to force the button to behave like an <a> tag and inherit the parent's hover effects.
<style>
.navbar-nav button.nav-link {
border: none;
border-radius: 0;
cursor: pointer;
padding: 28px 8px;
display: flex;
align-items: center;
text-transform: uppercase;
font-size: 14px;
font-family: 'montserratregular', sans-serif;
}
@media (max-width: 991.98px) {
.navbar-nav form {
width: 100%;
}
.navbar-nav button.nav-link {
padding: 28px 20px;
width: 100%;
justify-content: flex-start; /* Moves icon/text to the left */
text-align: left; /* Fallback for older browsers */
}
}
</style>
<script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
<script>
$(document).ready(function(){
$('.navbar-nav button.nav-link').hover(
function() {
// Code to execute on mouse enter (function_in)
$(this).css("background-color", "#6db33f");
},
function() {
// Code to execute on mouse leave (function_out)
$(this).css("background-color", "rgba(0, 0, 0, 0)");
}
);
});
</script>From Java 2, our User.java has Database rules (nullable = false), which means the database schema requires them. We changed the structure to not require them.
User.java is also missing Java Bean Validation rules (@NotEmpty). The Controller checks the Java rules, not the Database rules.
You need to import jakarta.validation.constraints.* and add the annotations to the fields.
The @Valid annotation in the AuthController looks for these tags.
package org.springframework.samples.petclinic.user;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.*;
import java.util.Set;
@Entity
@Table(name = "users")
@Data
@NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name="first_name", nullable = true, length = 50)
private String firstName;
@Column(name="last_name", nullable = true, length = 50)
private String lastName;
@Column(nullable = false, unique = true, length = 100)
@NotEmpty(message = "Email is required") // Stops empty strings
@Email(message = "Please enter a valid email") // Enforces email format
private String email;
@Column(name="password_hash", nullable = false, length = 255)
@NotEmpty(message = "Password is required")
@Size(min = 8, message = "Password must be at least 8 characters")
@Pattern(regexp = "^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z]).{8,}$", message = "Password must contain uppercase, lowercase, and number")
private String password;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
@EqualsAndHashCode.Exclude
private Set<Role> roles;
}Run the app, click Register, and sign up as yourname.student@kirkwood.edu. It should redirect you to the Kirkwood school page.
Sign up as yourname.student@uiowa.edu. It should redirect you to the Iowa school page.
Sign up as yourname.student@example.edu. It should redirect you to the Iowa school page.
Some schools have different types of emails for students or alumni, like "@student.kirkwood.edu" or "@alumni.uni.edu".
Update AuthController.java to add a helper method called findSchoolByRecursiveDomain that will loop through the domain parts until it finds a match or runs out of parts.
Update part 2 of the processRegisterForm method to call that method.
package org.springframework.samples.petclinic.user;
import jakarta.validation.Valid;
import org.springframework.samples.petclinic.school.School;
import org.springframework.samples.petclinic.school.SchoolRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import java.util.Optional;
@Controller
public class AuthController {
private final UserService userService;
private final SchoolRepository schoolRepository;
public AuthController(UserService userService, SchoolRepository schoolRepository) {
this.userService = userService;
this.schoolRepository = schoolRepository;
}
@GetMapping("/register")
public String initRegisterForm(Model model) {
model.addAttribute("user", new User());
return "auth/registerForm";
}
@PostMapping("/register")
public String processRegisterForm(@Valid User user, BindingResult result) {
if (result.hasErrors()) {
return "auth/registerForm";
}
// 1. Save the User (UserService handles password hashing)
try {
userService.registerNewUser(user);
} catch (RuntimeException ex) {
// Handle duplicate email or other service errors
result.rejectValue("email", "duplicate", "This email is already registered");
return "auth/registerForm";
}
// 2. Parse Domain and Redirect
String email = user.getEmail();
Optional<School> school = findSchoolByRecursiveDomain(email);
if (school.isPresent()) {
return "redirect:/schools/" + school.get().getId();
} else {
// Fallback if no school matches the email domain
return "redirect:/";
}
}
@GetMapping("/login")
public String initLoginForm() {
return "auth/loginForm";
}
private Optional<School> findSchoolByRecursiveDomain(String email) {
// 1. Extract the initial domain (e.g., "student.kirkwood.edu")
String domain = email.substring(email.indexOf("@") + 1);
// 2. Loop while the domain is valid (has at least one dot)
while (domain.contains(".")) {
// 3. Check Database
Optional<School> school = schoolRepository.findByDomain(domain);
if (school.isPresent()) {
return school; // Found match (e.g., "kirkwood.edu")
}
// 4. Strip the first part (e.g., "student.kirkwood.edu" -> "kirkwood.edu")
int dotIndex = domain.indexOf(".");
domain = domain.substring(dotIndex + 1);
}
return Optional.empty();
}
}
You should verify this works by creating AuthControllerTest.java with a unit test that processes a user register with subdomain redirect.
package org.springframework.samples.petclinic.user;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.DisabledInNativeImage;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.samples.petclinic.school.School;
import org.springframework.samples.petclinic.school.SchoolRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.aot.DisabledInAotMode;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@WebMvcTest(AuthController.class)
@DisabledInNativeImage
@DisabledInAotMode
class AuthControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private SchoolRepository schoolRepository;
@MockitoBean
private UserService userService;
@MockitoBean
private AuthenticationManager authenticationManager;
@Test
void testProcessRegister_WithSubdomainRedirect() throws Exception {
// Mock: School exists for "kirkwood.edu"
School kirkwood = new School();
kirkwood.setId(1);
kirkwood.setName("Kirkwood");
kirkwood.setDomain("kirkwood.edu");
// Repository only knows "kirkwood.edu"
given(schoolRepository.findByDomain("kirkwood.edu")).willReturn(Optional.of(kirkwood));
// Repository does NOT know "student.kirkwood.edu"
given(schoolRepository.findByDomain("student.kirkwood.edu")).willReturn(Optional.empty());
given(userService.registerNewUser(any(User.class))).willReturn(new User());
// MOCK THE LOGIN-When the controller asks to authenticate, return a dummy "Success" token
given(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class)))
.willReturn(new TestingAuthenticationToken("user", "password", "ROLE_STUDENT"));
// User registers with SUBDOMAIN
mockMvc.perform(post("/register")
.with(csrf())
.param("email", "alex@student.kirkwood.edu") // <--- Subdomain input
.param("password", "StrongPass1!"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/schools/1")); // Should still find ID 1
}
}
To implement a "Flash Message" (a temporary notification that survives a redirect), we use Spring's RedirectAttributes.
Step 1: Update AuthController.java
Update the processRegisterForm method to accept RedirectAttributes. This allows us to pass data (the message) that survives the browser redirect.
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller
public class AuthController {
@PostMapping("/register")
public String processRegisterForm(@Valid User user,
BindingResult result,
RedirectAttributes redirectAttributes) { // <--- Inject RedirectAttributes
// code omitted
if (school.isPresent()) {
// 1. Set the Success Message
redirectAttributes.addFlashAttribute("messageSuccess",
"Your user account is created. You have been redirected to " + school.get().getName() + "'s school page.");
return "redirect:/schools/" + school.get().getId();
} else {
// 1b. Set a Generic Message (Warn them since they didn't match a school)
redirectAttributes.addFlashAttribute("messageWarning",
"Your user account is created, but we could not find a school matching your email domain.");
// Fallback if no school matches the email domain
return "redirect:/";
}
}
}Step 2: Update layout.html HTML
We need to add a "Slot" for this message to appear. The best place is inside the main container, right above where the page content (th:replace="${template}") is injected.
It defines three distinct blocks. Since they all use the class flash-message, a jQuery script can be written to automatically handle the slide-up animation for any of them.
</nav>
<div class="container-fluid">
<div class="container xd-container">
<div th:if="${messageSuccess}"
class="alert alert-success alert-dismissible fade show flash-message"
role="alert">
<i class="fa fa-check-circle"></i>
<span th:text="${messageSuccess}">Success</span>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<div th:if="${messageWarning}"
class="alert alert-warning alert-dismissible fade show flash-message"
role="alert">
<i class="fa fa-exclamation-circle"></i>
<span th:text="${messageWarning}">Warning</span>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<div th:if="${messageDanger}"
class="alert alert-danger alert-dismissible fade show flash-message"
role="alert">
<i class="fa fa-times-circle"></i>
<span th:text="${messageDanger}">Error</span>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<th:block th:insert="${template}" />Step 3: Update layout.html JavaScript
To make it disappear automatically after 10 seconds, add this script.
<script>
$(document).ready(function(){
$('.navbar-nav button.nav-link').hover(
function() {
// Code to execute on mouse enter (function_in)
$(this).css("background-color", "#6db33f");
},
function() {
// Code to execute on mouse leave (function_out)
$(this).css("background-color", "rgba(0, 0, 0, 0)");
}
);
const $flash = $('.flash-message');
if ($flash.length) {
setTimeout(function() {
// slideUp creates a smooth "disappearing" animation
$flash.slideUp(500, function() {
// Once animation is done, remove from DOM
$(this).remove();
});
}, 10000);
}
});
</script>Spring Security creates session cookies automatically to track when a user is "logged in".
The missing piece is that right now, you are saving the user to the database, but you aren't logging them in. The user remains "Anonymous," so isAuthenticated() returns false, and your buttons don't change.
Your UserService.registerNewUser(user) method takes the password (e.g., "password123") and overwrites it with a Bcrypt hash (e.g., $2a$10$EixZa...).
To log the user in, we need the raw password. If we try to login with the hash, it will fail.
Open AuthController.java
We will modify processRegisterForm to:
Capture the raw password before the Service hashes it.
Inject HttpServletRequest to access the login functionality.
Log in the user using AuthenticationManager.
// Add these imports
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.ServletException;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
@Controller
public class AuthController {
private final UserService userService;
private final SchoolRepository schoolRepository;
private final AuthenticationManager authenticationManager; // Add this field
// Add to Constructor
public AuthController(UserService userService, SchoolRepository schoolRepository, AuthenticationManager authenticationManager) {
this.userService = userService;
this.schoolRepository = schoolRepository;
this.authenticationManager = authenticationManager;
}
@PostMapping("/register")
public String processRegisterForm(@Valid User user,
BindingResult result,
RedirectAttributes redirectAttributes) {
if (result.hasErrors()) {
return "auth/registerForm";
}
String rawPassword = user.getPassword();
// 1. Save the User (UserService handles password hashing)
try {
userService.registerNewUser(user);
} catch (RuntimeException ex) {
// Handle duplicate email or other service errors
result.rejectValue("email", "duplicate", "This email is already registered");
return "auth/registerForm";
}
// 2. LOGIN using the authenticationManager.
try {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(user.getEmail(), rawPassword);
Authentication authentication = authenticationManager.authenticate(authToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (Exception e) {
redirectAttributes.addFlashAttribute("messageDanger", "Account created, but auto-login failed.");
return "redirect:/login";
}
// 3. REDIRECT
Optional<School> school = findSchoolByRecursiveDomain(user.getEmail());
// existing code omitted
}
}How it works
request.login(email, password): This is a standard Jakarta EE method. It asks Spring Security to verify the credentials.
Success: If valid, Spring Security creates a session, generates a JSESSIONID cookie, sends it to the browser, and flips the user status to isAuthenticated().
Redirect: When the browser loads the next page (the School page), it sends the cookie back.
Layout: Your layout.html sees isAuthenticated() is true and renders the Edit Profile / Logout buttons.
Restart the application. Register a new user.
Upon redirect, look at the top right. You should immediately see EDIT PROFILE and LOGOUT.
If you restart the server and visit "/schools/new" and submit the form with no input, you'll get a 403 forbidden error.
This error is happening because in your SecurityConfig.java, you likely have this line which protects POST requests, but allows GET requests.
.requestMatchers(HttpMethod.GET).permitAll()
This allows an anonymous user to view the form (GET /schools/new).
However, you do not have a matching rule for the POST request. Therefore, it falls through to your catch-all rule:
.anyRequest().authenticated()
Because we disabled the default login form redirect (.formLogin(AbstractHttpConfigurer::disable)), Spring Security simply blocks the request with a 403 Forbidden instead of redirecting you to a login page.
If you want to test the form validation without logging in, you must explicitly allow POST requests to that URL.
Open SecurityConfig.java and add the specific matcher:
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(HttpMethod.GET).permitAll()
.requestMatchers("/register", "/login").permitAll()
// TEMPORARY: Allow anonymous users to POST to these forms
.requestMatchers(HttpMethod.POST, "/schools/new", "/owners/new").permitAll()
.anyRequest().authenticated()
)However, since we have built the Login/Register feature, the intended flow is that only logged-in users should be able to create schools or owners.
If you want to test the form validation without logging in, you must explicitly allow POST requests to that URL.
Open SecurityConfig.java and add the specific matcher:
If you want to test the form validation without logging in, you must explicitly allow POST requests to that URL.
Open SecurityConfig.java and add the specific matcher:
GitHub Actions works as your "robot butler." Instead of you manually running gradle build, docker build, and docker push on your laptop, GitHub does it automatically every time you push code.
Here is the workflow:
You push code to your GitHub repository.
GitHub Actions wakes up, spins up a temporary server (runner).
It builds your jar using Gradle.
It logs in to Docker Hub (using secrets you provide).
It builds and pushes your Docker image.
You should never put your Docker Hub password directly in the file. You use GitHub Secrets.
Go to your GitHub repository.
Click Settings -> Secrets and variables -> Actions.
Click New repository secret.
Add these two:
Name: DOCKERHUB_USERNAME
Value: your_actual_username
Name: DOCKERHUB_TOKEN
Value: (Go to Docker Hub -> Account Settings -> Security -> New Access Token. Use this token instead of your real password.)
In your project, create this directory structure and file: .github/workflows/deploy.yml
Paste this content in. I have customized it for Gradle and Java 17.
name: Build and Push Docker Image
on:
push:
branches: [ "main" ]
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
# 1. Download your code
- name: Checkout code
uses: actions/checkout@v4
# 2. Set up Java 17 (Same as your local environment)
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin' # or 'microsoft' if you prefer
# 3. Build the JAR file with Gradle
# We skip tests here to speed it up, but you can remove '-x test' to be safer
- name: Build with Gradle
run: ./gradlew clean bootJar -x test -x jar
# 4. Set up Docker Buildx (required for modern Docker builds)
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# 5. Log in to Docker Hub
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# 6. Build and Push the Docker image
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/petclinic:latestCommit this file and push it to GitHub.
git add .
git commit -m "Add GitHub Action workflow"
git push
Go to the "Actions" tab in your GitHub repository. You will see the workflow running. When it turns green, check Docker Hub—your image will have been freshly updated!
Right now, GitHub pushes the image, but Azure doesn't know about it yet. You have two choices:
Continuous Deployment (CD) in Azure:
Go to your Azure Container App in the portal.
Under Application -> Revision management (or Continuous deployment), you can enable a setting that says "Create a new revision when a new image is pushed."
Note: This often requires a webhook setup.
Add an Azure Step to the YAML:
You can add a final step to the GitHub Action that runs az containerapp update to tell Azure to pull the new image immediately.
Would you like the code snippet to force Azure to update at the end of this workflow?
DROP TABLE IF EXISTS leagues;
CREATE TABLE leagues (
id INT AUTO_INCREMENT PRIMARY KEY,
school_id INT NOT NULL,
location_id INT, -- Default location
user_id INT, -- League Manager/Contact
name VARCHAR(255) NOT NULL,
description TEXT,
registration_start DATETIME,
registration_end DATETIME,
league_start DATETIME,
league_end DATETIME,
is_public TINYINT DEFAULT 1,
type ENUM('male', 'female', 'coed') NOT NULL,
capacity INT,
capacity_type ENUM('team', 'individual') NOT NULL,
fee DECIMAL(6,2),
status_id ENUM('draft', 'active', 'inactive', 'postponed', 'cancelled', 'past') DEFAULT 'draft',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_leagues_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE,
CONSTRAINT fk_leagues_location FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE SET NULL,
CONSTRAINT fk_leagues_manager FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
-- Performance: Quickly find active leagues for a specific school
INDEX idx_leagues_school_status (school_id, status_id)
);
DROP TABLE IF EXISTS teams;
CREATE TABLE teams (
id INT AUTO_INCREMENT PRIMARY KEY,
league_id INT NOT NULL,
captain_user_id INT NOT NULL,
name VARCHAR(255) NOT NULL,
logo_url VARCHAR(255),
status_id ENUM('active', 'inactive', 'suspended') DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_teams_league FOREIGN KEY (league_id) REFERENCES leagues(id) ON DELETE CASCADE,
CONSTRAINT fk_teams_captain FOREIGN KEY (captain_user_id) REFERENCES users(id) ON DELETE CASCADE,
-- Performance: List all teams in a league
INDEX idx_teams_league (league_id)
);
DROP TABLE IF EXISTS team_users;
CREATE TABLE team_users (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
user_id INT NOT NULL,
role ENUM('member', 'captain') DEFAULT 'member',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_team_users_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_team_users_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
-- Integrity: A user cannot join the same team twice
UNIQUE INDEX idx_team_users_unique (team_id, user_id)
);DROP TABLE IF EXISTS events;
CREATE TABLE events (
id INT AUTO_INCREMENT PRIMARY KEY,
league_id INT NOT NULL,
location_id INT,
user_id INT, -- Event organizer/referee
name VARCHAR(255) NOT NULL,
description TEXT,
event_start DATETIME,
event_end DATETIME,
status_id ENUM('draft', 'active', 'ongoing', 'postponed', 'cancelled', 'final') DEFAULT 'draft',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_events_league FOREIGN KEY (league_id) REFERENCES leagues(id) ON DELETE CASCADE,
CONSTRAINT fk_events_location FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE SET NULL,
CONSTRAINT fk_events_contact FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
-- Integrity & Performance: Check for double-bookings at a location
INDEX idx_events_location_time (location_id, event_start)
);
DROP TABLE IF EXISTS matches;
CREATE TABLE matches (
id INT AUTO_INCREMENT PRIMARY KEY,
event_id INT NOT NULL,
home_team_id INT,
away_team_id INT,
winner_team_id INT,
home_score INT DEFAULT 0,
away_score INT DEFAULT 0,
status ENUM('scheduled', 'in_progress', 'final', 'forfeit') DEFAULT 'scheduled',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_matches_event FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
CONSTRAINT fk_matches_home FOREIGN KEY (home_team_id) REFERENCES teams(id) ON DELETE SET NULL,
CONSTRAINT fk_matches_away FOREIGN KEY (away_team_id) REFERENCES teams(id) ON DELETE SET NULL,
CONSTRAINT fk_matches_winner FOREIGN KEY (winner_team_id) REFERENCES teams(id) ON DELETE SET NULL,
-- Performance: Essential for calculating standings (W/L records)
INDEX idx_matches_home (home_team_id),
INDEX idx_matches_away (away_team_id)
);DROP TABLE IF EXISTS messages;
CREATE TABLE messages (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id_from INT NOT NULL,
user_id_to INT,
league_id INT,
event_id INT,
parent_message_id INT, -- For threaded replies
message VARCHAR(255) NOT NULL,
is_flagged TINYINT DEFAULT 0,
status_id ENUM('draft', 'active', 'hidden') DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_messages_from FOREIGN KEY (user_id_from) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_messages_to FOREIGN KEY (user_id_to) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_messages_league FOREIGN KEY (league_id) REFERENCES leagues(id) ON DELETE CASCADE,
CONSTRAINT fk_messages_event FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
CONSTRAINT fk_messages_parent FOREIGN KEY (parent_message_id) REFERENCES messages(id) ON DELETE CASCADE,
-- Performance: Quickly load chat history for a league or event
INDEX idx_messages_context (league_id, event_id)
);
DROP TABLE IF EXISTS message_reactions;
CREATE TABLE message_reactions (
id INT AUTO_INCREMENT PRIMARY KEY,
message_id INT NOT NULL,
user_id INT NOT NULL,
reaction ENUM('like', 'dislike', 'love', 'hug', 'sad', 'angry'),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_reactions_message FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
CONSTRAINT fk_reactions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
-- Integrity: One reaction per user per message
UNIQUE INDEX idx_reactions_unique (message_id, user_id)
);It is possible to replace Thymeleaf with a frontend JavaScript framework like React, Angular, or Vue.
This involves shifting your application from a Server-Side Rendered (SSR) architecture to a Client-Side Rendered (CSR) or Single Page Application (SPA) architecture.
Here is how that transition works:
Thymeleaf: The Spring Boot server generates the full HTML page. It merges the data (e.g., a list of Vets) with the template (vetList.html) and sends the finished HTML to the browser.
React/Angular/Vue: The Spring Boot server becomes a REST API. It sends only the raw data (usually in JSON format). The JavaScript framework running in the browser receives that JSON and builds the HTML dynamically.
A benefit of the layered architecture (Controller > Service > Repository) is that you do not need to change your Database or Repository layers—you only need to modify the Controller layer.
If you look at VetController.java, the application already has an endpoint ready for a JavaScript framework to use:
// This method is for Thymeleaf (Returns a View)
@GetMapping("/vets.html")
public String showVetList(...) { ... }
// This method is for External Clients/JS Frameworks (Returns JSON/XML)
@GetMapping({ "/vets" })
public @ResponseBody Vets showResourcesVetList() {
Vets vets = new Vets();
vets.getVetList().addAll(this.vetRepository.findAll());
return vets;
}The @ResponseBody annotation tells Spring: "Do not look for a Thymeleaf template. Just take this Java object, convert it to JSON, and send it back."
A React or Angular app would make a fetch('/vets') call to this URL, receive the list of doctors, and render the table itself.
To fully replace Thymeleaf, you would:
Update Controllers: Change your @Controller classes to @RestController (which automatically applies @ResponseBody to every method).
Return Data, Not Strings: Instead of returning strings like "owners/createOrUpdateOwnerForm", your methods would return Owner objects or ResponseEntity objects.
Delete Templates: You would eventually delete the src/main/resources/templates folder since the Java app no longer generates HTML.
Frontend Build: You would build your React/Angular app separately. You can then either run it on a separate server (like Node.js) that talks to your Spring Boot API, or package the built JavaScript files into the Spring Boot static folder to serve them together.
By Marc Hauschildt
Web Technologies and Computer Software Development Instructor at Kirkwood Community College in Cedar Rapids, IA.