Contents

Mapping HTTP request to sealed class with Jackson

TL;DR

Today I learned how to map http request to sealed class in Kotlin and decided to share that with you.

Context

Sometimes it can happen that our REST API consumes jsons that have conditional fields. Let’s say we have a following endpoint in our application:

POST /bet-offers

It is used for adding a new bet offer to our application which offers betting on different sport events. There are different types of bet offers:

  • WIN_LOSE - it’s a kind of bet offer when there are only two possible outcomes - the sport event will end with victory or failure of Team A;
  • WIN_DRAW_LOSE - this time there is also a third outcome - a draw;
  • AMOUNT_OF_GOALS - in this kind of bet offer we don’t care about outcome of meeting but take a look for amount of goals (for example if Team A scores less than 5 goals).

There can be many other bet types, but those three are going serve as our example.

For the first case request can look like that (very simplified version):

{
  "betType": "WIN_LOSE",
  "winLoseBetOffer": {
    "homeTeamWins": {
      "amount": "2.22",
      "currency": "PLN"
    },
    "awayTeamWins": {
      "amount": "2.46",
      "currency": "PLN"
    }
  }
}

For the second case request can look like that (very simplified version):

{
  "betType": "WIN_DRAW_LOSE",
  "winDrawLoseBetOffer": {
    "homeTeamWins": {
      "amount": "2.22",
      "currency": "PLN"
    },
    "draw": {
      "amount": "1.86",
      "currency": "PLN"
    },
    "awayTeamWins": {
      "amount": "2.46",
      "currency": "PLN"
    }
  }
}

For the third case request can look like that (very simplified version):

{
  "betType": "AMOUNT_OF_GOALS",
  "amountOfGoals": {
    "moreThan": [
      {
        "amount": "1",
        "odds": {
          "amount": "1.59",
          "currency": "PLN"
        }
      },
      ...
    ],
    "lessThan": [
      {
        "amount": "5",
        "odds": {
          "amount": "1.24",
          "currency": "PLN"
        }
      },
      ...
    ]
  }
}

So all of those requests have the same field: betType, however next field depends of the value of the first field - you got the idea.

Naive solution

Until today I would do something like that :

  • Create some container with nullable fields:
data class BetOfferDto(
    val betType: BetType,
    val winLoseBetOffer: WinLoseBetOffer?,
    val winDrawLoseBetOffer: WinDrawLoseBetOffer?,
    val amountOfGoals: AmountOfGoalsBetOffer?
)
  • In controller I would recognize based on the type in the field what I need:
    @PostMapping
    fun addBetOffer(betOffer: BetOfferDto) {
        when(betOffer.betType) {
            BetType.WIN_LOSE -> WinLoseBetOffer.from(betOffer)
            BetType.WIN_DRAW_LOSE -> WinDrawLoseBetOffer.from(betOffer)
            BetType.AMOUNT_OF_GOALS -> AmountOfGoalsBetOffer.from(betOffer)
        }
    }

and in from method I would do requireNotNull on specific field and do all the mapping. Not the best solution but it does the job.

Elegant solution

Today I found out that there is much easier way for that.

  • Create sealed class that represents all possible requests:
sealed class BetOfferDto
  • Create all possible variants (here I present only one):
data class WinLoseBetOfferWrapper(
    val winLoseBetOffer: WinLoseBetOffer
) : BetOfferDto()

data class WinLoseBetOffer(
    val homeTeamWins: Odds,
    val awayTeamWins: Odds
)
  • Annotate sealed class with @JsonSubTypes and @JsonTypeInfo:
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "betType")
@JsonSubTypes(
    JsonSubTypes.Type(value = WinLoseBetOfferWrapper::class, name = "WIN_LOSE"),
    JsonSubTypes.Type(value = WinDrawLoseBetOfferWrapper::class, name = "WIN_DRAW_LOSE"),
    JsonSubTypes.Type(value = AmountOfGoalsBetOfferWrapper::class, name = "AMOUNT_OF_GOALS")
)
sealed class BetOfferDto

And we don’t need to worry about all the mapping! Our controller looks much better:

    @PostMapping("/bet-offers")
    fun addBetOffer(@RequestBody betOffer: BetOfferDto) {
        when (betOffer) {
            is WinLoseBetOfferWrapper -> TODO("WinLoseBetOfferWrapper")
            is WinDrawLoseBetOfferWrapper -> TODO("WinDrawLoseBetOfferWrapper")
            is AmountOfGoalsBetOfferWrapper -> TODO("AmountOfGoalsBetOfferWrapper")
            null -> TODO("null")
        }
    }

Additionally it is worth to mention that @JsonSubtypes works fine on nullable fields.

Note: Remember about adding jackson-module-kotlin dependency, so request can be bound to data class.

Full example can be found here: https://github.com/piotr-proszowski/mapping-requests-to-sealed-class/

That’s all for today, happy coding!