Skip to content

Commit

Permalink
Make cask.QueryParams work for JSON endpoints, and form endpoints, …
Browse files Browse the repository at this point in the history
…replace `subpath = true` with `cask.RemainingPathSegments` (#109)

`subpath = true` as a named argument passed to the annotation runs into
issues if multiple named arguments are present (see
https://stackoverflow.com/questions/55032173/how-to-use-named-arguments-in-scala-user-defined-annotations),
which prevents us from using named arguments to the annotation more
broadly as a user-facing API.

Using typed parameters to the method `def` sidesteps this issue, and
neatly allows us to provide the captured value to the user. This is also
more in line with how we handle inputs in general: cookies, known/typed
query params, unknown query params, json input, form fields, etc.
  • Loading branch information
lihaoyi committed Jan 4, 2024
1 parent b808a2c commit addb2d3
Show file tree
Hide file tree
Showing 12 changed files with 59 additions and 25 deletions.
2 changes: 2 additions & 0 deletions cask/src/cask/endpoints/JsonEndpoint.scala
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ object JsReader{
implicit def paramReader[T: ParamReader]: JsReader[T] = new JsReader[T] {
override def arity = 0

override def unknownQueryParams: Boolean = implicitly[ParamReader[T]].unknownQueryParams
override def remainingPathSegments: Boolean = implicitly[ParamReader[T]].remainingPathSegments
override def read(ctx: cask.model.Request, label: String, v: ujson.Value) = {
implicitly[ParamReader[T]].read(ctx, label, Nil)
}
Expand Down
20 changes: 20 additions & 0 deletions cask/src/cask/endpoints/ParamReader.scala
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,24 @@ object ParamReader{
implicit object CookieParam extends NilParam[Cookie]((ctx, label) =>
Cookie.fromUndertow(ctx.exchange.getRequestCookies().get(label))
)

implicit object QueryParams extends ParamReader[cask.model.QueryParams] {
def arity: Int = 0

override def unknownQueryParams = true

def read(ctx: cask.model.Request, label: String, v: Unit) = {
cask.model.QueryParams(ctx.queryParams)
}
}

implicit object RemainingPathSegments extends ParamReader[cask.model.RemainingPathSegments] {
def arity: Int = 0

override def remainingPathSegments = true

def read(ctx: cask.model.Request, label: String, v: Unit) = {
cask.model.RemainingPathSegments(ctx.remainingPathSegments)
}
}
}
9 changes: 2 additions & 7 deletions cask/src/cask/endpoints/WebEndpoints.scala
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,8 @@ abstract class QueryParamReader[T]
def read(ctx: cask.model.Request, label: String, v: Seq[String]): T
}
object QueryParamReader{
implicit object QueryParams extends QueryParamReader[cask.model.QueryParams]{
def arity: Int = 0

override def unknownQueryParams = true
def read(ctx: cask.model.Request, label: String, v: Seq[String]) = {
cask.model.QueryParams(ctx.queryParams)
}

}
class SimpleParam[T](f: String => T) extends QueryParamReader[T]{
def arity = 1
def read(ctx: cask.model.Request, label: String, v: Seq[String]): T = f(v.head)
Expand Down Expand Up @@ -92,6 +85,8 @@ object QueryParamReader{
implicit def paramReader[T: ParamReader]: QueryParamReader[T] = new QueryParamReader[T] {
override def arity = 0

override def unknownQueryParams: Boolean = implicitly[ParamReader[T]].unknownQueryParams
override def remainingPathSegments: Boolean = implicitly[ParamReader[T]].remainingPathSegments
override def read(ctx: cask.model.Request, label: String, v: Seq[String]) = {
implicitly[ParamReader[T]].read(ctx, label, v)
}
Expand Down
6 changes: 5 additions & 1 deletion cask/src/cask/main/Main.scala
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,11 @@ object Main{
val segments = Util.splitPath(metadata.endpoint.path)
val methods = metadata.endpoint.methods.map(_ -> (routes, metadata: EndpointMetadata[_]))
val methodMap = methods.toMap[String, (Routes, EndpointMetadata[_])]
(segments, methodMap, metadata.endpoint.subpath)
val subpath =
metadata.endpoint.subpath ||
metadata.entryPoint.argSignatures.exists(_.exists(_.reads.remainingPathSegments))

(segments, methodMap, subpath)
}

val dispatchInputs = flattenedRoutes.groupBy(_._1).map { case (segments, values) =>
Expand Down
1 change: 1 addition & 0 deletions cask/src/cask/model/Params.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import io.undertow.server.HttpServerExchange
import io.undertow.server.handlers.CookieImpl

case class QueryParams(value: Map[String, collection.Seq[String]])
case class RemainingPathSegments(value: Seq[String])

case class Request(exchange: HttpServerExchange, remainingPathSegments: Seq[String])
extends geny.ByteData with geny.Readable {
Expand Down
2 changes: 2 additions & 0 deletions cask/src/cask/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ package object cask {
val Request = model.Request
type QueryParams = model.QueryParams
val QueryParams = model.QueryParams
type RemainingPathSegments = model.RemainingPathSegments
val RemainingPathSegments = model.RemainingPathSegments

// endpoints
type websocket = endpoints.websocket
Expand Down
1 change: 1 addition & 0 deletions cask/src/cask/router/Misc.scala
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ case class ArgSig[I, -T, +V, -C](name: String,
trait ArgReader[I, +T, -C]{
def arity: Int
def unknownQueryParams: Boolean = false
def remainingPathSegments: Boolean = false
def read(ctx: C, label: String, input: I): T
}
10 changes: 3 additions & 7 deletions docs/pages/1 - Cask: a Scala HTTP micro-framework.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,17 +151,13 @@ take
least one value
* `param: Seq[T] = Nil` for repeated params such as `?param=hello&param=world` allowing
zero values
* `queryParams: cask.QueryParams` if you want your route to be able to handle arbitrary
* `params: cask.QueryParams` if you want your route to be able to handle arbitrary
query params without needing to list them out as separate arguments
* `segments: cask.RemainingPathSegments` if you want to allow the endpoint to handle
arbitrary sub-paths of the given path
* `request: cask.Request` which provides lower level access to the things that the HTTP
request provides
If you need to capture the entire sub-path of the request, you can set the flag
`subpath=true` and ask for a `request: cask.Request` (the name of the param doesn't
matter). This will make the route match any sub-path of the prefix given to the
`@cask` decorator, and give you the remainder to use in your endpoint logic
as `request.remainingPathSegments`
## Multi-method Routes
$$$httpMethods
Expand Down
9 changes: 9 additions & 0 deletions example/formJsonPost/app/src/FormJsonPost.scala
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,14 @@ object FormJsonPost extends cask.MainRoutes{
image.fileName
}


@cask.postJson("/json-extra")
def jsonEndpointExtra(value1: ujson.Value,
value2: Seq[Int],
params: cask.QueryParams,
segments: cask.RemainingPathSegments) = {
"OK " + value1 + " " + value2 + " " + params.value + " " + segments.value
}

initialize()
}
6 changes: 6 additions & 0 deletions example/formJsonPost/app/test/src/ExampleTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ object ExampleTests extends TestSuite{
)
)
response5.text() ==> "my-best-image.txt"


val response6 = requests.post(
s"$host/json-extra/omg/wtf/bbq?iam=cow&hearme=moo",
data = """{"value1": true, "value2": [3]}"""
)
}
}
}
16 changes: 8 additions & 8 deletions example/variableRoutes/app/src/VariableRoutes.scala
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,18 @@ object VariableRoutes extends cask.MainRoutes{
}

@cask.get("/user2/:userName") // allow unknown query params
def getUserProfileAllowUnknown(userName: String, queryParams: cask.QueryParams) = {
s"User $userName " + queryParams.value
def getUserProfileAllowUnknown(userName: String, params: cask.QueryParams) = {
s"User $userName " + params.value
}

@cask.get("/path", subpath = true)
def getSubpath(request: cask.Request) = {
s"Subpath ${request.remainingPathSegments}"
@cask.get("/path")
def getSubpath(remainingPathSegments: cask.RemainingPathSegments) = {
s"Subpath ${remainingPathSegments.value}"
}

@cask.post("/path", subpath = true)
def postArticleSubpath(request: cask.Request) = {
s"POST Subpath ${request.remainingPathSegments}"
@cask.post("/path")
def postArticleSubpath(remainingPathSegments: cask.RemainingPathSegments) = {
s"POST Subpath ${remainingPathSegments.value}"
}

initialize()
Expand Down
2 changes: 0 additions & 2 deletions example/variableRoutes/app/test/src/ExampleTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,6 @@ object ExampleTests extends TestSuite{
res3 == "User lihaoyi Map(unknown1 -> WrappedArray(123), unknown2 -> WrappedArray(abc))" ||
res3 == "User lihaoyi Map(unknown1 -> ArraySeq(123), unknown2 -> ArraySeq(abc))"
)

}

}
}

0 comments on commit addb2d3

Please sign in to comment.