Basically we will build the Kitchen Duty Planning REST Resource and the Kitchen Duty Planning REST Model which will persist our selected weeks and users to the database.
You also might want to look into the Story Workshop again to recapitulate what we are going to build now.
After some serious brain usage on how to store the actual data of «who has Kitchen Duty in which week?» I came up with the Data-Model seen on the right.
The Week will be the actual week number in the year, and we don't care about the year in this tutorial. So if you had kitchen duty in week 24 this year you will have it the years after as well. With a little thinking ahead of how I want to use it in the JS Controller I basically will select a week via a date picker and calculate the week number in the frontend using moment.js
The User part is obvious. We will need to store a list of usernames in the database in relation with Weeks (@ManyToMany
).
Concerning the REST Endpoint I will want to have an URL which I can fire
GET requests on something like /planning/week/15/users
and receive a list of users.
Obviously the other way round would be a PUT request on /planning/week/15/users
with a list of users
as request body to store the user list for the week 15.
And for our stretch-goal a GET request on /planning/user/bob/weeks
should return a list of week numbers
in which Bob has Kitchen Duty.
Week | n m | User |
---|---|---|
week: Integer | username: String |
Alright we know how we want to use our REST Endpoint from the perspective of the JS Controller. And we have some kind of clue on how the data should be stored theoretically.
There are two main possibilities to persist data in JIRA.
All I will be writing about Active Objects is taken from the Developing your plugin with Active Objects Guide and the Getting Started with Active Objects Guide.
First of all add Active Objects dependency to your pom.xml.
<dependency>
<groupId>com.atlassian.activeobjects</groupId>
<artifactId>activeobjects-plugin</artifactId>
<version>1.1.5</version>
<scope>provided</scope>
</dependency>
We need a Component Import for ActiveObjects
which will be the object we use to access the database.
package io.codeclou.kitchen.duty.rest;
import com.atlassian.activeobjects.external.ActiveObjects;
import com.atlassian.plugin.spring.scanner.annotation.imports.ComponentImport;
...
@Named ("myPluginComponent")
public class MyPluginComponentImpl implements MyPluginComponent {
...
@ComponentImport
private ActiveObjects activeObjects;
...
}
Now we create our first Entity Week
and define the week as integer via getter/setter.
package io.codeclou.kitchen.duty.ao;
import net.java.ao.Entity;
import net.java.ao.Preload;
@Preload
public interface Week extends Entity {
Integer getWeek();
void setWeek(Integer week);
}
@Preload
which I think makes it not lazy loaded ... but you have
to read the docs if you want to know in detail.
Entity
that makes our Class automatically have getID()
so we just need to define our fields which is currently only the week number (week of year).
We just created the Week
Entity now and we need to tell the Active Objects module of our Plugin where he can find our Entity.
Therefore we add a section to our atlassian-plugin.xml
file listing the Entities (Maybe there is some kind of nice Annotation to skip that step, but since the docs are very thin on that I don't know. So if you find out please tell me).
<ao key="ao-module">
<description>The module configuring the Active Objects service used by this plugin</description>
<entity>io.codeclou.kitchen.duty.ao.Week</entity>
</ao>
We are agile coders and wanna see basic results now of what we did. Therefore we will create a very basic persistTest-Endpoint to try out the ORM stuff provided by Active Objects. We will get rid of the endpoint later we just use it to get a feel of how Active Objects works.
package io.codeclou.kitchen.duty.rest;
import com.atlassian.activeobjects.external.ActiveObjects;
import com.atlassian.jira.bc.user.search.UserSearchService;
import com.atlassian.plugins.rest.common.security.AnonymousAllowed;
import com.atlassian.sal.api.transaction.TransactionCallback;
import io.codeclou.kitchen.duty.ao.Week;
import javax.inject.Inject;
import javax.inject.Named;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.util.ArrayList;
import java.util.List;
@Named
@Path("/planning")
public class KitchenDutyPlanningResource {
private ActiveObjects activeObjects;
@Inject
public KitchenDutyPlanningResource(ActiveObjects activeObjects) {
this.activeObjects = activeObjects;
}
public KitchenDutyPlanningResource() {
}
@GET
@Path("/persistTest")
@Produces({MediaType.APPLICATION_JSON})
@AnonymousAllowed
public Response persistTest() {
activeObjects.executeInTransaction(new TransactionCallback<Week>()
{
@Override
public Week doInTransaction()
{
final Week testWeek = activeObjects.create(Week.class);
testWeek.setWeek(42);
testWeek.save();
return testWeek;
}
});
return Response.ok("ok").build();
}
@GET
@Path("/health")
@Produces({MediaType.APPLICATION_JSON})
@AnonymousAllowed
public Response health() {
return Response.ok("ok").build();
}
}
ActiveObjects
as property so that we can use it.
@Inject
to the constructor so that we get it injected via DI.
persistTest
and Method @Get
which we will use to play with the ORM.
Week
. You could set it to Void here too.
doInTransaction
and inside here we can do actual
Active Objects ORM stuff. Obviously here we could override more methods to handle transaction rollbacks
and usually we should wrap all these shenanigans inside a try-catch block and do proper exception handling
BUT we are currently just playing around so it is fine.
Week
object.
We call activeObjects.create(..)
and pass our Class-Type.
After that we set our week value and call .save()
.
Now our week is persisted to the Database. Banzai!
Alright now startup JIRA with atlas-run
but capture the output a file called jira-stdout.log
so that we can grep it later. You need to have tee installed.
...
[INFO] [talledLocalContainer] INFORMATION: Server startup in 38377 ms
[INFO] [talledLocalContainer] Tomcat 8.x started on port [2990]
[INFO] jira started successfully in 58s at http://localhost:2990/jira
[INFO] Type Ctrl-D to shutdown gracefully
[INFO] Type Ctrl-C to exit
Open another terminal to execute some CURL commands and once JIRA has started up execute a GET Request to our persistTest Endpoint.
Now our Week object with week 42 should have been persisted to the table Week in the database
Alright now you need to know that JIRA runs a H2 Database when started with atlas-run
. And it does NOT use HSQLDB anymore even though it has used HSQLDB up to some version of JIRA - but those days seem over.
You might have wondered why you needed to capture the STDOUT of JIRA previously to jira-stdout.log
.
Because on line 12131415 of one trillion overall lines that atlas-run echos you will find a line starting containing Database URL
which
tells you the location of the H2 Database.
And since we want to have a look we need to know where the Database resides in order to connect.
Now stop the atlas-run
by pressing CTRL+C (we can only look into the H2 DB if no other process uses it).
And grep for the Database URL in the captured STDOUT.
Copy the whole jdbc:h2:file:/Users/...../target/jira/home/database/h2db
JDBC URL (I have shortened the path here)
In the same terminal we now start the H2 Database Console by executing the h2.jar file. (Depending on your SDK version it should be somehwere around that location and might have an alternate version number.)
Now a Browser Tab opens with the console and you just paste the JDBC URL and click connect.
Please remember to always just look into the database. You should never alter the Database with SQL Statements since JIRA manages autoincrement IDs and some other Caches and you will surely run into strange errors if you still do so. When using Object Relational Mappers it is best not to fiddle with the Database. Number One Rule: Only alter the Database through Active Objects.
You should see our Week table and if you execute a SELECT Statement on it you can see contents.
You can see the whole process of calling the testPersist Endpoint and connecting to the H2 Database in the following gif.
Since we got Active Objects running successfully we now want to add a @ManyToMany
relationship between User
an Week
.
And when you have read the docs you will note that we need a Mapping Entity for that case. We will call it UserToWeek
.
But first we want to define the week and username as unique to avoid duplicate entries which are developer's nightmares.
package io.codeclou.kitchen.duty.ao;
import net.java.ao.Entity;
import net.java.ao.ManyToMany;
import net.java.ao.Preload;
import net.java.ao.schema.NotNull;
import net.java.ao.schema.Unique;
@Preload
public interface Week extends Entity {
@NotNull
@Unique
Integer getWeek();
void setWeek(Integer week);
@ManyToMany(value = UserToWeek.class)
User[] getUsers();
}
@NotNull
which does not allow NULL values anymore.
@Unique
which does not allow duplicate values anymore.
getUsers()
with @ManyToMany
. The value = UserToWeek.class
defines which
entity is used to map this relationship. You might know that you always need a mapping-table in the database
when mapping to objects many-to-many since the relational paradigma can only map one-to-many.
We will create UserToWeek
a little further down below.
package io.codeclou.kitchen.duty.ao;
import net.java.ao.Entity;
import net.java.ao.ManyToMany;
import net.java.ao.Preload;
import net.java.ao.schema.NotNull;
import net.java.ao.schema.Unique;
@Preload
public interface User extends Entity {
@NotNull
@Unique
String getName();
void setName(String week);
@ManyToMany(value = UserToWeek.class)
Week[] getWeeks();
}
@NotNull
which does not allow NULL values anymore.
@Unique
which does not allow duplicate values anymore.
getWeeks()
with @ManyToMany
. It works the same way as in Week
just the other way round.
We just defined our real entities and the relation they have through UserToWeek
so all that is left is to define the mapping entity.
package io.codeclou.kitchen.duty.ao;
import net.java.ao.Entity;
public interface UserToWeek extends Entity {
void setUser(User user);
User getUser();
void setWeek(Week week);
Week getWeek();
}
UserToWeek
also extends Entity
.
User
.
Week
.
That was all we needed to do to map the Many-To-Many relationship with Active Objects. And now don't forget to list the entities in the ao
section of your atlassian-plugin.xml
.
<ao key="ao-module">
<description>The module configuring the Active Objects service used by this plugin</description>
<entity>io.codeclou.kitchen.duty.ao.Week</entity>
<entity>io.codeclou.kitchen.duty.ao.User</entity>
<entity>io.codeclou.kitchen.duty.ao.UserToWeek</entity>
</ao>
We change the testPersist Endpoint to now persist a full relationship.
package io.codeclou.kitchen.duty.rest;
...
@Named
@Path("/planning")
public class KitchenDutyPlanningResource {
...
@GET
@Path("/persistTest")
@Produces({MediaType.APPLICATION_JSON})
@AnonymousAllowed
public Response persistTest() {
activeObjects.executeInTransaction(new TransactionCallback<Week>()
{
@Override
public Week doInTransaction()
{
final Week testWeek = activeObjects.create(
Week.class,
new DBParam("WEEK", 42));
testWeek.save();
final User user = activeObjects.create(
User.class,
new DBParam("NAME", "ichiban"));
user.save();
final UserToWeek relationship =
activeObjects.create(UserToWeek.class);
relationship.setUser(user);
relationship.setWeek(testWeek);
relationship.save();
return testWeek;
}
});
return Response.ok("ok").build();
}
...
}
activeObjects#create(...)
to create a Week
object with reference to the Database.
DBParam
is used to immediately set the @NotNull
values when creating the object.
Why do we need this? You can read 'Creating an entity with "not null"-constraints'
OR let me explain. What does actually happen when activeObjects#create(...)
is called? Usually the Active Objects
does an INSERT into the database with null values and the autoincrement ID of the table row is then the only thing set (except for default values).
So right after create(Week.class)
the week object already has an ID when calling getID()
.
And now comes the tricky part. If you have defined a property as @NotNull
Active Objects also just tries
to do a INSERT into the table to write the ID back to the object BUT WAIT an ActiveObjectsSqlException
is thrown since our field
is null and it is not allowed to be null. SOLUTION: We use DBParams
to immediately set the NotNull-Values
so that the initial INSERT that is triggered by create(...)
contains actual values for our NotNull-Fields.
User
object mapped to a Database Table row as we just did with Week
.
NAME
value initially for our NotNull-Fields.
UserToWeek
object mapped to a Database Table row as we just did with User
.
User
and Week
objects as reference and save.
By that the Many-To-Many relation between User
and Week
is now established.
We are all set now to start JIRA again and to test if we can really save our relationship.
But before we do that do a quick atlas-clean
before the next atlas-run
so that the database gets purged.
Otherwise you might run into Unique index or primary key violation
errors since we have 4 week entries already all with week=42 values.
We startup JIRA again and call the persistTest Endpoint again.
And by calling the testPersist endpoint a second time we will get a unique constraint exception as expected. That is fine since that only shows that the unique constraint works and we will be throwing away the persistTest Endpoint anyways.
We shutdown JIRA again and fire up the H2 Console again to see what tables have been created.
Ok we have played around a little with active objects so far and now have our data model and entities in place. That means we can now create the real Endpoint Methods and throw away the testPersist Endpoint.
package io.codeclou.kitchen.duty.rest;
...
@Named
@Path("/planning")
public class KitchenDutyPlanningResource {
...
/**
* Get the Users assigned to the weekNumber.
*
* @param weekNumber
* @return
*/
@GET
@Path("/week/{weekNumber}/users")
@Produces({MediaType.APPLICATION_JSON})
@AnonymousAllowed
public Response getUsersForWeek(@PathParam("weekNumber") final Integer weekNumber) {
Week[] weeks = activeObjects.executeInTransaction(new TransactionCallback<Week[]>() {
@Override
public Week[] doInTransaction() {
return activeObjects.find(
Week.class,
Query.select().where("WEEK = ?", weekNumber));
}
});
List<KitchenDutyPlanningResourceUserModel> users = new ArrayList<>();
if (weeks != null && weeks.length > 0) {
for (User user : weeks[0].getUsers()) {
users.add(
new KitchenDutyPlanningResourceUserModel(
user.getID(),
user.getName()
)
);
}
}
return Response.ok(users).build();
}
/**
* Get the Weeks assigned to the User.
*
* @param weekNumber
* @return
*/
@GET
@Path("/user/{username}/weeks")
@Produces({MediaType.APPLICATION_JSON})
@AnonymousAllowed
public Response getWeeksForUser(@PathParam("username") final String username) {
User[] users = activeObjects.executeInTransaction(new TransactionCallback<User[]>() {
@Override
public User[] doInTransaction() {
return activeObjects.find(User.class, Query.select().where("NAME = ?", username));
}
});
List<KitchenDutyPlanningResourceWeekModel> weeks = new ArrayList<>();
if (users != null && users.length > 0) {
for (Week week : users[0].getWeeks()) {
weeks.add(new KitchenDutyPlanningResourceWeekModel(week.getID(), week.getWeek()));
}
}
return Response.ok(weeks).build();
}
...
}
/week/{weekNumber}/users
Endpoint
we start a Transaction and specify Week[]
as return type since there is no way (at least I did not find one)
to get only one entity from the Database. We always get a List. Since we are having our unique constraints setup our lists will most likely
only contain one value.
find(...)
of the Active Objects class which will return a List of Weeks
found in the database.
Query.select().where(...)
we define that we only want to get records that have
the week number set we passed as the Endpoints @PathParam
.
And please be aware that you HAVE TO add spaces in "WEEK = ?"
otherwise it is Exception-Day for you.
@Preload
).
That being said and me being very paranoid when it comes to my REST APIs I always want to seperate the Data Model that is populate
to the User via the API from my internal Data Model used by the Database. Therefore I always setup simple POJOs that get a copy of the relevant database values.
For example your REST-API User object might not contain a password property even though your Database User object does.
(And yes we could hide it with some kind of Hide-Json-Annotation, but a clear separation is what worked best for me so far.)
If you like you can try to skip out this step and directly pass your Database Entitiy objects out via the REST Endpoint.
You might need to setup the JAX-WS Annotations on your Entities then.
/user/{username}/weeks
Endpoint. The same explanations apply.
We have just used the REST-API-Pojos which we will define right now. No magic just getters and setters and some JAX-WS Annotations you have seen in previous steps of the tutorial already. The only fancy thing is the convenience constructor but yeah ... just copy paste those two since it's not a big deal.
package io.codeclou.kitchen.duty.rest;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlRootElement;
@XmlRootElement(name = "weeks")
@XmlAccessorType(XmlAccessType.FIELD)
public class KitchenDutyPlanningResourceWeekModel {
@XmlElement
private Integer id;
@XmlElement
private Integer week;
KitchenDutyPlanningResourceWeekModel() { }
KitchenDutyPlanningResourceWeekModel(Integer id, Integer week) {
this.id = id;
this.week = week;
}
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public Integer getWeek() {
return week;
}
public void setWeek(Integer week) {
this.week = week;
}
}
package io.codeclou.kitchen.duty.rest;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlRootElement;
@XmlRootElement(name = "users")
@XmlAccessorType(XmlAccessType.FIELD)
public class KitchenDutyPlanningResourceUserModel {
@XmlElement
private Integer id;
@XmlElement
private String username;
KitchenDutyPlanningResourceUserModel() { }
KitchenDutyPlanningResourceUserModel(Integer id, String username) {
this.id = id;
this.username = username;
}
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
}
Alright we again are at a point where we want to start JIRA and fire some request against our API to check it out.
Now we can query these two endpoints with the following example requests.
List assigned users for week 42.
List weeks for user ichiban.
That is working fine. In the next section we will deal with assigning Users to Weeks and removing assignments.
Now we add the functionality to add users to weeks and remove them from weeks.
Therefore we will need a DELETE and PUT Endpoint which we add to the KitchenDutyPlanningResource
Class.
We start with the PUT Endpoint to assign Users to Weeks.
...
/**
* Add the User to the Week
*
* @param weekNumber
* @return
*/
@PUT
@Path("/week/{weekNumber}/users")
@Produces({MediaType.APPLICATION_JSON})
@AnonymousAllowed
public Response addUserToWeek(@PathParam("weekNumber") final Integer weekNumber,
final KitchenDutyPlanningResourceUserModel userParam) {
activeObjects.executeInTransaction(new TransactionCallback<Void>() {
@Override
public Void doInTransaction() {
//
// WEEK
//
Week week = KitchenDutyActiveObjectHelper.findUniqueWeek(
activeObjects,
weekNumber
);
if (week == null) {
week = activeObjects.create(
Week.class,
new DBParam("WEEK", weekNumber)
);
week.save();
}
//
// USER
//
User user = KitchenDutyActiveObjectHelper.findUniqueUser(
activeObjects,
userParam.getUsername()
);
if (user == null) {
user = activeObjects.create(
User.class,
new DBParam("NAME", userParam.getUsername())
);
user.save();
}
//
// Establish ManyToMany Relationship
//
UserToWeek relationship = KitchenDutyActiveObjectHelper
.findRelationship(activeObjects, user, week);
if (relationship != null) {
// relation already exists
return null;
}
relationship = activeObjects.create(UserToWeek.class);
relationship.setUser(user);
relationship.setWeek(week);
relationship.save();
return null;
}
});
return Response.ok().build();
}
...
PUT /week/{weekNumber}/users
Endpoint
with method @PUT
(you can also use @Patch
which would be more appropriate since we are sending increments to the Endpoints but it is up to you).
There is nothing more time consuming as to argue with colleagues about REST API design ... at best you look at the GitHub REST API or
the Spring Data REST Docs to get a feel for good API design. And if you
are ever in the position to nag about someones API just ask him "Does it come with HATEOAS?" and you will immediately sound
like an expert. Then take a deep sip of your coffee, lean back and nod your head constantly to everything beeing said as if everything
being said is boring you ....
iswoWeekNumber
is a @PathParam
again.
KitchenDutyPlanningResourceUserModel
and you can access it via userParam
.
Void
as return type since we will do all our shenanigans inside the
transaction.
findUniqueWeek
helper method which we will define later - just trust me that
it will return one for the weekNumber passed or null.
User
. First try to find the existing user and if the user does not exist create it.
User
and Week
mapped by UserToWeek
.
Therefore I create a helper method findRelationship
which returns an UserToWeek
object representing an existing relationship or null.
To break this up a little concerning readability now follows the DELETE Endpoint to remove Week assignments of Users in the same file.
...
/**
* Remove the User from Week
*
* @param weekNumber
* @return
*/
@DELETE
@Path("/week/{weekNumber}/users")
@Produces({MediaType.APPLICATION_JSON})
@AnonymousAllowed
public Response deleteUserFomWeek(@PathParam("weekNumber") final Integer weekNumber,
final KitchenDutyPlanningResourceUserModel userParam) {
activeObjects.executeInTransaction(new TransactionCallback<Void>() {
@Override
public Void doInTransaction() {
//
// WEEK
//
Week week = KitchenDutyActiveObjectHelper.findUniqueWeek(
activeObjects,
weekNumber
);
if (week == null) {
// week does not exist => no relation to delete
return null;
}
//
// USER
//
User user = KitchenDutyActiveObjectHelper.findUniqueUser(
activeObjects,
userParam.getUsername()
);
if (user == null) {
// user does not exist => no relation to delete
return null;
}
//
// Delete ManyToMany Relationship
//
UserToWeek relationship = KitchenDutyActiveObjectHelper
.findRelationship(activeObjects, user, week);
if (relationship != null) {
activeObjects.delete(relationship);
}
return null;
}
});
return Response.ok().build();
}
...
@DELETE
.
findUniqueWeek
helper method. And if it is null it indicates that there cannot be any existing relation between user and week
and that we can skip out here.
findUniqueUser
helper method. And if it is null it indicates that there cannot be any existing relation between user and week
and that we can skip out here.
findRelationship
helper method. And if it is not null delete the relationship.
To prevent code duplication and improve readability of Endpoint-code I moved some common data-access-spaghetti-code into some static Methods of the KitchenDutyActiveObjectHelper
Class.
It should be pretty self-explanatory.
package io.codeclou.kitchen.duty.ao;
import com.atlassian.activeobjects.external.ActiveObjects;
import net.java.ao.Query;
public class KitchenDutyActiveObjectHelper {
public static Week findUniqueWeek(ActiveObjects activeObjects, Integer weekNumber) {
Week[] weekRes = activeObjects.find(Week.class, Query.select().where("WEEK = ?", weekNumber));
if ((weekRes != null && weekRes.length > 0)) {
return weekRes[0];
}
return null;
}
public static User findUniqueUser(ActiveObjects activeObjects, String username) {
User[] userRes = activeObjects.find(User.class, Query.select().where("NAME = ?", username));
if ((userRes != null && userRes.length > 0)) {
return userRes[0];
}
return null;
}
public static UserToWeek findRelationship(ActiveObjects activeObjects, User user, Week week) {
UserToWeek[] relationships = activeObjects.find(UserToWeek.class, Query.select().where("USER_ID = ? AND WEEK_ID = ?", user.getID(), week.getID()));
if ((relationships != null && relationships .length > 0)) {
return relationships[0];
}
return null;
}
}
Now we can fire up atlas-run
once more and wait for JIRA startup.
Let's add a user called jimmy to a week 42. Fire up this curl command and it should return a HTTP 200 telling you it worked (we use the CURL -i
parameter to echo out the response headers and return code).
We have written our Endpoints in a way so that the CURLs are idemponent. So if you fire it a second time you will get a HTTP 200 as well but since jimmy already is assigned to week 42 nothing happens internally.
And by the way, we don't need to pass an ID for user since we don't know it and since the username has a unique constraint which makes it our technical primary key.
Let's remove a user called ichiban from week 42.
List assigned users for week 42.
Now we have all our Endpoints in place and can continue to the next step. There we will actually use our Endpoints from the JS-Controller.
The graphic shows marked green which components we implemented in this section.