Skip to content

Commit

Permalink
add in full stack app example
Browse files Browse the repository at this point in the history
  • Loading branch information
bishabosha committed Apr 15, 2023
1 parent 090a12e commit 40e5b37
Show file tree
Hide file tree
Showing 27 changed files with 629 additions and 228 deletions.
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@
.scala-build
.metals
.vscode
.idea
.idea
examples/1-full-stack-app/repo-data
.scala-builder
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,23 @@ Scala Builder
The new way to glue scala-cli modules

## Use Cases
- full stack app with Scala.js front-end and JVM/Native/Node.js server
- [full stack app](examples/1-full-stack-app/builder.toml) with Scala.js front-end and JVM/Native/Node.js server

## Building Scala Builder

on macOS/Linux

1. add `~/.local/bin` to the `PATH`
2. package `scala-builder` command with
```bash
scala --power package -f -o ~/.local/bin/scala-builder --workspace . modules/scala-builder
```
3. change to `examples/1-full-stack-app` directory, and then run

```bash
cd examples/1-full-stack-app
scala-builder run webapp
```

## Setting up project

Expand Down
29 changes: 29 additions & 0 deletions examples/1-full-stack-app/builder.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# This project is mostly derived from https://github.com/bishabosha/scala3-full-stack-example/tree/for-api-video
scalaVersion = "3.2.2"

[modules.model]
platforms = ["jvm", "scala-js"]

[modules.webpage-dom]
platforms = ["scala-js"]

[modules.webpage-client]
platforms = ["scala-js"]
dependsOn = ["model"] # should smartly chose the scala-js dep

[modules.webpage]
platforms = ["scala-js"]
kind = "application"
mainClass = "example.start"
dependsOn = ["webpage-client", "webpage-dom"] # should smartly chose the scala-js dep

[modules.cask-extensions]

[modules.webserver]
kind = "application"
mainClass = "example.WebServer"
dependsOn = ["model", "cask-extensions"] # should smartly chose the jvm dep
resourceGenerators = [
# depending on a js application module should select its linked output
{ module = "webpage", dest = "assets/main.js" }
]
1 change: 1 addition & 0 deletions examples/1-full-stack-app/cask-extensions/project.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
//> using dependency "com.lihaoyi::cask:0.9.1"
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package caskx.restApi

import cask.*
import cask.endpoints.*
import cask.router.*
import cask.internal.Util

export cask.getJson

// Source code copied from upickle, but modified to wrap the JSON body in a field called "jsonBody"
abstract class JsonBody(val path: String, override val subpath: Boolean = false)
extends HttpEndpoint[Response[JsonData], ujson.Value] {
val methods = Seq("post")
type InputParser[T] = JsReader[T]

def wrapFunction(ctx: Request, delegate: Delegate): Result[Response.Raw] = {
val obj = for
str <-
try
val boas = new java.io.ByteArrayOutputStream()
Util.transferTo(ctx.exchange.getInputStream, boas)
Right(new String(boas.toByteArray))
catch
case e: Throwable => Left(cask.model.Response(
"Unable to deserialize input JSON text: " + e + "\n" + Util.stackTraceString(e),
statusCode = 400
))
json <-
try Right(ujson.read(str))
catch
case e: Throwable => Left(cask.model.Response(
"Input text is invalid JSON: " + e + "\n" + Util.stackTraceString(e),
statusCode = 400
))
yield Map("jsonBody" -> json)
obj match
case Left(r) => Result.Success(r.map(Response.Data.WritableData(_)))
case Right(params) => delegate(params)
}

def wrapPathSegment(s: String): ujson.Value = ujson.Str(s)
}

class postJson(path: String, subpath: Boolean = false) extends JsonBody(path, subpath):
override val methods = Seq("post")

class putJson(path: String, subpath: Boolean = false) extends JsonBody(path, subpath):
override val methods = Seq("put")

class deleteJson(path: String, subpath: Boolean = false) extends cask.getJson(path, subpath):
override val methods = Seq("delete")
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package caskx.websocketApi

import castor.*
import upickle.default.*
import cask.Ws
import cask.util.Logger


case class Broadcast[T](msg: T)

class WsSubscriberActor[T](using Context, Writer[T])
extends castor.SimpleActor[Broadcast[T]] { self =>

private val broadcaster = WsBroadcaster[T]()

def broadcast(t: T): Unit = self.send(Broadcast(t))

def run(msg: Broadcast[T]): Unit = broadcaster.send(msg)

def subscribe(channel: cask.WsChannelActor): castor.Actor[Ws.Event] =
new Subscriber(channel) { self =>
broadcaster.send(SubscribeMessage.AddSubscriber(self))

def run(msg: Ws.Event): Unit = msg match
case Ws.Close(_, _) =>
broadcaster.send(SubscribeMessage.RemoveSubscriber(self))
case _ =>
// ignore other input messages

}
}

private type WsSubscriberMessage[T] = SubscribeMessage | Broadcast[T]

private trait Subscriber(val channel: cask.WsChannelActor) extends castor.SimpleActor[Ws.Event]

