Building a fitness tracking app with Java - Part two
What we’re going to work on
As mentioned in part one, we’re going to start building our backend API, we’ll look at building the following:
- Integrating jOOQ for data access
- Securing our API by building an authentication mechanism
Integrating jOOQ
What is jOOQ?
If you’re unfamilar with jOOQ, it’s a library that generates Java classes from your database structure, letting you write type safe queries.
Why not use Hibernate?
In my day-to-day work, and in other projects, I’ve used either Hibernate, or plain JDBC.
For Hibernate specifically, I really dislike the following:
- Doing anything more than a simple join seems overly complicated, for example fetching associations which have their own associations.
- Half the time you are trying to conjure the right combination of annotations to get Hibernate to produce the SQL you want, you end up just writing HQL anyway.
- Once you learn these things, you can’t apply the knowledge anywhere else - which I guess is true for lots of things, but I really do feel cheated when I spend an hour making Hibernate behave the way I want to.
These problems could be down to my own ignorance or ineptitutde, but I don’t think it should be this complicated. I know the exact SQL I want to write, I just need something to map the result set into POJOs.
Setting up
We’ve already got the jooq
dependency in our pom.xml
:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jooq</artifactId>
</dependency>
This deals with setting up jOOQ for us automatically, and giving us access to a DSLContext
, an object we can use for performing DB operations.
Next we need to set up the Maven code generator, which comes in the form of a Maven plugin:
<plugin>
<groupId>org.jooq</groupId>
<artifactId>jooq-codegen-maven</artifactId>
<version>3.19.10</version>
<configuration>
<!-- Connection config -->
<jdbc>
<driver>com.mysql.cj.jdbc.Driver</driver>
<url>jdbc:mysql://${DB_HOST}/</url>
<user>${DB_USER}</user>
<password>${DB_PASSWORD}</password>
</jdbc>
<!-- Generator config -->
<generator>
<database>
<name>org.jooq.meta.mysql.MySQLDatabase</name>
<!-- Which tables to include -->
<includes>.*</includes>
<excludes></excludes>
<!-- The schema -->
<inputSchema>ft</inputSchema>
</database>
<!-- Where to put the generated code -->
<target>
<packageName>com.dmoffat.fitnesstracker.db</packageName>
<directory>src/main/java</directory>
</target>
</generator>
</configuration>
<!-- Execute the plugin when we run `mvn generate-sources` -->
<executions>
<execution>
<id>jooq-codegen</id>
<phase>generate-sources</phase>
<goals>
<goal>generate</goal>
</goals>
</execution>
</executions>
</plugin>
I read the following docs to figure this out:
- Running the code generator with Maven
- Configuration and setup of the generator
- Codegen Includes and Excludes
And to generate the classes we run:
./mvnw generate-sources
This fails because Maven can’t resolve ${DB_USER}
/${DB_PASSWORD}
. Let’s write a script (generate-db-classes
) which exports variables in our .env
file, and runs the aforementioned Maven command:
#!/usr/bin/env bash
set -o allexport
source ../.env
set +o allexport
./mvnw generate-sources
Running this produces the following:
[INFO] ----------------------------------------------------------
[INFO] Generating catalogs : Total: 1
[INFO] Version : Database version is supported by dialect MYSQL: 8.0.38
[INFO] ARRAYs fetched : 0 (0 included, 0 excluded)
[INFO] Domains fetched : 0 (0 included, 0 excluded)
[INFO] Tables fetched : 6 (6 included, 0 excluded)
[INFO] Embeddables fetched : 0 (0 included, 0 excluded)
[INFO] Enums fetched : 0 (0 included, 0 excluded)
[INFO] Packages fetched : 0 (0 included, 0 excluded)
[INFO] Routines fetched : 0 (0 included, 0 excluded)
[INFO] Sequences fetched : 0 (0 included, 0 excluded)
[INFO] No schema version is applied for catalog . Regenerating.
[INFO]
[INFO] Generating catalog : DefaultCatalog.java
[INFO] ==========================================================
[INFO] Comments fetched : 0 (0 included, 0 excluded)
[INFO] Generating schemata : Total: 1
[INFO] No schema version is applied for schema ft. Regenerating.
[INFO] Generating schema : Ft.java
[INFO] ----------------------------------------------------------
[INFO] UDTs fetched : 0 (0 included, 0 excluded)
[INFO] Generating tables
[INFO] Generating table : BodyWeight.java [input=body_weight, pk=KEY_body_weight_PRIMARY]
[INFO] Indexes fetched : 5 (5 included, 0 excluded)
[INFO] Generating table : Exercise.java [input=exercise, pk=KEY_exercise_PRIMARY]
[INFO] Generating table : FlywaySchemaHistory.java [input=flyway_schema_history, pk=KEY_flyway_schema_history_PRIMARY]
[INFO] Generating table : User.java [input=user, pk=KEY_user_PRIMARY]
[INFO] Generating table : Workout.java [input=workout, pk=KEY_workout_PRIMARY]
[INFO] Generating table : WorkoutExercise.java [input=workout_exercise, pk=KEY_workout_exercise_PRIMARY]
[INFO] Tables generated : Total: 412.834ms
[INFO] Generating table records
[INFO] Generating record : BodyWeightRecord.java
[INFO] Generating record : ExerciseRecord.java
[INFO] Generating record : FlywaySchemaHistoryRecord.java
[INFO] Generating record : UserRecord.java
[INFO] Generating record : WorkoutRecord.java
[INFO] Generating record : WorkoutExerciseRecord.java
[INFO] Table records generated : Total: 485.481ms, +72.646ms
[INFO] Generating table references
[INFO] Table refs generated : Total: 489.557ms, +4.076ms
[INFO] Generating Keys
[INFO] Keys generated : Total: 498.308ms, +8.75ms
[INFO] Generating Indexes
[INFO] Indexes generated : Total: 506.206ms, +7.897ms
[INFO] Generation finished: ft : Total: 506.43ms, +0.224ms
Our classes got added to server/src/main/java/db
which is great!
There were two problems I noticed when inspecting the classes:
- It includes the
FlywaySchemaHistory
table, which we don’t need - The foreign keys we defined in the previous post have auto-generated names (e.g.
user_id_ibfk_1
), resulting in our generated code having names likeBODY_WEIGHT_IBFK_1
.
Both are simple fixes:
- Update
<excludes>
in ourpom.xml
to<excludes>ft.flyway_schema_history</excludes>
- Update the migration
Using the generated classes
Now we’ve got our generated classes, let’s query the database. Because our database doesn’t have any data in, we’ll quickly add some records to our exercise
reference table and run that migration.
With Hibernate, you interact with the database using a Session
/EntityManager
. In jOOQ you use DSLContext
.
I wrote a simple class named ExerciseService
and added the following code:
@Service
public class ExerciseService {
private DSLContext dsl;
@Autowired
public ExerciseService(DSLContext dsl) {
this.dsl = dsl;
}
public void printExercises() {
Result<ExerciseRecord> exercises = dsl.selectFrom(Tables.EXERCISE).fetch();
System.out.println(exercises);
}
}
To test it, I created a test class and called the method I just wrote:
@SpringBootTest
class ExerciseServiceTest {
@Autowired
private ExerciseService exerciseService;
@Test
void testJooqIntegration() {
exerciseService.printExercises();
}
}
Which produces:
Executing query : select `ft`.`exercise`.`id`, `ft`.`exercise`.`name`, `ft`.`exercise`.`brand`, `ft`.`exercise`.`type` from `ft`.`exercise`
Version : Database version is supported by dialect MYSQL: 8.0.38
Fetched result : +----+-------------------+------+-----------+
: | id|name |brand |type |
: +----+-------------------+------+-----------+
: | 1|Back squat |{null}|FREE_WEIGHT|
: | 2|Barbell Bench press|{null}|FREE_WEIGHT|
: | 3|Deadlift |{null}|FREE_WEIGHT|
: +----+-------------------+------+-----------+
Fetched row(s) : 3
Everything seems to be working nicely.
Creating DAOs
Most Spring apps I’ve worked are split into 3 layers:
- Controller, this is where you do presentational things (defining HTTP responses, request validation, etc)
- Service, this is where your business logic goes (e.g. in an ecommerce website you’d find things like
BasketService#addToBasket
) - Repository/DAO, this is where you access the database
I’m not really sure if this pattern has a name, but it seems pretty prevalent in Spring apps. The idea is that each layer only knows about the layer below it, for example, the service talks to the repository to fetch things from the database, but doesn’t know that it’s been called from a REST API endpoint. Similarly the repository just cares about talking to the database, it has no knowledge or reference to the service that calls it.
Usually in a Hibernate project, you end up creating some HibernateDao<KeyType, EntityType>
class, I’ll do the same for jOOQ, merely to wrap the jOOQ types and use my own models throughout the rest of the codebase.
I briefly looked at jOOQs generated DAOs (and read the article “To DAO, or not to DAO? written by jOOQ’s author) and didn’t see the benefit. Although the generated DAOs have a bunch of useful methods I’ll most likely end up writing myself anyway, I don’t like the fact it creates a POJO - my app would have three types representing one thing, ExerciseRecord
(jOOQ database class), Exercise
(jOOQ POJO) and Exercise
(my own type). It’s just needlessly complicated for no reason and goes against my own principle - don’t create things that don’t provide a clear benefit.
My DAO looks like this:
@Repository
public class ExerciseDao {
@Autowired private DSLContext context;
public List<Exercise> findAll() {
return context
.selectFrom(EXERCISE)
.fetchInto(Exercise.class);
}
public Exercise findOne(Integer id) {
return context
.selectFrom(EXERCISE)
.where(EXERCISE.ID.eq(UInteger.valueOf(id)))
.fetchOneInto(Exercise.class);
}
}
You may notice it doesn’t contain any methods to update an exercise, or create a new exercise - this is intentional. Because my app doesn’t have this functionality (exercises will be managed through database migrations, and won’t be editable by users), we don’t need methods to do this.
Securing our API
We need to secure our API so that only I can access it - we don’t want other people logging workouts for my user account.
The basic premise for securing our app will be:
- Mobile app makes request to
/auth/login
API /auth/login
API validates details with user database, creates a session and returns a session token- Mobile app stores session token in secure storage and includes it in the
Authorization
header in subsequent requests. - In subsequent requests, say for example, for logging a workout, the server will log the workout for the user that’s contained in the session token.
The downside to this approach is that each call to the API will incur a session lookup. I think this is acceptable as the app will have at most 1 user (me!).
At work we use a home-grown authentication/session system, so for this project I’ll use this opportunity to use Spring Security + Spring session. We’ll need Spring Security to handle authn/authz and Spring Session for session management.
Installing Spring Session
We add the following to our pom.xml
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-jdbc</artifactId>
</dependency>
Configuring Spring Session
By default, sessions (HttpSession
) are stored in memory. This is fine for this project, but they have a large downside - when the app is restarted, all sessions will be lost, meaning I’ll have to authenticate again. We need to persist the sessions in the database, so for this, I have to use spring-session-jdbc
, which saves sessions in a database by default.
To configure this, I read the documentation and had to do a few things to get it working:
- Change the session resolving mechanism, by default, sessions are set/read using cookies, we want them to be resolved from a HTTP header.
- Create the session tables, by default, Spring Session will manage your database tables, but I’d like to manage them myself using our own database migrations (and change the names)
- Set some Spring Session config in application.properties, I used this link as a reference. I changed the session expiry time, the table names, and the automatic session table creation behaviour.
Testing out Spring Session
Once done, I created two endpoints:
/
, this endpoint creates a session/me
, this endpoint reads data from the current session
static class SessionContents {
private final String testing;
private final String testing2;
public SessionContents(Object testing, Object testing2) {
this.testing = (String)testing;
this.testing2 = (String)testing2;
}
public String getTesting() {
return testing;
}
public String getTesting2() {
return testing2;
}
}
@GetMapping("/")
public String home(HttpSession session) {
session.setAttribute("testing", "1234");
session.setAttribute("testing2", "{\"foo\": \"bar\"}");
return "{}";
}
@GetMapping("/me")
public SessionContents me(HttpSession session) {
return new SessionContents(session.getAttribute("testing"), session.getAttribute("testing2"));
}
Using cURL, I called the /
endpoint, which returned (other headers omitted):
$ curl -v http://localhost:8080/
< HTTP/1.1 200
< ....
< X-Auth-Token: 1c7ebf74-af2c-4699-b1dd-2a1e396b0120
< ...
Then I made a second request to /me
with the X-Auth-Token
header:
$ curl -v --header "X-Auth-Token: 1c7ebf74-af2c-4699-b1dd-2a1e396b0120" http://localhost:8080/me
{"testing":"1234","testing2":"{\"foo\": \"bar\"}"}
Now we have sessions working and are able to add arbitary data to a HttpSession
, we can configure Spring Security.
Installing Spring Security
We add the following to our pom.xml
:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Configuring Spring Security
Spring Security has some pretty extensive documentation which I read to configure it. It’s great that it describes its architecture in detail, but at times I felt like there was too much information, coupled with my inexperience using the library, it was quite frustrating.
As with many Spring libraries, searching for information online can be frustrating, there are so many versions of Spring, and Spring Security that many times you’re reading something only to find out it’s for an older version, and now there’s a newer, easier way to achieve the same thing.
I finally got it working how I wanted to, which I’ll describe in detail below - this took a lot of trial and error, reading of documentation and examining logs. I did not arrive at this result immediately. I’m not going to go into detail on how the different parts of Spring Security work together - maybe I’ll write another post on that in the future for my own sake.
Firstly I had to create SecurityConfig.java
, this is where I define Spring Security’s config:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// Disable CSRF
.csrf(AbstractHttpConfigurer::disable)
// Turn off "saved request" logic - this prevents sessions being created when trying to access protected routes without an auth token
.requestCache(RequestCacheConfigurer::disable)
// Require certain HTTP requests to require auth
.authorizeHttpRequests(authorize ->
authorize
// Allow anyone to visit /api/v1/auth/login
.requestMatchers("/api/v1/auth/login").permitAll()
// For every other URL, require authentication
.anyRequest().authenticated()
)
.sessionManagement(session -> {
// Only create a HttpSession when required
session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED);
});
return http.build();
}
// Provide a custom mechanism for looking up User objects
@Bean
public AuthenticationManager authenticationManager(UserDetailsService userDetailsService) {
DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
authenticationProvider.setUserDetailsService(userDetailsService);
return new ProviderManager(authenticationProvider);
}
// Store the 'SecurityContext' in the HttpSession between requests
@Bean
public SecurityContextRepository securityContextRepository() {
return new HttpSessionSecurityContextRepository();
}
}
In this config we use a custom UserDetailsService
, which is what Spring Security uses to lookup User
s, ours is defined like this:
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserDao userDao;
@Autowired
public CustomUserDetailsService(UserDao userDao) {
this.userDao = userDao;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userDao.findByEmail(username);
if(user == null) {
throw new UsernameNotFoundException("Username not found");
}
return new org.springframework.security.core.userdetails.User(user.getEmail(), user.getPassword(), Collections.emptyList());
}
}
It’s really simple, it uses jOOQ to fetch a User
from the DB by email (instead of username). We use Spring Security’s own implementation of UserDetails
(org.springframework.security.core.userdetails.User
).
Next I had to write my /api/v1/auth/login
endpoint:
@RestController
public class AuthController {
private final AuthenticationManager authenticationManager;
private final SecurityContextRepository securityContextRepository;
private final SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
@Autowired
public AuthController(
AuthenticationManager authenticationManager,
SecurityContextRepository securityContextRepository) {
this.authenticationManager = authenticationManager;
this.securityContextRepository = securityContextRepository;
}
// todo: Return proper request/response types instead of JSON strings
@PostMapping("/api/v1/auth/login")
public String handleLogin(
@RequestBody LoginRequest loginRequest,
HttpServletRequest req,
HttpServletResponse res) {
UsernamePasswordAuthenticationToken authentication =
UsernamePasswordAuthenticationToken.unauthenticated(loginRequest.email(), loginRequest.password());
try {
// This calls 'CustomUserDetailsService' under the hood and checks the plain-text password against the hashed password contained
// on the user record.
// If the user doesn't exist (email doesn't match) or the password is incorrect, it will throw an exception.
Authentication response = authenticationManager.authenticate(authentication);
// Here we manually add the authentication to 'SecurityContext'
SecurityContext context = securityContextHolderStrategy.createEmptyContext();
context.setAuthentication(response);
securityContextHolderStrategy.setContext(context);
// Finally we persist the `SecurityContext` to the HttpSession
securityContextRepository.saveContext(context, req, res);
return "{\"success\": true}";
} catch (AuthenticationException ex) {
// Authentication failed, don't save anything to the HttpSession
return "{\"error\": \"Wrong credentials\"}";
}
}
public record LoginRequest(String email, String password) {}
}
This isn’t finished, but demonstrates the basic idea. We don’t use Spring Security’s built-in mechanisms for authenticating users (form login, HTTP basic auth), we do it manually, as described in their docs.
The result of this is that the authenticated user gets saved to the SPRING_SESSION
table and subsequent requests with that session ID (through the X-Auth-Token
header) will give us access to the authenticated user’s details.
Testing out Spring Security
Let’s make sure everything is working how we expect it to. In our user
table we have the following user:
email | password
------------------------------
danmofo@gmail.com | <hashed_password>
First, let’s try and authenticate with invalid credentials (wrong password):
$ curl -v --header "Content-Type: application/json" --data '{"email":"danmofo@gmail.com","password":"passwo"}' http://localhost:8080/api/v1/auth/login
{"error": "Wrong credentials"}
We also check the Spring Session table, and see that it hasn’t created a row, this means that unsuccessful authorisation attempts do not needlessly create session records.
Now let’s try and authenticate with valid credentials:
$ curl --header "Content-Type: application/json" --data '{"email":"danmofo@gmail.com","password":"password"}' http://localhost:8080/api/v1/auth/login
# Headers:
< X-Auth-Token: e3dc1ba6-bd62-4cd6-b077-6f26def4e0a9
# Response
{"success": true}
We can see it’s returned a success response, and a header containing our session token. We then check the Spring Session table, and see that it’s created a row, with our authenticated user details inside:
|PRIMARY_ID |SESSION_ID |CREATION_TIME |LAST_ACCESS_TIME |MAX_INACTIVE_INTERVAL|EXPIRY_TIME |PRINCIPAL_NAME |
|------------------------------------|------------------------------------|-----------------|-----------------|---------------------|-----------------|-----------------|
|101db7e6-335b-4802-aeeb-63000c81b9ff|e3dc1ba6-bd62-4cd6-b077-6f26def4e0a9|1,720,611,678,696|1,720,611,678,696|86,400 |1,720,698,078,696|danmofo@gmail.com|
The last thing we want to test is accessing a protected route with both a valid and an invalid token.
The controller looks like this:
@RestController
public class UserDetailController {
@GetMapping("/auth")
public String protectedRoute(@AuthenticationPrincipal User user) {
return "{\"user\": \"" + user.getUsername() + "\"}";
}
}
Let’s make a request with a valid token:
$ curl --header "x-auth-token: e3dc1ba6-bd62-4cd6-b077-6f26def4e0a9" http://localhost:8080/auth
{"user": "danmofo@gmail.com"}
And with an invalid token:
$ curl -v --header "x-auth-token: fads" http://localhost:8080/auth
< HTTP/1.1 403
This works! Checking our Spring Session table, and we can see it hasn’t created any new rows. In the DEBUG
logs I could see it’s redirecting to some /error
endpoint, which we don’t want it to do (as it then performs a bunch of additional logic), but we can sort that out later on.
Writing some integration tests
Now we’ve got a basic example working, and we’ve tested it manually, we’ll write some integration tests to validate it works, should we want to change anything in the future.
I created an integration test like so:
@SpringBootTest
@AutoConfigureMockMvc
class AuthControllerLoginIntegrationTest {
@Autowired private MockMvc mockMvc;
@Autowired private DSLContext db;
/**
* This tests a few things after authenticating:
* - Session token is set in X-Auth-Token header
* - Session is saved to the database
* - Session in database contains the user that's authenticated
*/
@Test
void shouldReturnSessionTokenInHeaderAndCreateSessionAfterAuthentication() throws Exception {
MvcResult result = this.mockMvc.perform(loginRequest("danmofo@gmail.com", "password"))
.andExpect(status().isOk())
.andExpect(header().exists("X-Auth-Token"))
.andExpect(jsonPath("$.success").value(true))
.andReturn();
// Grab the session ID from the response header.
String sessionId = result.getResponse().getHeader("X-Auth-Token");
// Check it got saved in the DB
String emailSavedInSession = findPrincipalForSessionId(sessionId);
assertEquals("danmofo@gmail.com", emailSavedInSession);
}
private String findPrincipalForSessionId(String sessionId) {
String emailSavedInSession = db.selectFrom(SPRING_SESSION)
.where(SPRING_SESSION.SESSION_ID.eq(sessionId))
.fetchOne(SPRING_SESSION.PRINCIPAL_NAME);
// Remove the record
db.delete(SPRING_SESSION)
.where(SPRING_SESSION.SESSION_ID.eq(sessionId))
.execute();
return emailSavedInSession;
}
private MockHttpServletRequestBuilder loginRequest(String email, String password) throws JsonProcessingException {
ObjectMapper mapper = new ObjectMapper();
AuthController.LoginRequest request = new AuthController.LoginRequest(email, password);
return post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(request));
}
}
This works but has a few problems (which we can resolve at a later date):
- It requires the database to have some existing state (in this case, a user with the email danmofo[at]gmail.com)
- We have to manually remove the created sessions from the session table afterwards to stop it cluttering up our dev database
I thought I could deal with these problems as usual by wrapping the test method in @Transactional
, causing all SQL operations to be rolled back at the end of the test, but had issues with jOOQ “seeing” the session records (when I queried the session table, there were none there, despite the session being created). Spring Session manages sessions in its own transaction (REQUIRES_NEW
), so maybe that’s got something to do with it.
I didn’t spend any more time investigating this (maybe something for the future), and carried on writing more tests.
This makes sure that an email and password are provided:
@Test
void shouldReturnErrorWhenCredentialsMissing() throws Exception {
this.mockMvc.perform(loginRequest(null, null))
.andExpect(status().is4xxClientError())
.andExpect(jsonPath("$.errorCode").value(ErrorCode.VALIDATION.toString()))
.andExpect(jsonPath("$.validationErrors.length()").value(2))
.andExpect(jsonPath("$.validationErrors[*].field", containsInAnyOrder("email", "password")))
.andDo(print());
}
Our controller method signature needed to be updated to this to validate the request:
@PostMapping("/api/v1/auth/login")
public String handleLogin(
@Valid @RequestBody LoginRequest loginRequest,
HttpServletRequest req,
HttpServletResponse res) {}
…and we had to add some annotations to our LoginRequest
model:
public record LoginRequest(
@NotEmpty String email,
@NotEmpty String password) {}
Now when validation fails, Spring will throw a MethodArgumentNotValidException
, we can handle this in a global fashion by creating a ErrorHandler
class like so:
@ControllerAdvice
public class ErrorHandler {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseBody
public ValidationErrorResponse handleValidationExceptions(MethodArgumentNotValidException ex) {
List<ValidationError> validationErrors = ex.getBindingResult().getAllErrors().stream()
.map(error ->
new ValidationError(((FieldError) error).getField(), error.getDefaultMessage())
)
.toList();
return new ValidationErrorResponse(validationErrors);
}
}
This maps BindingResult
s field/error messages to a ValidationErrorResponse
and returns it as JSON to the client:
{
"errorCode": "VALIDATION",
"validationErrors": [
{
"field": "email",
"message": "must not be empty"
},
{
"field": "password",
"message": "must not be empty"
}
]
}
Let’s write a test to make sure that an error is returned when the email does not match:
@Test
void shouldReturnErrorWhenEmailDoesNotMatchAnyUserRecords() throws Exception {
this.mockMvc.perform(loginRequest("i-do-not-exist@gmail.com", "password"))
.andExpect(status().is4xxClientError())
.andExpect(jsonPath("$.error").value("Wrong credentials"))
.andDo(print());
}
This fails because currently, our controller is returning a 200 status code, regardless of the authenticatino outcome.
To change that, we can modify our controller signature again:
@PostMapping("/api/v1/auth/login")
public ResponseEntity<ApiResponse> handleLogin(
@Valid @RequestBody LoginRequest loginRequest,
HttpServletRequest req,
HttpServletResponse res) {}
We now return a ResponseEntity<ApiResponse>
. A ResponseEntity
can have a status code and a body, and it gets serialised as JSON by Spring. We cannot just annotate our method with a @ResponseStatus
as it needs return a different status code in different circumstances.
Because we need to return two different types in our method, LoginSuccessResponse
when authentication is successful and ErrorResponse
when it fails, we use a common parent interface ApiResponse
as the return type of our controller - ResponseEntity<ApiResponse>
. We can now return anything that implements that interface, and our code will still compile.
Now we’ve done that, let’s write our final test:
@Test
void shouldReturnErrorWhenPasswordDoesNotMatchUsersPassword() throws Exception {
this.mockMvc.perform(loginRequest("danmofo@gmail.com", "wrong-password"))
.andExpect(status().is4xxClientError())
.andExpect(jsonPath("$.errorCode").value(ErrorCode.INVALID_CREDENTIALS.toString()))
.andDo(print());
}
This is pretty much identical to the previous test.
Side note, you may notice by looking at my tests that I’m testing things indirectly (the authentication service for example). This is intentional - we want to make sure all of the different parts work together.
Conclusion
Setting up jOOQ, Spring Session and Spring Security took much longer than I was anticipating, but now I feel much more comfortable setting them up.
In the next part we’ll start building the individual API endpoints needed by our app.