kostja
kostja

Reputation: 61538

How to get the correct parameter validation error

I have a simple route, where the parameters should be extracted into case classes:

val myRoute: Route =
    get {
      path("resource") {
        parameters('foo, 'x.as[Int]).as(FooParams) { params =>
        ...
        } ~
        parameters('bar, 'x.as[Int]).as(BarParams) { params =>
          ...
        }
    }
}

case class FooParams(foo: String, x: Int) {
require(x > 1 && x < 10, "x for foos must be between 2 and 9")
}

case class BarParams(bar: String, x: Int) {
require(x > 10 && x < 20, "x for bars must be between 11 and 19")
}

The case classes should validate the input, so invalid input would be rejected with a 400.

The rejection happens, but with a 404 and the error message is misleading

I expect it to be x for foos must be between 2 and 9 for .../resource?foo=a&x=0 but it is Request is missing required query parameter 'bar'

Same for bars, while I expect .../resource?bar=a&x=0 to result in a 400 with x for bars must be between 11 and 19, it responds with a 404 with Request is missing required query parameter 'foo'.

What am I misunderstanding here and how to fix it?

akka-http 2.0.3

EDIT

4lex1v's solution works for me. What bothers me a bit is that I am deliberately abandoning the help the framework offers me: I have to handle the case where both foo and bar are missing 'manually'. Same goes for the rejections on x ranges. OTOH, the code is much more explicit, including the handling for the case where both foo and bar are given and the MissingQueryParamRejection can be customized when both are missing:

val myRoute2: Route =
(get & path("resource")) {
  parameters('foo ?, 'bar ?, 'x.as[Int]) {
    case (Some(foo), None, x) if x > 1 && x < 10 => {
      val params = FooParams(foo, x)
      ...
    }
    case (Some(foo), None, x) => reject(MalformedQueryParamRejection("x", s"x for foos must be between 2 and 10 but was $x"))

    case (None, Some(bar), x) if x > 10 && x < 20 => {
      val params = BarParams(bar, x)
      ...
    }
    case (None, Some(bar), x) => reject(MalformedQueryParamRejection("x", s"x for bars must be between 11 and 19 but was $x"))

    case (Some(foo), Some(bar), x) => reject(MalformedQueryParamRejection("bar", "expecting either foo or bar, received both"))
    case (None, None, x) => reject(MissingQueryParamRejection("foo or bar"))
  }
}

Upvotes: 1

Views: 3724

Answers (2)

cmbaxter
cmbaxter

Reputation: 35443

I think the main part of the issue you are seeing comes from how your route is defined. By defining both of those possible parameter sets under the path "resource", then when it misses on the foo param you end up with a MissingParemeterRejection at the head of the list of rejections. A ValidationRejection ends up in there too, but the default rejection handler must prefer the MissingParameterRejection when deciding whats status code and message to convey to the caller. If you simply redefined your routes like so:

val myRoute: Route =
  get {
    path("resource") {
      parameters('foo, 'x.as[Int]).as(FooParams) { params =>
      ...
      } ~
    }
    path("resource2"){
      parameters('bar, 'x.as[Int]).as(BarParams) { params =>
        ...
      }
    }
  }

Then everything works as expected. In this case, it doesn't even attempt to evaluate the params until it has accepted the root path. And with each root path having a different param set, there is no chance of getting that unnecessary missing param exception at the head of the list.

Now if that's not an acceptable alternative, then you can wrap that route with something like mapRejections to remove the unnecessary missing param rejection if it contains a validation rejection. Something like this:

val validationWins = mapRejections{ rej =>
  val mapped = rej.filter(_.isInstanceOf[ValidationRejection])
  if (mapped.isEmpty) rej else mapped
}

val myRoute = 
  get {
    path("resource") {
      validationWins{
        parameters('foo, 'x.as[Int]).as(FooParams) { params =>
          complete(StatusCodes.OK)
        } ~ 
        parameters('bar, 'x.as[Int]).as(BarParams) { params =>
          complete(StatusCodes.OK)
        }        
    }
  }

Ideally, I prefer to use cancelRejections in my route tree to remove things that don't matter going forward in the tree, but there wasn't a clean place to do that, so I used mapRejections instead.

Upvotes: 3

4lex1v
4lex1v

Reputation: 21547

I wouldn't do it with two distinct parameters directives, instead i would advice to use one and make your parameters as option, i.e parameters('foo?, 'bar?, x.as[Int]). This directive would extract the data you need, which you can later match on and convert the the case you need, something like this:

(get & path("...")) {
  parameters('foo?, 'bar?, x.as[Int]) {
    case (None, Some(bar), x) => BarParams(bar, x)
    case (Some(foo), None, x) => FooParams(foo, x)
    /**
     * Here comes you Rejection (you can make your own)
     */
    case _ => reject(MalfromedParametersRejection)
  }
}

Another thing i'd consider a bad practice using require in constructor, given solution allows you to use guards to handle the case you've described:

parameters('foo?, 'bar?, x.as[Int]) {
  case (None, Some(bar), x) if x > 1 && x < 10 => BarParams(bar, x)
  //...
}

Upvotes: 3

Related Questions