private enum SubscribeMessage:
case AddSubscriber(subscriber: Subscriber)
case RemoveSubscriber(subscriber: Subscriber)

private class WsBroadcaster[T](using Context, Writer[T])
extends SimpleActor[WsSubscriberMessage[T]] { outer =>

private var subscribers = Set.empty[Subscriber]

def run(msg: WsSubscriberMessage[T]): Unit = msg match
case SubscribeMessage.AddSubscriber(subscriber) =>
println(s"Adding subscriber $subscriber")
subscribers += subscriber
case SubscribeMessage.RemoveSubscriber(subscriber) =>
println(s"Closing subscriber $subscriber")
subscribers -= subscriber
case Broadcast(msg) =>
if subscribers.nonEmpty then
val txt = Ws.Text(write(msg))
subscribers.foreach(_.channel.send(txt))

}
2 changes: 2 additions & 0 deletions examples/1-full-stack-app/model/project.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
//> using platform "jvm", "scala-js"
//> using dependency "com.lihaoyi::upickle::3.1.0"
2 changes: 2 additions & 0 deletions examples/1-full-stack-app/model/project.test.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
//> using platform "jvm", "scala-js"
//> using dependency "org.scalameta::munit::1.0.0-M7"
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package example

import upickle.default.{ReadWriter as Codec, *}

final case class Note(id: String, title: String, content: String) derives Codec
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package example

import upickle.default.{ReadWriter as Codec, *}

enum SubscriptionMessage derives Codec:
case Delete(noteId: String)
case Create(note: Note)
case Update(note: Note)
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package example

import upickle.default.*

class JsonParsingSpec extends munit.FunSuite:
test("parse Note") {
val note = Note("1234", "Hello, world!", "Nice to meet you")
val json = write(note)
assertEquals(read[Note](json), note)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package example

import org.scalajs.dom.*
import scala.scalajs.js

import java.io.IOException

import scala.concurrent.Future
import scala.concurrent.ExecutionContext

import upickle.default.*

class HttpClient(using ExecutionContext):

private var _socket: Option[WebSocket] = None

def subscribe(op: SubscriptionMessage => Unit): Unit =
_socket match
case None =>
val socket = new WebSocket(s"ws://${window.location.host}/api/notes/subscribe")
_socket = Some(socket)
socket.onmessage = e =>
val msg = read[SubscriptionMessage](e.data.toString)
op(msg)
socket.onclose = e =>
_socket = None

case Some(value) => // already subscribed

def unsubscribe(): Unit =
_socket match
case Some(socket) =>
socket.close()
_socket = None

case None => // already unsubscribed


def getAllNotes(): Future[Seq[Note]] =
for
resp <- Fetch.fetch("./api/notes/all").toFuture
notes <- resp.to[Seq[Note]]
yield notes

def createNote(title: String, content: String): Future[Note] =
val request = Request(
"./api/notes/create",
new:
method = HttpMethod.POST
headers = js.Dictionary("Content-Type" -> "application/json")
body = write(ujson.Obj("title" -> title, "content" -> content))
)
for
resp <- Fetch.fetch(request).toFuture
note <- resp.to[Note]
yield note

def deleteNote(id: String): Future[Boolean] =
val request = Request(
s"./api/notes/delete/$id",
new:
method = HttpMethod.DELETE
)
for
resp <- Fetch.fetch(request).toFuture
res <- resp.to[Boolean]
yield res

extension (resp: Response)
private def to[T: Reader]: Future[T] =
if resp.ok then
for json <- resp.text().toFuture
yield read[T](json)
else Future.failed(new IOException(resp.statusText))
2 changes: 2 additions & 0 deletions examples/1-full-stack-app/webpage-client/scala/project.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
//> using platform "scala-js"
//> using dep "org.scala-js::scalajs-dom::2.4.0"
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package example

import org.scalajs.dom.document
import org.scalajs.dom.html.*

object DomHelper:
export org.scalajs.dom.html.Element

def div(children: Element*): Div =
val elem = document.createElement("div")
for child <- children do elem.appendChild(child)
elem.asInstanceOf[Div]

def h1(textContent: String): Heading =
val elem = document.createElement("h1")
elem.textContent = textContent
elem.asInstanceOf[Heading]

def h2(textContent: String): Heading =
val elem = document.createElement("h2")
elem.textContent = textContent
elem.asInstanceOf[Heading]

def p(textContent: String): Paragraph =
val elem = document.createElement("p")
elem.textContent = textContent
elem.asInstanceOf[Paragraph]

def input(): Input =
val elem = document.createElement("input")
elem.asInstanceOf[Input]

def textarea(): TextArea =
val elem = document.createElement("textarea")
elem.asInstanceOf[TextArea]

def button(textContent: String): Button =
val elem = document.createElement("button")
elem.textContent = textContent
elem.asInstanceOf[Button]
2 changes: 2 additions & 0 deletions examples/1-full-stack-app/webpage-dom/scala/project.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
//> using platform "scala-js"
//> using dep "org.scala-js::scalajs-dom::2.4.0"
Loading

0 comments on commit 40e5b37

Please sign in to comment.