Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions asset/src/main/scala/krop/asset/AssetRoute.scala
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@ import krop.route.ReversibleRoute
import krop.route.Route
import krop.route.RouteHandler

final class AssetRoute(base: Path[EmptyTuple, EmptyTuple], directory: Fs2Path)
extends ReversibleRoute[Tuple1[Fs2Path], EmptyTuple],
final class AssetRoute(
base: Path[EmptyTuple, EmptyTuple, Path.Open],
directory: Fs2Path
) extends ReversibleRoute[Tuple1[Fs2Path], EmptyTuple],
ClientRoute[Tuple1[Fs2Path], Array[Byte]],
InternalRoute[Tuple1[Fs2Path], Fs2Path],
Handler { self =>
Expand Down Expand Up @@ -128,6 +130,9 @@ final class AssetRoute(base: Path[EmptyTuple, EmptyTuple], directory: Fs2Path)
}
}
object AssetRoute {
def apply(base: Path[EmptyTuple, EmptyTuple], directory: String): AssetRoute =
def apply(
base: Path[EmptyTuple, EmptyTuple, Path.Open],
directory: String
): AssetRoute =
new AssetRoute(base, Fs2Path(directory))
}
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 @@ -34,6 +34,7 @@ object all {
export krop.route.Param
export krop.route.Params
export krop.route.Segment
export krop.route.Segments

export krop.tool.DefaultAssets

Expand Down
9 changes: 4 additions & 5 deletions core/jvm/src/test/scala/krop/route/PathParseSuite.scala
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import org.http4s.implicits.*

class PathParseSuite extends FunSuite {
val nonCapturingPath = Path / "user" / "create"
val nonCapturingAllPath = Path / "assets" / "html" / Segment.all
val nonCapturingAllPath = Path / "assets" / "html" / Segments
val capturingAllPath = Path / "assets" / "html" / Params.seq
val simplePath = Path / "user" / Param.int.withName("<userId>") / "view"
val simpleQueryPath = Path / "user" / Param.int :? Query[String]("mode")
Expand Down Expand Up @@ -125,10 +125,9 @@ class PathParseSuite extends FunSuite {
)
}

test("Closed path raises exception when adding additional segments") {
intercept[IllegalStateException](nonCapturingAllPath / "crash")
intercept[IllegalStateException](capturingAllPath / "crash")
}
// Closed paths cannot have additional segments added. This is now enforced
// at compile time by the Path.Open / Path.Closed phantom types, so there is
// no runtime test here.

test("Path.pathTo produces expected path") {
assertEquals(nonCapturingPath.pathTo(EmptyTuple), "/user/create")
Expand Down
2 changes: 1 addition & 1 deletion core/jvm/src/test/scala/krop/route/PathUnparseSuite.scala
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import org.http4s.Uri
class PathUnparseSuite extends FunSuite {
val rootPath = Path.root
val nonCapturingPath = Path / "user" / "create"
val nonCapturingAllPath = Path / "assets" / "html" / Segment.all
val nonCapturingAllPath = Path / "assets" / "html" / Segments
val capturingAllPath = Path / "assets" / "html" / Params.seq
val simplePath = Path / "user" / Param.int.withName("<userId>") / "view"
val pathWithQuery = Path / "user" / "view" :? Query[Int]("id")
Expand Down
192 changes: 107 additions & 85 deletions core/shared/src/main/scala/krop/route/Path.scala
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import scala.annotation.tailrec
import scala.collection.mutable

/** 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 `/`
* component of the URI of a request. `Paths` are created by calling the `/`
* method to add segments to the pattern. For example
*
* ```
Expand All @@ -46,11 +46,11 @@ import scala.collection.mutable
*
* A `Path` will fail to match if the URI's path has more segments than the
* `Path` matches. So `Path.root / "user" / "create"` will not match
* `/user/create/1234`. Use `Segment.all` to match and ignore all the segments
* `/user/create/1234`. Use [[Segments]] to match and ignore all the segments
* to the end of the URI's path. For example
*
* ```
* Path / "assets" / Segment.all
* Path / "assets" / Segments
* ```
*
* will match `/assets/example.css` and `/assets/css/example.css`.
Expand All @@ -63,62 +63,18 @@ import scala.collection.mutable
*
* will capture the remainder of the URI's path as a `Seq[String]`.
*
* A `Path` that matches all segments is called a closed path. Attempting to
* add an element to a closed path will result in an exception.
* A `Path` that ends with [[Segments]], [[Params]], or a query (`:?`) is
* called a closed path. The type parameter `S` tracks this at compile time:
* [[Path.Open]] paths can have further segments added; [[Path.Closed]] paths
* cannot.
*/
final class Path[P <: Tuple, Q <: Tuple] private (
val segments: Vector[Segment | Param[?] | Params[?]],
final class Path[P <: Tuple, Q <: Tuple, S] private (
val segments: Vector[Segment | Segments.type | Param[?] | Params[?]],
// The number of segments that are of type Param and hence the length of the
// Tuple P
val paramCount: Int,
val query: Query[Q],
// Indicates if this path can still have segments added to it. A Path that
// matches the rest of a path is not open. Otherwise it is open.
open: Boolean
val query: Query[Q]
) {
//
// Combinators ---------------------------------------------------------------
//

/** Add a segment to this `Path`. */
def /(segment: String): Path[P, Q] = {
assertOpen()
Path(segments :+ Segment.One(segment), paramCount, query, true)
}

/** Add a segment to this `Path`. */
def /(segment: Segment): Path[P, Q] = {
assertOpen()
segment match {
case Segment.One(_) => Path(segments :+ segment, paramCount, query, true)
case Segment.All => Path(segments :+ segment, paramCount, query, false)
}
}

/** Add a segment that extracts a single parameter to this `Path`. */
def /[B](param: Param[B]): Path[Tuple.Append[P, B], Q] = {
assertOpen()
Path(segments :+ param, paramCount + 1, query, true)
}

/** Add a segment that extracts all remaining parameters to this `Path`. */
def /[B](params: Params[B]): Path[Tuple.Append[P, B], Q] = {
assertOpen()
Path(segments :+ params, paramCount + 1, query, false)
}

def :?[B <: Tuple](query: Query[B]): Path[P, B] =
Path(segments, paramCount, query, false)

private def assertOpen(): Boolean =
if open then true
else
throw new IllegalStateException(
s"""Cannot add a segment or parameter to a closed path.
|
| A path is closed when it has a segment or parameter that matches all remaining elements.
| A closed path cannot have additional segments of parameters added to it.""".stripMargin
)

//
// Interpreters --------------------------------------------------------------
Expand All @@ -128,13 +84,13 @@ final class Path[P <: Tuple, Q <: Tuple] private (
* example, with the path
*
* ```scala
* val path = Path.root / "user" / Param.id / "edit"
* val path = Path / "user" / Param.int / "edit"
* ```
*
* calling
*
* ```scala
* path.pathTo(1234)
* path.pathTo(Tuple1(1234))
* ```
*
* produces the `String` `"/user/1234/edit"`.
Expand All @@ -145,7 +101,7 @@ final class Path[P <: Tuple, Q <: Tuple] private (
@tailrec
def loop(
idx: Int,
segments: Vector[Segment | Param[?] | Params[?]],
segments: Vector[Segment | Segments.type | Param[?] | Params[?]],
builder: mutable.StringBuilder
): String = {
if segments.isEmpty then builder.result()
Expand All @@ -154,9 +110,9 @@ final class Path[P <: Tuple, Q <: Tuple] private (
val tl = segments.tail

hd match {
case Segment.All => builder.addOne('/').result()
case Segment.One(value) =>
loop(idx, tl, builder.addOne('/').append(value))
case Segments => builder.addOne('/').result()
case s: Segment =>
loop(idx, tl, builder.addOne('/').append(s.value))
case p: Params[a] =>
builder
.addOne('/')
Expand Down Expand Up @@ -197,27 +153,29 @@ final class Path[P <: Tuple, Q <: Tuple] private (
uri: Uri
)(using raise: Raise[ParseFailure]): Types.TupleConcat[P, Q] = {
def loop(
matchSegments: Vector[Segment | Param[?] | Params[?]],
matchSegments: Vector[Segment | Segments.type | Param[?] | Params[?]],
pathSegments: Vector[UriPath.Segment]
): Tuple =
if matchSegments.isEmpty then {
if pathSegments.isEmpty then EmptyTuple
else Path.failure.raise(Path.failure.noMoreMatches)
} else {
matchSegments.head match {
case Segment.One(value) =>
case s: Segment =>
if pathSegments.isEmpty then
Path.failure.raise(Path.failure.noMorePathSegments)
else {
val decoded = pathSegments(0).decoded()

if decoded == value then
if decoded == s.value then
loop(matchSegments.tail, pathSegments.tail)
else
Path.failure.raise(Path.failure.segmentMismatch(decoded, value))
Path.failure.raise(
Path.failure.segmentMismatch(decoded, s.value)
)
}

case Segment.All => EmptyTuple
case Segments => EmptyTuple

case p: Param[a] =>
if pathSegments.isEmpty then
Expand Down Expand Up @@ -270,7 +228,7 @@ final class Path[P <: Tuple, Q <: Tuple] private (
@tailrec
def loop(
idx: Int,
segments: Vector[Segment | Param[?] | Params[?]],
segments: Vector[Segment | Segments.type | Param[?] | Params[?]],
path: Uri.Path
): Uri.Path = {
if segments.isEmpty then path
Expand All @@ -279,9 +237,9 @@ final class Path[P <: Tuple, Q <: Tuple] private (
val tl = segments.tail

hd match {
case Segment.All => path.addEndsWithSlash
case Segment.One(value) =>
loop(idx, tl, path.addSegment(value))
case Segments => path.addEndsWithSlash
case s: Segment =>
loop(idx, tl, path.addSegment(s.value))
case p: Params[a] =>
path.addSegments(
p.encode(pArr(idx).asInstanceOf[a]).map(Uri.Path.Segment.apply)
Expand All @@ -307,6 +265,7 @@ final class Path[P <: Tuple, Q <: Tuple] private (
val p = segments
.map {
case s: Segment => s.describe
case Segments => Segments.describe
case p: Param[?] => p.name
case p: Params[?] => p.name
}
Expand All @@ -319,31 +278,94 @@ final class Path[P <: Tuple, Q <: Tuple] private (
}
object Path {

/** Phantom type indicating a [[Path]] that can have further segments added.
*/
sealed trait Open

/** Phantom type indicating a [[Path]] that cannot have further segments
* added.
*/
sealed trait Closed

/** The `Path` representing the root. You can start constructing paths using
* `Path.root` but it is more idiomatic to call one of the `/` method
* directly on the `Path` companion object.
* `Path.root` but it is more idiomatic to call `/` directly on the `Path`
* companion object.
*/
final val root =
Path[EmptyTuple, EmptyTuple](Vector.empty, 0, Query.empty, true)
final val root: Path[EmptyTuple, EmptyTuple, Open] =
new Path[EmptyTuple, EmptyTuple, Open](Vector.empty, 0, Query.empty)

/** Create a `Path` that matches the given segment. */
def /(segment: String): Path[EmptyTuple, EmptyTuple] =
root / segment
def /(segment: String): Path[EmptyTuple, EmptyTuple, Open] =
new Path[EmptyTuple, EmptyTuple, Open](
Vector(Segment(segment)),
0,
Query.empty
)

/** Create a `Path` that matches the given segment. */
def /(segment: Segment): Path[EmptyTuple, EmptyTuple] =
root / segment
/** Create a `Path` that matches the given literal segment. */
def /(segment: Segment): Path[EmptyTuple, EmptyTuple, Open] =
new Path[EmptyTuple, EmptyTuple, Open](Vector(segment), 0, Query.empty)

/** Create a `Path` that matches all remaining segments. */
def /(segments: Segments.type): Path[EmptyTuple, EmptyTuple, Closed] =
new Path[EmptyTuple, EmptyTuple, Closed](Vector(segments), 0, Query.empty)

/** Create a `Path` that matches a segment and extracts it as a parameter. */
def /[A](param: Param[A]): Path[Tuple1[A], EmptyTuple] =
root / param
def /[A](param: Param[A]): Path[Tuple1[A], EmptyTuple, Open] =
new Path[Tuple1[A], EmptyTuple, Open](Vector(param), 1, Query.empty)

/** Create a `Path` that extracts all remaining segments as a parameter. */
def /[A](params: Params[A]): Path[Tuple1[A], EmptyTuple] =
root / params
def /[A](params: Params[A]): Path[Tuple1[A], EmptyTuple, Closed] =
new Path[Tuple1[A], EmptyTuple, Closed](Vector(params), 1, Query.empty)

extension [P <: Tuple, Q <: Tuple](path: Path[P, Q, Open])

/** Add a literal segment to this `Path`. */
def /(segment: String): Path[P, Q, Open] =
new Path[P, Q, Open](
path.segments :+ Segment(segment),
path.paramCount,
path.query
)

/** Add a literal segment to this `Path`. */
def /(segment: Segment): Path[P, Q, Open] =
new Path[P, Q, Open](
path.segments :+ segment,
path.paramCount,
path.query
)

/** Add a wildcard that matches all remaining segments to this `Path`. */
def /(segments: Segments.type): Path[P, Q, Closed] =
new Path[P, Q, Closed](
path.segments :+ segments,
path.paramCount,
path.query
)

/** Add a segment that extracts a single parameter to this `Path`. */
def /[B](param: Param[B]): Path[Tuple.Append[P, B], Q, Open] =
new Path[Tuple.Append[P, B], Q, Open](
path.segments :+ param,
path.paramCount + 1,
path.query
)

/** Add a segment that extracts all remaining parameters to this `Path`. */
def /[B](params: Params[B]): Path[Tuple.Append[P, B], Q, Closed] =
new Path[Tuple.Append[P, B], Q, Closed](
path.segments :+ params,
path.paramCount + 1,
path.query
)

/** Add query parameters to this `Path`. */
def :?[B <: Tuple](query: Query[B]): Path[P, B, Closed] =
new Path[P, B, Closed](path.segments, path.paramCount, query)

/** This contains detailed descriptions of why a Path can fail, and utilites
* to construct a `ParseFailure` instances and raise them.
/** This contains detailed descriptions of why a Path can fail, and utilities
* to construct `ParseFailure` instances and raise them.
*/
object failure {
def raise(reason: ParseFailure)(using raise: Raise[ParseFailure]) =
Expand All @@ -355,7 +377,7 @@ object Path {
"The URI has more segments than expected",
"""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
|remaining segments use Segments. To match and capture all remaining
|segments use Params.seq or another variant that captures all
|segments.""".stripMargin
)
Expand Down
Loading
Loading