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.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

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:

  1. This if check prevents the update affordance from getting added to the final hypermedia response if the entity‘s state is PUBLISHED. 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, these if checks are exactly how you would implement context-sensitive affordances.
  2. Build the affordance by specifying its HTTP method and its name.
  3. 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"
        }
      ]
    }
  }
}
Note: Remember to use 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:

  1. getPastEvents() returns all events the state machine had consumed. buildFromHistory() restores a state machine to its current state by replaying all these events.
  2. getNextTransitions() returns a list of events the state machine can consume at its current state. In 3LRW, a state machine at AUTHOR_SUBMITTED state supports TE_APPROVE and TE_REJECT events (see the 3LRW state diagram).
  3. You build the affordance for each action by passing those event values to the handleAction() of ArticleController.

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.

Network Request

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!