From 29784da7634cffc2d24633e7031c35c46c053511 Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Thu, 4 Jul 2024 11:49:55 +0100 Subject: [PATCH] Path reports errors from parsing 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). --- .../src/test/scala/krop/route/PathSuite.scala | 30 ++--- core/shared/src/main/scala/krop/Types.scala | 34 +++++ .../main/scala/krop/route/ParseFailure.scala | 26 ++++ .../src/main/scala/krop/route/Path.scala | 120 ++++++++++++++---- .../src/main/scala/krop/route/Request.scala | 4 +- 5 files changed, 170 insertions(+), 44 deletions(-) create mode 100644 core/shared/src/main/scala/krop/Types.scala create mode 100644 core/shared/src/main/scala/krop/route/ParseFailure.scala diff --git a/core/jvm/src/test/scala/krop/route/PathSuite.scala b/core/jvm/src/test/scala/krop/route/PathSuite.scala index 9e27845..3fc7365 100644 --- a/core/jvm/src/test/scala/krop/route/PathSuite.scala +++ b/core/jvm/src/test/scala/krop/route/PathSuite.scala @@ -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) ) } @@ -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") { @@ -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) ) } diff --git a/core/shared/src/main/scala/krop/Types.scala b/core/shared/src/main/scala/krop/Types.scala new file mode 100644 index 0000000..d7e730c --- /dev/null +++ b/core/shared/src/main/scala/krop/Types.scala @@ -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] + } + } +} diff --git a/core/shared/src/main/scala/krop/route/ParseFailure.scala b/core/shared/src/main/scala/krop/route/ParseFailure.scala new file mode 100644 index 0000000..252a2f5 --- /dev/null +++ b/core/shared/src/main/scala/krop/route/ParseFailure.scala @@ -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 +} diff --git a/core/shared/src/main/scala/krop/route/Path.scala b/core/shared/src/main/scala/krop/route/Path.scala index 8b28bea..d741fd6 100644 --- a/core/shared/src/main/scala/krop/route/Path.scala +++ b/core/shared/src/main/scala/krop/route/Path.scala @@ -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 @@ -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 `/` @@ -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. */ @@ -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 + } } diff --git a/core/shared/src/main/scala/krop/route/Request.scala b/core/shared/src/main/scala/krop/route/Request.scala index 27877b7..dbd2637 100644 --- a/core/shared/src/main/scala/krop/route/Request.scala +++ b/core/shared/src/main/scala/krop/route/Request.scala @@ -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 @@ -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 @@ -113,7 +114,6 @@ final case class Request[P <: Tuple, Q <: Tuple, H <: Tuple, E, O] private ( ) } } - } object Request {