diff --git a/_includes/_markdown/install-cask.md b/_includes/_markdown/install-cask.md new file mode 100644 index 0000000000..afb2754321 --- /dev/null +++ b/_includes/_markdown/install-cask.md @@ -0,0 +1,37 @@ +{% altDetails require-info-box 'Getting Cask' %} + +{% tabs cask-install class=tabs-build-tool %} + +{% tab 'Scala CLI' %} +You can declare a dependency on Cask with the following `using` directive: +```scala +//> using dep "com.lihaoyi::cask::0.9.2" +``` +{% endtab %} + +{% tab 'sbt' %} +In your `build.sbt`, you can add a dependency on Cask: +```scala +lazy val example = project.in(file("example")) + .settings( + scalaVersion := "3.4.2", + libraryDependencies += "com.lihaoyi" %% "cask" % "0.9.2", + fork := true + ) +``` +{% endtab %} + +{% tab 'Mill' %} +In your `build.sc`, you can add a dependency on Cask: +```scala +object example extends RootModule with ScalaModule { + def scalaVersion = "3.3.3" + def ivyDeps = Agg( + ivy"com.lihaoyi::cask::0.9.2" + ) +} +``` +{% endtab %} + +{% endtabs %} +{% endaltDetails %} diff --git a/_overviews/toolkit/OrderedListOfMdFiles b/_overviews/toolkit/OrderedListOfMdFiles index ea248772fe..b2790bd58a 100644 --- a/_overviews/toolkit/OrderedListOfMdFiles +++ b/_overviews/toolkit/OrderedListOfMdFiles @@ -27,3 +27,10 @@ http-client-request-body.md http-client-json.md http-client-upload-file.md http-client-what-else.md +web-server-intro.md +web-server-static.md +web-server-dynamic.md +web-server-query-parameters.md +web-server-input.md +web-server-websockets.md +web-server-cookies-and-decorators.md diff --git a/_overviews/toolkit/http-client-what-else.md b/_overviews/toolkit/http-client-what-else.md index 11b577449d..865a031557 100644 --- a/_overviews/toolkit/http-client-what-else.md +++ b/_overviews/toolkit/http-client-what-else.md @@ -4,7 +4,7 @@ type: section description: An incomplete list of features of sttp num: 29 previous-page: http-client-upload-file -next-page: +next-page: web-server-intro --- {% include markdown.html path="_markdown/install-upickle.md" %} diff --git a/_overviews/toolkit/introduction.md b/_overviews/toolkit/introduction.md index 1656ed9662..9bc97cb2d1 100644 --- a/_overviews/toolkit/introduction.md +++ b/_overviews/toolkit/introduction.md @@ -22,6 +22,10 @@ toolkit-index: description: Sending HTTP requests and uploading files with sttp. icon: "fa fa-globe" link: /toolkit/http-client-intro.html + - title: Web servers + description: Building web servers with Cask. + icon: "fa fa-server" + link: /toolkit/web-server-intro.html --- ## What is the Scala Toolkit? diff --git a/_overviews/toolkit/web-server-cookies-and-decorators.md b/_overviews/toolkit/web-server-cookies-and-decorators.md new file mode 100644 index 0000000000..36caeac4de --- /dev/null +++ b/_overviews/toolkit/web-server-cookies-and-decorators.md @@ -0,0 +1,188 @@ +--- +title: How to use cookies and decorators? +type: section +description: Using cookies and decorators with Cask +num: 36 +previous-page: web-server-websockets +next-page: +--- + +{% include markdown.html path="_markdown/install-cask.md" %} + +## Using cookies + +Cookies are saved by adding them to the `cookies` parameter of the `cask.Response` constructor. + +In this example, we are building a rudimentary authentication service. The `getLogin` method provides a form where +the user can enter their username and password. The `postLogin` method reads the credentials. If they match the expected ones, it generates a session +identifier is generated, saves it in the application state, and sends back a cookie with the identifier. + +Cookies can be read either with a method parameter of `cask.Cookie` type or by accessing the `cask.Request` directly. +If using the former method, the names of parameters have to match the names of cookies. If a cookie with a matching name is not +found, an error response will be returned. In the `checkLogin` function, the former method is used, as the cookie is not +present before the user logs in. + +To delete a cookie, set its `expires` parameter to an instant in the past, for example `Instant.EPOCH`. + +{% tabs web-server-cookies-1 class=tabs-scala-version %} +{% tab 'Scala 2' %} + +```scala +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +object Example extends cask.MainRoutes { + + val sessionIds = ConcurrentHashMap.newKeySet[String]() + + @cask.get("/login") + def getLogin(): cask.Response[String] = { + val html = + """ + | + | + |
+ |
+ |
+ |
+ |

