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?
In software engineering, much effort goes toward building a loosely coupled system. A loosely coupled system allows you to reduce the effect of changes made in one part of the system on other parts.
You can decouple the frontend application from the server by making the latter send extra information in the response. This information tells the client what actions are possible on a given resource. Clients can then use this information to drive the application from one state to another. This is HATEOAS.
HATEOAS stands for Hypermedia as the Engine of Application State. Although it sounds overwhelming, the idea is rather simple. It’s the way of designing a REST application so the server embeds hypermedia links as metadata in the response. The server crafts these links using the current application state before sending them to the client. The client can then use those hypermedia links to drive the application from the current state to another.
This article and every other one you’ve read on our website went through a review workflow. In this tutorial, you’ll build a hypermedia-based server by modeling a similar review workflow that’s practiced by our tutorial team.
In the process, you’ll:
- Learn the concepts of HATEOAS.
- Build a state machine to model the review workflow.
- Use Spring-HATEOAS to build a hypermedia-powered Spring Boot server.
- Handle change requirements and see how hypermedia-based clients can adapt.
Getting Started
Click Download Materials at the top or bottom of the tutorial to download the starter project. Fire up the IntelliJ IDEA and select Open…. Then, navigate to and open the starter project.
First, make sure you’ve installed JDK 11 or higher. Open Application.kt and run the project.
Or you can also open a Terminal and execute:
./gradlew bootRun
You’ll find the server running on port 8080.
The starter project also contains a React application in the /ui folder. To build and run it, make sure you have Node.js v16.15.1 and npm v8.11.0 (or greater) installed by executing the following commands:
node -v
And:
npm -v
If you don’t have them, go to the official site to download and install them.
Then, build and run the React application by executing this:
cd ui
npm ci
npm run start
You should find it running on port 3000.
Open your browser and go to http://localhost:3000. By default, it makes requests to localhost:8080 (where the Spring Boot server is running), so you’ll see a list of articles like this:
The app isn’t fully usable now, so minimize it.
Now, look at the starter project. It contains the following files:
- ArticleSeedInitializer.kt seeds the empty database with initial data.
-
StateMachineConfigurer.kt provides APIs you use to configure the states and the transitions of a state machine. Then, you supply it to a
StateMachineFactory
. -
ArticleStateMachineBeanConfig.kt is where you configure
ArticleStateMachineFactory
. -
StateMachineFactoryProvider.kt provides methods to retrieve a
StateMachineFactory
bean you’ve configured. - FakeNetworkDelayFilter.kt adds a random delay to every request.
What Is a State Machine?
A state machine is a mathematical model of computation. It has a finite number of states and can be in any one of them at a time.
It starts from an initial state and responds to events by transitioning from its current state to the next if the current state allows it. On reaching the next state, it becomes ready to accept events again.
In this project, you use a state machine to drive the review workflow. Before you dive into the code, you’ll first learn about the business problem you’ll solve.
Three Level Review Workflow
Each article goes through a review process before it gets published. For now, consider there are just three steps. Call it the Three Level Review Workflow (3LRW)!
The author starts the article. They pick a topic from the topic pool and write the article. Once the draft is ready for review, it goes to the technical editor (TE).
Technical editors proofread the article and perform an expert-level review of the content. They can either approve the article and send it to the final pass editor (FPE) or reject the article with feedback if they feel it needs further polish.
Final pass editors stand as the last line of defense. Once they approve the article, they publish it, making it available to readers. After this, any updates to the article are not allowed. However, if the article fails the quality check, the final pass editor can send it back to the author and the process restarts.
The 3LRW state diagram is:
Next, you’ll model this workflow using a state machine.
Building the 3LRW State Machine
The starter project already has an outline of a state machine implemented.
ArticleState
defines all the states a state machine can go through in 3LRW, namely DRAFT
, AUTHOR_SUBMITTED
,TE_APPROVED
and PUBLISHED
.
Similarly, AUTHOR_SUBMIT
, TE_APPROVE
, TE_REJECT
, FPE_APPROVE
and FPE_REJECT
are the events the 3LRW supports. They’re defined in the ArticleEvent
.
Refer to the state diagram to understand how these events and the states relate to each other.
To build a StateMachine
with a 3LRW configuration, you need a StateMachineFactory
. Therefore, you also need to provide a configuration to the StateMachineFactory
to comply with 3LRW. Open ArticleStateMachineBeanConfig.kt and go to providesThreeLevelReviewStateMachineFactory()
:
fun providesThreeLevelReviewStateMachineFactory(): ArticleStateMachineFactory {
// ...
.withTransitions {
// Author
defineTransition(start = DRAFT, trigger = AUTHOR_SUBMIT, end = AUTHOR_SUBMITTED)
// TODO: define other transitions
}
// ...
This is an incomplete implementation of a 3LRW state machine. So replace all the block content with the following snippet:
// Author
defineTransition(start = DRAFT, trigger = AUTHOR_SUBMIT, end = AUTHOR_SUBMITTED)
// TE
defineTransition(start = AUTHOR_SUBMITTED, trigger = TE_APPROVE, end = TE_APPROVED)
defineTransition(start = AUTHOR_SUBMITTED, trigger = TE_REJECT, end = DRAFT)
// FPE
defineTransition(start = TE_APPROVED, trigger = FPE_APPROVE, end = PUBLISHED)
defineTransition(start = TE_APPROVED, trigger = FPE_REJECT, end = DRAFT)
Here, you’ve defined the event (i.e., trigger
) that — when consumed — causes the state machine to transition from the start
state to the end
state.
Notice the return type of this function is an ArticleStateMachineFactory
. This is just an alias for StateMachineFactory<ArticleState, ArticleEvent>
.
Handling Events
To handle the events, open ArticleService.kt and look at the following method:
fun handleEvent(articleId: Long, event: ArticleEvent) {
val article = repository.findById(articleId).orElseThrow()
// 1
val stateMachineFactory = stateMachineFactoryProvider
.getDefaultStateMachineFactory<ArticleState, ArticleEvent>()
// 2
val stateMachine = stateMachineFactory
.buildFromHistory(article.getPastEvents())
// 3
stateMachine.setOnTransitionListener(object :
OnStateTransitionListener<ArticleState, ArticleEvent> {
override fun onTransition(
prevState: ArticleState,
event: ArticleEvent,
nextState: ArticleState
) {
article.state = nextState
article.consumeEvent(event)
}
})
// 4
val eventResult = stateMachine.sendEvent(event)
if (!eventResult) {
throw StaleStateException("Event ${event.alias} could not be accepted.")
}
repository.save(article)
}
Here’s what’s going on:
-
You get the default
StateMachineFactory
, which is the one whose transitions you reconfigured before. -
On this factory, you could call
create()
to get a fresh instance of the 3LRWStateMachine
. But in this case, you need to restore aStateMachine
to the article’s current state by callingbuildFromHistory()
and passing in the list of events it must consume to reach that state. This is called Event Sourcing. - Whenever a transition happens, you update the state of the article and store the corresponding event.
-
You call
sendEvent()
to initiate a state transition. All the events that have been consumed successfully get persisted to the database (and are used for Event Sourcing).
Take a few moments to explore this setup. In the next section, you’ll learn about HATEOAS.
Understanding Hypermedia Responses
Consider a hypothetical endpoint that serves articles. Sending a GET
request to /articles/1
should return an article with ID of 1.
{
"id": 1,
"title": "Heading 1",
"body": "Lorem ipsum.",
"status": "DRAFT"
}
It’s difficult to know what actions are allowed on a resource, just from the response above. However, a hypermedia response includes actions that are currently available as well. The server crafts them using the current snapshot of the resource.
So what does this “hypermedia response” look like? A GET /articles/1
but on a hypermedia-based server returns this:
{
"id": 1,
"title": "Heading 1",
"body": "Lorem ipsum.",
"status": "DRAFT",
"_links": {
"self": {
"href": "http://localhost:8080/articles/1"
},
"update": {
"href": "http://localhost:8080/articles/1"
}
}
}
Through this response, a client can determine what operations are allowed on a resource. The presence of _links.update
implies it’s possible to update that resource and use that information to, for example, display an Update button to the users.
Next, you’ll learn to build these hypermedia links.
Supporting Hypermedia in Spring Boot
The sample project uses the Spring-HATEOAS library to construct the hypermedia response. You’ll notice the following dependency in the build.gradle.kts:
implementation("org.springframework.boot:spring-boot-starter-hateoas")
This library provides static methods under WebMvcLinkBuilder
to introspect methods. It also has classes like RepresentationModel
, CollectionModel
and so on to model the hypermedia response.
You enable the support for hypermedia by annotating the Application
using @EnableHypermediaSupport
(the project is already annotated with it):
import org.springframework.hateoas.config.EnableHypermediaSupport
@SpringBootApplication
@EnableHypermediaSupport(type = [
EnableHypermediaSupport.HypermediaType.HAL,
EnableHypermediaSupport.HypermediaType.HAL_FORMS,
])
class Application
Later, you’ll learn what these HypermediaType.HAL
and HypermediaType.HAL_FORMS
are. For now, go to ArticleResource.kt, where you’ll find ArticleResource
inheriting from RepresentationModel
. ArticleController
exposes the /articles
endpoint that returns an ArticleResource
or a variation of it.
Finally, go to the most important class: ArticleAssembler.kt. Here, you craft the hypermedia response the client receives. Its outline looks like this:
@Component
class ArticleAssembler
@Autowired constructor(
private val stateMachineFactoryProvider: StateMachineFactoryProvider
) : RepresentationModelAssembler<ArticleEntity, ArticleResource> {
// ...
}
The ArticleAssembler
translates the database entity ArticleEntity
to the ArticleResource
.
Build and run the server and execute this curl command:
curl http://localhost:8080/articles -H "Accept:application/hal+json"
And fetch the article with ID 1 using:
curl http://localhost:8080/articles/1 -H "Accept:application/hal+json"
The responses you get aren’t any different than the non-hypermedia-based responses. Next, you’ll learn what this application/hal+json
is!
Media Types
In the curl commands you executed before, you sent a strange looking Accept
header: application/hal+json
. This is the registered media type identifier for JSON HAL (JSON Hypertext Application Language). HAL is the simplest and most widely adopted hypermedia media type.
Another media type called HAL-FORMS provides information on HTTP methods, message content-type and request parameters to use when making a request to the server. Its associated media type identifier is application/prs.hal-forms+json
.
Other media types are available for hypermedia as well, and the “On choosing a hypermedia type for your API” blog does a great job of comparing them.
The React client is built with support for a HAL-FORMS-based response. Thus, it sends application/prs.hal-forms+json
as an Accept
header on every request.
Now that you’re familiar with media types and identifiers, you’ll learn to enrich the hypermedia response in the upcoming sections.
Building the “Self” Link
The _links field is a reserved property. It contains one or more link objects. One of them is the self link, with which a client can get the full representation of the resource.
Adding a self link is straightforward. Go to the ArticleAssembler.kt. There, you’ll find buildSelfLink()
:
fun buildSelfLink(entity: ArticleEntity): Link {
return linkTo(
methodOn(ArticleController::class.java)
.getById(entity.id!!)
)
.withSelfRel()
}
Both linkTo()
and methodOn()
are static methods from org.springframework.hateoas.server.mvc.WebMvcLinkBuilder
. The getById()
is a method in ArticleController
, which is used in building a self link.
Finally, inside toModel()
, call this method by replacing the // TODO: Add Link here
with the snippet below:
val resourceLink = buildSelfLink(entity)
resource.add(resourceLink)
Again, build and run the project and execute the following command:
curl http://localhost:8080/articles/1 -H "Accept:application/hal+json"
It returns:
{
"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": "FOUR_LEVEL_WORKFLOW",
"_links": {
"self": {
"href": "http://localhost:8080/articles/1"
}
}
}
As you can see, it now includes the _links.self
field.
Before you continue enriching the response, you’ll first learn about affordances. To the next section!
Affordance
Hypermedias are resources and affordances. An article is a resource, and the actions you can perform on that resource are its affordances.
As mentioned in the “hypermedia affordances” blog, affordances are what the resource “offers” and how they offer it.
So updating an article by sending a PUT
request to /articles/1
can be one of its affordances. Moreover, they also need to be context-sensitive. If you aren’t allowed to edit or delete an article that’s already published, the affordances that allow for those two actions shouldn’t be available in the response.
Affordances also convey what “actions” are available, and clients can use that information to drive the application state forward. Hence, unlike resources, affordances are represented as verbs, not nouns.
To see these “actions” in action, open ArticleController.kt and check the handleAction()
:
@PostMapping("/{articleId}/{action}")
fun handleAction(
@PathVariable articleId: Long,
@PathVariable action: String
): RepresentationModel<ArticleResource> {
val event =
eventMapper.getArticleEvent(action) ?: throw IllegalArgumentException("$action is invalid")
service.handleEvent(articleId, event)
return articleAssembler.toModel(service.findById(articleId))
}
ArticleEventMapper
translates the “verbified” alias to the corresponding ArticleEvent
that the state machine consumes in the handleEvent()
. If that event is supported, it transitions into the next state while also updating the article’s state.
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!
Four Level Review Workflow
The tutorial team is growing! With team members from around the world, the management team decided to improve the content’s quality by introducing Editors into 3LRW.
After technical editing, the article goes to the editor. Editors ensure the writing is clean, concise and grammatically correct and follows guidelines before they send it to the final pass editor. This successor to 3LRW is called the Four Level Review Workflow (4LRW):
This is a breaking change for non-hypermedia clients. But can this be implemented without making any changes to the client?
Building the 4LRW State Machine
Before starting with the code, ensure the React app is running. Go to localhost:3000 in your browser and ensure 3LRW is working.
First, you need to define the additional states and events the 4LRW requires. Go to ArticleState.kt and add EDITOR_DONE
state to the enum class. And in the ArticleEvent.kt, add EDITOR_APPROVE("editorApprove")
.
In the ArticleStateMachineBeanConfig
, define a new ArticleStateMachineFactory
for 4LRW by adding the following method:
@Primary
@Bean(FOUR_LEVEL_REVIEW_STATE_MACHINE)
fun providesFourLevelReviewStateMachineFactory(): ArticleStateMachineFactory {
val configuration = StateMachineConfigurer.StateBuilder<ArticleState, ArticleEvent>()
.withStartState(DRAFT)
.withEndState(PUBLISHED)
.withStates(
DRAFT,
AUTHOR_SUBMITTED,
TE_APPROVED,
EDITOR_DONE,
PUBLISHED
)
.and()
.withTransitions {
// Author
defineTransition(start = DRAFT, trigger = AUTHOR_SUBMIT, end = AUTHOR_SUBMITTED)
// TE
defineTransition(start = AUTHOR_SUBMITTED, trigger = TE_APPROVE, end = TE_APPROVED)
defineTransition(start = AUTHOR_SUBMITTED, trigger = TE_REJECT, end = DRAFT)
// Editor
defineTransition(start = TE_APPROVED, trigger = EDITOR_APPROVE, end = EDITOR_DONE)
// FPE
defineTransition(start = EDITOR_DONE, trigger = FPE_APPROVE, end = PUBLISHED)
defineTransition(start = EDITOR_DONE, trigger = FPE_REJECT, end = DRAFT)
}
return StateMachineFactory(ReviewType.FOUR_LEVEL_WORKFLOW, configuration)
}
Outside the class, add the following line:
const val FOUR_LEVEL_REVIEW_STATE_MACHINE = "FourLevelReviewStateMachineFactory"
And finally, add FOUR_LEVEL_WORKFLOW
in the ReviewType
:
FOUR_LEVEL_WORKFLOW {
override val key = this.name
}
The most important part is annotating the providesFourLevelReviewStateMachineFactory()
with @Primary
and removing it from the providesThreeLevelReviewStateMachineFactory()
. After this, the outline of the ArticleStateMachineBeanConfig
becomes:
const val THREE_LEVEL_REVIEW_STATE_MACHINE = "ThreeLevelReviewStateMachineFactory"
const val FOUR_LEVEL_REVIEW_STATE_MACHINE = "FourLevelReviewStateMachineFactory"
@Configuration
class ArticleStateMachineBeanConfig {
@Bean(THREE_LEVEL_REVIEW_STATE_MACHINE)
fun providesThreeLevelReviewStateMachineFactory(): ArticleStateMachineFactory {
// ...
}
@Primary
@Bean(FOUR_LEVEL_REVIEW_STATE_MACHINE)
fun providesFourLevelReviewStateMachineFactory(): ArticleStateMachineFactory {
// ...
}
}
The StateMachineFactoryProvider
‘s getDefaultStateMachineFactory()
will now return the 4LRW StateMachineFactory
because you annotated it with @Primary
.
Check ArticleService
‘s save()
:
fun save(title: String, body: String): ArticleEntity {
val stateMachineFactory = stateMachineFactoryProvider.getDefaultStateMachineFactory()
return ArticleEntity
.create(
title = title,
body = body,
reviewType = stateMachineFactory.identifier as ReviewType
)
.let(repository::save)
}
Every newly created article will use 4LRW, because getDefaultStateMachineFactory()
will return the 4LRW factory.
Also, look at the handleEvent()
:
fun handleEvent(articleId: Long, event: ArticleEvent) {
// ...
val stateMachineFactory = stateMachineFactoryProvider
.getDefaultStateMachineFactory<ArticleState, ArticleEvent>()
val stateMachine = stateMachineFactory
.buildFromHistory(article.getPastEvents())
// ...
As you might have guessed, this code won’t work for existing 3LRW articles because the default factory now follows 4LRW.
Because there is more than one StateMachineFactory
now — one for 3LRW and one for 4LRW — you need to choose which StateMachineFactory
to use. Do this by passing the review type to StateMachineFactoryProvider
‘s getStateMachineFactory()
.
Replace the code in handleEvent()
, where it gets the default factory, to the following:
fun handleEvent(articleId: Long, event: ArticleEvent) {
// ...
val stateMachineFactory = stateMachineFactoryProvider
.getStateMachineFactory<ArticleState, ArticleEvent>(article.reviewType)
// ...
Also, open ArticleAssembler.kt and replace the code in getAvailableActions()
where it gets the default factory to the following:
private fun getAvailableActions(entity: ArticleEntity): List<ArticleEvent> {
// ...
val stateMachineFactory = stateMachineFactoryProvider
.getStateMachineFactory<ArticleState, ArticleEvent>(entity.reviewType)
// ...
This means the code now is retro-compatible, because getStateMachineFactory<ArticleState, ArticleEvent>(article.reviewType)
will return the 3LRW factory for those articles previously saved with the THREE_LEVEL_WORKFLOW
review type.
Now, restart the server and go to localhost:3000. Use the form at the bottom of the page to create a new article. Go to its detail page and use the workflow buttons to approve or reject the article. You’ll find that the newly created article uses 4LRW without you having to change any client-side code. Also, try out the 3LRW retro-compatibility! Choose an article created with 3LRW and go through the workflow. It should now follow the 4LRW process.
The client “adjusts” itself to changes in the workflow. This is possible because the hypermedia-based server offers the necessary metadata.
Where to Go From Here?
Click Download Materials at the top or bottom of the tutorial to download the final project.
Good job making it all the way through! You learned to create a hypermedia-based server using Spring-HATEOAS and how it helps lower the coupling between the client and the server. You also saw how the hypermedia-based client adjusted to the changes in the review workflow.
To complete the review workflow, try introducing the illustrator into the workflow. When the editor is done, the illustrator picks that article up and designs the perfect illustration for it before handing it over to the final pass editor. Its state diagram is:
Find its solution in the challenge folder of the materials.
Here are some links for further reading:
- Spring HATEOAS – Reference Documentation
- Richardson Maturity Model
- REST APIs must be hypertext-driven
If you have questions or comments, please join the forum discussion below!