Skip to content

Commit

Permalink
Path reports errors from parsing
Browse files Browse the repository at this point in the history
Attempting to parse a URI path now reports useful errors. Parsing is
parameterised by a `Raise` effect, so error reporting can be turned off
when performance is required (e.g. in Production mode).
  • Loading branch information
noelwelsh committed Jul 4, 2024
1 parent 67766fd commit 29784da
Show file tree
Hide file tree
Showing 5 changed files with 170 additions and 44 deletions.
30 changes: 15 additions & 15 deletions core/jvm/src/test/scala/krop/route/PathSuite.scala
Original file line number Diff line number Diff line change
Expand Up @@ -33,44 +33,44 @@ class PathSuite extends FunSuite {
test("Non-capturing path succeeds with empty tuple") {
val okUri = uri"http://example.org/user/create"

assertEquals(nonCapturingPath.extract(okUri), Some(EmptyTuple))
assertEquals(nonCapturingPath.parseToOption(okUri), Some(EmptyTuple))
}

test("Path extracts expected element from http4s path") {
test("Path parseToOptions expected element from http4s path") {
val okUri = uri"http://example.org/user/1234/view"

assertEquals(simplePath.extract(okUri), Some(1234 *: EmptyTuple))
assertEquals(simplePath.parseToOption(okUri), Some(1234 *: EmptyTuple))
}

test("Path fails when cannot parse element from URI path") {
test("Path fails when cannot parseToOption element from URI path") {
val badUri = uri"http://example.org/user/foobar/view"

assertEquals(simplePath.extract(badUri), None)
assertEquals(simplePath.parseToOption(badUri), None)
}

test("Path fails when insufficient segments in URI path") {
val badUri = uri"http://example.org/user/"

assertEquals(simplePath.extract(badUri), None)
assertEquals(simplePath.parseToOption(badUri), None)
}

test("Path fails when too many segments in URI path") {
val badUri = uri"http://example.org/user/1234/view/this/that"

assertEquals(simplePath.extract(badUri), None)
assertEquals(simplePath.parseToOption(badUri), None)
}

test("Path with all segment matches all extra segments") {
val okUri = uri"http://example.org/assets/html/this/that/theother.html"

assertEquals(nonCapturingAllPath.extract(okUri), Some(EmptyTuple))
assertEquals(nonCapturingAllPath.parseToOption(okUri), Some(EmptyTuple))
}

test("Path with all param captures all extra segments") {
val okUri = uri"http://example.org/assets/html/this/that/theother.html"

assertEquals(
capturingAllPath.extract(okUri),
capturingAllPath.parseToOption(okUri),
Some(Vector("this", "that", "theother.html") *: EmptyTuple)
)
}
Expand All @@ -80,9 +80,9 @@ class PathSuite extends FunSuite {
val oneUri = uri"http://example.org/assets/html/example.html"
val manyUri = uri"http://example.org/assets/html/a/b/c/example.html"

assertEquals(nonCapturingAllPath.extract(zeroUri), Some(EmptyTuple))
assertEquals(nonCapturingAllPath.extract(oneUri), Some(EmptyTuple))
assertEquals(nonCapturingAllPath.extract(manyUri), Some(EmptyTuple))
assertEquals(nonCapturingAllPath.parseToOption(zeroUri), Some(EmptyTuple))
assertEquals(nonCapturingAllPath.parseToOption(oneUri), Some(EmptyTuple))
assertEquals(nonCapturingAllPath.parseToOption(manyUri), Some(EmptyTuple))
}

test("Path with all param matches zero or more segments") {
Expand All @@ -91,15 +91,15 @@ class PathSuite extends FunSuite {
val manyUri = uri"http://example.org/assets/html/a/b/c/example.html"

assertEquals(
capturingAllPath.extract(zeroUri),
capturingAllPath.parseToOption(zeroUri),
Some(Vector.empty[String] *: EmptyTuple)
)
assertEquals(
capturingAllPath.extract(oneUri),
capturingAllPath.parseToOption(oneUri),
Some(Vector("example.html") *: EmptyTuple)
)
assertEquals(
capturingAllPath.extract(manyUri),
capturingAllPath.parseToOption(manyUri),
Some(Vector("a", "b", "c", "example.html") *: EmptyTuple)
)
}
Expand Down
34 changes: 34 additions & 0 deletions core/shared/src/main/scala/krop/Types.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* 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

object Types {

/** A variant of Tuple.Concat that considers the right-hand type (B) before
* the left-hand type. This enables it to produce smaller types for the
* common case of appending tuples from left-to-right.
*/
type TupleConcat[A <: Tuple, B <: Tuple] <: Tuple =
B match {
case EmptyTuple => A
case _ =>
A match {
case EmptyTuple => B
case _ => Tuple.Concat[A, B]
}
}
}
26 changes: 26 additions & 0 deletions core/shared/src/main/scala/krop/route/ParseFailure.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* 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

final case class ParseFailure(stage: ParseStage, reason: String)

enum ParseStage {
case Uri
case Header
case Entity
case Other
}
120 changes: 93 additions & 27 deletions core/shared/src/main/scala/krop/route/Path.scala
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
package krop.route

import cats.syntax.all.*
import krop.route
import krop.Types
import krop.raise.Raise
import krop.route.Param.All
import krop.route.Param.One
import org.http4s.Uri
Expand All @@ -26,6 +27,7 @@ import org.http4s.Uri.Path as UriPath
import scala.annotation.tailrec
import scala.collection.mutable
import scala.compiletime.constValue
import scala.util.boundary

/** A [[krop.route.Path]] represents a pattern to match against the path
* component of the URI of a request.`Paths` are created by calling the `/`
Expand Down Expand Up @@ -198,56 +200,73 @@ final class Path[P <: Tuple, Q <: Tuple] private (
s"pathTo(params)?$qParams"
}

/** Optionally extract the captured parts of the URI's path. */
def extract(uri: Uri): Option[Tuple.Concat[P, Q]] = {
/** Extract the captured parts of the URI's path. */
def parse(
uri: Uri
)(using raise: Raise[ParseFailure]): Types.TupleConcat[P, Q] = {
def loop(
matchSegments: Vector[Segment | Param[?]],
pathSegments: Vector[UriPath.Segment]
): Option[Tuple] =
): Tuple =
if matchSegments.isEmpty then {
if pathSegments.isEmpty then Some(EmptyTuple)
else None
if pathSegments.isEmpty then EmptyTuple
else Path.failure.fail(Path.failure.noMoreMatches)
} else {
matchSegments.head match {
case Segment.One(value) =>
if pathSegments.nonEmpty && pathSegments(0).decoded() == value then
loop(matchSegments.tail, pathSegments.tail)
else None
if pathSegments.isEmpty then
Path.failure.fail(Path.failure.noMorePathSegments)
else {
val decoded = pathSegments(0).decoded()

case Segment.All => Some(EmptyTuple)
if decoded == value then
loop(matchSegments.tail, pathSegments.tail)
else
Path.failure.fail(Path.failure.segmentMismatch(decoded, value))
}

case Segment.All => EmptyTuple

case Param.One(_, parse, _) =>
if pathSegments.isEmpty then None
if pathSegments.isEmpty then
Path.failure.fail(Path.failure.noMorePathSegments)
else
parse(pathSegments(0).decoded()) match {
case Left(_) => None
case Left(err) =>
Path.failure.fail(Path.failure.paramMismatch(err))
case Right(value) =>
loop(matchSegments.tail, pathSegments.tail) match {
case None => None
case Some(tail) => Some(value *: tail)
}
value *: loop(matchSegments.tail, pathSegments.tail)
}

case Param.All(_, parse, _) =>
parse(pathSegments.map(_.decoded())) match {
case Left(_) => None
case Right(value) => Some(value *: EmptyTuple)
case Left(err) =>
Path.failure.fail(Path.failure.paramMismatch(err))
case Right(value) => value *: EmptyTuple
}
}
}

val result =
for {
p <- loop(segments, uri.path.segments).asInstanceOf[Option[P]]
q <- query.parse(uri.multiParams).toOption
} yield q match {
case EmptyTuple => p
case other => p :* other
}
val p: P = loop(segments, uri.path.segments).asInstanceOf[P]
val q: Q = query.parse(uri.multiParams) match {
case Left(err) => Path.failure.fail(Path.failure.queryFailure(err))
case Right(value) => value
}
val result = q match {
case EmptyTuple => p
case other => p :* other
}

result.asInstanceOf[Option[Tuple.Concat[P, Q]]]
result.asInstanceOf[Types.TupleConcat[P, Q]]
}

/** Convenience that is mostly used for testing */
def parseToOption(uri: Uri): Option[Types.TupleConcat[P, Q]] =
Raise.toOption(parse(uri))

def unparse(params: Types.TupleConcat[P, Q]): Uri =
???

/** Produces a human-readable representation of this Path. The toString method
* is used to output the usual programmatic representation.
*/
Expand Down Expand Up @@ -285,4 +304,51 @@ object Path {
*/
def /[A](param: Param[A]): Path[Tuple1[A], EmptyTuple] =
root / param

/** This contains detailed descriptions of why a Path can fail, and utility to
* construct a `ParseFailure` instance from a reason.
*/
object failure {
def apply(reason: String): ParseFailure =
ParseFailure(ParseStage.Uri, reason)

def fail(reason: String)(using raise: Raise[ParseFailure]) =
raise.raise(failure(reason))

val noMoreMatches =
"""This Path does not match any more segments in the URI
|
|The URI this Path was matching against still contains segments. However
|this Path does not match any more segments. To match and ignore all the
|remaining segments use Segment.all. The match and capture all remaining
|segments use Param.seq or another variant that captures all
|segments.""".stripMargin

val noMorePathSegments =
"""The URI does not contain any more segments
|
|This Path is expecting one or more segments in the URI. However the URI
|does not contain any more segment to match against.""".stripMargin

def segmentMismatch(actual: String, expected: String) =
s"""The URI segment does not match the expected segment
|
|This Path is expecting the segment ${expected}. However the URI
|contained the segment ${actual} which does not match.""".stripMargin

def paramMismatch(error: ParamParseFailure) =
s"""The URI segment does not match the parameter
|
|This Path is expecting a segment to match the Param
|${error.description}. However the URI contained the segment
|${error.value} which does not match.""".stripMargin

def queryFailure(error: QueryParseFailure) =
s"""The URI's query parameters did contain an expected value
|
|The URI's query parameters were not successfully parsed with the
|following problem:
|
| ${error.message}""".stripMargin
}
}
4 changes: 2 additions & 2 deletions core/shared/src/main/scala/krop/route/Request.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package krop.route

import cats.effect.IO
import cats.syntax.all.*
import krop.raise.Raise
import org.http4s.EntityDecoder
import org.http4s.Media
import org.http4s.Method
Expand Down Expand Up @@ -100,7 +101,7 @@ final case class Request[P <: Tuple, Q <: Tuple, H <: Tuple, E, O] private (

Option
.when(request.method == method)(())
.flatMap(_ => path.extract(request.uri)) match {
.flatMap(_ => Raise.toOption(path.parse(request.uri))) match {
case None => IO.pure(None)
case Some(value) =>
request
Expand All @@ -113,7 +114,6 @@ final case class Request[P <: Tuple, Q <: Tuple, H <: Tuple, E, O] private (
)
}
}

}
object Request {

Expand Down

0 comments on commit 29784da

Please sign in to comment.