+ | + |
+ | + |""".stripMargin + + cask.Response(data = html, headers = Seq("Content-Type" -> "text/html")) + } + + @cask.postForm("/login") + def postLogin(name: String, password: String): cask.Response[String] = { + if (name == "user" && password == "password") { + val sessionId = UUID.randomUUID().toString + sessionIds.add(sessionId) + cask.Response(data = "Success!", cookies = Seq(cask.Cookie("sessionId", sessionId))) + } else { + cask.Response(data = "Authentication failed", statusCode = 401) + } + } + + @cask.get("/check") + def checkLogin(request: cask.Request): String = { + val sessionId = request.cookies.get("sessionId") + if (sessionId.exists(cookie => sessionIds.contains(cookie.value))) { + "You are logged in" + } else { + "You are not logged in" + } + } + + @cask.get("/logout") + def logout(sessionId: cask.Cookie) = { + sessionIds.remove(sessionId.value) + cask.Response(data = "Successfully logged out!", cookies = Seq(cask.Cookie("sessionId", "", expires = Instant.EPOCH))) + } + + initialize() +} +``` +{% endtab %} +{% tab 'Scala 3' %} +```scala +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +object Example extends cask.MainRoutes: + + val sessionIds = ConcurrentHashMap.newKeySet[String]() + + @cask.get("/login") + def getLogin(): cask.Response[String] = + val html = + """ + | + | + |
+ |
+ |
+ |
+ |

