//

Custom Akka HTTP PathMatcher

7.11.2017 | 4 minutes of reading time

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:

1 val route =
2    path(countryMatcher) { country =>
3      complete(OK -> country.toString)
4    }
5 
6 
7  val pathParamIs = afterWord("path parameter is")
8 
9  "CountryMatcher" should {
10 
11    "match the path parameter and complete correctly" when pathParamIs {
12 
13      "/de" in {
14        Get("/de") ~> route ~> check {
15          handled should equal (true)
16          status should equal(OK)
17          responseAs[String] should equal("De")
18        }
19      }
20 
21 
22      "/fr" in {
23        Get("/fr") ~> route ~> check {
24          handled should equal(true)
25          status should equal(OK)
26          responseAs[String] should equal("Fr")
27        }
28      }
29 
30        "/gb" in {
31        Get("/gb") ~> route ~> check {
32          handled should equal (true)
33          status should equal(OK)
34          responseAs[String] should equal("Gb")
35        }
36      }
37 
38      "/us" in {
39        Get("/us") ~> route ~> check {
40          handled should equal (true)
41          status should equal(OK)
42          responseAs[String] should equal("Us")
43        }
44      }
45    }
46 
47    "not handle" when pathParamIs {
48 
49      "/De" in {
50        Get("/De") ~> route ~> check {
51          handled should equal (false)
52        }
53      // more tests
54     }
55    }
56  }
57

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:

1sealed trait Country
2case object De extends Country
3case object Fr extends Country
4case object Gb extends Country
5case object Us extends Country
6 
7object Country {
8 
9  def apply(from: String): Option[Country] = from match {
10    case "de" => Some(De)
11    case "fr" => Some(Fr)
12    case "gb" => Some(Gb)
13    case "us" => Some(Us)
14    case _ => None
15  }
16 
17  val countryMatcher: PathMatcher1[Country] = {
18    PathMatcher("[a-z]{2}".r).flatMap { path =>
19      Country(path)
20    }
21  }
22}
23

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:

1 val route =
2    path(proposalNumberMatcher) { proposalNumber: ProposalNumber =>
3      complete(OK -> proposalNumber.toString)
4    }
5 
6  val pathParamIs = afterWord("path parameter is")
7 
8  "ProposalNumberMatcher" should {
9 
10    "match the path parameter and complete correctly" when pathParamIs {
11 
12      "/20170020439" in {
13        Get("/20170020439") ~> route ~> check {
14          handled should equal(true)
15          status should equal(OK)
16          responseAs[String] should equal(ProposalNumber(2017, 20439).toString)
17        }
18      }
19 
20      "/20171000000" in {
21        Get("/20171000000") ~> route ~> check {
22          handled should equal(true)
23          status should equal(OK)
24          responseAs[String] should equal(ProposalNumber(2017, 1000000).toString)
25        }
26      }
27    }
28 
29    "not handle" when pathParamIs {
30 
31      "/20170020439000000" in {
32        Get("/20170020439000000") ~> route ~> check {
33          handled should equal(false)
34        }
35      }
36     // more tests
37    }
38  }
39

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.

1final case class ProposalNumber(year: Int, number: Int)
2 
3object ProposalNumber {
4 
5  def apply(from: String): Option[ProposalNumber] = {
6    val year = from.substring(0, 4)
7    val number = from.substring(4)
8    Some(ProposalNumber(year.toInt, number.toInt))
9  }
10 
11  val proposalNumberMatcher: PathMatcher1[ProposalNumber] = {
12    PathMatcher("[0-9]{4}[0-9]{7}".r).flatMap { path =>
13      ProposalNumber(path)
14    }
15  }
16}
17

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 .

share post

Likes

0

//

More articles in this subject area\n

Discover exciting further topics and let the codecentric world inspire you.

//

Gemeinsam bessere Projekte umsetzen

Wir helfen Deinem Unternehmen

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.