Sections of the tutorial will continuously be published at this web page.
This tutorial serves to give you the practical knowledge required to execute the group project in true AGILE fashion.
The tutorial will use a simple event registration app as a running example. The event registration app allows users to sign up and register for events using their name.
1. Requirements Engineering
Before designing and implementing the event registration app, we must precisely specify the features and characteristics we expect from it.
1.1. Functional Requirements
Functional requirements describe the functionality (i.e., features) we expect the app to have. It might be helpful to start with the high-level requirements (i.e., user requirements) and then derive more specific requirements (i.e., system requirements) from those. Here are examples of features that the event registration app should have.
-
The event registation application shall allow prospective users to create an account.
-
The event registration application shall allow users to create events.
-
The event registration application shall allow users to view events.
-
The event registration application shall allow users to register for events.
Notice how, even for user requirements, our requirements describe the system’s behaviour, they do not place requirements on or make assumptions about the user.
Now, for each user requirement, we can derive one or more system requirements.
-
The event registation application shall allow prospective users to create an account.
-
The event registration application shall allow prospective users to create an account by specifying a name and a password.
-
The event registration application shall record the date of registration for all new users.
-
-
The event registration application shall allow users to create events.
-
The event registration application shall allow users to create an online event by specifying an event name, a date, a start time, an end time, and a URL.
-
The event registration application shall allow users to create an in-person event by specifying an event name, a date, a start time, an end time, and an address.
-
-
The event registration application shall allow users to view events.
-
The event registration application shall allow users to view a list containing a summary of every event. For each event, the summary shall include the event name and date.
-
The event registration application shall allow users to filter the list of events by name.
-
The event registration application shall allow users to filter the list of events by a range of dates.
-
The event registration shall allow users to view the full details of a specific event, including name, date, start and end times, and address or URL.
-
Note that not all system functional requirements need to be derived from user requirements. For example, we might have requirements describing processes that happen automatically and are not visible to end users.
-
Every Sunday at 23:30 EST, the event registration application shall automatically delete all events whose end date has passed.
-
It shall be possible to update the event registration application at any time with no more than 15 minutes of downtime.
1.2. Non-Functional Requirements
Non-functional requirements describe characteristics that we expect the application to have. For example, we might have expectations about the system’s speed, the amount of memory or storage it uses, how user-friendly it is, etc. Here are some examples of non-functional requirements for the event registration app.
-
When a user creates a valid event, the event registration application shall save the event and provide confirmation in no more than 2 seconds, 95% of the time.
-
The event registration application shall not use more than 250 MB of memory on an end user’s computer at any time.
-
The event registation application shall be sufficiently user-friendly that a new user with basic computer literacy shall be able to create their account in no more than 15 minutes.
1.3. Use Case Diagram
We also want to describe the use cases for the event registration application using a use case diagram.
The diagram above shows the following.
-
Any user can search for and view events.
-
To register for an event, a user must have an account.
-
Premium users have all the same privileges as a regular account holder. In addition, they can create events.
-
Only guests can sign up and log in (users who are already logged in don’t need these features).
1.4. Use Case Specifications
A detailed use case specification describes a use case in greater detail. It should have a main success scenario, as well as alternate scenarios in case something unusal happens. Steps are numbered. Alternatives to step 1 are labelled 1a, 1b, and so on. Then the steps in the alternative scenario 1a are labelled 1a.1, 1a.2, and so on. An alternative scenario may end in success, it may end in failure, or the use case may continue at another step.
-
ID: UC1
-
Title: Create in-person event
-
Description: An account holder schedules an in-person event.
-
Actor: An account holder
-
Main scenario:
-
1) The user indicates that they want to create an in-person event.
-
2) The system displays the in-person event creation form.
-
3) The user enters an event name, date, start time, end time, and address.
-
4) The system confirms that the event was created successfully.
-
-
Alternative scenarios:
-
4a) The user entered an empty event name.
-
4a.1) The system informs the user that the event name is required.
-
4a.2) The use case continues at step 3.
-
-
4b) The user selected a start datetime or an end datetime in the past.
-
4b.1) The system informs the user that events can only be scheduled for the future.
-
4b.2) The use case continues at step 3.
-
-
etc.
-
-
ID: UC2
-
Title: Search for events
-
Description: A user searches for multiple existing events.
-
Actor: Any user
-
Main scenario:
-
1) The user indicates that they want to search for an event.
-
2) The system displays the event search page.
-
3) The user optionally specifies a full or partial event name or a date range or both.
-
4) The system displays all events that satisfy the given conditions.
-
-
Alternative scenarios:
-
4a) No events satisfy the conditions.
-
4a.1) The system warns the user that no such events exist.
-
4a.2) The use case ends in failure.
-
-
Use case specifications can refer back to other use cases if needed.
-
ID: UC3
-
Title: Search for one event
-
Description: A user searches for a single existing event.
-
Actor: Any user
-
Main scenario:
-
1) The user searches for multiple events (see UC2).
-
2) The user selects one event from the list.
-
-
ID: UC4
-
Title: Register for event
-
Description: An account holder signs up for a specific event.
-
Actor: An account holder
-
Main scenario:
-
1) The user searches for one event (see UC3).
-
2) The user indicates that they wish to register for that event.
-
3) The system confirms that the registration was successful.
-
-
Alternative scenarios:
-
3b) The event has already ended.
-
3b.1) The system informs the user that they cannot register for events in the past.
-
3b.2) The use case continues at step 1.
-
-
2. Project Management
2.1. GitHub Project Repository
One of the core components of Agile development is being able to manage the development problem space. GitHub projects extends GitHub’s utility to make problem space management easy.
To create a project, click your user icon and select Your projects from the dropdown menu.
Select the New Project button to create a project. When selecting the template, select Board under the Start from scratch section of the pop-up menu and click Create.
Name your project by selecting the default titular text and replacing it with your own project’s name. This layout is known as Kanban. By default there are three columns: To do, In progress and Done. More columns can be added by clicking the + button at the far right.
Any repository can be added to a project by navigating to the Projects tab of the repository, selecting the Add project button and choosing the desired project from the dropdown menu. Project Kanban boards can be viewed by clicking the Projects tab of the repository and selecting the appropriate Project.
To help with better management of the project as you move through project phases, it is prudent to add Milestones. A new milestone can be created by selecting the Issues tab in the repository, and selecting the Milestones tab located next to the New issue button.
To create a new milestone, select the New Milestone button. Then, fill out the form with an appropriate name, due date and description. Once your milestone has been created, you can attach issues to the milestone and see their progress by selecting the Milestones tab. Name your Milestone appropriately, denote the due date and enter a description.
With Milestones and Projects set up, issues can now be assigned to the appropriate project and Milestones. Their status should be changed so they are automatically triaged under the correct Kanban column that matches. As issues are completed and closed, don’t forget to change their status. Closing issues will fill the progress bar on Milestones, while assigning the status of Done shifts issues on the Kanban board.
Note
|
The status of issues can only be changed after they are created. |
When creating a new issue it is imperative to be concise but also as descriptive as possible. All the issues you create should have a title, with a comment to describe the issue in detail.
All issues at the time of creation should be assigned to someone. You can always change this later. Label your issues. If none of the default labels fit, new labels can be created to meet your need. This is accomplished by selecting the Labels tab next to the Milestones tab under the Issues section. Then click the New Label button. Finally, assign your issue to the appropriate milestone and project.
For the purpose of tracking progress through the project, never delete issues. Issues should be closed and reopened as needed but never deleted. Even if a mistake was made during creation of an issue, issues can be edited by their creator.
If you’ve set everything up correctly, your issue board should match your Kanban board. The Kanban board should be a snapshot of how the project is going. Nothing should be done manually here. All the manual labor of opening, moving and triaging issues should be done on the issue board, with automated results appearing on the Kanban board.
3. Backend
3.1. Setting Up a Local PostgreSQL Database
In this section, we will set up a local PostgreSQL database to store our application’s data.
3.1.1. Installation
Download the latest version of PostgreSQL from https://www.enterprisedb.com/downloads/postgres-postgresql-downloads. Once the download is complete, run the installer. You can stick with the default values for most screens.
Important
|
For your project, each team member will need to set up their own database on their own machine. You should coordinate with your team members and choose the same port number and password. This will make it easier to configure your app to connect to the database. |
The default installation directory should be fine.
Leave every component checked.
The default data directory should be fine.
Choose a password. IMPORTANT: do not forget this password. You will need it later.
The default port number should be fine. However, there’s no problem using a different port number in case a different app is using 5432 for some reason. Just remember your choice so that you know which port your app should connect to. Here is a list of typical TCP/UDP ports and their reservation status. Do not use a port that is reserved.
The default locale should be fine.
The summary might look something like this:
There’s no need for other tools, so you can skip the Stack Builder after the installation by unchecking the checkbox.
Once PostgreSQL is installed, you should be able to connect to your local instance by running the command psql --username postgres
and entering your password (I hope you haven’t forgotten it already).
If you didn’t use the default port number, you can pass the additional command-line argument --port
(e.g., psql --username postgres --port 5433
).
Note
|
If you get an error with some variation of the message "command 'psql' not found," then you likely need to add psql to your PATH environment variable.
It should be straightforward to find online instructions to do so on your operating system.
|
For the course project, each team member will need to set up a separate database instance on their own computer.
To simplify configuring your app to connect to the database, each team member should use the same password and port number.
If you initially chose different passwords, you can change your password by running psql
, running the command \password postgres
, and then entering the new password when prompted.
You can similarly change the port number (e.g., by following these instructions).
In short:
-
In
psql
, run the commandshow config_file;
(note the trailing semicolon) to locate the configuration file which stores the port number. -
Exit
psql
. -
Open the configuration file, locate the line
port = 5432
(where 5432 is replaced by your old port number), change the port number, and save the file. -
Restart the PostgreSQL service (or just restart your computer).
3.1.2. Creating a Database
One database management system (in this case, PostgreSQL) can host multiple databases.
In psql
, create a new database for the event registration app using the command
CREATE DATABASE event_registration;
Check that the database exists by running the command \l
:
3.2. Setting up a Spring Boot Project
We will use the Spring Boot framework to implement the backend of the event registration system. In this section, we will use Spring Initializr to quickly generate the folder structure and some files for a new Spring Boot project.
-
Go to https://start.spring.io/.
-
Set the project type to Gradle.
-
Leave the Spring Boot version at the default value.
-
Set the names for the group, package, etc.
-
Set the Java version.
-
Add the following dependencies:
-
web
-
data-jpa
-
postgresql
-
Click "GENERATE" and you should get a zip file. Unzip it, move the files into your Git repository, and rename the directory to EventRegistration-Backend. Later, we will add a new directory EventRegistration-Frontend for the user interface code. Your Git repository should look something like this:
.
├── EventRegistration-Backend
│ ├── build.gradle
│ ├── gradle
│ │ └── wrapper
│ │ ├── gradle-wrapper.jar
│ │ └── gradle-wrapper.properties
│ ├── gradlew
│ ├── gradlew.bat
│ ├── HELP.md
│ ├── settings.gradle
│ └── src
│ ├── main
│ │ ├── java
│ │ │ └── ca
│ │ │ └── mcgill
│ │ │ └── ecse321
│ │ │ └── eventregistration
│ │ │ └── EventregistrationApplication.java
│ │ └── resources
│ │ ├── application.properties
│ │ ├── static
│ │ └── templates
│ └── test
│ └── java
│ └── ca
│ └── mcgill
│ └── ecse321
│ └── eventregistration
│ └── EventregistrationApplicationTests.java
├── .git
│ └── [omitted...]
├── .gitignore
└── README.md
Open EventRegistration-Backend/src/main/resources/application.properties
and write the following configuration information:
spring.datasource.driver-class-name = org.postgresql.Driver
# What to do with existing database tables on startup and shutdown.
# See https://docs.spring.io/spring-boot/how-to/data-initialization.html#howto.data-initialization.using-hibernate.
# ddl-auto=create-drop means all database tables are created on startup and
# dropped (deleted) on shutdown.
# ddl-auto=update does not drop tables on shutdown. It will add new tables and
# columns on startup, but will not delete existing ones.
spring.jpa.hibernate.ddl-auto = update
# Adding the following line leads to better error messages in case the URL or
# credentials are wrong
spring.jpa.database-platform = org.hibernate.dialect.PostgreSQLDialect
# Be careful with the URL format: it is easy to make a typo here
spring.datasource.url = jdbc:postgresql://localhost:5432/event_registration
spring.datasource.username = postgres
spring.datasource.password = PASSWORD
# Decide which port our backend will listen on.
# This is relevant for deliverable 2.
# Could also just set server.port = 8080 to always listen on port 8080.
# The advantage of using the following form is that you can change the port on
# startup, e.g., using .\gradlew bootRun --args='--port=9090'.
server.port = ${port:8080}
(PASSWORD
is the password you chose while setting up the local database.)
Warning
|
For simplicity, we store our local database password directly in the public configuration file. Don’t do this with real credentials. Look for resources on proper secrets management (e.g., https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions). |
3.3. The Domain Model
3.3.1. Designing the Model
Before we can program the event registration app, we need to model it.
We clearly need classes representing people and events. We can associate those two classes to keep track of who is registered for each event. It is also standard practice in domain modeling to include a root class that includes all the other classes.
However, this domain model is not suitable for a database.
-
Since we are only developing one event registration system, the root class is unnecessary. Solution: remove the root class.
-
Bidirectional associations mean duplicate data in the database; every time someone registers for an event, we would need to add the event to their list of events and add the person to the list of registrants for the event. This is problematic both because (1) it wastes storage space and (2) the data may become inconsistent (what happens if Alice has a certain event in her list of events but Alice is not in the list of registrants for that event?). Solution: make all associations unidirectional.
-
Many-to-many associations require an extra table in the database. Explicitly representing this as a class in the domain model will make our lives easier. Solution: add a class called "Registration" between User and Event.
-
In the database, each table needs a primary key column. Solution: in this example, we could theoretically use the email address as a primary key for users and a combination of attributes (e.g., name, date, start time) as the primary key for events. However, it is usually best to simply add an integer ID to be a primary key. Using a single integer is usually simpler than using a combination of attributes and it saves space compared to using a string like an email address or username. Using a meaningful attribute like an email address, name, date, or start time in a primary key also makes it very difficult to change that attribute after the object is created, whereas an integer ID never needs to be changed.
-
"User" is a reserved keyword in PostgreSQL (https://www.postgresql.org/docs/current/sql-keywords-appendix.html). If we call a database table "User," we will run into syntax errors. Solution: rename the class (to "Person," "Customer," etc.).
Warning
|
For simplicity, we store users' passwords in plaintext. This is highly insecure. Don’t ever do this with real passwords. See https://www.youtube.com/watch?v=8ZtInClXe1Q. |
Note that we need to ensure people cannot register multiple times for the same event twice. There are at least two ways to do this.
-
Let the primary key for registrations be a "composite key" consisting of the primary keys for the registrant and event. Then, if Alice signs up for the same event twice, there will already be a row in the registrations table with her ID and the event’s ID, and therefore PostgreSQL will prevent the creation of a new row.
-
In the backend, before creating a new registration, check if the given user is already registered for the given event.
3.3.2. Generating Java Code
Now that we have a good domain model, we translate it to Java code. This can be done by hand or using a tool like Umple. Full documentation on how to use Umple can be found here.
Whether you write the model code by hand or using Umple, create a new package called model
under src/main/java/ca/mcgill/ecse321/eventregistration
.
Each class must have its own file.
Make sure your model files declare the package:
package ca.mcgill.ecse321.eventregistration.model;
Your Git repository should now look like this:
.
├── EventRegistration-Backend
│ ├── build.gradle
│ ├── gradle
│ │ └── wrapper
│ │ ├── gradle-wrapper.jar
│ │ └── gradle-wrapper.properties
│ ├── gradlew
│ ├── gradlew.bat
│ ├── HELP.md
│ ├── settings.gradle
│ └── src
│ ├── main
│ │ ├── java
│ │ │ └── ca
│ │ │ └── mcgill
│ │ │ └── ecse321
│ │ │ └── eventregistration
│ │ │ ├── EventregistrationApplication.java
│ │ │ └── model
│ │ │ ├── Event.java
│ │ │ ├── Person.java
│ │ │ └── Registration.java
│ │ └── resources
│ │ ├── application.properties
│ │ ├── static
│ │ └── templates
│ └── test
│ └── java
│ └── ca
│ └── mcgill
│ └── ecse321
│ └── eventregistration
│ └── EventregistrationApplicationTests.java
├── .git
│ └── [omitted...]
├── .gitignore
└── README.md
3.3.3. JPA Annotations
This is an exercise in being able to write JPA compliant code simply by looking at the domain model, which is left up to the students. The documentation for Hibernate 6.5 (the ORM we will be using) can be found here: https://docs.jboss.org/hibernate/orm/6.5/userguide/html_single/Hibernate_User_Guide.html. These are the essential attributes:
-
@Entity
: Placed before the class declaration to mark a class as an "entity," which may have a corresponding table in the database. Hibernate has a few requirements for entity classes (e.g., it must have a public, protected, or package-private no-args constructor). Read the docs for more details. -
@Inheritance(strategy=STRATEGY)
: Used to specify the inheritance strategy for a class hierarchy, where the superclass is annotated with the@Entity
tag. Read the docs for more details. -
@Id
: Placed before the attribute declaration that will serve as the primary unique identifier for the class in the corresponding database table. Read the docs for more details. -
@EmbeddedId
,@Embedded
: Used to define a composite primary key. Read the docs for more details. -
@GeneratedValue(strategy=STRATEGY)
: Placed between the @Id tag and the attribute declaration, indicating the attribute is to be generated automatically. Read the docs for more details. -
@OneToOne
,@ManyToOne
, etc.: Placed before the attribute declaration to specify the multiplicity in associative relationship between the current class and reference class. The first word is the multiplicity of the current class, with the other representing the multiplicity of the other class. Read the docs for more details.
3.3.4. Generating the Database Tables
Once you have added all the required JPA annotations, Hibernate will be able to generate the database tables automatically.
From the EventRegistration-Backend/
directory, run ./gradlew test
.
This will run the default test in EventRegistrationApplicationTests.java
.
While the test is starting, Hibernate will create the database.
After the test passes, connect to the database using psql --username postgres --dname event_registration
and list the tables using \dt
.
There should be one table per model class.
If the tests fail, see Debugging Failing Tests.
3.4. CRUD Repositories
In this section will introduce the Spring framework’s inbuilt support for such CRUD operations via the org.springframework.data.repository.CrudRepository
interface and will show how to use such repositories to implement your use cases in so-called service classes.
If you would like to, you can obtain a version of the project that already has the code for the backend and model classes from the previous tutorials: https://github.com/McGill-ECSE321-Winter2024/tutorial-friday/tree/master/EventRegistration-Backend/src/main/java/ca/mcgill/ecse321/eventregistration.
3.4.1. Creating a CRUD Repository
-
Create a new interface
PersonRepository
in theca.mcgill.ecse321.eventregistration.repository
package and extend theCrudRepository<Person, String>
interface. -
Create a new method
Person findById(int id)
.
PersonRepository.javapackage ca.mcgill.ecse321.eventregistration.repository; import org.springframework.data.repository.CrudRepository; import ca.mcgill.ecse321.eventregistration.model.Person; public interface PersonRepository extends CrudRepository<Person, Integer>{ Person findPersonById(int id); }
-
Since Spring supports automated JPA Query creation from method names (see possible language constructs here) we don’t need to implement the interface manually, Spring JPA will create the corresponding queries runtime! This way we don’t need to write SQL queries either.
Create interfaces for the Event
and Registration
classes as well.
3.5. Testing the Persistence Layer
Now we will write some tests to verify that our persistence layer is working as expected.
3.5.1. Writing a Test
The basic structure of a repository layer test is:
-
Create a new object.
-
Save the object to the database using the repository.
-
Read the object from the database using the repository.
-
Assert that the object from the database has the correct attributes.
Use dependency injection (via the @Autowired
annotation) to get an instance of your repository. Also add the @Test
annotation to each test method.
You will want to clear the database after each run so that you don’t waste storage space and avoid violating any unique constraints. Define a method clearDatabase()
with the annotation @AfterEach
that clears all relevant tables. Make sure you clear your dependent classes prior to clearing your independent classes, as operations only cascade if the associative relationship is a composition!
For example, the test class for the PersonRepository
should be similar to the following code. The package and import statements have been omited for clarity.
@SpringBootTest
public class PersonRepositoryTests {
@Autowired
private PersonRepository personRepository;
@AfterEach
public void clearDatabase() {
personRepository.deleteAll();
}
@Test
public void testPersistAndLoadPerson() {
// Create person
String name = "Muffin Man";
String emailAddress = "muffin.man@gmail.com";
String password = "i_love_muffins";
Person muffinMan = new Person();
muffinMan.setName(name);
muffinMan.setEmailAddress(emailAddress);
muffinMan.setPassword(password);
// Save person
muffinMan = personRepository.save(person);
int id = muffinMan.getId();
// Read person from database
Person muffinManFromDb = personRepository.findPersonById(id);
// Assert correct response
assertNotNull(person);
assertEquals(muffinManFromDb.getName(), name);
assertEquals(muffinManFromDb.getEmailAddress(), emailAddress);
assertEquals(muffinManFromDb.getPassword(), password);
}
}
3.5.2. Running the Tests
To run the tests, cd
into the EventRegistration
folder and issue the command ./gradlew test
.
3.5.3. Debugging Failing Tests
You will almost certainly encounter failing tests at some point. When this happens, it is usually helpful to read the stack trace and see if there are any helpful hints. At first you will probably find that the stack traces are short and not very informative. Add the following snippet inside the test
task in your build.gradle
file:
testLogging {
exceptionFormat "full"
//...other configurations
}
Re-run the tests and you should see much longer stack traces with helpful hints.
Alternatively, you can check the test report for the full stack traces.
There should be a link to the report in the output of gradlew
.
In either case, focus on the "Caused by:" parts.
The error messages go from high-level to low-level, so if you find the messages at the beginning too vague then you might be better off reading the stack trace bottom-up.
If all the tests are failing (including contextLoads()
), it means your project failed to start. This is generally because it failed to connect to the database or there is an issue with your model.
Common Errors
-
Missing or badly-formatted data in
application.properties
. You need a database driver, the URL of an existing database, a username, and a password. The database URL is particularly easy to mess up: it needs to follow the formatjdbc:postgresql://localhost:port/database_name
. -
Using reserved keywords for class names (e.g.,
User
). This will result in a nasty SQL syntax error. -
Incorrectly named repository methods. For example, if you have a property
eventName
and you call your repository methodfindEventByName()
, Spring will complain that there’s no property calledname
in theEvent
class. -
Missing annotations. For example, if you forget an
@Id
annotation, Spring will complain that there’s no unique identifier for your class.
3.6. Creating RESTful Web Services in Spring
The following steps provide guidance on (1) implementing business logic that implements the required functionality (classes annotated with @Service
) and (2) exposing them using a REST API in the context of the Event Registration Application (classes annotated with @RestController
).
3.6.1. Implementing Service Methods
We implement use-cases in service classes by using the CRUD repositories for each data type of the domain model.
In src/main/java, create a new package ca.mcgill.ecse321.eventregistration.service
.
As an example, some simple person-related methods can be implemented as follows:
package ca.mcgill.ecse321.eventregistration.service;
import java.sql.Date;
import java.time.LocalDate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import ca.mcgill.ecse321.eventregistration.model.Person;
import ca.mcgill.ecse321.eventregistration.repository.PersonRepository;
import jakarta.transaction.Transactional;
@Service
public class PersonService {
@Autowired
private PersonRepository personRepo;
public Person findPersonById(int pid) {
Person p = personRepo.findPersonById(pid);
if (p == null) {
throw new IllegalArgumentException("There is no person with ID " + pid + ".");
}
return p;
}
@Transactional
public Person createPerson(String name, String email, String password) {
if (password == null || password.length() < 8) {
throw new IllegalArgumentException("Password too short.");
}
Date now = Date.valueOf(LocalDate.now());
Person personToCreate = new Person(name, email, password, now);
return personRepo.save(personToCreate);
}
}
The @Transactional
annotations means that the annotated method will be run in a transactional way.
Spring Boot automatically begins a transaction when a method marked @Transactional
is called.
If that method throws an exception, the framework will "roll back" the transaction so that, if the method has already made some changes in the database, those changes do not take effect.
In this example it is perhaps unnecessary because we only perform one insertion, but in methods that perform multiple database operations it is important to use transactions to avoid data corruption.
See the Spring Boot documentation on the @Transactional
annotation and transaction management for more details.
We can use transactions directly in PostgreSQL using begin transaction
, rollback transaction
, and commit transaction
.
For example, consider the following scenario.
Note
|
This is not required for the project, but it is good general knowledge if you plan on working with databases in the future. |
$ psql -U postgres --dbname event_registration
Password for user postgres:
psql (14.13 (Ubuntu 14.13-0ubuntu0.22.04.1))
Type "help" for help.
event_registration=# select * from person;
id | creation_date | email_address | name | password
------+---------------+----------------------+-------+------------
1502 | 2024-10-24 | alice@mail.mcgill.ca | Alice | pass123456
(1 row)
At first, the database contains only one person.
We begin a transaction, insert a new person, and confirm that the new person is there.
However, after this, we accidentally wipe the person table.
We can revert back to the previous state of the database by running rollback transaction
.
After rolling back, Alice is still in the database, but Bob is not.
event_registration=# begin transaction;
BEGIN
event_registration=*# insert into person values (1, '2024-10-25', 'bob@mail.mcgill.ca', 'Bob', 'pass123');
INSERT 0 1
event_registration=*# select * from person;
id | creation_date | email_address | name | password
------+---------------+----------------------+-------+------------
1502 | 2024-10-24 | alice@mail.mcgill.ca | Alice | pass123456
1 | 2024-10-25 | bob@mail.mcgill.ca | Bob | pass123
(2 rows)
event_registration=*# delete from person;
DELETE 2
event_registration=*# rollback transaction;
ROLLBACK
event_registration=# select * from person;
id | creation_date | email_address | name | password
------+---------------+----------------------+-------+------------
1502 | 2024-10-24 | alice@mail.mcgill.ca | Alice | pass123456
(1 row)
If we instead commit the transaction, the changes take effect as usual.
event_registration=# begin transaction;
BEGIN
event_registration=*# insert into person values (1, '2024-10-25', 'bob@mail.mcgill.ca', 'Bob', 'pass123');
INSERT 0 1
event_registration=*# commit transaction;
COMMIT
event_registration=# select * from person;
id | creation_date | email_address | name | password
------+---------------+----------------------+-------+------------
1502 | 2024-10-24 | alice@mail.mcgill.ca | Alice | pass123456
1 | 2024-10-25 | bob@mail.mcgill.ca | Bob | pass123
(2 rows)
Important
|
If you’re ever manually working with a database (hopefully only a test database), you should perform updates in a transaction. That way you have the option to undo if you make a mistake. |
3.6.2. Exposing Service Functionality via a RESTful API
Building a RESTful Web Service Using a Controller and Data Transfer Objects
To handle receiving HTTP requests and sending responses, we create a separate controller layer.
We put this in a new package called ca.mcgill.ecse321.eventregistration.controller
.
We also want to define data transfer objects (DTOs) to precisely represent the bodies of the HTTP requests and responses our API accepts. Simply using the model classes would cause several problems. For example:
-
Model classes may have unnecessary or even sensitive information (e.g., a person’s password) that should not normally be included in responses.
-
Model classes may have fields that are generated by the server and should not be included in requests (e.g., a person’s ID or creation date).
Important
|
DTOs are an entirely different concept from the DAOs (data access objects, a.k.a. CRUD repositories) discussed earlier. |
We create yet another package, ca.mcgill.ecse321.eventregistration.controller
, to store these DTOs.
As an example, we can use the following person DTO in responses.
package ca.mcgill.ecse321.eventregistration.dto;
import java.time.LocalDate;
import ca.mcgill.ecse321.eventregistration.model.Person;
public class PersonResponseDto {
private int id;
private String name;
private String email;
private LocalDate creationDate;
// Jackson needs a default constructor, but it doesn't need to be public
@SuppressWarnings("unused")
private PersonResponseDto() {
}
public PersonResponseDto(Person model) {
this.id = model.getId();
this.name = model.getName();
this.email = model.getEmail();
this.creationDate = model.getCreationDate().toLocalDate();
}
public LocalDate getCreationDate() {
return creationDate;
}
public String getEmail() {
return email;
}
public int getId() {
return id;
}
public String getName() {
return name;
}
public void setCreationDate(LocalDate creationDate) {
this.creationDate = creationDate;
}
public void setEmail(String email) {
this.email = email;
}
public void setId(int id) {
this.id = id;
}
public void setName(String name) {
this.name = name;
}
}
Note that we omit the password in the DTO above. The DTO for requests (e.g., when creating a person for the first time) would be similar, but would not have the ID or creation date (these should be generated server-side) and it should have the password.
With these DTOs and the PersonService
in hand, we can finally define a few HTTP endpoints in our controller layer.
package ca.mcgill.ecse321.eventregistration.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import ca.mcgill.ecse321.eventregistration.dto.PersonRequestDto;
import ca.mcgill.ecse321.eventregistration.dto.PersonResponseDto;
import ca.mcgill.ecse321.eventregistration.model.Person;
import ca.mcgill.ecse321.eventregistration.service.PersonService;
@RestController
public class PersonController {
@Autowired
private PersonService personService;
/**
* Return the person with the given ID.
*
* @param pid The primary key of the person to find.
* @return The person with the given ID.
*/
@GetMapping("/people/{pid}")
public PersonResponseDto findPersonById(@PathVariable int pid) {
Person person = personService.findPersonById(pid);
return new PersonResponseDto(person);
}
/**
* Create a new person.
*
* @param person The person to create.
* @return The created person, including their ID.
*/
@PostMapping("/people")
public PersonResponseDto createPerson(@RequestBody PersonRequestDto person) {
Person createdPerson = personService.createPerson(person.getName(), person.getEmail(), person.getPassword());
return new PersonResponseDto(createdPerson);
}
}
The @GetMapping
annotation indicates that findPersonById
handles requests to the endpoint GET /people/{pid}
(where {pid}
should be replaced by an integer ID).
Spring Boot will extract the pid
from the URL for us and pass the value to findPersonById
via the pid
parameter.
findPersonById
simply calls the service-layer method and then converts the response to a DTO.
Similarly, the @PostMapping
annotation indicates that createPerson
handles requests to POST /people
.
Since the person
parameter is marked @RequestBody
, Spring Boot will parse the body of the POST request to a PersonRequestDto
.
Trying (Smoke Testing of) the Application
It is often helpful to manually test that our HTTP endpoints are working as expected.
This can be done using command line tools like curl
(which is available in Git Bash in a typical Git installation) or GUI tools like Postman or Firefox’s Advanced REST Client.
First, start the app using the command ./gradlew bootRun
.
Using curl
, we can then send a POST request to /people
as follows, assuming our app is listening on port 8080.
curl --request POST 'http://localhost:8080/people' --data '{"name": "Alice", "email": "alice.allison@mail.mcgill.ca", "password": "password123"}' --header 'Content-Type: application/json'
We expect a response like this:
{"id":602,"name":"Alice","email":"alice.allison@mail.mcgill.ca","creationDate":"2024-10-21"}
Notice that the ID and creation date are included in the response, but the password is not (since we used a DTO).
We can then fetch the newly-created person using the ID returned by the backend.
curl --request GET 'http://localhost:8080/people/602'
Since we used the same DTO for the responses of both endpoints, we expect the same output.
3.7. Testing Service Methods in Backend
3.7.1. Implementing Unit Tests for Service Class
We can unit test our service classes by replacing our real CRUD repository implementations with "mocks," which have the same interface as the real repositories but do not connect to the database. We use the Mockito library to define these mocks.
Consider the following unit tests for the PersonService
.
package ca.mcgill.ecse321.eventregistration.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.sql.Date;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.boot.test.context.SpringBootTest;
import ca.mcgill.ecse321.eventregistration.exception.EventRegistrationException;
import ca.mcgill.ecse321.eventregistration.model.Person;
import ca.mcgill.ecse321.eventregistration.repository.PersonRepository;
@SpringBootTest
public class PersonServiceTests {
@Mock
private PersonRepository repo;
@InjectMocks
private PersonService service;
@SuppressWarnings("null")
@Test
public void testCreateValidPerson() {
// Arrange
String name = "Bob";
String email = "bob@mail.mcgill.ca";
String password = "12345678";
Person bob = new Person(name, email, password, Date.valueOf(LocalDate.now()));
when(repo.save(any(Person.class))).thenReturn(bob);
// You could also do it with thenAnswer(), which is more flexible but more
// verbose
// when(repo.save(notNull(Person.class))).thenAnswer((InvocationOnMock iom) ->
// iom.getArgument(0));
// Act
Person createdPerson = service.createPerson(name, email, password);
// Assert
assertNotNull(createdPerson);
assertEquals(name, createdPerson.getName());
assertEquals(email, createdPerson.getEmail());
assertEquals(password, createdPerson.getPassword());
assertEquals(Date.valueOf(LocalDate.now()), createdPerson.getCreationDate());
verify(repo, times(1)).save(bob);
}
@Test
public void testReadPersonByValidId() {
// Arrange
int id = 42;
Person charlie = new Person("Charlie", "charlie@mail.mcgill.ca", "password123", Date.valueOf("2024-03-01"));
when(repo.findPersonById(id)).thenReturn(charlie);
// Act
Person person = service.findPersonById(id);
// Assert
assertNotNull(person);
assertEquals(charlie.getName(), person.getName());
assertEquals(charlie.getEmail(), person.getEmail());
assertEquals(charlie.getPassword(), person.getPassword());
assertEquals(charlie.getCreationDate(), person.getCreationDate());
}
@Test
public void testReadPersonByInvalidId() {
// Set up
int id = 42;
// Default is to return null, so you could omit this
when(repo.findPersonById(id)).thenReturn(null);
// Act
// Assert
EventRegistrationException e = assertThrows(EventRegistrationException.class, () -> service.findPersonById(id));
assertEquals("There is no person with ID " + id + ".", e.getMessage());
// assertThrows is basically like the following:
// try {
// service.findPersonById(id);
// fail("No exception was thrown.");
// } catch (IllegalArgumentException e) {
// assertEquals("There is no person with ID " + id + ".", e.getMessage());
// }
}
}
As usual, we annotate the test class with @SpringBootTest
and each test method with @Test
.
Instead of using @Autowired
to get a real PersonRepository
, we use @Mock
to get a mock instance.
And instead of using @Autowired
to get a PersonService
, we use @InjectMocks
, which constructs a PersonService
using the mock PersonRepository
.
Within each test method, we follow the usual arrange-act-assert template.
In testCreateValidPerson
, the statement when(repo.save(any(Person.class))).thenReturn(bob);
instructs the mock repository to return the object bob
whenever we call the save
method.
Likewise, in testReadPersonByValidId
, when(repo.findPersonById(id)).thenReturn(charlie);
instructs the mock repository to return charlie
whenever we call findPersonById
with the specific value in id
.
By default, the repository will return null
, but whenever we expect a different response we must tell the mock what to return.
These "canned responses" should be similar to the real thing.
For example, if the real CRUD repositories return null
when they can’t find the person, the mock should also return null
when we want to simulate not finding a person.
For methods that write to the database (e.g., createPerson
), it is also good to check that the right thing was written.
In testCreateValidPerson
, we do this using verify(repo, times(1)).save(bob);
, which checks that the save()
method was called exactly once with an argument that’s equal to bob
.
3.7.2. Service Integration Testing with Spring’s TestRestTemplate
We already know how to manually test our REST API using curl
.
However, for automated tests, it is convenient to test the application using Spring Boot’s testing features (such as TestRestTemplate
).
This has a few benefits.
For example, you can use built-in assertion methods to more precisely specify your test success conditions and you don’t need any extra steps to integrate these tests into your build system: running ./gradlew test
will automatically run the integration tests.
It’s also nice to be able to see the code coverage for your integration tests, even if it’s not required for your project.
-
Create a new integration testing class and annotate it with
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
. This tells Spring to launch the app with a random port (to avoid conflicts if you are already running the app separately). -
Autowire a
TestRestTemplate
. This will act as your API client (like the Advanced REST Client, Postman, orcurl
). -
Autowire any repositories you need to clear the database before your tests.
-
You can send POST requests to your API using
TestRestTemplate.postForEntity()
, send GET requests usingTestRestTemplate.getForEntity()
, and so on. You’ll need to specify:-
A URL (which does not need to include the base URL, since the autowired
TestRestTemplate
already knows where your app is running) -
The type of object you expect to receive in response (which should be a DTO).
-
For requests that include a body (e.g., POST), the body
-
A simple suite of integration tests for the Person
-related endpoints might look like this:
package ca.mcgill.ecse321.eventregistration.integration;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.LocalDate;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.TestInstance.Lifecycle;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import ca.mcgill.ecse321.eventregistration.dto.ErrorDto;
import ca.mcgill.ecse321.eventregistration.dto.PersonRequestDto;
import ca.mcgill.ecse321.eventregistration.dto.PersonResponseDto;
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@TestInstance(Lifecycle.PER_CLASS)
public class PersonIntegrationTests {
@Autowired
private TestRestTemplate client;
private final String VALID_NAME = "Alice";
private final String VALID_EMAIL = "alice@mail.mcgill.ca";
private final String VALID_PASSWORD = "password123";
private final String INVALID_PASSWORD = "123";
private final int INVALID_ID = 0;
private int validId;
@Test
@Order(1)
public void testCreateValidPerson() {
// Arrange
PersonRequestDto request = new PersonRequestDto(VALID_NAME, VALID_EMAIL, VALID_PASSWORD);
// Act
ResponseEntity<PersonResponseDto> response = client.postForEntity("/people", request, PersonResponseDto.class);
// Assert
assertNotNull(response);
assertEquals(HttpStatus.CREATED, response.getStatusCode());
PersonResponseDto createdPerson = response.getBody();
assertNotNull(createdPerson);
assertEquals(VALID_NAME, createdPerson.getName());
assertEquals(VALID_EMAIL, createdPerson.getEmail());
assertNotNull(createdPerson.getId());
assertTrue(createdPerson.getId() > 0, "Response should have a positive ID.");
assertEquals(LocalDate.now(), createdPerson.getCreationDate());
this.validId = createdPerson.getId();
}
@Test
@Order(2)
public void testReadPersonByValidId() {
// Arrange
String url = "/people/" + this.validId;
// Act
ResponseEntity<PersonResponseDto> response = client.getForEntity(url, PersonResponseDto.class);
// Assert
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
PersonResponseDto person = response.getBody();
assertNotNull(person);
assertEquals(VALID_NAME, person.getName());
assertEquals(VALID_EMAIL, person.getEmail());
assertEquals(this.validId, person.getId());
assertEquals(LocalDate.now(), person.getCreationDate());
}
}
Notice that this test code uses the annotations @TestMethodOrder(MethodOrderer.OrderAnnotation.class)
and @Order
to ensure the tests run in a predictable order.
We also add the annotation @TestInstance(Lifecycle.PER_CLASS)
so that JUnit uses the same instance of PersonIntegrationTests
for each test method.
By default, JUnit would create a new instance per method and tests that rely on a field having been set by a previous test (in this example, testReadPersonByInvalidId
) would fail.
Requiring tests to run in a specific order is not good practice for unit tests, but some people consider it acceptable for integration testing.
If the order-dependence makes you uncomfortable, you can merge tests that depend on one another into one test.
3.8. Assessing Code Coverage using JaCoCo
This tutorial covers the basics of setting up JaCoCo as a gradle plugin to assess code coverage during tests.
3.8.1. Event Registration Application Unit Test Code Coverage
-
To assess code coverage using the Jacoco plugin for gradle, use the plugin ID
jacoco
. By adding it to the build.gradle file, your file should look like this:
plugins {
id 'org.springframework.boot' version '2.2.4.RELEASE'
id 'io.spring.dependency-management' version '1.0.9.RELEASE'
id 'java'
id 'jacoco'
}
//...other configurations
tasks.named('test') {
useJUnitPlatform()
testLogging {
exceptionFormat "full"
events "passed", "failed"
}
finalizedBy jacocoTestReport
}
tasks.named('jacocoTestReport') {
dependsOn test // tests are required to run before generating the report
}
-
This new plugin gives us the
jacocoTestReport
task that can generate html reports. Try executing this task from the terminal and see the generated HTML files under build/reports/jacoco/test/html/! -
If you wish, you can check enforce a certain threshold on the test cases with this Jacoco plugin. With the definition below, the
./gradlew jacocoTestCoverageVerification
task will fail if code coverage is below 60%. A good coverage to aspire to is 75% or greater. Achieving 100 % coverage is often impractical.
tasks.named('test') {
//...other configurations
finalizedBy jacocoTestCoverageVerification
}
tasks.named('jacocoTestCoverageVerification') {
violationRules {
rule {
limit {
minimum = 0.6
}
}
}
dependsOn test // tests are required to run before checking code coverage.
}
3.9. Writing API Documentation
Important
|
If your API documentation is not simply a wiki page, you must clearly document in your project wiki how to access the API documentation. |
Important
|
If you use springdoc, make sure you use the right version for your Spring Boot version (https://springdoc.org/ for Spring Boot v3, https://springdoc.org/v1/ for Spring Boot v1 or v2). Your Spring Boot version is displayed when you start your app with ./gradlew bootRun .
|
There are tools that can assist you in producing nice documentation for your API. For example, the springdoc-openapi library can generate an HTML page describing each endpoint and the request and response formats (see https://springdoc.org/#getting-started). Note that:
-
The dependency at https://springdoc.org/#getting-started is specified using XML, as required by other build systems like Maven. In Gradle, you would instead add the dependency as
implementation groupId:artifactId:version
. -
The springdoc website mentions the context path of your app. This is "/" by default, so you should be able to access your documentation at http://localhost:8080/swagger-ui.html (assuming your backend is listening on port 8080).
springdoc can also include information from Javadoc comments (see https://springdoc.org/#javadoc-support).
4. Frontend
4.1. Installation Instructions: Vue.js
Vue.js is a popular web frontend for building user interfaces in Javascript, which is considered to be easier to learn compared to React and Angular.
Before installing Vue.js, you will need to install node.js and npm (this comes with Node by default).
4.1.1. Install Vue.js
-
Open a shell or terminal.
-
Check that you successfully installed node.js and npm e.g. by checking their versions:
$ node -v v22.11.0 $ npm -v v10.9.0
-
Navigate to your local Git repository of the Event Registration System
$ cd ~/git/eventregistration
-
Generate initial content using the command
npm create vue@latest
(see https://github.com/vuejs/create-vue). For the tutorial code, we use the setup below.$ npm create vue@latest > npx > create-vue Vue.js - The Progressive JavaScript Framework ✔ Project name: … EventRegistration-Frontend ✔ Package name: … eventregistration-frontend ✔ Add TypeScript? … No ✔ Add JSX Support? … No ✔ Add Vue Router for Single Page Application development? … Yes ✔ Add Pinia for state management? … No ✔ Add Vitest for Unit Testing? … No ✔ Add an End-to-End Testing Solution? › No ✔ Add ESLint for code quality? › No Scaffolding project in /home/louis-ta/dev/eventregistration/EventRegistration-Frontend... Done. Now run: cd EventRegistration-Frontend npm install npm run dev
-
Now execute those commands one after the other to move into the
EventRegistration-Frontend diectory
, install the dependencies, and start the frontend server. -
The command
npm run dev
should output the URL of the development server (e.g., http://localhost:5173/). -
You can stop this development server by pressing Ctrl+C in the shell
4.1.2. Setting up your development server
If you want to change the port for the frontend server (e.g., in case another app is already using it), you can do so in vite.config.js
.
Set server.port
to be the new port number.
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
// Added
server: {
port: 8087
}
})
If you start your development server again using npm run dev
, the same web application should now appear at http://localhost:8087/
Stop the development server by pressing Ctrl+C.
4.1.3. Commit your work to Github
-
If everything works then commit your work to your Github repository.
-
Notice that many libraries and files are omitted, which is intentional. Check the
.gitignore
file for details.
4.2. Create a Static Vue.js Component
Vue.js promotes the use of components which encapsulate GUI elements and their behavior in order to build up rich user interfaces in a modular way. A component consists of
-
template: A template of (a part of) an HTML document enriched with data bindings, conditional expressions, loops, etc.
-
script: The behavior of the user interface programmed in JavaScript.
-
style: The customized graphical appearance of HTML document elements.
We will first create a new Vue.js component and then connect it to a backend Java Spring service via HTTP requests.
4.2.1. Create a component file
Note
|
We use . below to refer to the EventRegistration-Frontend directory.
|
Create a new file EventsView.vue
in ./src/views
with the following initial content:
<template>
</template>
<script>
</script>
<style>
</style>
Create some static HTML content of the template part starting with a <main>
element corresponding to your component.
<template>
<main>
<h1>Event Registration</h1>
<h2>New Event</h2>
<div>
<select>
<option value="IN_PERSON">In person</option>
<option value="ONLINE">Online</option>
</select>
<input type="text" placeholder="Name" />
<input type="date" placeholder="Date" />
<input type="time" placeholder="Start Time" />
<input type="time" placeholder="End Time" />
<input type="text" placeholder="Registration Limit" />
<input type="text" placeholder="Location" />
<button>Create Event</button>
<button class="danger-btn">Clear</button>
</div>
<h2>Events</h2>
<table>
<tbody>
<tr>
<th>Name</th>
<th>Date</th>
<th>Type</th>
</tr>
<tr>
<td>My In-Person Event</td>
<td>2024-11-15</td>
<td>In person</td>
</tr>
<tr>
<td>My Online Event</td>
<td>2024-11-15</td>
<td>Online</td>
</tr>
</tbody>
</table>
</main>
</template>
Customize the <style>
part with your designated CSS content.
A detailed CSS reference documentation is available at https://developer.mozilla.org/en-US/docs/Web/CSS.
For example, we can use the following styles to
-
Make the table fill the entire width of the page.
-
Underline the sub-headings and add some padding above them.
-
Tweak the style of the table (get rid of the spacing between cells, add padding within cells, and make the border white).
-
Make the "Clear" button red.
<style>
main {
display: flex;
flex-direction: column;
align-items: stretch;
}
h2 {
padding-top: 1em;
text-decoration: underline;
}
table {
border-collapse: collapse;
}
td, th {
border: 1px solid var(--color-border);
padding: 0.25em;
}
.danger-btn {
border: 1px solid red;
color: red;
}
</style>
4.2.2. Create a new routing command
By default, going to http://localhost:8087/ brings us to the initial "Home" view, not our events view.
We change this in ./src/router/index.js
.
Move the existing HomeView
entry to a different URL (e.g., /hello
) and create a new entry for the events view.
import EventsView from '@/views/EventsView.vue'
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'events',
component: EventsView,
},
{
path: '/hello',
name: 'home',
component: HomeView
},
// etc.
],
})
export default router
If you go to http://localhost:8087
now, you should see the events view.
4.3. Vue.js Components with Dynamic Content
Next we add event handling and dynamic content to our events view.
First, let’s make the event list dynamic instead of hard-coding it in the HTML.
Add the following code in the <script>
block…
<script>
export default {
name: "events",
data() {
return {
events: [
{ type: "IN_PERSON", name: "My in-person event", date: "2024-11-15", startTime: "13:35:00", endTime: "15:25:00", registrationLimit: 1, location: "McGill" },
{ type: "ONLINE", name: "My online event", date: "2024-11-16", startTime: "12:00:00", endTime: "13:00:00", registrationLimit: 100, location: "Zoom" },
],
};
}
}
</script>
…and replace the hard-coded <table>
element with the following table:
<table>
<tbody>
<tr>
<th>Name</th>
<th>Date</th>
<th>Type</th>
</tr>
<tr v-for="e in events">
<td>{{ e.name }}</td>
<td>{{ e.date }}</td>
<td>{{ e.type === "IN_PERSON" ? "In person" : "Online" }}</td>
</tr>
</tbody>
</table>
Vue.js will automatically inject a new <tr>
element for each entry in the events
array (defined above in the <script>
block).
The "mustache variables" (e.g., {{ e.name }}
) will be replaced with the value for the event e
in the current row.
To implement event creation, we define variables to hold the values entered by the user…
export default {
// ...
data() {
return {
events: /* ... */,
newEventType: "IN_PERSON",
newEventName: null,
newEventDate: null,
newEventStartTime: null,
newEventEndTime: null,
newEventRegLimit: null,
newEventLocation: null
};
},
// ...
}
…we define methods createEvent()
and clearInputs()
…
export default {
// ...
methods: {
createEvent() {
const newEvent = {
type: this.newEventType,
name: this.newEventName,
date: this.newEventDate,
startTime: this.newEventStartTime,
endTime: this.newEventEndTime,
registrationLimit: this.newEventRegLimit,
location: this.newEventLocation
};
this.events.push(newEvent);
},
clearInputs() {
this.newEventType = "IN_PERSON";
this.newEventName = null;
this.newEventDate = null;
this.newEventStartTime = null;
this.newEventEndTime = null;
this.newEventRegLimit = null;
this.newEventLocation = null;
},
isEventValid() {
return this.newEventName
&& this.newEventDate
&& this.newEventStartTime
&& this.newEventRegLimit
&& this.newEventLocation;
}
}
}
…and we bind these new variables and methods to the input elements using v-bind
and @click
, respectively.
<div>
<select v-model="newEventType">
<option value="IN_PERSON">In person</option>
<option value="ONLINE">Online</option>
</select>
<input type="text" placeholder="Name" v-model="newEventName" />
<input type="date" placeholder="Date" v-model="newEventDate" />
<input type="time" placeholder="Start Time" v-model="newEventStartTime" />
<input type="time" placeholder="End Time" v-model="newEventEndTime" />
<input type="text" placeholder="Registration Limit" v-model="newEventRegLimit" />
<input type="text" placeholder="Location" v-model="newEventLocation" />
<button id="create-btn" @click="createEvent" v-bind:disabled="!isEventValid()">Create Event</button>
<button class="danger-btn" @click="clearInputs">Clear</button>
</div>
Whenever the button is clicked, the method specified by the @click
attribute (in this case, createEvent
or clearInputs
) will be called.
createEvent
accesses the values entered by the user via this.newEventName
, this.newEventDate
, etc.
It then adds a new event to the array, which will result in the DOM being updated.
To clear the inputs, clearInputs
simply updates the variables this.newEventName
, this.newEventDate
, etc.
Notice that we also disable the button in case the user has not entered all the required information yet.
We take advantage of the fact that empty strings, null
, and undefined
are all "falsy" values; that is, isEventValid
will return false
if any of the values are empty, null
, or undefined
.
Important
|
The v-model attribute implements two-way data binding.
If our JavaScript code updates one of the variables, the HTML will be updated accordingly.
Conversely, if the users types something, the value of the corresponding variable will be updated.
|
4.4. Calling Backend Services
Next we change our frontend to issue calls to the backend via the Rest API provided by the Java Spring framework.
4.4.1. Install additional dependencies
Add Axios as a dependency. We will use Axios for issuing REST API calls.
npm install axios@1.5
4.4.2. Calling backend services in from Vue.js components
In the <script>
block of EventsView.vue
, create a new Axios client:
<script>
import axios from "axios";
const axiosClient = axios.create({
// NOTE: it's baseURL, not baseUrl
baseURL: "http://localhost:8080"
});
export default {
// ...
}
</script>
Note
|
For simplicity we hard-code the backend URL here, but generally you’d put it in a config file instead. |
To get the list of events when the page loads, we can send an HTTP request in the created()
method:
<script>
// ...
export default {
// ...
async created() {
try {
const response = await axiosClient.get("/events");
} catch (e) {
// TODO: Show a message to the user or something
console.error(e);
}
}
// ...
};
</script>
We mark the created
method async
and use await
when sending the request so that we can use try/catch for exception handling.
See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function.
To send newly-created events to the backend, we update the createEvent
method as follows:
<script>
// ...
export default {
// ...
methods: {
async createEvent() {
const newEvent = {
type: this.newEventType,
name: this.newEventName,
date: this.newEventDate,
startTime: this.newEventStartTime,
endTime: this.newEventEndTime,
registrationLimit: this.newEventRegLimit,
location: this.newEventLocation
};
try {
const response = await axiosClient.post("/events", newEvent);
} catch (e) {
// TODO: show a message to the user or something
console.error(e);
}
this.events.push(response.data);
this.clearInputs();
},
// ...
}
};
</script>
If you run the frontend, you should see that you can now create an event and it’ll appear in the events list. Furthermore, the event will remain in the list if you refresh the page.
Note that, at first, you will likely run into CORS (Cross-Origin Resource Sharing) errors.
You must add the @CrossOrigin
annotation to your controller classes to allow requests from your frontend.
Note
|
You can also use wildcards in the @CrossOrigin annotation, as in @CrossOrigin(origin = "*") .
This allows cross-origin requests from *any* domain, which is less secure but possibly more convenient.
|
package ca.mcgill.ecse321.eventregistration.controller;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.RestController;
@RestController
@CrossOrigin(origins = "http://localhost:8087")
public class EventController {
// ...
}
4.5. Further documentation
-
MDN web docs for general HTML, CSS, and JavaScript questions: https://developer.mozilla.org/en-US/docs/Web/JavaScript
-
Vue.js guide: https://vuejs.org/guide/introduction.html
-
Vue router guide: https://router.vuejs.org/guide/
5. Working Example
A working version of the app is available at https://github.com/McGill-ECSE321-Fall2024/tutorial-003/.
Congratulations on making it until the end of the tutorial notes. Good luck with your project and on the final exam!