+ | + |
+ | + |""".stripMargin + + cask.Response(data = html, headers = Seq("Content-Type" -> "text/html")) + + @cask.postForm("/login") + def postLogin(name: String, password: String): cask.Response[String] = + if name == "user" && password == "password" then + val sessionId = UUID.randomUUID().toString + sessionIds.add(sessionId) + cask.Response(data = "Success!", cookies = Seq(cask.Cookie("sessionId", sessionId))) + else + cask.Response(data = "Authentication failed", statusCode = 401) + + @cask.get("/check") + def checkLogin(request: cask.Request): String = + val sessionId = request.cookies.get("sessionId") + if sessionId.exists(cookie => sessionIds.contains(cookie.value)) then + "You are logged in" + else + "You are not logged in" + + @cask.get("/logout") + def logout(sessionId: cask.Cookie): cask.Response[String] = + sessionIds.remove(sessionId.value) + cask.Response(data = "Successfully logged out!", cookies = Seq(cask.Cookie("sessionId", "", expires = Instant.EPOCH))) + + initialize() +``` +{% endtab %} +{% endtabs %} + +## Using decorators + +Decorators can be used for extending endpoints functionality with validation or new parameters. They are defined by extending +`cask.RawDecorator` class. They are used as annotations. + +In this example, the `loggedIn` decorator is used to check if the user is logged in before accessing the `/decorated` +endpoint. + +The decorator class can pass additional arguments to the decorated endpoint using a map. The passed arguments are available +through the last argument group. Here we are passing the session identifier to an argument named `sessionId`. + +{% tabs web-server-cookies-2 class=tabs-scala-version %} +{% tab 'Scala 2' %} +```scala +class loggedIn extends cask.RawDecorator { + override def wrapFunction(ctx: cask.Request, delegate: Delegate): Result[Raw] = { + ctx.cookies.get("sessionId") match { + case Some(cookie) if sessionIds.contains(cookie.value) => delegate(Map("sessionId" -> cookie.value)) + case _ => cask.router.Result.Success(cask.model.Response("You aren't logged in", 403)) + } + } +} + +@loggedIn() +@cask.get("/decorated") +def decorated()(sessionId: String): String = { + s"You are logged in with id: $sessionId" +} +``` +{% endtab %} +{% tab 'Scala 3' %} +```scala +class loggedIn extends cask.RawDecorator: + override def wrapFunction(ctx: cask.Request, delegate: Delegate): Result[Raw] = + ctx.cookies.get("sessionId") match + case Some(cookie) if sessionIds.contains(cookie.value) => + delegate(Map("sessionId" -> cookie.value)) + case _ => + cask.router.Result.Success(cask.model.Response("You aren't logged in", 403)) + + +@loggedIn() +@cask.get("/decorated") +def decorated()(sessionId: String): String = s"You are logged in with id: $sessionId" +``` +{% endtab %} +{% endtabs %} \ No newline at end of file diff --git a/_overviews/toolkit/web-server-dynamic.md b/_overviews/toolkit/web-server-dynamic.md new file mode 100644 index 0000000000..49101505c7 --- /dev/null +++ b/_overviews/toolkit/web-server-dynamic.md @@ -0,0 +1,237 @@ +--- +title: How to serve a dynamic page? +type: section +description: Serving a dynamic page with Cask +num: 32 +previous-page: web-server-static +next-page: web-server-query-parameters +--- + +{% include markdown.html path="_markdown/install-cask.md" %} + +## Serving dynamically generated content + +You can create an endpoint returning dynamically generated content with the `@cask.get` annotation. + +{% tabs web-server-dynamic-1 class=tabs-scala-version %} +{% tab 'Scala 2' %} +```scala +import java.time.ZonedDateTime + +object Example extends cask.MainRoutes { + @cask.get("/time") + def dynamic(): String = s"Current date is: ${ZonedDateTime.now()}" + + initialize() +} +``` +{% endtab %} +{% tab 'Scala 3' %} +```scala +import java.time.ZonedDateTime + +object Example extends cask.MainRoutes: + @cask.get("/time") + def dynamic(): String = s"Current date is: ${ZonedDateTime.now()}" + + initialize() +``` +{% endtab %} +{% endtabs %} + +The example above creates an endpoint that returns the current date and time available at `/time`. The exact response will be +recreated every time you refresh the webpage. + +Since the endpoint method has the `String` output type, the result will be sent with the `text/plain` [content type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type). +If you want an HTML output to be interpreted by the browser, you will need to set the `Content-Type` header manually +or [use the Scalatags templating library](/toolkit/web-server-dynamic.html#using-html-templates), supported by Cask. + +### Running the example + +Run the example the same way as before, assuming you use the same project structure as described in [the static file tutorial](/toolkit/web-server-static.html). + +{% tabs web-server-dynamic-2 class=tabs-build-tool %} +{% tab 'Scala CLI' %} +In the terminal, the following command will start the server: +``` +scala-cli run Example.scala +``` +{% endtab %} +{% tab 'sbt' %} +In the terminal, the following command will start the server: +``` +sbt example/run +``` +{% endtab %} +{% tab 'Mill' %} +In the terminal, the following command will start the server: +``` +./mill run +``` +{% endtab %} +{% endtabs %} + +Access the endpoint at [http://localhost:8080/time](http://localhost:8080/time). You should see a result similar to the one below. + +``` +Current date is: 2024-07-22T09:07:05.752534+02:00[Europe/Warsaw] +``` + +## Using path segments + +Cask gives you the ability to access segments of the URL path within the endpoint function. +Building on the example above, you can add a segment to specify that the endpoint should return the date and time +in a given city. + +{% tabs web-server-dynamic-3 class=tabs-scala-version %} +{% tab 'Scala 2' %} +```scala +import java.time.{ZoneId, ZonedDateTime} + +object Example extends cask.MainRoutes { + + private def getZoneIdForCity(city: String): Option[ZoneId] = { + import scala.jdk.CollectionConverters._ + ZoneId.getAvailableZoneIds.asScala.find(_.endsWith("/" + city)).map(ZoneId.of) + } + + @cask.get("/time/:city") + def dynamicWithCity(city: String): String = { + getZoneIdForCity(city) match { + case Some(zoneId) => s"Current date is: ${ZonedDateTime.now().withZoneSameInstant(zoneId)}" + case None => s"Couldn't find time zone for city $city" + } + } + + initialize() +} +``` +{% endtab %} +{% tab 'Scala 3' %} +```scala +import java.time.{ZoneId, ZonedDateTime} + +object Example extends cask.MainRoutes: + + private def getZoneIdForCity(city: String): Option[ZoneId] = + import scala.jdk.CollectionConverters.* + ZoneId.getAvailableZoneIds.asScala.find(_.endsWith("/" + city)).map(ZoneId.of) + + @cask.get("/time/:city") + def dynamicWithCity(city: String): String = + getZoneIdForCity(city) match + case Some(zoneId) => s"Current date is: ${ZonedDateTime.now().withZoneSameInstant(zoneId)}" + case None => s"Couldn't find time zone for city $city" + + initialize() +``` +{% endtab %} +{% endtabs %} + +In the example above, the `:city` segment in `/time/:city` is available through the `city` argument of the endpoint method. +The name of the argument must be identical to the segment name. The `getZoneIdForCity` helper method finds the timezone for +a given city, and then the current date and time are translated to that timezone. + +Accessing the endpoint at [http://localhost:8080/time/Paris](http://localhost:8080/time/Paris) will result in: +``` +Current date is: 2024-07-22T09:08:33.806259+02:00[Europe/Paris] +``` + +You can use more than one path segment in an endpoint by adding more arguments to the endpoint method. It's also possible to use paths +with an unspecified number of segments (for example `/path/foo/bar/baz/`) by giving the endpoint method an argument with `cask.RemainingPathSegments` type. +Consult the [documentation](https://com-lihaoyi.github.io/cask/index.html#variable-routes) for more details. + +## Using HTML templates + +To create an HTML response, you can combine Cask with the [Scalatags](https://com-lihaoyi.github.io/scalatags/) templating library. + +Import the Scalatags library: + +{% tabs web-server-dynamic-4 class=tabs-build-tool %} +{% tab 'Scala CLI' %} +Add the Scalatags dependency in `Example.sc` file: +```scala +//> using dep "com.lihaoyi::scalatags::0.13.1" +``` +{% endtab %} +{% tab 'sbt' %} +Add the Scalatags dependency in `build.sbt` file: +```scala +libraryDependencies += "com.lihaoyi" %% "scalatags" % "0.13.1" +``` +{% endtab %} +{% tab 'Mill' %} +Add the Scalatags dependency in `build.cs` file: +```scala +ivy"com.lihaoyi::scalatags::0.13.1" +``` +{% endtab %} +{% endtabs %} + +Now the example above can be rewritten to use a template. Cask will build a response out of the `doctype` automatically, +setting the `Content-Type` header to `text/html`. + +{% tabs web-server-dynamic-5 class=tabs-scala-version %} +{% tab 'Scala 2' %} +```scala +import java.time.{ZoneId, ZonedDateTime} +import scalatags.Text.all._ + +object Example extends cask.MainRoutes { + + private def getZoneIdForCity(city: String): Option[ZoneId] = { + import scala.jdk.CollectionConverters._ + ZoneId.getAvailableZoneIds.asScala.find(_.endsWith("/" + city)).map(ZoneId.of) + } + + @cask.get("/time/:city") + def dynamicWithCity(city: String): doctype = { + val text = getZoneIdForCity(city) match { + case Some(zoneId) => s"Current date is: ${ZonedDateTime.now().withZoneSameInstant(zoneId)}" + case None => s"Couldn't find time zone for city $city" + } + + doctype("html")( + html( + body( + p(text) + ) + ) + ) + } + + initialize() +} +``` +{% endtab %} +{% tab 'Scala 3' %} +```scala +import java.time.{ZoneId, ZonedDateTime} +import scalatags.Text.all.* + +object Example extends cask.MainRoutes: + + private def getZoneIdForCity(city: String): Option[ZoneId] = + import scala.jdk.CollectionConverters.* + ZoneId.getAvailableZoneIds.asScala.find(_.endsWith("/" + city)).map(ZoneId.of) + + @cask.get("/time/:city") + def dynamicWithCity(city: String): doctype = + val text = getZoneIdForCity(city) match + case Some(zoneId) => s"Current date is: ${ZonedDateTime.now().withZoneSameInstant(zoneId)}" + case None => s"Couldn't find time zone for city $city" + doctype("html")( + html( + body( + p(text) + ) + ) + ) + + initialize() +``` +{% endtab %} +{% endtabs %} + +Here we get the text of the response and wrap it in a Scalatags template. Notice that the return type changed from `String` +to `doctype`. \ No newline at end of file diff --git a/_overviews/toolkit/web-server-input.md b/_overviews/toolkit/web-server-input.md new file mode 100644 index 0000000000..8be4c659d3 --- /dev/null +++ b/_overviews/toolkit/web-server-input.md @@ -0,0 +1,243 @@ +--- +title: How to handle user input? +type: section +description: Handling user input with Cask +num: 34 +previous-page: web-server-query-parameters +next-page: web-server-websockets +--- + +{% include markdown.html path="_markdown/install-cask.md" %} + +## Handling form-encoded input + +To create an endpoint that handles the data provided in an HTML form, use the `@cask.postForm` annotation. Add arguments to the endpoint method +with names corresponding to names of fields in the form and set the form method to `post`. + +{% tabs web-server-input-1 class=tabs-scala-version %} +{% tab 'Scala 2' %} +```scala +object Example extends cask.MainRoutes { + + @cask.get("/form") + def getForm(): cask.Response[String] = { + val html = + """ + | + | + |
+ |
+ |
+ |
+ |

