Skip to content

Commit

Permalink
Clarify semantics of QueryParam
Browse files Browse the repository at this point in the history
  • Loading branch information
noelwelsh committed Feb 9, 2024
1 parent 2e16ee6 commit 37cb9f3
Show file tree
Hide file tree
Showing 7 changed files with 245 additions and 42 deletions.
1 change: 1 addition & 0 deletions core/jvm/src/main/scala/krop/all.scala
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ object all {
export krop.route.Routes
export krop.route.Request
export krop.route.Query
export krop.route.QueryParam
export krop.route.Response
export krop.route.Path
export krop.route.Param
Expand Down
29 changes: 23 additions & 6 deletions core/jvm/src/test/scala/krop/route/ParamSuite.scala
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,35 @@
package krop.route

import munit.FunSuite

import scala.util.Success

class ParamSuite extends FunSuite {
def paramOneParsesValid[A](param: Param.One[A], values: Seq[(String, A)])(using munit.Location) =
values.foreach{ case (str, a) => assertEquals(param.parse(str), Success(a)) }
def paramOneParsesValid[A](param: Param.One[A], values: Seq[(String, A)])(
using munit.Location
) =
values.foreach { case (str, a) =>
assertEquals(param.parse(str), Success(a))
}

def paramOneParsesInvalid[A](param: Param.One[A], values: Seq[String])(using munit.Location) =
values.foreach{ (str) => assert(param.parse(str).isFailure) }
def paramOneParsesInvalid[A](param: Param.One[A], values: Seq[String])(using
munit.Location
) =
values.foreach { (str) => assert(param.parse(str).isFailure) }

test("Param.one parses valid parameter") {
paramOneParsesValid(Param.int, Seq(("1" -> 1), ("42" -> 42), ("-10" -> -10)))
paramOneParsesValid(Param.string, Seq(("a" -> "a"), ("42" -> "42"), ("baby you and me" -> "baby you and me")))
paramOneParsesValid(
Param.int,
Seq(("1" -> 1), ("42" -> 42), ("-10" -> -10))
)
paramOneParsesValid(
Param.string,
Seq(
("a" -> "a"),
("42" -> "42"),
("baby you and me" -> "baby you and me")
)
)
}

test("Param.one fails to parse invalid parameter") {
Expand Down
96 changes: 96 additions & 0 deletions core/jvm/src/test/scala/krop/route/QueryParamSuite.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* Copyright 2023 Creative Scala
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package krop.route

import munit.FunSuite

import scala.util.Failure
import scala.util.Success

class QueryParamSuite extends FunSuite {
test("Required QueryParam succeeds if first value parses") {
val qp = QueryParam("id", Param.int)
assertEquals(
qp.parse(Map("id" -> List("1"), "name" -> List("Van Gogh"))),
Success(1)
)
assertEquals(
qp.parse(Map("id" -> List("1", "foobar"), "name" -> List("Van Gogh"))),
Success(1)
)
}

test("Required QueryParam fails if first value fails to parse") {
val qp = QueryParam("id", Param.int)
assertEquals(
qp.parse(Map("id" -> List("abc"))),
Failure(QueryParseException.ValueParsingFailed("id", "abc", Param.int))
)
}

test("Required QueryParam fails if no values are found for name") {
val qp = QueryParam("id", Param.int)
assertEquals(
qp.parse(Map("id" -> List())),
Failure(QueryParseException.NoValuesForName("id"))
)
}

test("Required QueryParam fails if name does not exist") {
val qp = QueryParam("id", Param.int)
assertEquals(
qp.parse(Map("foo" -> List("1"))),
Failure(QueryParseException.NoParameterWithName("id"))
)
}

test("Optional QueryParam succeeds if first value parses") {
val qp = QueryParam.optional("id", Param.int)
assertEquals(
qp.parse(Map("id" -> List("1"), "name" -> List("Van Gogh"))),
Success(Some(1))
)
assertEquals(
qp.parse(Map("id" -> List("1", "foobar"), "name" -> List("Van Gogh"))),
Success(Some(1))
)
}

test("Optional QueryParam fails if first value fails to parse") {
val qp = QueryParam.optional("id", Param.int)
assertEquals(
qp.parse(Map("id" -> List("abc"))),
Failure(QueryParseException.ValueParsingFailed("id", "abc", Param.int))
)
}

test("Optional QueryParam succeeds if no values are found for name") {
val qp = QueryParam.optional("id", Param.int)
assertEquals(
qp.parse(Map("id" -> List())),
Success(None)
)
}

test("Optional QueryParam succeeds if name does not exist") {
val qp = QueryParam.optional("id", Param.int)
assertEquals(
qp.parse(Map("foo" -> List("1"))),
Success(None)
)
}
}
21 changes: 0 additions & 21 deletions core/shared/src/main/scala/krop/route/Query.scala
Original file line number Diff line number Diff line change
Expand Up @@ -20,27 +20,6 @@ import cats.syntax.all.*

import scala.util.Try

/** Exception raised when query parsing fails. */
enum QueryParseException(message: String) extends Exception(message) {

/** Query parameter parsing failed because no parameter with the given name
* was found in the query parameters.
*/
case NoParameterWithName(name: String)
extends QueryParseException(
s"There was no query parameter with the name ${name}."
)

/** Query parameter parsing failed because there was a parameter with the
* given name in the query parameters, but that parameter was not associated
* with any values.
*/
case NoValuesForName(name: String)
extends QueryParseException(
s"There were no values associated with the name ${name}"
)
}

final case class Query[A <: Tuple](segments: Vector[QueryParam[?]]) {
//
// Combinators ---------------------------------------------------------------
Expand Down
46 changes: 34 additions & 12 deletions core/shared/src/main/scala/krop/route/QueryParam.scala
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,25 @@

package krop.route

import krop.route.Param.One

import scala.util.Failure
import scala.util.Success
import scala.util.Try
import krop.route.Param.One

/** A [[package.QueryParam]] extracts values from a URI's query parameters. It
* consists of a [[package.Param]], which does the necessary type conversion,
* and the name under which the parameters should be found.
*
* There are two types of `QueryParam`:
* There are three types of `QueryParam`:
*
* * required params, which fail if there are no values associated with the
* name; and
* name;
*
* * optional parameters, that return `None` if there is no value for the name.
* * optional parameters, that return `None` if there is no value for the name;
* and
*
* * the `QueryParam` that returns all the query parameters.
*/
enum QueryParam[A] {
import QueryParseException.*
Expand All @@ -51,21 +55,36 @@ enum QueryParam[A] {
param match {
case Param.All(_, parse, _) => parse(values)
case Param.One(_, parse, _) =>
if params.isEmpty then
Failure(NoValuesForName(name).fillInStackTrace())
else parse(values.head)
if values.isEmpty then Failure(NoValuesForName(name))
else {
val hd = values.head
parse(hd).recoverWith(_ =>
Failure(
QueryParseException.ValueParsingFailed(name, hd, param)
)
)
}
}
case None => Failure(NoParameterWithName(name).fillInStackTrace())
case None => Failure(NoParameterWithName(name))
}

case Optional(name, param) =>
params.get(name) match {
case Some(values) =>
param match {
case Param.All(name, parse, unparse) => parse(values).map(Some(_))
case Param.One(name, parse, unparse) =>
if params.isEmpty then Success(None)
else parse(values.head).map(Some(_))
case Param.All(_, parse, _) => parse(values).map(Some(_))
case Param.One(_, parse, _) =>
if values.isEmpty then Success(None)
else {
val hd = values.head
parse(hd)
.map(Some(_))
.recoverWith(_ =>
Failure(
QueryParseException.ValueParsingFailed(name, hd, param)
)
)
}
}

case None => Success(None)
Expand Down Expand Up @@ -103,5 +122,8 @@ object QueryParam {
def apply[A](name: String, param: Param[A]): QueryParam[A] =
QueryParam.Required(name, param)

def optional[A](name: String, param: Param[A]): QueryParam[Option[A]] =
QueryParam.Optional(name, param)

val all = QueryParam.All
}
43 changes: 43 additions & 0 deletions core/shared/src/main/scala/krop/route/QueryParseException.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright 2023 Creative Scala
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package krop.route

/** Exception raised when query parsing fails. */
enum QueryParseException(message: String) extends Exception(message) {

/** Query parameter parsing failed because no parameter with the given name
* was found in the query parameters.
*/
case NoParameterWithName(name: String)
extends QueryParseException(
s"There was no query parameter with the name ${name}."
)

/** Query parameter parsing failed because there was a parameter with the
* given name in the query parameters, but that parameter was not associated
* with any values.
*/
case NoValuesForName(name: String)
extends QueryParseException(
s"There were no values associated with the name ${name}"
)

case ValueParsingFailed(name: String, value: String, param: Param[?])
extends QueryParseException(
s"Parsing the value ${value} as ${param.describe} failed for the query parameter ${name}"
)
}
51 changes: 48 additions & 3 deletions docs/src/pages/routes/paths.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,16 +86,61 @@ Constructing a `QueryParam` requires a name and a `Param`, which is the same as
val param = QueryParam("id", Param.int)
```

Now we can call the `optional` method on the `QueryParam` to indicate that it is optional. Optional parameters don't cause a route to fail to match if the parameter is missing. Instead `None` is returned.
We can also call the `optional` constructor on the `QueryParam` companion object to create an optional query parameter. Optional parameters don't cause a route to fail to match if the parameter is missing. Instead `None` is returned.

```scala mdoc:silent
Path / "user" :? Query(param.optional)
val optional = QueryParam.optional("id", Param.int)
```

To collect all the query parameters as a `Map[String, List[String]]` use `QueryParam.all`.

```scala mdoc:silent
Path / "user" :? Query(QueryParam.all)
val all = QueryParam.all
```


### Query Parameter Semantics

Query parameter semantics can be quite complex. There are four cases to consider:

1. A parameter exists under the given name and the associated value can be parsed.
2. A parameter exists under the given name and the associated value cannot be parsed.
3. A parameter exists under the given name but there is no associated value.
4. No parameter exists under the given name.

The first case is the straightforward one where query parameter parsing always succeeds.

```scala mdoc:reset:invisible
import krop.all.*
```
```scala mdoc:silent
val required = QueryParam("id", Param.int)
val optional = QueryParam.optional("id", Param.int)
```
```scala mdoc
required.parse(Map("id" -> List("1")))
optional.parse(Map("id" -> List("1")))
```

In the second case both required and optional query parameters fail.

```scala mdoc
required.parse(Map("id" -> List("abc")))
optional.parse(Map("id" -> List("abc")))
```

A required parameter will fail in the third case, but an optional parameter will succeed with `None`.

```scala mdoc
required.parse(Map("id" -> List()))
optional.parse(Map("id" -> List()))
```

Similarly, a required parameter will fail in the third case but an optional parameter will succeed with `None`.

```scala mdoc
required.parse(Map())
optional.parse(Map())
```


Expand Down

0 comments on commit 37cb9f3

Please sign in to comment.