Skip to content

Commit

Permalink
💥 matchRawUrl of PathSegment now demands that full path is matched
Browse files Browse the repository at this point in the history
Previously, the `matchRawUrl` method of `PathSegment` would succeed if there were unused segments in the path.

We change that behaviour in such a way that `matchRawUrl` now **fails** if there are unused segments after applying the path.
Users who want to go back to the old behaviour should use the new `ignoreRemaining` method.

See [issue](#24)
  • Loading branch information
sherpal committed Dec 21, 2024
1 parent 285cfc6 commit 891596e
Show file tree
Hide file tree
Showing 16 changed files with 140 additions and 88 deletions.
6 changes: 3 additions & 3 deletions url-dsl/src/main/scala/urldsl/language/AllImpl.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ package urldsl.language
import urldsl.errors.{FragmentMatchingError, ParamMatchingError, PathMatchingError}

final class AllImpl[P, Q, F] private (implicit
protected val pathError: PathMatchingError[P],
protected val queryError: ParamMatchingError[Q],
protected val fragmentError: FragmentMatchingError[F]
val pathError: PathMatchingError[P],
val queryError: ParamMatchingError[Q],
val fragmentError: FragmentMatchingError[F]
) extends PathSegmentImpl[P]
with QueryParametersImpl[Q]
with FragmentImpl[F]
Expand Down
2 changes: 1 addition & 1 deletion url-dsl/src/main/scala/urldsl/language/Fragment.scala
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import scala.reflect.ClassTag
* @tparam E
* type of the error that this PathSegment produces on "illegal" url paths.
*/
trait Fragment[T, +E] extends UrlPart[T, E] {
trait Fragment[T, E] extends UrlPart[T, E] {

import Fragment.factory

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ import urldsl.vocabulary.{

final class PathQueryFragmentRepr[
PathType,
+PathError,
PathError,
ParamsType,
+ParamsError,
ParamsError,
FragmentType,
+FragmentError
FragmentError
] private[language] (
pathSegment: PathSegment[PathType, PathError],
queryParams: QueryParameters[ParamsType, ParamsError],
Expand Down
73 changes: 51 additions & 22 deletions url-dsl/src/main/scala/urldsl/language/PathSegment.scala
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import scala.language.implicitConversions
* @tparam A
* type of the error that this PathSegment produces on "illegal" url paths.
*/
trait PathSegment[T, +A] extends UrlPart[T, A] {
trait PathSegment[T, A] extends UrlPart[T, A] {

/** Tries to match the list of [[urldsl.vocabulary.Segment]]s to create an instance of `T`. If it can not, it returns
* an error indicating the reason of the failure. If it could, it returns the value of `T`, as well as the list of
Expand All @@ -31,6 +31,30 @@ trait PathSegment[T, +A] extends UrlPart[T, A] {
*/
def matchSegments(segments: List[Segment]): Either[A, PathMatchOutput[T]]

protected implicit def errorImpl: PathMatchingError[A]

/** Tries to match the provided list of [[urldsl.vocabulary.Segment]]s to create an instance of `T`.
*
* If it can't, it returns an error indicating the reason of the failure. If it can but there are unused segments
* left over, it fails with a `endOfSegmentRequired` error. If it can, it returns the output.
*
* It is thus similar to [[matchSegments]], but requiring that all segments have been consumed.
*
* @see
* [[matchSegments]]
*
* @param segments
* The list of [[urldsl.vocabulary.Segment]] to match this path segment again.
* @return
*/
def matchFullSegments(segments: List[Segment]): Either[A, T] = for {
matchOuptput <- matchSegments(segments)
t <- matchOuptput.unusedSegments match {
case Nil => Right(matchOuptput.output)
case segments => Left(errorImpl.endOfSegmentRequired(segments))
}
} yield t

/** Matches the given raw `url` using the given [[urldsl.url.UrlStringParserGenerator]] for creating a
* [[urldsl.url.UrlStringParser]].
*
Expand All @@ -52,10 +76,10 @@ trait PathSegment[T, +A] extends UrlPart[T, A] {
url: String,
urlStringParserGenerator: UrlStringParserGenerator = UrlStringParserGenerator.defaultUrlStringParserGenerator
): Either[A, T] =
matchSegments(urlStringParserGenerator.parser(url).segments).map(_.output)
matchFullSegments(urlStringParserGenerator.parser(url).segments)

def matchPath(path: String, decoder: UrlStringDecoder = UrlStringDecoder.defaultDecoder): Either[A, T] =
matchSegments(decoder.decodePath(path)).map(_.output)
matchFullSegments(decoder.decodePath(path))

/** Generate a list of segments representing the argument `t`.
*
Expand Down Expand Up @@ -85,8 +109,8 @@ trait PathSegment[T, +A] extends UrlPart[T, A] {
/** Concatenates `this` [[urldsl.language.PathSegment]] with `that` one, "tupling" the types with the [[Composition]]
* rules.
*/
final def /[U, A1 >: A](that: PathSegment[U, A1])(implicit c: Composition[T, U]): PathSegment[c.Composed, A1] =
PathSegment.factory[c.Composed, A1](
final def /[U](that: PathSegment[U, A])(implicit c: Composition[T, U]): PathSegment[c.Composed, A] =
PathSegment.factory[c.Composed, A](
(segments: List[Segment]) =>
for {
firstOut <- this.matchSegments(segments)
Expand Down Expand Up @@ -118,16 +142,16 @@ trait PathSegment[T, +A] extends UrlPart[T, A] {
* - in a multi-part segment, ensure consistency between the different component (e.g., a range of two integers
* that should not be too large...)
*/
final def filter[A1 >: A](predicate: T => Boolean, error: List[Segment] => A1): PathSegment[T, A1] =
PathSegment.factory[T, A1](
final def filter(predicate: T => Boolean, error: List[Segment] => A): PathSegment[T, A] =
PathSegment.factory[T, A](
(segments: List[Segment]) =>
matchSegments(segments)
.filterOrElse(((_: PathMatchOutput[T]).output).andThen(predicate), error(segments)),
createSegments
)

/** Sugar for when `A =:= DummyError` */
final def filter(predicate: T => Boolean)(implicit ev: A <:< DummyError): PathSegment[T, DummyError] = {
final def filter(predicate: T => Boolean)(implicit ev: A =:= DummyError): PathSegment[T, DummyError] = {
// type F[+E] = PathSegment[T, E]
// ev.liftCo[F].apply(this).filter(predicate, _ => DummyError.dummyError)
// we keep the ugliness below while supporting 2.12 todo[scala3] remove this
Expand All @@ -137,8 +161,8 @@ trait PathSegment[T, +A] extends UrlPart[T, A] {
/** Builds a [[PathSegment]] that first tries to match with this one, then tries to match with `that` one. If both
* fail, the error of the second is returned (todo[behaviour]: should that change?)
*/
final def ||[U, A1 >: A](that: PathSegment[U, A1]): PathSegment[Either[T, U], A1] =
PathSegment.factory[Either[T, U], A1](
final def ||[U](that: PathSegment[U, A]): PathSegment[Either[T, U], A] =
PathSegment.factory[Either[T, U], A](
segments =>
this.matchSegments(segments) match {
case Right(output) => Right(PathMatchOutput(Left(output.output), output.unusedSegments))
Expand Down Expand Up @@ -169,19 +193,21 @@ trait PathSegment[T, +A] extends UrlPart[T, A] {
(_: Unit) => createSegments(default)
)

final def ignoreRemaining: PathSegment[T, A] = this / PathSegment.remainingSegments.ignore(Nil)

/** Forgets the information contained in the path parameter by injecting one. This turn this "dynamic" [[PathSegment]]
* into a fix one.
*/
final def provide[A1 >: A](
final def provide(
t: T
)(implicit pathMatchingError: PathMatchingError[A1], printer: Printer[T]): PathSegment[Unit, A1] =
PathSegment.factory[Unit, A1](
)(implicit printer: Printer[T]): PathSegment[Unit, A] =
PathSegment.factory[Unit, A](
segments =>
for {
tMatch <- matchSegments(segments)
PathMatchOutput(tOutput, unusedSegments) = tMatch
unitMatched <-
if (tOutput != t) Left(pathMatchingError.wrongValue(printer(t), printer(tOutput)))
if (tOutput != t) Left(errorImpl.wrongValue(printer(t), printer(tOutput)))
else Right(PathMatchOutput((), unusedSegments))
} yield unitMatched,
(_: Unit) => createSegments(t)
Expand Down Expand Up @@ -211,16 +237,18 @@ object PathSegment {
def factory[T, A](
matching: List[Segment] => Either[A, PathMatchOutput[T]],
creating: T => List[Segment]
): PathSegment[T, A] = new PathSegment[T, A] {
)(implicit errors: PathMatchingError[A]): PathSegment[T, A] = new PathSegment[T, A] {
protected def errorImpl: PathMatchingError[A] = errors

def matchSegments(segments: List[Segment]): Either[A, PathMatchOutput[T]] = matching(segments)

def createSegments(t: T): List[Segment] = creating(t)
}

/** Simple path segment that matches everything by passing segments down the line. */
final def empty: PathSegment[Unit, Nothing] =
factory[Unit, Nothing](segments => Right(PathMatchOutput((), segments)), _ => Nil)
final def root: PathSegment[Unit, Nothing] = empty
final def empty[A](implicit pathMatchingError: PathMatchingError[A]): PathSegment[Unit, A] =
factory[Unit, A](segments => Right(PathMatchOutput((), segments)), _ => Nil)
final def root[A](implicit pathMatchingError: PathMatchingError[A]): PathSegment[Unit, A] = empty

/** Simple path segment that matches nothing. This is the neutral of the || operator. */
final def noMatch[A](implicit pathMatchingError: PathMatchingError[A]): PathSegment[Unit, A] =
Expand Down Expand Up @@ -274,10 +302,11 @@ object PathSegment {
*
* This can be useful for static resources.
*/
final def remainingSegments[A]: PathSegment[List[String], A] = factory[List[String], A](
segments => Right(PathMatchOutput(segments.map(_.content), Nil)),
_.map(Segment.apply)
)
final def remainingSegments[A](implicit pathMatchingError: PathMatchingError[A]): PathSegment[List[String], A] =
factory[List[String], A](
segments => Right(PathMatchOutput(segments.map(_.content), Nil)),
_.map(Segment.apply)
)

/** [[PathSegment]] that matches one of the given different possibilities.
*
Expand Down
9 changes: 6 additions & 3 deletions url-dsl/src/main/scala/urldsl/language/PathSegmentImpl.scala
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ trait PathSegmentImpl[A] {
/** implementation of [[urldsl.errors.PathMatchingError]] for type A. */
implicit protected val pathError: PathMatchingError[A]

val root: PathSegment[Unit, A] = PathSegment.root
val remainingSegments: PathSegment[List[String], A] = PathSegment.remainingSegments
lazy val root: PathSegment[Unit, A] = PathSegment.root
lazy val remainingSegments: PathSegment[List[String], A] = PathSegment.remainingSegments
lazy val ignoreRemaining: PathSegment[Unit, A] = remainingSegments.ignore(Nil)
lazy val endOfSegments: PathSegment[Unit, A] = PathSegment.endOfSegments
lazy val noMatch: PathSegment[Unit, A] = PathSegment.noMatch[A]

Expand All @@ -54,13 +55,15 @@ trait PathSegmentImpl[A] {
(_: Unit) => Segment(printer(t))
)

type Path[T] = PathSegment[T, A]

}

object PathSegmentImpl {

/** Invoker. */
def apply[A](implicit error: PathMatchingError[A]): PathSegmentImpl[A] = new PathSegmentImpl[A] {
implicit protected val pathError: PathMatchingError[A] = error
implicit val pathError: PathMatchingError[A] = error
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import app.tulz.tuplez.Composition
import urldsl.url.{UrlStringDecoder, UrlStringGenerator, UrlStringParserGenerator}
import urldsl.vocabulary._

final class PathSegmentWithQueryParams[PathType, +PathError, ParamsType, +ParamsError] private[language] (
final class PathSegmentWithQueryParams[PathType, PathError, ParamsType, ParamsError] private[language] (
pathSegment: PathSegment[PathType, PathError],
queryParams: QueryParameters[ParamsType, ParamsError]
) extends UrlPart[UrlMatching[PathType, ParamsType], Either[PathError, ParamsError]] {
Expand Down Expand Up @@ -73,13 +73,13 @@ final class PathSegmentWithQueryParams[PathType, +PathError, ParamsType, +Params
): String =
pathSegment.createPath(path, generator) ++ "?" ++ queryParams.createParamsString(params, generator)

def &[OtherParamsType, ParamsError1 >: ParamsError](otherParams: QueryParameters[OtherParamsType, ParamsError1])(
implicit c: Composition[ParamsType, OtherParamsType]
): PathSegmentWithQueryParams[PathType, PathError, c.Composed, ParamsError1] =
new PathSegmentWithQueryParams[PathType, PathError, c.Composed, ParamsError1](
def &[OtherParamsType](otherParams: QueryParameters[OtherParamsType, ParamsError])(implicit
c: Composition[ParamsType, OtherParamsType]
): PathSegmentWithQueryParams[PathType, PathError, c.Composed, ParamsError] =
new PathSegmentWithQueryParams[PathType, PathError, c.Composed, ParamsError](
pathSegment,
(queryParams & otherParams)
.asInstanceOf[QueryParameters[c.Composed, ParamsError1]] // not necessary but IntelliJ complains.
.asInstanceOf[QueryParameters[c.Composed, ParamsError]] // not necessary but IntelliJ complains.
)

def withFragment[FragmentType, FragmentError](
Expand Down
24 changes: 7 additions & 17 deletions url-dsl/src/main/scala/urldsl/language/QueryParameters.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import urldsl.errors.{DummyError, ParamMatchingError, SimpleParamMatchingError}
import urldsl.url.{UrlStringDecoder, UrlStringGenerator, UrlStringParserGenerator}
import urldsl.vocabulary._

trait QueryParameters[Q, +A] extends UrlPart[Q, A] {
trait QueryParameters[Q, A] extends UrlPart[Q, A] {

import QueryParameters._

Expand Down Expand Up @@ -63,10 +63,10 @@ trait QueryParameters[Q, +A] extends UrlPart[Q, A] {
* called, you can end up with "Q = (Int, String)" or "Q = (String, Int)". This property is called
* "QuasiCommutativity" in the tests.
*/
final def &[R, A1 >: A](that: QueryParameters[R, A1])(implicit
final def &[R](that: QueryParameters[R, A])(implicit
c: Composition[Q, R]
): QueryParameters[c.Composed, A1] =
factory[c.Composed, A1](
): QueryParameters[c.Composed, A] =
factory[c.Composed, A](
(params: Map[String, Param]) =>
for {
firstMatch <- this.matchParams(params)
Expand Down Expand Up @@ -109,7 +109,7 @@ trait QueryParameters[Q, +A] extends UrlPart[Q, A] {
* @return
* a new [[QueryParameters]] instance with the same types
*/
final def filter[A1 >: A](predicate: Q => Boolean, error: Map[String, Param] => A1): QueryParameters[Q, A1] = factory(
final def filter(predicate: Q => Boolean, error: Map[String, Param] => A): QueryParameters[Q, A] = factory(
(params: Map[String, Param]) =>
matchParams(params).filterOrElse(((_: ParamMatchOutput[Q]).output).andThen(predicate), error(params)),
createParams
Expand All @@ -131,16 +131,6 @@ trait QueryParameters[Q, +A] extends UrlPart[Q, A] {
(codec.rightToLeft _).andThen(createParams)
)

/** Associates this [[QueryParameters]] with the given [[Fragment]] in order to match raw urls satisfying both
* conditions, and returning the outputs from both.
*
* The path part of the url will be *ignored* (and will return Unit).
*/
final def withFragment[FragmentType, FragmentError](
fragment: Fragment[FragmentType, FragmentError]
): PathQueryFragmentRepr[Unit, Nothing, Q, A, FragmentType, FragmentError] =
new PathQueryFragmentRepr(PathSegment.root, this, fragment)

}

object QueryParameters {
Expand All @@ -153,13 +143,13 @@ object QueryParameters {
def createParams(q: Q): Map[String, Param] = creating(q)
}

final def empty: QueryParameters[Unit, Nothing] = factory[Unit, Nothing](
final def empty[A]: QueryParameters[Unit, A] = factory[Unit, A](
(params: Map[String, Param]) => Right(ParamMatchOutput((), params)),
_ => Map()
)

/** Alias for empty which seems to better reflect the semantic. */
final def ignore: QueryParameters[Unit, Nothing] = empty
final def ignore[A]: QueryParameters[Unit, A] = empty

final def simpleQueryParam[Q, A](
paramName: String,
Expand Down
8 changes: 1 addition & 7 deletions url-dsl/src/main/scala/urldsl/language/UrlPart.scala
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import urldsl.url.{UrlStringGenerator, UrlStringParserGenerator}
* A [[UrlPart]] is also able to generate its corresponding part of the URL by ingesting an element of type T. When
* doing that, it outputs a String (whose semantic may vary depending on the type of [[UrlPart]] you are dealing with).
*/
trait UrlPart[T, +E] {
trait UrlPart[T, E] {

def matchRawUrl(
url: String,
Expand Down Expand Up @@ -40,10 +40,4 @@ object UrlPart {
def createPart(t: T, encoder: UrlStringGenerator): String = generator(t, encoder)
}

/** Type alias when you don't care about what kind of error is issued. [[Any]] can seem weird, but it has to be
* understood as "since it can fail with anything, I won't be able to do anything with the error, which means that I
* can only check whether it failed or not".
*/
type SimpleUrlPart[T] = UrlPart[T, Any]

}
4 changes: 2 additions & 2 deletions url-dsl/src/test/scala/urldsl/examples/CombinedExamples.scala
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ final class CombinedExamples extends AnyFlatSpec with Matchers {
)

/** And we can of course combine a [[urldsl.language.QueryParameters]] and a [[urldsl.language.Fragment]] */
queryPart.withFragment(fragmentPart).matchRawUrl(sampleUrl) should be(
(root ? queryPart).withFragment(fragmentPart).matchRawUrl(sampleUrl) should be(
Right(PathQueryFragmentMatching((), ("stuff", List(2, 3)), "the-ref"))
)

Expand All @@ -56,7 +56,7 @@ final class CombinedExamples extends AnyFlatSpec with Matchers {
"""foo/23/true#some-other-ref"""
)

queryPart
(root ? queryPart)
.withFragment(fragmentPart)
.createPart(PathQueryFragmentMatching((), ("stuff", List(2, 3)), "the-ref")) should be(
"""?bar=stuff&other=2&other=3#the-ref"""
Expand Down
Loading

0 comments on commit 891596e

Please sign in to comment.