+ | + |
+ | + |""".stripMargin + + cask.Response(data = html, headers = Seq("Content-Type" -> "text/html")) + } + + @cask.postForm("/form") + def formEndpoint(name: String, surname: String): String = + "Hello " + name + " " + surname + + initialize() +} +``` +{% endtab %} +{% tab 'Scala 3' %} +```scala +object Example extends cask.MainRoutes: + + @cask.get("/form") + def getForm(): cask.Response[String] = + val html = + """ + | + | + |
+ |
+ |
+ |
+ |

+ | + |
+ | + |""".stripMargin + + cask.Response(data = html, headers = Seq("Content-Type" -> "text/html")) + + @cask.postForm("/form") + def formEndpoint(name: String, surname: String): String = + "Hello " + name + " " + surname + + initialize() +``` +{% endtab %} +{% endtabs %} + +In this example we create a form asking for name and surname of a user and then redirect the user to a greeting page. Notice the +use of `cask.Response`. The `cask.Response` type allows the user to set the status code, headers and cookies. The default +content type for an endpoint method returning a `String` is `text/plain`. Set it to `text/html` in order for the browser to display the form correctly. + +The `formEndpoint` endpoint reads the form data using the `name` and `surname` parameters. The names of parameters must +be identical to the field names of the form. + +## Handling JSON-encoded input + +JSON fields are handled in the same way as form fields, except that we use the `@cask.PostJson` annotation. The fields +will be read into the endpoint method arguments. + +{% tabs web-server-input-2 class=tabs-scala-version %} +{% tab 'Scala 2' %} +```scala +object Example extends cask.MainRoutes { + + @cask.postJson("/json") + def jsonEndpoint(name: String, surname: String): String = + "Hello " + name + " " + surname + + initialize() +} +``` +{% endtab %} +{% tab 'Scala 3' %} +```scala +object Example extends cask.MainRoutes: + + @cask.postJson("/json") + def jsonEndpoint(name: String, surname: String): String = + "Hello " + name + " " + surname + + initialize() +``` +{% endtab %} +{% endtabs %} + +Send the POST request using `curl`: + +```shell +curl --header "Content-Type: application/json" \ + --data '{"name":"John","surname":"Smith"}' \ + http://localhost:8080/json +``` + +The response will be: +``` +Hello John Smith +``` + +The endpoint will accept JSONs that have only the fields with names specified as the endpoint method arguments. If there +are more fields than expected, some fields are missing or have an incorrect data type, an error message +will be returned with the response code 400. + +To handle the case when the fields of the JSON are not known in advance, you can use an argument with the `ujson.Value` type, +from uPickle library. + +{% tabs web-server-input-3 class=tabs-scala-version %} +{% tab 'Scala 2' %} +```scala +object Example extends cask.MainRoutes { + + @cask.postJson("/json") + def jsonEndpoint(value: ujson.Value): String = + value.toString + + initialize() +} + +``` +{% endtab %} +{% tab 'Scala 3' %} +```scala +object Example extends cask.MainRoutes: + + @cask.postJson("/json") + def jsonEndpoint(value: ujson.Value): String = + value.toString + + initialize() + +``` +{% endtab %} +{% endtabs %} + +In this example the JSON is merely converted to `String`. Check the [*uPickle tutorial*](/toolkit/json-intro.html) for more information +on what can be done with the `ujson.Value` type. + +Send a POST request. +```shell +curl --header "Content-Type: application/json" \ + --data '{"value":{"name":"John","surname":"Smith"}}' \ + http://localhost:8080/json2 +``` + +The server will respond with: +``` +"{\"name\":\"John\",\"surname\":\"Smith\"}" +``` + +## Handling JSON-encoded output + +Cask endpoints can return JSON objects returned by uPickle library functions. Cask will automatically handle the `ujson.Value` +type and set the `Content-Type` header to `application/json`. + +In this example, the `TimeData` case class stores the information about the time zone and current time in a chosen +location. To serialize a case class into JSON, use type class derivation or define the serializer in its companion object in the case of Scala 2. + +{% tabs web-server-input-4 class=tabs-scala-version %} +{% tab 'Scala 2' %} +```scala +import java.time.{ZoneId, ZonedDateTime} + +object Example extends cask.MainRoutes { + import upickle.default.{ReadWriter, macroRW, writeJs} + case class TimeData(timezone: Option[String], time: String) + object TimeData { + implicit val rw: ReadWriter[TimeData] = macroRW + } + + private def getZoneIdForCity(city: String): Option[ZoneId] = { + import scala.jdk.CollectionConverters._ + ZoneId.getAvailableZoneIds.asScala.find(_.endsWith("/" + city)).map(ZoneId.of) + } + + @cask.get("/time_json/:city") + def timeJSON(city: String): ujson.Value = { + val timezone = getZoneIdForCity(city) + val time = timezone match { + case Some(zoneId) => s"Current date is: ${ZonedDateTime.now().withZoneSameInstant(zoneId)}" + case None => s"Couldn't find time zone for city $city" + } + writeJs(TimeData(timezone.map(_.toString), time)) + } + + initialize() +} +``` +{% endtab %} +{% tab 'Scala 3' %} +```scala +import java.time.{ZoneId, ZonedDateTime} + +object Example extends cask.MainRoutes: + import upickle.default.{ReadWriter, writeJs} + case class TimeData(timezone: Option[String], time: String) derives ReadWriter + + private def getZoneIdForCity(city: String): Option[ZoneId] = + import scala.jdk.CollectionConverters.* + ZoneId.getAvailableZoneIds.asScala.find(_.endsWith("/" + city)).map(ZoneId.of) + + @cask.get("/time_json/:city") + def timeJSON(city: String): ujson.Value = + val timezone = getZoneIdForCity(city) + val time = timezone match + case Some(zoneId)=> s"Current date is: ${ZonedDateTime.now().withZoneSameInstant(zoneId)}" + case None => s"Couldn't find time zone for city $city" + writeJs(TimeData(timezone.map(_.toString), time)) + + initialize() +``` +{% endtab %} +{% endtabs %} \ No newline at end of file diff --git a/_overviews/toolkit/web-server-intro.md b/_overviews/toolkit/web-server-intro.md new file mode 100644 index 0000000000..4a1efcdc6a --- /dev/null +++ b/_overviews/toolkit/web-server-intro.md @@ -0,0 +1,23 @@ +--- +title: Building web servers with Cask +type: chapter +description: The introduction of the Cask library +num: 30 +previous-page: http-client-what-else +next-page: web-server-static +--- + +Cask is an HTTP micro-framework, providing a simple and flexible way to build web applications. + +Its main focus is on the ease of use, which makes it ideal for newcomers, at the cost of eschewing some features other +frameworks provide, like asynchronicity. + +To define an endpoint it's enough to annotate a function with an annotation specifying the request path. +Cask allows for building the response manually using tools that the library provides, specifying the content, headers, +status code, etc. An endpoint function can also return a string, a [uPickle](https://com-lihaoyi.github.io/upickle/) JSON type, or a [Scalatags](https://com-lihaoyi.github.io/scalatags/) +template. In that case, Cask will automatically create a response with the appropriate headers. + +Cask comes bundled with the uPickle library for handling JSONs, supports WebSockets and allows for extending endpoints with +decorators, which can be used to handle authentication or rate limiting. + +{% include markdown.html path="_markdown/install-cask.md" %} diff --git a/_overviews/toolkit/web-server-query-parameters.md b/_overviews/toolkit/web-server-query-parameters.md new file mode 100644 index 0000000000..8da1dc9ccc --- /dev/null +++ b/_overviews/toolkit/web-server-query-parameters.md @@ -0,0 +1,74 @@ +--- +title: How to handle query parameters? +type: section +description: Handling query parameters with Cask +num: 33 +previous-page: web-server-dynamic +next-page: web-server-input +--- + +{% include markdown.html path="_markdown/install-cask.md" %} + +Query parameters are the key-value pairs coming after the question mark in a URL. They can be used for filtering, +sorting or limiting the results provided by the server. For example, in the `/time?city=Paris` URL, the `city` part +is the name of a parameter, and `Paris` is its value. Cask allows for reading the query parameters by defining an endpoint +method with arguments matching the names of the expected parameters and not matching any of the URL segments. + +In this example, we give an `Option` type and the default value `None` to the `city` parameter. This tells Cask that it is optional. +If not provided, the time for the current timezone will be returned. + +{% tabs web-server-query-1 class=tabs-scala-version %} +{% tab 'Scala 2' %} +```scala +import java.time.{ZoneId, ZonedDateTime} + +object Example extends cask.MainRoutes { + + private def getZoneIdForCity(city: String): Option[ZoneId] = { + import scala.jdk.CollectionConverters._ + ZoneId.getAvailableZoneIds.asScala.find(_.endsWith("/" + city)).map(ZoneId.of) + } + + @cask.get("/time") + def dynamicWithParam(city: Option[String] = None): String = { + city match { + case Some(value) => getZoneIdForCity(value) match { + case Some(zoneId) => s"Current date is: ${ZonedDateTime.now().withZoneSameInstant(zoneId)}" + case None => s"Couldn't find time zone for city $value" + } + case None => s"Current date is: ${ZonedDateTime.now()}" + } + } + + initialize() +} +``` +{% endtab %} +{% tab 'Scala 3' %} +```scala +import java.time.{ZoneId, ZonedDateTime} + +object Example extends cask.MainRoutes: + + private def getZoneIdForCity(city: String): Option[ZoneId] = + import scala.jdk.CollectionConverters.* + ZoneId.getAvailableZoneIds.asScala.find(_.endsWith("/" + city)).map(ZoneId.of) + + @cask.get("/time") + def dynamicWithParam(city: Option[String] = None): String = + city match + case Some(value) => getZoneIdForCity(value) match + case Some(zoneId) => s"Current date is: ${ZonedDateTime.now().withZoneSameInstant(zoneId)}" + case None => s"Couldn't find time zone for city $value" + case None => s"Current date is: ${ZonedDateTime.now()}" + + initialize() +``` +{% endtab %} +{% endtabs %} + +Run the example as before and access the endpoint at [http://localhost:8080/time?city=Paris](http://localhost:8080/time?city=Paris). +You should get a result similar to the following one. +``` +Current date is: 2024-07-22T10:08:18.218736+02:00[Europe/Paris] +``` diff --git a/_overviews/toolkit/web-server-static.md b/_overviews/toolkit/web-server-static.md new file mode 100644 index 0000000000..5e7f0c417f --- /dev/null +++ b/_overviews/toolkit/web-server-static.md @@ -0,0 +1,159 @@ +--- +title: How to serve a static file? +type: section +description: Serving a static file with Cask +num: 31 +previous-page: web-server-intro +next-page: web-server-dynamic +--- + +{% include markdown.html path="_markdown/install-cask.md" %} + +## Serving a static file + +An endpoint is a specific URL where a particular webpage can be accessed. In Cask, an endpoint is a function returning the +webpage data, together with an annotation describing its URL. + +To create an endpoint serving static files, we need two things: an HTML file with the page content and a function that +points to that file. + +Create a minimal HTML file named `hello.html` with the following contents. + +```html + + + + Hello World + + +

Hello world

+ + +``` + +Place it in the `resources` directory. + +{% tabs web-server-static-1 class=tabs-build-tool %} +{% tab 'Scala CLI' %} +``` +example +├── Example.scala +└── resources + └── hello.html +``` +{% endtab %} +{% tab 'sbt' %} +``` +example +└──src + └── main + ├── resources + │ └── hello.html + └── scala + └── Example.scala +``` +{% endtab %} +{% tab 'Mill' %} +``` +example +├── src +│ └── Example.scala +└── resources + └── hello.html +``` +{% endtab %} +{% endtabs %} + +The `@cask.staticFiles` annotation specifies at which path the webpage will be available. The endpoint function returns +the location of the file. + +{% tabs web-server-static-2 class=tabs-scala-version %} +{% tab 'Scala 2' %} +```scala +object Example extends cask.MainRoutes { + @cask.staticFiles("/static") + def staticEndpoint(): String = "src/main/resources" // or "resources" if not using SBT + + initialize() +} +``` +{% endtab %} +{% tab 'Scala 3' %} +```scala +object Example extends cask.MainRoutes: + @cask.staticFiles("/static") + def staticEndpoint(): String = "src/main/resources" // or "resources" if not using SBT + + initialize() +``` +{% endtab %} +{% endtabs %} + +In the example above, `@cask.staticFiles` instructs the server to look for files accessed at the `/static` path in the +`src/main/resources` directory. Cask will match any subpath coming after `/static` and append it to the directory path. +If you access the `/static/hello.html` file, it will serve the file available at `src/main/resources/hello.html`. +The directory path can be any path available to the server, relative or not. If the requested file cannot be found in the +specified location, the server will return a 404 response with an error message. + +The `Example` object inherits from the `cask.MainRoutes` class. It provides the main function that starts the server. The `initialize()` +method call initializes the server routes, i.e., the association between URL paths and the code that handles them. + +### Using the resources directory + +The `@cask.staticResources` annotation works in the same way as the `@cask.staticFiles` used above, with the difference that +the path returned by the endpoint method describes the location of files _inside_ the resources directory. Since the +previous example conveniently used the resources directory, it can be simplified with `@cask.staticResources`. + +{% tabs web-server-static-3 class=tabs-scala-version %} +{% tab 'Scala 2' %} +```scala +object Example extends cask.MainRoutes { + @cask.staticResources("/static") + def staticEndpoint(): String = "." + + initialize() +} +``` +{% endtab %} +{% tab 'Scala 3' %} +```scala +object Example extends cask.MainRoutes: + @cask.staticResources("/static") + def staticEndpoint(): String = "." + + initialize() +``` +{% endtab %} +{% endtabs %} + +In the endpoint method, the location is set to `"."`, telling the server that the files are available directly in the +resources directory. In general, you can use any nested location within the resources directory. For instance, you could opt +for placing your HTML files in the `static` directory inside the resources directory or using different directories to sort out +files used by different endpoints. + +## Running the example + +Run the example with the build tool of your choice. + +{% tabs munit-unit-test-4 class=tabs-build-tool %} +{% tab 'Scala CLI' %} +In the terminal, the following command will start the server: +``` +scala-cli run Example.scala +``` +{% endtab %} +{% tab 'sbt' %} +In the terminal, the following command will start the server: +``` +sbt example/run +``` +{% endtab %} +{% tab 'Mill' %} +In the terminal, the following command will start the server: +``` +./mill run +``` +{% endtab %} +{% endtabs %} + +The example page will be available at [http://localhost:8080/static/hello.html](http://localhost:8080/static/hello.html). \ No newline at end of file diff --git a/_overviews/toolkit/web-server-websockets.md b/_overviews/toolkit/web-server-websockets.md new file mode 100644 index 0000000000..653bd7f154 --- /dev/null +++ b/_overviews/toolkit/web-server-websockets.md @@ -0,0 +1,118 @@ +--- +title: How to use websockets? +type: section +description: Using websockets with Cask +num: 35 +previous-page: web-server-input +next-page: web-server-cookies-and-decorators +--- + +{% include markdown.html path="_markdown/install-cask.md" %} + +You can create a WebSocket endpoint with the `@cask.websocket` annotation. The endpoint method should return a +`cask.WsHandler` instance defining how the communication should take place. It can also return a `cask.Response`, which rejects the +attempt at forming a WebSocket connection. + +The connection can also be closed by sending a `cask.Ws.close()` message through the WebSocket channel. + +Create an HTML file named `websockets.html` with the following content and place it in the `resources ` directory. + +```html + + + +
+ + +
+
+ + + +``` + +The JavaScript code opens a WebSocket connection using the `ws://localhost:8080/websocket` endpoint. The `ws.onmessage` +event handler is executed when the server pushes a message to the browser and `ws.onclose` when the connection is closed. + +Create an endpoint for serving static files using the `@cask.staticResources` annotation and an endpoint for handling +the WebSocket connection. + +{% tabs web-server-websocket-1 class=tabs-scala-version %} +{% tab 'Scala 2' %} +```scala +@cask.staticResources("/static") +def static() = "." + +private def getZoneIdForCity(city: String): Option[ZoneId] = { + import scala.jdk.CollectionConverters._ + ZoneId.getAvailableZoneIds.asScala.find(_.endsWith("/" + city)).map(ZoneId.of) +} + +@cask.websocket("/websocket") +def websocket(): cask.WsHandler = { + cask.WsHandler { channel => + cask.WsActor { + case cask.Ws.Text("") => channel.send(cask.Ws.Close()) + case cask.Ws.Text(city) => + val text = getZoneIdForCity(city) match { + case Some(zoneId) => s"Current date is: ${ZonedDateTime.now().withZoneSameInstant(zoneId)}" + case None => s"Couldn't find time zone for city $city" + } + channel.send(cask.Ws.Text(text)) + } + } +} + +initialize() +``` +{% endtab %} +{% tab 'Scala 3' %} +```scala +@cask.staticResources("/static") +def static() = "." + +private def getZoneIdForCity(city: String): Option[ZoneId] = + import scala.jdk.CollectionConverters.* + ZoneId.getAvailableZoneIds.asScala.find(_.endsWith("/" + city)).map(ZoneId.of) + +@cask.websocket("/websocket") +def websocket(): cask.WsHandler = + cask.WsHandler { channel => + cask.WsActor { + case cask.Ws.Text("") => channel.send(cask.Ws.Close()) + case cask.Ws.Text(city) => + val text = getZoneIdForCity(city) match + case Some(zoneId) => s"Current date is: ${ZonedDateTime.now().withZoneSameInstant(zoneId)}" + case None => s"Couldn't find time zone for city $city" + channel.send(cask.Ws.Text(text)) + } + } + +initialize() +``` +{% endtab %} +{% endtabs %} + +In the `cask.WsHandler` we define a `cask.WsActor`. It reacts to events (of type `cask.util.Ws.Event`) and uses the +WebSocket channel to send messages. In this example, we receive the name of a city and return the current time there. If the server +receives an empty message, the connection is closed. \ No newline at end of file