The code has been developed and tested on a Linux Mint 20.2 Uma machine with kernel version 5.15.0-78-generic
and Java 17.
We employ a MySQL database for persistence (version 8.0.33-0ubuntu0.20.04.2
), and an H2 database for tests. The application.properties
file of the
application lets it create all the entities on the database, so minimal database legwork should be required.
You just need to create the database cards_app_db
, a user named cardsappuser
with the provided password
and grant all privileges on cards_app_db
to cardsappuser
. This is how we did it in our machine. Open up a shell and type:
sudo mysql --password
Input your sudo
password, and this should open up the mysql
prompt, where you should type:
create database cards_app_db; -- Creates the new database
create user 'cardsappuser'@'%' identified by 'ThePassword882100##'; -- Same password we have in the application.properties
grant all on cards_app_db.* to 'cardsappuser'@'%'; -- Gives all privileges to the new user on the newly created database
You can now run the Spring Server by running the CardsApplication
class. Once the server
is up - and - running, for security reasons, we recommend downgrading the privileges of 'cardsappuser'
to just the absolutely
necessary ones through the mysql
prompt:
revoke all on cards_app_db.* from 'cardsappuser'@'%';
grant select, insert, delete, update on cards_app_db.* to 'cardsappuser'@'%';
You can use plain curl
calls, a tool like POSTMAN or even OpenAPI 3.
Some details for Postman and OpenAPI follow.
We provide a POSTMAN collection in the file Cards App.postman_collection.json
. This file
has example calls that you can use to interact with the API. Every call to the
cards API has the Authorization
header assigned to the string Bearer {{BEARER_TOKEN}}
, where
{{BEARER_TOKEN}}
is a POSTMAN environment variable that contains the JWT
returned by the authentication endpoint (see below, section "Registration & Authentication"). Here is how
you can set this variable. First, click on the "Environment Quick Look" icon on the
upper-right corner, then on "Edit":
This will pull up the "Environments" tab, and you can set a variable called
BEARER_TOKEN
with the JWT as the current value.
We have prepared an OpenApi
bean in class OpenAPI30Configuration
and have several annotations in our
controllers and DTOs that make the OpenApi 3 page rendered in a user-friendly
fashion. Once the app is running, access the page by sending your browser to
http://localhost:8080/swagger-ui/index.html#.
To authenticate using the OpenAPI page, make the same POST
call described above
to the cardsapi/authenticate
endpoint (make sure the user has been registered first!), copy the JWT returned and then click on the "Authorize" button:
Paste the JWT and click on "Authorize":
This should now "unlock" all the card REST calls so that you can perform them without getting a 401 "Unauthorized" Http Status code.
An advantage of using the OpenAPI page over the Postman collection is better documentation of the endpoints, with examples of the status codes that are returned as well as various example formats of the payloads. A disadvantage is that you will have to re-authenticate if you refresh the page.
The API generates JWTs for authentication, with the secret stored in application.properties
. The provided POSTMAN collection
shows some examples of user registration, but you can also use the OpenAPI page
if you prefer. For example, POST
-ing the following payload
to the cardsapi/register
endpoint registers Maria Giannarou as an admin:
{
"email" : "[email protected]",
"password" : "mgiannaroupass",
"role" : "ADMIN"
}
while the following registers Orestis Ktenas as a member:
{
"email" : "[email protected]",
"password" : "oktenaspass",
"role" : "MEMBER"
}
Attempting to register a user twice will result in a 409 CONFLICT
Http Error Code
sent back. All exceptional situations are decorated with Exception Advices implemented in
the class ExceptionAdvice
.
After registering, you should receive a JSON with just your username (password ommitted for security) and a 201 CREATED
Http Response code:
{
"username": <THE_USERNAME_YOU_CHOSE>
}
To receive the Bearer Token, POST
your username and password
to the /cardsapi/authenticate
endpoint, for example:
{
"email" : "[email protected]",
"password" : "mgiannaroupass"
}
You should receive a JSON with your JWT alongside a 200 OK
.
{
"jwtToken": <A_JWT>
}
The token has been configured to last 5 hours by default, but you can
tune that by changing the value of the variable JWT_VALIDITY
in the Constants
class.
Once logged in as either an admin or a member, you can POST the following payload
to the /cardsapi/card
endpoint to create a card with name CARD-1
:
{
"name" : "CARD-1",
"description" : "My first card",
"color": "#093HGG"
}
If successful, this should return a payload with a DB-generated unique ID,
a status of TODO
, audit information generated by extending the Auditable
interface
and some HAL-formatted links to the GET endpoints
for the card itself and all the cards:
{
"id": 1,
"name": "CARD-1",
"description": "My first card",
"color": "#093HGG",
"status": "TODO",
"createdDateTime": "02/08/2023 13:04:21.428",
"createdBy": "[email protected]",
"lastModifiedDateTime": "02/08/2023 13:04:21.428",
"lastModifiedBy": "[email protected]",
"_links": {
"self": {
"href": "http://localhost:8080/cardsapi/card/1"
},
"all_cards": {
"href": "http://localhost:8080/cardsapi/card?page=0&items_in_page=5&sort_by_field=id&sort_order=ASC"
}
}
}
We use some SpringHATEOAS static
methods to render
the links. Check the class CardModelAssembler
for details.
Additionally, we ignore any efforts of the user to change the value of the status
field since a newly
created card should have status TODO. The user can also attempt to change the value of the audit fields
or pretty much set any irrelevant key-value pair they like, but those values will also be quietly ignored by the API.
For example, try POST-ing the following by logging in as any user and see for yourself:
{
"name" : "TST",
"description" : "A test card",
"color" : "#A89012",
"createdBy": "[email protected]",
"foo" : "bar"
}
You should get back a well-formed card (we are logged in as Dimitris Polissiou in this example):
{
"id": 11,
"name": "TST",
"description": "A test card",
"color": "#A89012",
"status": "TODO",
"createdDateTime": "02/08/2023 18:51:54.223",
"createdBy": "[email protected]",
"lastModifiedDateTime": "02/08/2023 18:51:54.223",
"lastModifiedBy": "[email protected]",
"_links": {
"self": {
"href": "http://localhost:8080/cardsapi/card/11"
},
"all_cards": {
"href": "http://localhost:8080/cardsapi/card?page=0&items_in_page=5&sort_by_field=id&sort_order=ASC"
}
}
}
Performing a GET at the endpoint cardsapi/card/{id}
will retrieve the information of the card uniquely
identified by {id}
. For example, a GET at localhost:8080/cardsapi/card/1
should retrieve the same payload that the POST
above retrieved:
{
"id": 1,
"name": "CARD-1",
"description": "My first card",
"color": "#093HGG",
"status": "TODO",
"createdDateTime": "02/08/2023 13:04:21.428",
"createdBy": "[email protected]",
"lastModifiedDateTime": "02/08/2023 13:04:21.428",
"lastModifiedBy": "[email protected]",
"_links": {
"self": {
"href": "http://localhost:8080/cardsapi/card/1"
},
"all_cards": {
"href": "http://localhost:8080/cardsapi/card?page=0&items_in_page=5&sort_by_field=id&sort_order=ASC"
}
}
}
However, if in the meantime we have logged in as a different "member" user, we will
get a 403 FORBIDDEN
error, since we do not allow member users to retrieve the cards
of other members or admins.
Let's replace the card we posted above by PUT-ting the following payload
to cardsapi/card/1
:
{
"name" : "CARD-1-REPLACEMENT",
"description" : "Replacement of card 1",
"status" : "IN_PROGRESS"
}
We should receive the following updated card.
{
"id": 1,
"name": "CARD-1-REPLACEMENT",
"description": "Replacement of card 1",
"color": null,
"status": "IN_PROGRESS",
"createdDateTime": "02/08/2023 13:04:21.428",
"createdBy": "[email protected]",
"lastModifiedDateTime": "02/08/2023 13:48:08.499",
"lastModifiedBy": "[email protected]",
"_links": {
"self": {
"href": "http://localhost:8080/cardsapi/card/1"
},
"all_cards": {
"href": "http://localhost:8080/cardsapi/card?page=0&items_in_page=5&sort_by_field=id&sort_order=ASC"
}
}
}
Notice that the color
has now been nullified since
we did not provide it in the payload we PUT.
Also notice that, as a design choice, when a user PUTs a card, the "created" audit information
is NOT changed. The "last modified" information, on the other hand, is, of course, changed.
As with the POST endpoint, a user MUST supply a name for the card.
Let's now PATCH the same card by changing its color and status. Send a PATCH
with the following payload to
cardsapi/card/1
:
{
"color" : "#FFFFFF",
"status" : "DONE"
}
You should receive:
{
"id": 1,
"name": "CARD-1-REPLACEMENT",
"description": "Replacement of card 1",
"color": "#FFFFFF",
"status": "DONE",
"createdDateTime": "02/08/2023 13:04:21.428",
"createdBy": "[email protected]",
"lastModifiedDateTime": "02/08/2023 13:50:43.403",
"lastModifiedBy": "[email protected]",
"_links": {
"self": {
"href": "http://localhost:8080/cardsapi/card/1"
},
"all_cards": {
"href": "http://localhost:8080/cardsapi/card?page=0&items_in_page=5&sort_by_field=id&sort_order=ASC"
}
}
}
The PATCH endpoint ignores fields that are missing or NULL
, assuming that the user simply does not
want to change those fields. For example, neither the name
nor the descripton
attributes of
this card were changed. Additionally, if the
user attempts to clear the name of a card by supplying a whitespace-only string, they will receive a
400 BAD_request
response; we do not allow for the clearing of the name field.
To delete a card, send a DELETE
request at cardsapi/cards/{id}
. So, for example,
the card above can be deleted by sending a DELETE
request at cardsapi/cards/1
.
Note that our semantics for DELETE
are "hard-delete". Calling DELETE
on an ID
removes the physical entry from the database. We also return a 404 NOT_FOUND
Http error
if the ID we want to DELETE is not in the database. The community seems to be divided on whether
one should return a 204 NO_CONTENT
or a 404 NOT_FOUND
in this case, and what exactly is meant by "idempotent" when
we say that "DELETE should be an idempotent operation".
Under src/test/java
you can find unit and integration tests. Unit tests make extensive use
of Mockito, while integration tests load the spring context and use the default in-memory
H2 database.
The following are the code coverage metrics generated by IntelliJ:
We use basic AOP to enable logging at the INFO
and WARN
levels for all public
methods at the controller,
service and persistence layers. Examine the package com.logicea.cardsapp.util.logger
for the implementation,
and peek at the Spring terminal after every call to the API to see the logging in action.
Timestamps in the application are formatted in a European-friendly format, specifically
"dd/MM/yyyy HH:mm:ss.SSS"
. This is the format that is expected, for example, if filtering by
creation / ending date-time in aggregate GET queries and yes, this unfortunately includes
miliseconds...
We unfortunately did not have time to implement some interesting features such as:
- An implementation of the GET ALL queries at the persistence layer using the
Spring Data JPA Specification
interface. The current implementation uses the Criteria API directly,
and is somewhat spaghetti-fied. Refer to class
AggregateCardQueryRepositoryImpl
for details. - It would have also been nice to cache the previous and next page of data after a user requests a specific data page with specific sorting criteria, for faster access.
- "Soft" deletes of cards, perhaps with a column called "active" in the
CARD
database table. This would allow for some historical queries of form "How many cards did user XYZ create within a given timeframe", even if some (or all) of said cards had been deleted in the meantime. - Setting up the MySQL database through a
docker-compose
script so that any OS could run the app without issue.
- If you call an endpoint that requires an ID request parameter (e.g
DELETE
orGET
at/cardapi/card/{id}
) but neglect to pass the request parameter{id}
, you will get a401 Unauthorized
HTTP Error. This is because of the way that thecommence()
method has been overloaded inJwtAuthenticationEntryPoint
and could probably have been handled better. - We use the Hibernate
@Email
validator for validating e-mails, and that validator is sensitive to whitespace. Please be careful when typing e-mail addresses in authentication endpoints, and if filtering by e-mail in aggregate GET calls. - This is a bit far-fetched, but still noteworthy. If a member tries to access a card that
is not in the repository, then we send them a
404 NOT_FOUND
. Since we hard-delete, this means that even if the member would not have had access to the card in the first place, we send them a404
instead of a403 FORBIDDEN
. If we had instead implemented a soft-delete, we could be fetching an entity from the database to which the user could be determined to not have access, even in its current "deleted" (or "inactive") state. So it's possible that we are leaking some information to members that should be kept private. Now, if the card was never there in the first place, a404
would be appropriate.