Overview

Custom Akka HTTP PathMatcher

No Comments

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
     }
    }
  }

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)
    }
  }
}

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
    }
  }

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)
    }
  }
}

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:

  1. A path parameter can be validated against several rules, e.g. format and range. Validations do not pollute the route definition.
  2. Extraction and conversion of the value are hidden. You only get a valid value inside the route.
  3. Implementation and testing are easy.
  4. 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.

Comment

Your email address will not be published. Required fields are marked *