Akka HTTP provides some nice PathMatchers such as JavaUuid and number-specific matchers next to the string-based matchers to handle path parameters. But sometimes this is not sufficient. A path parameter has to be transformed into a specific data structure or should match against a given range. These checks and transformations can be done in a method after a path parameter has been treated as a segment and then be handled within the route definition. This is ugly, though, because the concerns are not separated. A dedicated PathMatcher suits better. This article shows how to solve both issues.
What to achieve?
We will create two custom PatchMatchers. One handles a path parameter that is representing a country value. This value can only be de, fr, gb or us.
The other one handles a proposal number. This proposal number looks like this: 20170020439
The first four digits represent the year of the proposal. The remaining digits consist of a seven-digit number with leading zeros.
Country Specific PathMatcher
Our PathMatcher has to pass the following tests:
val route =
path(countryMatcher) { country =>
complete(OK -> country.toString)
}
val pathParamIs = afterWord("path parameter is")
"CountryMatcher" should {
"match the path parameter and complete correctly" when pathParamIs {
"/de" in {
Get("/de") ~> route ~> check {
handled should equal (true)
status should equal(OK)
responseAs[String] should equal("De")
}
}
"/fr" in {
Get("/fr") ~> route ~> check {
handled should equal(true)
status should equal(OK)
responseAs[String] should equal("Fr")
}
}
"/gb" in {
Get("/gb") ~> route ~> check {
handled should equal (true)
status should equal(OK)
responseAs[String] should equal("Gb")
}
}
"/us" in {
Get("/us") ~> route ~> check {
handled should equal (true)
status should equal(OK)
responseAs[String] should equal("Us")
}
}
}
"not handle" when pathParamIs {
"/De" in {
Get("/De") ~> route ~> check {
handled should equal (false)
}
// more tests
}
}
} |
val route =
path(countryMatcher) { country =>
complete(OK -> country.toString)
} val pathParamIs = afterWord("path parameter is") "CountryMatcher" should { "match the path parameter and complete correctly" when pathParamIs { "/de" in {
Get("/de") ~> route ~> check {
handled should equal (true)
status should equal(OK)
responseAs[String] should equal("De")
}
} "/fr" in {
Get("/fr") ~> route ~> check {
handled should equal(true)
status should equal(OK)
responseAs[String] should equal("Fr")
}
} "/gb" in {
Get("/gb") ~> route ~> check {
handled should equal (true)
status should equal(OK)
responseAs[String] should equal("Gb")
}
} "/us" in {
Get("/us") ~> route ~> check {
handled should equal (true)
status should equal(OK)
responseAs[String] should equal("Us")
}
}
} "not handle" when pathParamIs { "/De" in {
Get("/De") ~> route ~> check {
handled should equal (false)
}
// more tests
}
}
}
We define a route that consists of a path directive and put our countryMatcher as parameter. The route completes the request with status Ok and the string representation of the extracted country value.
We set up two groups of tests:
- The first group matches all values of the range and they will be handled correctly.
- In the second group, none of the values are in the given range and they will not be handled.
The code for the countryMatcher is the following:
sealed trait Country
case object De extends Country
case object Fr extends Country
case object Gb extends Country
case object Us extends Country
object Country {
def apply(from: String): Option[Country] = from match {
case "de" => Some(De)
case "fr" => Some(Fr)
case "gb" => Some(Gb)
case "us" => Some(Us)
case _ => None
}
val countryMatcher: PathMatcher1[Country] = {
PathMatcher("[a-z]{2}".r).flatMap { path =>
Country(path)
}
}
} |
sealed trait Country
case object De extends Country
case object Fr extends Country
case object Gb extends Country
case object Us extends Country object Country { def apply(from: String): Option[Country] = from match {
case "de" => Some(De)
case "fr" => Some(Fr)
case "gb" => Some(Gb)
case "us" => Some(Us)
case _ => None
} val countryMatcher: PathMatcher1[Country] = {
PathMatcher("[a-z]{2}".r).flatMap { path =>
Country(path)
}
}
}
An ADT represents a range of values. We define two things in the companion object:
- an apply function with a string parameter that returns an Option of the trait
- a value countryMatcher
The apply function matches the given string against a lower-case string representation of each object. If one matches, it wraps and returns the current object. Since nothing matches, it returns None.
The countryMatcher has the type PathMatcher1. Inside the definition there is a PathMatcher with a regular expression. This regex tests the path parameter. If the regex matches, the path parameter will be passed to the mentioned apply function.
Proposal Number PathMatcher
The tests for the proposal number PathMatcher are:
val route =
path(proposalNumberMatcher) { proposalNumber: ProposalNumber =>
complete(OK -> proposalNumber.toString)
}
val pathParamIs = afterWord("path parameter is")
"ProposalNumberMatcher" should {
"match the path parameter and complete correctly" when pathParamIs {
"/20170020439" in {
Get("/20170020439") ~> route ~> check {
handled should equal(true)
status should equal(OK)
responseAs[String] should equal(ProposalNumber(2017, 20439).toString)
}
}
"/20171000000" in {
Get("/20171000000") ~> route ~> check {
handled should equal(true)
status should equal(OK)
responseAs[String] should equal(ProposalNumber(2017, 1000000).toString)
}
}
}
"not handle" when pathParamIs {
"/20170020439000000" in {
Get("/20170020439000000") ~> route ~> check {
handled should equal(false)
}
}
// more tests
}
} |
val route =
path(proposalNumberMatcher) { proposalNumber: ProposalNumber =>
complete(OK -> proposalNumber.toString)
} val pathParamIs = afterWord("path parameter is") "ProposalNumberMatcher" should { "match the path parameter and complete correctly" when pathParamIs { "/20170020439" in {
Get("/20170020439") ~> route ~> check {
handled should equal(true)
status should equal(OK)
responseAs[String] should equal(ProposalNumber(2017, 20439).toString)
}
} "/20171000000" in {
Get("/20171000000") ~> route ~> check {
handled should equal(true)
status should equal(OK)
responseAs[String] should equal(ProposalNumber(2017, 1000000).toString)
}
}
} "not handle" when pathParamIs { "/20170020439000000" in {
Get("/20170020439000000") ~> route ~> check {
handled should equal(false)
}
}
// more tests
}
}
The tests have the same structure as before. The route takes the proposalNumberMatcher as parameter. There is an extracted value proposalNumber. We finally call proposalNumber.toString in the complete directive. At first we do tests with valid path parameters. Afterwards the tests have invalid values.
final case class ProposalNumber(year: Int, number: Int)
object ProposalNumber {
def apply(from: String): Option[ProposalNumber] = {
val year = from.substring(0, 4)
val number = from.substring(4)
Some(ProposalNumber(year.toInt, number.toInt))
}
val proposalNumberMatcher: PathMatcher1[ProposalNumber] = {
PathMatcher("[0-9]{4}[0-9]{7}".r).flatMap { path =>
ProposalNumber(path)
}
}
} |
final case class ProposalNumber(year: Int, number: Int) object ProposalNumber { def apply(from: String): Option[ProposalNumber] = {
val year = from.substring(0, 4)
val number = from.substring(4)
Some(ProposalNumber(year.toInt, number.toInt))
} val proposalNumberMatcher: PathMatcher1[ProposalNumber] = {
PathMatcher("[0-9]{4}[0-9]{7}".r).flatMap { path =>
ProposalNumber(path)
}
}
}
case class ProposalNumber handles the proposal number. In addition, it has a companion object that includes our PathMatcher logic. There are two definitions as well. The apply function extracts the data from a path parameter and returns a wrapped instance. The proposalNumberMatcher tests the pattern and uses the apply function.
Benefits
A custom PathMatcher provides some benefits:
- A path parameter can be validated against several rules, e.g. format and range. Validations do not pollute the route definition.
- Extraction and conversion of the value are hidden. You only get a valid value inside the route.
- Implementation and testing are easy.
- A custom PathMatcher is reusable.
Conclusion
Implementing custom PathMatchers supports you in defining and securing routes. Just a few lines of codes are sufficient to extend the given possibilities.
Get the code here.