Kotlin and Spring Boot: Hypermedia Driven Web Service
Learn about HATEOAS, build a state machine to model an article review workflow, use Spring-HATEOAS and see how hypermedia clients adapt. By Prashant Barahi.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Kotlin and Spring Boot: Hypermedia Driven Web Service
30 mins
- Getting Started
- What Is a State Machine?
- Three Level Review Workflow
- Building the 3LRW State Machine
- Handling Events
- Understanding Hypermedia Responses
- Supporting Hypermedia in Spring Boot
- Media Types
- Building the “Self” Link
- Affordance
- Building the “update” Link
- Building the Tasks Link
- Understanding the Hypermedia Client
- A Non-Hypermedia-Based Client
- Four Level Review Workflow
- Building the 4LRW State Machine
- Where to Go From Here?
Building the “update” Link
Users shouldn’t be allowed to update a published article. Hence, the update affordance is context-sensitive.
To implement this, go to ArticleAssembler.kt and add the following method:
private fun Link.addUpdateAffordance(entity: ArticleEntity): Link {
if (entity.isPublished()) return this // 1
val configurableAffordance = Affordances.of(this)
return configurableAffordance.afford(HttpMethod.PUT) // 2
.withName(UPDATE)
.withTarget(
linkTo(
methodOn(ArticleController::class.java)
.updateArticle(entity.id!!, null) // 3
).withRel(UPDATE)
)
.withInput(ArticleRequest::class.java)
.toLink()
}
And the following imports:
import com.yourcompany.articlereviewworkflow.models.ArticleRequest
import org.springframework.hateoas.mediatype.Affordances
Here:
- This
if
check prevents the update affordance from getting added to the final hypermedia response if theentity
‘s state isPUBLISHED
. For simplicity, this project doesn’t distinguish between users or their roles so it doesn’t implement any Role-based Access Control. If it was supported, theseif
checks are exactly how you would implement context-sensitive affordances. - Build the affordance by specifying its HTTP method and its name.
- Supply the handler
updateArticle()
to the static methods for introspection.
Now, invoke it by chaining it to buildSelfLink()
in the toModel()
:
val resourceLink = buildSelfLink(entity)
.addUpdateAffordance(entity)
Finally, build and run the project and execute the following command:
curl http://localhost:8080/articles/1 -H "Accept:application/prs.hal-forms+json"
And you’ll get the following response:
{
"id": 1,
"state": "DRAFT",
"title": "[...]",
"body": "[...]",
"updatedDate": "2022-05-22T23:43:33.990617",
"createdDate": "2022-05-22T23:43:33.990576",
"reviewType": "THREE_LEVEL_WORKFLOW",
"_links": {
"self": {
"href": "http://localhost:8080/articles/1"
}
},
"_templates": {
"default": {
"method": "PUT",
"properties": [
{
"name": "body",
"readOnly": true,
"type": "text"
},
{
"name": "title",
"readOnly": true,
"type": "text"
}
]
}
}
}
application/prs.hal-form+json
as an Accept
header, because this is what the React app uses to get the HAL-FORMS-based response.
Because the update affordance shares the URL with _links.self.href, its URL isn’t present in the response.
The PUT
method you’ve added – appeared as “default” link. This is a weak point of HAL-FORMS specification.
So to offset the links you added previously (and those you’ll add later), call the addDefaultAffordance()
right after the buildSelfLink()
:
val resourceLink = buildSelfLink(entity)
.addDefaultAffordance()
.addUpdateAffordance(entity)
This hack adds a “dummy” TRACE
method as the first entry in the _templates
and hence is named as “default”.
Restart the server and execute the previous command again. You should see the following response:
{
"id": 1,
"state": "DRAFT",
"title": "[...]",
"body": "[...]",
"updatedDate": "2022-05-22T23:43:33.990617",
"createdDate": "2022-05-22T23:43:33.990576",
"reviewType": "THREE_LEVEL_WORKFLOW",
"_links": {
"self": {
"href": "http://localhost:8080/articles/1"
}
},
"_templates": {
"default": {
"method": "TRACE",
"properties": []
},
"update": {
"method": "PUT",
"properties": [
{
"name": "body",
"readOnly": true,
"type": "text"
},
{
"name": "title",
"readOnly": true,
"type": "text"
}
]
}
}
}
Building the Tasks Link
To let the client know what workflow-related actions they can perform next, add the following methods in the ArticleAssembler.kt:
private fun getAvailableActions(entity: ArticleEntity): List<ArticleEvent> {
if (entity.isPublished()) return emptyList()
val stateMachineFactory = stateMachineFactoryProvider
.getDefaultStateMachineFactory<ArticleState, ArticleEvent>()
val stateMachine = stateMachineFactory
.buildFromHistory(entity.getPastEvents()) // 1
val nextEvents = stateMachine.getNextTransitions() // 2
return nextEvents.toList()
}
private fun Link.addActionsAffordances(entity: ArticleEntity): Link {
val buildActionTargetFn: (ArticleEvent) -> Link = { event ->
linkTo(
methodOn(ArticleController::class.java)
.handleAction(entity.id!!, event.alias)
).withRel(ACTIONS)
}
val events = getAvailableActions(entity)
if (events.isEmpty()) return this
// 3
val configurableAffordance = Affordances.of(this)
.afford(HttpMethod.POST)
.withName(events.first().name)
.withTarget(buildActionTargetFn(events.first()))
return events.subList(1, events.size)
.fold(configurableAffordance) { acc, articleEvent ->
acc.andAfford(HttpMethod.POST)
.withName(articleEvent.name)
.withTarget(buildActionTargetFn(articleEvent))
}.toLink()
}
Ensure you import the following:
import com.yourcompany.articlereviewworkflow.statemachine.articles.ArticleEvent
import com.yourcompany.articlereviewworkflow.statemachine.articles.ArticleState
Here:
-
getPastEvents()
returns all events the state machine had consumed.buildFromHistory()
restores a state machine to its current state by replaying all these events. -
getNextTransitions()
returns a list of events the state machine can consume at its current state. In 3LRW, a state machine atAUTHOR_SUBMITTED
state supportsTE_APPROVE
andTE_REJECT
events (see the 3LRW state diagram). - You build the affordance for each action by passing those event values to the
handleAction()
ofArticleController
.
Finally, invoke this method in the toModel()
by hooking it to the buildSelfLink()
:
val resourceLink = buildSelfLink(entity)
.addDefaultAffordance()
.addUpdateAffordance(entity)
.addActionsAffordances(entity)
Restart the server and execute the following command:
curl http://localhost:8080/articles/1 -H "Accept:application/prs.hal-forms+json"
And you get the following response:
{
"id": 1,
"state": "DRAFT",
"title": "Getting Started with Cucumber",
"body": "[...]",
"updatedDate": "2022-05-22T23:43:33.990617",
"createdDate": "2022-05-22T23:43:33.990576",
"reviewType": "THREE_LEVEL_WORKFLOW",
"_links": {
"self": {
"href": "http://localhost:8080/articles/1"
}
},
"_templates": {
"default": {
"method": "TRACE",
"properties": []
},
"update": {
"method": "PUT",
"properties": [
{
"name": "body",
"readOnly": true,
"type": "text"
},
{
"name": "title",
"readOnly": true,
"type": "text"
}
]
},
"AUTHOR_SUBMIT": {
"method": "POST",
"properties": [],
"target": "http://localhost:8080/articles/1/authorSubmit"
}
}
}
This time, the AUTHOR_SUBMIT included the target because it’s different than the _links.self.href.
Great! With 3LRW implemented, you can fully use the React app.
Understanding the Hypermedia Client
So how does the React client make sense of the HAL-FORMS hypermedia response?
It checks for the presence of the _templates.update
field to determine whether the resource allows updates. If it does, it enables the input field and shows the Save button in the UI.
Clicking the Save button makes a request using the HTTP verb in _templates.update.method
. It creates a request body using the input fields from the UI. Remember that the absence of a _templates.update.target
field implies it has exactly the same value as _links.self.href
, so the client uses the _links.self.href
as the URL to make the request.
The curl request for the update affordance is:
curl -X PUT http://localhost:8080/articles/1 -d '{"title":"Title from UI", "body":"Body from UI"}' -H "Accept:application/prs.hal-forms+json" -H "Content-Type:application/json"
As for the action buttons, the UI considers all the links with _templates.*.method
equal to POST
and with target
like http://localhost:8080/articles/{articleId}/*
as the workflow actions and renders the button accordingly. Because the AUTHOR_SUBMIT
link meets these criteria, it’s treated as a workflow action. This is why you see the AUTHOR SUBMIT button in the UI. The text in the workflow button is derived by omitting the underscores in the link name.
Use Chrome Developer Tools to see what response the browser gets from the hypermedia-based server at each stage.
You might think: “All this effort, but for what?”. Well, you’ll have your question answered in the next section!
A Non-Hypermedia-Based Client
A typical response for an article resource from a non-hypermedia server looks like this:
{
"id": 1,
"title": "Heading 1",
"body": "Lorem ipsum.",
"status": "DRAFT"
}
Now, to show the details of an article using the response above, a typical Kotlin client-side code (in a hypothetical UI framework) might look like:
fun buildUI(article: Article) {
titleTextView.text = article.title
descriptionTextView.text = article.body
if (article.state == ArticleState.AUTHOR_SUBMITTED) {
buildSaveButton(article.id).show()
buildTEApproveButton(article.id).show()
buildTERejectButton(article.id).show()
}
// and other branches
// ...
}
private fun buildTEApproveButton(id: Long): Button {
val btn = Button()
btn.text = "TE APPROVE"
btn.setOnClickListener {
// makes POST request to /articles/{id}/teApprove
}
return btn
}
private fun buildTERejectButton(id: Long): Button {
// makes POST request to /articles/{id}/teReject
}
// other build Button methods
// ...
So what’s wrong with this? Well, for starters, the client is coupled to the workflow. The client needs to know all the states of the article and the possible transitions in each state and also how to initiate these transitions (by making a POST
request to /articles/{id}/*
endpoint). Also, any change in workflow breaks the client, and you’ll need to update and then redeploy the client.
In the next section, you’ll see how the hypermedia server and client handle the inevitable changes in business requirements!