├── .gitignore ├── .scalafmt.conf ├── README.md ├── build.sbt ├── custom.webpack.config.js ├── js ├── src │ └── main │ │ ├── js │ │ └── assets │ │ │ └── img │ │ │ └── logo.png │ │ └── scala │ │ └── tutorial │ │ ├── App.scala │ │ ├── AutowireClient.scala │ │ └── FileBrowser.scala └── yarn.lock ├── jvm └── src │ └── main │ └── scala │ └── tutorial │ ├── AkkaHttpServer.scala │ ├── ApiImpl.scala │ └── AutowireIntegration.scala ├── project ├── build.properties └── plugins.sbt └── shared └── src ├── main └── scala │ └── tutorial │ ├── Api.scala │ ├── InstantCodec.scala │ ├── LookupResult.scala │ └── PathRef.scala └── test └── scala └── tutorial └── SharedTest.scala /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .idea 3 | .DS_Store 4 | node_modules 5 | .bloop 6 | .bsp 7 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version=2.7.5 2 | style = default 3 | danglingParentheses = true 4 | align { 5 | tokens.add = [":", "="] 6 | } 7 | maxColumn = 120 8 | rewrite.rules = [SortImports, RedundantBraces, RedundantParens, PreferCurlyFors] 9 | 10 | project.excludeFilters = [ 11 | node_modules 12 | ] 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scala.js example fullstack program 2 | 3 | ## TLDR 4 | ``` 5 | $ npm install -g yarn 6 | $ git clone https://github.com/oyvindberg/scalajs-fullstack.git 7 | $ cd scalajs-fullstack 8 | $ sbt 9 | sbt> dev 10 | ``` 11 | 12 | Application served via `webpack-dev-server` at [http://localhost:8081](http://localhost:8081). 13 | The server will be running at [http://localhost:8080](http://localhost:8080). 14 | 15 | Look at **Suggestions** at the bottom 16 | 17 | ## About 18 | This project consists of a simple file browser that uses 19 | [Akka Http](https://doc.akka.io/docs/akka-http/current/) 20 | on the backend, and 21 | [Scala.js](https://www.scala-js.org/), 22 | [Slinky](https://slinky.dev) 23 | [ScalablyTyped](https://scalablytyped.org) 24 | and [Antd](https://ant.design/) for the frontend code. 25 | 26 | Furthermore, we use the [«Li Haoyi stack»](https://github.com/lihaoyi) for 27 | type-safe Ajax calls ([Autowire](https://github.com/lihaoyi/autowire)), 28 | json serialization ([uPickle](https://github.com/lihaoyi/upickle)) 29 | and testing ([uTest](https://github.com/lihaoyi/utest)). 30 | 31 | These are all examples of good micro-libraries that are cross-compiled for Scala.js 32 | 33 | ### Compiling Scala.js 34 | 35 | The Scala.js compiler has two modes: 36 | 37 | - `fastOptJS` generates unoptimized javascript. 38 | Since it is by far the fastest mode, we will use this for development 39 | - `fullOptJS` also pipes the resulting javascript through the 40 | [Google Closure compiler](https://developers.google.com/closure/compiler/) 41 | which does heavy DCE (Dead Code Elimination), among other optimizations. 42 | This is slower, but output file size drops from several megabytes to hundreds of kilobytes. 43 | 44 | Usage is just running either of those commands: 45 | ``` 46 | sbt> fastOptJS 47 | sbt> fullOptJS 48 | ``` 49 | 50 | ### Rapid development 51 | Since the project both has client and server code, we can reload one or both 52 | on code changes. 53 | 54 | For the backend we use [sbt-revolver](https://github.com/spray/sbt-revolver). 55 | Usage is like this: 56 | ``` 57 | # start server 58 | sbt> tutorialJVM/reStart 59 | 60 | # restart server 61 | sbt> tutorialJVM/reStart 62 | 63 | # stop server 64 | sbt> tutorialJVM/reStop 65 | 66 | # status 67 | sbt> reStatus 68 | 69 | # continuously restart server on changed code 70 | sbt> ~tutorialJVM/reStart 71 | 72 | # alias 73 | sbt> devBack 74 | ``` 75 | 76 | Frontend: 77 | ``` 78 | # continuously compile and bundle code 79 | sbt> ~tutorialJS/fastOptJS::webpack 80 | 81 | # alias 82 | sbt> devFront 83 | ``` 84 | 85 | If you make changes both on client and server side, this snippet should do it: 86 | ``` 87 | # alias 88 | sbt> dev 89 | 90 | ``` 91 | Note that there is no synchronization between the two restarts, so 92 | it's possible that the client will reload just as the server is restarting. 93 | In that case, simply reload the browser, or use `devFront` or `devBack` 94 | 95 | 96 | ## Testing 97 | Test code is transpiled and then executed on Node.js, which you need to install 98 | on your system if you want to run tests. 99 | 100 | To run the frontend tests do this: 101 | ``` 102 | sbt> tutorialJS/test 103 | ``` 104 | 105 | ## Production 106 | You can build a fatjar which is executable and will serve frontend contents as well: 107 | ``` 108 | sbt>tutorialJVM/assembly 109 | # [info] Packaging .../jvm/target/scala-2.13/tutorial-assembly-0.1.0-SNAPSHOT.jar ... 110 | 111 | shell> java -jar .../jvm/target/scala-2.13/tutorial-assembly-0.1.0-SNAPSHOT.jar 112 | ``` 113 | 114 | ## Ideas 115 | 116 | This repo was originally created for a workshop, and the idea was that people can play around. 117 | These are some suggestions for things that could be fun to play with: 118 | 119 | - Try to break it! 120 | The compiler generally has your back, and a *lot* 121 | of the pain points from traditional web development are gone, 122 | though some remain. By refactoring the application you 123 | can get a feeling for what is still brittle 124 | 125 | - Extend the application to show metadata. 126 | Last changed? file size? Right now it's pretty bare bones 127 | 128 | - Add support for showing content of files. 129 | Such basic functionality missing!. Can you make it happen? 130 | 131 | - Add support for several file browsers in tabs on the same page. 132 | Bootstrap has [tabs](http://getbootstrap.com/components/#nav), 133 | and the file browser just needs a DOM element to render to) 134 | 135 | - Add support for remembering state. 136 | The Local Storage API is defined in `dom.localStorage`. 137 | You probably want to use uPickle for serialization 138 | 139 | - Breadcrumbs for the parent folders instead of the back button. 140 | 141 | ## Resources 142 | 143 | - [Scala.js Gitter room](https://gitter.im/scala-js/scala-js) 144 | Probably the best place for support 145 | 146 | ### Talks 147 | - Li Haoyi - «Scala.js - Safety & Sanity in the wild west of the web» 148 | [Video](https://vimeo.com/124702603) 149 | [Slides](http://www.lihaoyi.com/post/slides/PhillyETE-Scala.js.pdf) 150 | 151 | - Otto Chrons - «Scala.js: Next generation front end development in Scala» 152 | [Video](https://www.youtube.com/watch?v=n1GgVWOThhY) 153 | [Slides](http://www.slideshare.net/OttoChrons/scalajs-next-generation-front-end-development-in-scala) 154 | 155 | ### Further reading 156 | 157 | - [www.scala-js.org/](http://www.scala-js.org/) 158 | Tutorial, community, list of libraries, etc 159 | 160 | - [Basic tutorial](http://www.scala-js.org/tutorial/basic/) 161 | Tutorial on which this workshop was partly based 162 | 163 | - [Hands-on Scala.js](www.lihaoyi.com/hands-on-scala-js/) 164 | Comprehensive introductory material 165 | 166 | - [Scala.js SPA-tutorial](https://github.com/ochrons/scalajs-spa-tutorial) 167 | A more comprehensive starter project that includes 168 | [Scalajs-React](https://github.com/japgolly/scalajs-react) 169 | and [Diode](https://github.com/ochrons/diode) 170 | support, as well how to package a project for production. 171 | 172 | - [TodoMVC example](http://todomvc.com/examples/scalajs-react/) 173 | Frontend-only todo application with Scalajs-React 174 | 175 | - [Scala.js semantics compared to Scala](http://www.scala-js.org/doc/semantics.html) 176 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import org.scalajs.linker.interface.ModuleKind.CommonJSModule 2 | 3 | lazy val tutorial = 4 | crossProject(JSPlatform, JVMPlatform) 5 | .in(file(".")) 6 | .settings( 7 | organization := "com.olvind", 8 | scalaVersion := "2.13.5", 9 | scalacOptions ++= Seq("-encoding", "UTF-8", "-feature", "-unchecked", "-Xlint", "-deprecation"), 10 | testFrameworks += new TestFramework("utest.runner.Framework"), 11 | /* shared dependencies */ 12 | libraryDependencies ++= Seq( 13 | "com.lihaoyi" %%% "upickle" % "1.3.11", 14 | "com.lihaoyi" %%% "autowire" % "0.3.3", 15 | "com.lihaoyi" %%% "utest" % "0.7.8" % Test 16 | ) 17 | ) 18 | 19 | lazy val tutorialJvm: Project = 20 | tutorial.jvm 21 | .enablePlugins(WebScalaJSBundlerPlugin) 22 | .settings( 23 | name := "tutorialJVM", 24 | /* Normal scala dependencies */ 25 | libraryDependencies ++= Seq( 26 | "com.typesafe.akka" %% "akka-http" % "10.2.4", 27 | "com.typesafe.akka" %% "akka-stream" % "2.6.14", 28 | ), 29 | scalaJSProjects := Seq(tutorialJs), 30 | Assets / pipelineStages := Seq(scalaJSPipeline) 31 | ) 32 | 33 | lazy val tutorialJs: Project = 34 | tutorial.js 35 | .enablePlugins(ScalablyTypedConverterPlugin) 36 | .settings( 37 | name := "tutorialJS", 38 | webpackDevServerPort := 8081, 39 | /* discover main and make the bundle run it */ 40 | scalaJSUseMainModuleInitializer := true, 41 | useYarn := true, 42 | /* disabled because it somehow triggers many warnings */ 43 | scalaJSLinkerConfig := scalaJSLinkerConfig.value.withSourceMap(false).withModuleKind(CommonJSModule), 44 | /* javascript dependencies */ 45 | Compile / npmDependencies ++= Seq( 46 | "antd" -> "4.15.0", 47 | "react" -> "17.0.2", 48 | "react-dom" -> "17.0.2", 49 | "@types/react" -> "17.0.3", 50 | "@types/react-dom" -> "17.0.3", 51 | ), 52 | /* custom webpack file */ 53 | Compile / webpackConfigFile := Some((ThisBuild / baseDirectory).value / "custom.webpack.config.js"), 54 | Compile / fastOptJS / webpackDevServerExtraArgs += "--mode=development", 55 | /* dependencies for custom webpack file */ 56 | Compile / npmDevDependencies ++= Seq( 57 | "webpack-merge" -> "5.2.0", 58 | "css-loader" -> "5.0.0", 59 | "style-loader" -> "2.0.0", 60 | "file-loader" -> "6.1.1", 61 | "url-loader" -> "4.1.1", 62 | "html-webpack-plugin" -> "4.5.0", 63 | ), 64 | /* don't need to override anything for test. revisit this if you depend on code which imports resources, 65 | for instance (you probably shouldn't need to) */ 66 | Test / webpackConfigFile := None, 67 | Test / npmDependencies ++= Seq( 68 | "source-map-support" -> "0.5.19" 69 | ), 70 | Test / requireJsDomEnv := true, 71 | stIgnore ++= List("source-map-support", "@babel/runtime"), 72 | stFlavour := Flavour.Slinky, 73 | stReactEnableTreeShaking := Selection.All, 74 | scalacOptions += "-Ymacro-annotations", 75 | ) 76 | 77 | def cmd(name: String, commands: String*) = 78 | Command.command(name)(s => s.copy(remainingCommands = commands.toList.map(cmd => Exec(cmd, None)) ++ s.remainingCommands)) 79 | 80 | commands ++= List( 81 | cmd("dev", "fastOptJS::startWebpackDevServer", "~;tutorialJVM/reStart;tutorialJS/fastOptJS::webpack"), 82 | cmd("devFront", "fastOptJS::startWebpackDevServer", "~tutorialJS/fastOptJS::webpack"), 83 | cmd("devBack", "~;tutorialJVM/reStart"), 84 | ) 85 | -------------------------------------------------------------------------------- /custom.webpack.config.js: -------------------------------------------------------------------------------- 1 | var { merge } = require('webpack-merge'); 2 | var generated = require('./scalajs.webpack.config'); 3 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | 5 | var local = { 6 | module: { 7 | rules: [ 8 | { 9 | test: /\.css$/, 10 | use: ['style-loader', 'css-loader'] 11 | }, 12 | { 13 | test: /\.(ttf|eot|woff|png|svg)$/, 14 | use: 'file-loader' 15 | }, 16 | { 17 | test: /\.(eot)$/, 18 | use: 'url-loader' 19 | } 20 | ] 21 | }, 22 | devServer: { 23 | inline: true, 24 | compress: true, 25 | proxy: { 26 | '/api': 'http://localhost:8080' 27 | } 28 | }, 29 | devtool: "source-map", 30 | plugins: [new HtmlWebpackPlugin()] 31 | }; 32 | 33 | module.exports = merge(generated, local); 34 | -------------------------------------------------------------------------------- /js/src/main/js/assets/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oyvindberg/scalajs-fullstack/7559f70391280c049318defc4a5a14b3344e2449/js/src/main/js/assets/img/logo.png -------------------------------------------------------------------------------- /js/src/main/scala/tutorial/App.scala: -------------------------------------------------------------------------------- 1 | package tutorial 2 | 3 | import autowire._ 4 | import org.scalajs.dom 5 | import slinky.web.ReactDOM 6 | import typings.react.components._ 7 | import typings.react.mod.CSSProperties 8 | 9 | import scala.scalajs.js 10 | import scala.scalajs.js.annotation.JSImport 11 | 12 | object App { 13 | implicit val ec = scala.scalajs.concurrent.JSExecutionContext.Implicits.queue 14 | 15 | 16 | @JSImport("./assets/img/logo.png", JSImport.Default) 17 | @js.native 18 | val Logo: String = js.native 19 | 20 | @JSImport("antd/dist/antd.css", JSImport.Namespace) 21 | @js.native 22 | object Css extends js.Object 23 | 24 | def main(args: Array[String]): Unit = { 25 | Css // touch to load 26 | 27 | ReactDOM.render( 28 | div( 29 | header(img.src(Logo).height(100)).style(CSSProperties().setPadding("25px")), 30 | FileBrowser(remoteFetchPaths = path => AutowireClient[Api].fetchPathsUnder(path).call()) 31 | ), 32 | dom.document.body 33 | ) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /js/src/main/scala/tutorial/AutowireClient.scala: -------------------------------------------------------------------------------- 1 | package tutorial 2 | 3 | import org.scalajs.dom.ext.Ajax 4 | import upickle.default 5 | import upickle.default._ 6 | 7 | import scala.concurrent.Future 8 | import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue 9 | 10 | // client-side implementation, and call-site 11 | object AutowireClient extends autowire.Client[String, Reader, Writer] { 12 | def write[Result: Writer](r: Result): String = default.write(r) 13 | 14 | def read[Result: Reader](p: String): Result = default.read[Result](p) 15 | 16 | override def doCall(req: Request): Future[String] = 17 | Ajax 18 | .post( 19 | url = s"api/${req.path.mkString("/")}", 20 | data = default.write(req.args.toSeq) 21 | ) 22 | .map(req => req.responseText) 23 | } 24 | -------------------------------------------------------------------------------- /js/src/main/scala/tutorial/FileBrowser.scala: -------------------------------------------------------------------------------- 1 | package tutorial 2 | 3 | import slinky.core.FunctionalComponent 4 | import slinky.core.annotations.react 5 | import slinky.core.facade.Hooks 6 | import typings.antDesignIcons.components.AntdIcon 7 | import typings.antDesignIconsSvg.fileOutlinedMod.{default => FileOutlineIcon} 8 | import typings.antDesignIconsSvg.folderOutlinedMod.{default => FolderOutlineIcon} 9 | import typings.antd.listMod.ListItemLayout 10 | import typings.antd.{antdStrings, components => antd} 11 | import typings.react.components._ 12 | 13 | import scala.concurrent.Future 14 | import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue 15 | import scala.scalajs.js 16 | import scala.scalajs.js.JSConverters._ 17 | import scala.util.{Failure, Success} 18 | 19 | @react 20 | object FileBrowser { 21 | case class Props(remoteFetchPaths: PathRef.DirectoryLike => Future[LookupResult]) 22 | 23 | sealed trait State { 24 | def pathOpt: Option[PathRef] = 25 | this match { 26 | case State.AtDir(path, _) => Some(path) 27 | case State.Error(_, _) => None 28 | case State.Loading => None 29 | } 30 | } 31 | 32 | object State { 33 | case object Loading extends State 34 | case class AtDir(dir: PathRef.DirectoryLike, contents: js.Array[PathRef.NotRoot]) extends State 35 | case class Error(msg: String, retry: () => Unit) extends State 36 | } 37 | 38 | val component = FunctionalComponent[Props] { props => 39 | val (state, setState) = Hooks.useState[State](State.Loading) 40 | 41 | def fetch(wantedPath: PathRef.DirectoryLike): Future[Unit] = { 42 | setState(State.Loading) 43 | 44 | props.remoteFetchPaths(wantedPath).transform { result => 45 | def error(msg: String): State.Error = 46 | State.Error(msg, () => fetch(wantedPath)) 47 | 48 | val nextState = result match { 49 | case Success(LookupResult.Ok(dirContents)) => 50 | State.AtDir(wantedPath, dirContents.toJSArray) 51 | 52 | case Success(LookupResult.NotFound) => 53 | error(s"$wantedPath was not found") 54 | 55 | case Success(LookupResult.AccessDenied) => 56 | error(s"Access denied to $wantedPath") 57 | 58 | case Failure(throwable) => 59 | error(s"Unexpected error: ${throwable.getMessage}") 60 | } 61 | 62 | Success(setState(nextState)) 63 | } 64 | } 65 | 66 | // initial load 67 | Hooks.useEffect(() => fetch(PathRef.RootRef), List()) 68 | 69 | state match { 70 | case State.AtDir(dir, contents) => 71 | div( 72 | antd.Typography.Title("Currently browsing", dir.toString), 73 | state.pathOpt.flatMap(_.parentOpt).map { parent => 74 | antd.Button.onClick(_ => fetch(parent))("Back") 75 | }, 76 | antd.Button.onClick(_ => fetch(dir))("Refresh"), 77 | antd 78 | .List() 79 | .dataSource(contents) 80 | .itemLayout(ListItemLayout.horizontal) 81 | .renderItem { 82 | case (dir @ PathRef.Directory(_, dirName), _) => 83 | val meta = antd.List.Item.Meta 84 | .title(antd.Typography.Link(dirName)) 85 | .avatar(AntdIcon(FolderOutlineIcon)) 86 | 87 | antd.List.Item.withKey(dirName)(meta).onClick(_ => fetch(dir)) 88 | case (PathRef.File(_, fileName), _) => 89 | val meta = antd.List.Item.Meta 90 | .title(antd.Typography.Text(fileName)) 91 | .avatar(AntdIcon(FileOutlineIcon)) 92 | 93 | antd.List.Item.withKey(fileName)(meta) 94 | } 95 | ) 96 | 97 | case State.Loading => 98 | antd.Typography.Title("Loading") 99 | 100 | case State.Error(msg, retry) => 101 | antd.Alert 102 | .`type`(antdStrings.warning) 103 | .onClick(_ => retry()) 104 | .message(msg) 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /jvm/src/main/scala/tutorial/AkkaHttpServer.scala: -------------------------------------------------------------------------------- 1 | package tutorial 2 | 3 | import java.io.File 4 | 5 | import akka.actor.ActorSystem 6 | import akka.http.scaladsl.Http 7 | import akka.http.scaladsl.Http.ServerBinding 8 | import akka.http.scaladsl.model.StatusCodes 9 | import akka.http.scaladsl.server.Directives._ 10 | import akka.http.scaladsl.server.Route 11 | 12 | import scala.concurrent.ExecutionContextExecutor 13 | 14 | object AkkaHttpServer extends App { 15 | 16 | /* Akka infrastructure */ 17 | implicit val system: ActorSystem = 18 | ActorSystem() 19 | 20 | /* needed for the future flatMap/onComplete in the end */ 21 | implicit val executionContext: ExecutionContextExecutor = 22 | system.dispatcher 23 | 24 | val apiImpl: ApiImpl = 25 | ApiImpl(new File("..")) 26 | 27 | /* serve index template and static resources */ 28 | val indexRoute: Route = 29 | get { 30 | pathSingleSlash { 31 | redirect("index.html", StatusCodes.MovedPermanently) 32 | } 33 | } ~ 34 | /* when packaged (`tutorialJVM/assembly`) we find assets in the fatjar */ 35 | getFromResourceDirectory("META-INF/resources/webjars/tutorialjvm/0.1.0-SNAPSHOT/") ~ 36 | /* when run from sbt (`tutorialJVM/run`) we find assets through file system */ 37 | getFromDirectory("jvm/target/web/classes/main/META-INF/resources/webjars/tutorialjvm/0.1.0-SNAPSHOT") ~ 38 | AutowireAkkaHttpRoute("api", _.route[Api](apiImpl)) 39 | 40 | Http(system) 41 | .newServerAt("0.0.0.0", 8080) 42 | .bindFlow(indexRoute) 43 | .foreach { (sb: ServerBinding) => 44 | println(s"Server online at ${sb.localAddress}") 45 | 46 | Option(System.console).foreach { console => 47 | console.readLine("Press ENTER to stop server") 48 | 49 | sb.unbind() // trigger unbinding from the port 50 | .onComplete(_ => system.terminate()) // and shutdown when done 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /jvm/src/main/scala/tutorial/ApiImpl.scala: -------------------------------------------------------------------------------- 1 | package tutorial 2 | 3 | import java.io.File 4 | import java.nio.file.Files 5 | import java.time.Instant 6 | 7 | case class ApiImpl(sandbox: File) extends Api { 8 | 9 | override def fetchPathsUnder(path: PathRef.DirectoryLike): LookupResult = 10 | parsePathToFile(path) match { 11 | case Left(notFound) => 12 | notFound 13 | 14 | //lets pretend this is good enough 15 | case Right(file) if file.getAbsolutePath.startsWith(sandbox.getAbsolutePath) => 16 | val filesUnder: Seq[File] = 17 | Option(file.list()).toSeq.flatten.map(new File(file, _)) 18 | 19 | LookupResult.Ok( 20 | filesUnder 21 | .collect { 22 | case f if f.isDirectory => PathRef.Directory(path, f.getName) 23 | case f if f.isFile => PathRef.File(path, f.getName) 24 | } 25 | .sortBy(f => (f.isInstanceOf[PathRef.File], f.name)) 26 | ) 27 | 28 | case outsideSandbox => 29 | LookupResult.AccessDenied 30 | } 31 | 32 | def lastModified(f: File): Instant = 33 | Instant.ofEpochMilli(f.lastModified()) 34 | 35 | def readFile(file: File): String = 36 | new String(Files.readAllBytes(file.toPath), "UTF-8") 37 | 38 | def parsePathToFile(loc: PathRef): Either[LookupResult.NotFound.type, File] = 39 | loc match { 40 | case PathRef.RootRef => 41 | Right(sandbox) 42 | case PathRef.File(parent, name) => 43 | existingFile(parent, name) 44 | case PathRef.Directory(parent, name) => 45 | existingFile(parent, name) 46 | } 47 | 48 | def existingFile(parent: PathRef, name: String): Either[LookupResult.NotFound.type, File] = 49 | parsePathToFile(parent).flatMap { parentFile => 50 | new File(parentFile, name) match { 51 | case f if f.exists => 52 | Right(f) 53 | case _ => 54 | Left(LookupResult.NotFound) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /jvm/src/main/scala/tutorial/AutowireIntegration.scala: -------------------------------------------------------------------------------- 1 | package tutorial 2 | 3 | import upickle.default.{Reader, Writer, read => readJson, write => writeJson} 4 | 5 | /** Since we are using micro-libraries, it often falls to the 6 | * user to do the integration between them. 7 | * 8 | * This needs to be done for every combination of 9 | * autowire + a serialization library + a HTTP library. 10 | * 11 | * You're in luck, though, because here it is provided for you 12 | */ 13 | /* integration between Autowire and uPickle */ 14 | object AutowireUpickleServer extends autowire.Server[String, Reader, Writer] { 15 | def read[Result: Reader](p: String): Result = 16 | readJson[Result](p) 17 | 18 | def write[Result: Writer](r: Result): String = 19 | writeJson(r) 20 | } 21 | 22 | /* integration between Autowire/uPickle and Akka Http */ 23 | object AutowireAkkaHttpRoute { 24 | 25 | import akka.http.scaladsl.server.Directives._ 26 | import akka.http.scaladsl.server._ 27 | 28 | /** @param f Need to expose this to user in order to not break macro 29 | * @return Akka Http route 30 | */ 31 | def apply(uri: PathMatcher[Unit], f: AutowireUpickleServer.type => AutowireUpickleServer.Router): Route = 32 | post { 33 | path(uri / Segments) { paths: List[String] => 34 | entity(as[String]) { argsString => 35 | complete { 36 | val decodedArgs: Map[String, String] = 37 | readJson[List[(String, String)]](argsString).toMap 38 | 39 | val router: AutowireUpickleServer.Router = 40 | f(AutowireUpickleServer) 41 | 42 | router(autowire.Core.Request(paths, decodedArgs)) 43 | } 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.4.9 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.0.0") 2 | addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.5.1") 3 | addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1") 4 | addSbtPlugin("ch.epfl.scala" % "sbt-web-scalajs-bundler" % "0.20.0") 5 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.10") 6 | addSbtPlugin("org.scalablytyped.converter" % "sbt-converter" % "1.0.0-beta31") 7 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.2") 8 | -------------------------------------------------------------------------------- /shared/src/main/scala/tutorial/Api.scala: -------------------------------------------------------------------------------- 1 | package tutorial 2 | 3 | /** The shared API for the application 4 | */ 5 | trait Api { 6 | def fetchPathsUnder(dir: PathRef.DirectoryLike): LookupResult 7 | } 8 | -------------------------------------------------------------------------------- /shared/src/main/scala/tutorial/InstantCodec.scala: -------------------------------------------------------------------------------- 1 | package tutorial 2 | 3 | import java.time.Instant 4 | 5 | import upickle.default.{Reader, Writer} 6 | 7 | /** These needs to be in scope to use `Instant` with upickle (json) and autowire 8 | */ 9 | object InstantCodec { 10 | implicit val InstantReader: Reader[Instant] = 11 | implicitly[Reader[Long]].map(Instant.ofEpochMilli) 12 | 13 | implicit val InstantWriter: Writer[Instant] = 14 | implicitly[Writer[Long]].comap[Instant](_.toEpochMilli) 15 | } 16 | -------------------------------------------------------------------------------- /shared/src/main/scala/tutorial/LookupResult.scala: -------------------------------------------------------------------------------- 1 | package tutorial 2 | 3 | import upickle.default.{ReadWriter => RW, macroRW} 4 | 5 | sealed trait LookupResult 6 | 7 | object LookupResult { 8 | sealed trait LookupError extends LookupResult 9 | case object NotFound extends LookupError 10 | case object AccessDenied extends LookupError 11 | case class Ok(contents: Seq[PathRef.NotRoot]) extends LookupResult 12 | 13 | implicit val rwLookupNotFound: RW[NotFound.type] = macroRW 14 | implicit val rwLookupAccessDenied: RW[AccessDenied.type] = macroRW 15 | implicit val rwLookupError: RW[LookupError] = macroRW 16 | implicit val rwOk: RW[Ok] = macroRW 17 | implicit val rwLookupResult: RW[LookupResult] = macroRW 18 | } 19 | -------------------------------------------------------------------------------- /shared/src/main/scala/tutorial/PathRef.scala: -------------------------------------------------------------------------------- 1 | package tutorial 2 | 3 | import upickle.default.{ReadWriter => RW, macroRW} 4 | 5 | sealed trait PathRef { 6 | def name: String 7 | 8 | override final def toString: String = { 9 | def go(loc: PathRef): List[String] = 10 | loc match { 11 | case d: PathRef.Directory => loc.name :: go(d.parent) 12 | case f: PathRef.File => loc.name :: go(f.parent) 13 | case PathRef.RootRef => Nil 14 | } 15 | 16 | go(this).reverse.mkString("/", "/", "") 17 | } 18 | 19 | def parentOpt: Option[PathRef.DirectoryLike] = 20 | this match { 21 | case d: PathRef.Directory => Some(d.parent) 22 | case f: PathRef.File => Some(f.parent) 23 | case PathRef.RootRef => None 24 | } 25 | } 26 | 27 | 28 | object PathRef { 29 | implicit val rwFileRef: RW[File] = macroRW 30 | implicit val rwDirRef: RW[Directory] = macroRW 31 | implicit val rwDirPathRef: RW[DirectoryLike] = macroRW 32 | implicit val rwNotRoot: RW[NotRoot] = macroRW 33 | implicit val rwPathRef: RW[PathRef] = macroRW 34 | 35 | sealed trait DirectoryLike extends PathRef 36 | sealed trait NotRoot extends PathRef 37 | 38 | final case class Directory(parent: DirectoryLike, name: String) extends DirectoryLike with NotRoot 39 | 40 | final case class File(parent: DirectoryLike, name: String) extends PathRef with NotRoot 41 | 42 | case object RootRef extends DirectoryLike { 43 | val name = "/" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /shared/src/test/scala/tutorial/SharedTest.scala: -------------------------------------------------------------------------------- 1 | package tutorial 2 | 3 | import utest._ 4 | 5 | object SharedTest extends TestSuite { 6 | override def tests = 7 | Tests { 8 | "works" - { 9 | assert(1 + 1 == 2) 10 | } 11 | } 12 | } 13 | --------------------------------------------------------------------------------