├── project ├── build.properties └── plugins.sbt ├── img └── web.png ├── jvm └── src │ ├── main │ ├── resources │ │ ├── db │ │ │ └── migration │ │ │ │ ├── U1.1__AddUsersTable.sql │ │ │ │ ├── V1__InitSetup.sql │ │ │ │ └── V1.1__AddUsersTable.sql │ │ ├── favicon.ico │ │ ├── application.conf │ │ └── logback.xml │ └── scala │ │ └── example │ │ ├── model │ │ ├── SqlData.scala │ │ ├── ChatData.scala │ │ ├── Errors.scala │ │ ├── MongoData.scala │ │ └── GQLData.scala │ │ ├── modules │ │ ├── services │ │ │ ├── RandomService.scala │ │ │ ├── ChatFlowBuilder.scala │ │ │ ├── ChatService.scala │ │ │ ├── CryptoService.scala │ │ │ ├── ChatFlowBuilderLive.scala │ │ │ ├── ScoreboardService.scala │ │ │ ├── auth │ │ │ │ ├── AuthService.scala │ │ │ │ └── AuthServiceLive.scala │ │ │ ├── TodoService.scala │ │ │ └── ChatServiceLive.scala │ │ ├── db │ │ │ ├── DoobieTransactor.scala │ │ │ ├── MongoConn.scala │ │ │ ├── FlywayHandler.scala │ │ │ ├── ScoreboardRepository.scala │ │ │ ├── UserRepository.scala │ │ │ └── TodoRepository.scala │ │ └── AppConfig.scala │ │ ├── endpoints │ │ ├── ChatEndpoints.scala │ │ ├── StaticEndpoints.scala │ │ ├── RestEndpoints.scala │ │ ├── ScoreboardEndpoints.scala │ │ ├── AuthEndpoints.scala │ │ └── TodoEndpoints.scala │ │ └── Hello.scala │ └── test │ ├── scala │ └── example │ │ ├── DummyTest.scala │ │ ├── Http4sTestHelper.scala │ │ ├── endpoints │ │ ├── RestEndpointsTest.scala │ │ ├── ScoreboardEndpointsTest.scala │ │ └── TodoEndpointsTest.scala │ │ ├── TestEnvs.scala │ │ └── modules │ │ └── services │ │ ├── ScoreboardServiceTest.scala │ │ ├── auth │ │ └── AuthServiceTest.scala │ │ └── TodoServiceTest.scala │ └── resources │ ├── application.conf │ ├── db │ └── fulldb │ │ └── V1000__FillDb.sql │ └── logback.xml ├── .travis.yml ├── .gitignore ├── js └── src │ └── main │ ├── resources │ ├── front-res │ │ ├── img │ │ │ ├── logo-mini.png │ │ │ ├── flappy │ │ │ │ ├── bird.png │ │ │ │ ├── pipe.png │ │ │ │ ├── ground.png │ │ │ │ ├── restart.png │ │ │ │ ├── score.png │ │ │ │ └── background.png │ │ │ └── logos │ │ │ │ ├── react.png │ │ │ │ ├── docker.png │ │ │ │ ├── mongodb.png │ │ │ │ ├── postgres.png │ │ │ │ └── bootstrap.png │ │ └── css │ │ │ └── style.css │ ├── index.html │ └── index-dev.html │ └── scala │ └── example │ ├── bridges │ ├── PathToRegexp.scala │ └── reactrouter │ │ ├── ReactRouterDOM.scala │ │ ├── HashRouter.scala │ │ ├── NavLink.scala │ │ └── RouteProps.scala │ ├── components │ ├── BlueButton.scala │ ├── ScoreboardList.scala │ ├── ScoreboardLi.scala │ ├── chat │ │ ├── ChatUserList.scala │ │ └── ChatView.scala │ ├── AuthLastError.scala │ ├── PotScoreboardList.scala │ ├── GlobalName.scala │ ├── TodoLi.scala │ ├── DeleteDialog.scala │ └── graphql │ │ ├── ItemsList.scala │ │ └── ItemDetails.scala │ ├── modules │ ├── DynamicPage.scala │ ├── flappybird │ │ ├── Hood.scala │ │ ├── Ground.scala │ │ ├── Scoreboard.scala │ │ ├── Bird.scala │ │ ├── PipeElem.scala │ │ └── GameLogic.scala │ ├── GraphQL.scala │ ├── Home.scala │ ├── About.scala │ ├── Secured.scala │ ├── SimpleExamples.scala │ ├── Flappy.scala │ ├── MainRouter.scala │ ├── Layout.scala │ ├── SignIn.scala │ ├── Chat.scala │ ├── Todos.scala │ └── Register.scala │ ├── services │ ├── handlers │ │ ├── SecuredTextHandler.scala │ │ ├── ExampleHandlers.scala │ │ ├── GlobalNameHandler.scala │ │ ├── GenericHandlers.scala │ │ ├── GraphQLHandlers.scala │ │ ├── AuthHandler.scala │ │ ├── ScoreboardHandler.scala │ │ ├── ChatHandler.scala │ │ ├── TodosHandler.scala │ │ └── WebsockLifecycleHandler.scala │ ├── ReactDiode.scala │ ├── GraphQLClientData.scala │ ├── Validator.scala │ ├── GraphQLClient.scala │ ├── ChatWebsock.scala │ └── AjaxClient.scala │ └── ScalaJSExample.scala ├── shared └── src │ └── main │ └── scala │ └── example │ └── shared │ ├── HelloShared.scala │ └── Dto.scala ├── package.json ├── .scalafmt.conf ├── .scalafix.conf ├── LICENSE ├── .circleci └── config.yml ├── docker-compose.yml └── README.md /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.5.3 2 | -------------------------------------------------------------------------------- /img/web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oen9/full-stack-zio/HEAD/img/web.png -------------------------------------------------------------------------------- /jvm/src/main/resources/db/migration/U1.1__AddUsersTable.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE users; 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: scala 2 | scala: 3 | - 2.13.3 4 | jdk: 5 | - openjdk11 6 | -------------------------------------------------------------------------------- /jvm/src/main/resources/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oen9/full-stack-zio/HEAD/jvm/src/main/resources/favicon.ico -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.class 2 | *.log 3 | 4 | .idea/ 5 | .bloop/ 6 | .metals/ 7 | .bsp/ 8 | target/ 9 | cache/ 10 | sess.vim 11 | metals.sbt 12 | -------------------------------------------------------------------------------- /js/src/main/resources/front-res/img/logo-mini.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oen9/full-stack-zio/HEAD/js/src/main/resources/front-res/img/logo-mini.png -------------------------------------------------------------------------------- /js/src/main/resources/front-res/img/flappy/bird.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oen9/full-stack-zio/HEAD/js/src/main/resources/front-res/img/flappy/bird.png -------------------------------------------------------------------------------- /js/src/main/resources/front-res/img/flappy/pipe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oen9/full-stack-zio/HEAD/js/src/main/resources/front-res/img/flappy/pipe.png -------------------------------------------------------------------------------- /js/src/main/resources/front-res/img/logos/react.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oen9/full-stack-zio/HEAD/js/src/main/resources/front-res/img/logos/react.png -------------------------------------------------------------------------------- /shared/src/main/scala/example/shared/HelloShared.scala: -------------------------------------------------------------------------------- 1 | package example.shared 2 | 3 | object HelloShared { 4 | val TEST_STR = "shared string!" 5 | } 6 | -------------------------------------------------------------------------------- /js/src/main/resources/front-res/img/flappy/ground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oen9/full-stack-zio/HEAD/js/src/main/resources/front-res/img/flappy/ground.png -------------------------------------------------------------------------------- /js/src/main/resources/front-res/img/flappy/restart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oen9/full-stack-zio/HEAD/js/src/main/resources/front-res/img/flappy/restart.png -------------------------------------------------------------------------------- /js/src/main/resources/front-res/img/flappy/score.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oen9/full-stack-zio/HEAD/js/src/main/resources/front-res/img/flappy/score.png -------------------------------------------------------------------------------- /js/src/main/resources/front-res/img/logos/docker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oen9/full-stack-zio/HEAD/js/src/main/resources/front-res/img/logos/docker.png -------------------------------------------------------------------------------- /js/src/main/resources/front-res/img/logos/mongodb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oen9/full-stack-zio/HEAD/js/src/main/resources/front-res/img/logos/mongodb.png -------------------------------------------------------------------------------- /js/src/main/resources/front-res/img/logos/postgres.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oen9/full-stack-zio/HEAD/js/src/main/resources/front-res/img/logos/postgres.png -------------------------------------------------------------------------------- /js/src/main/resources/front-res/img/logos/bootstrap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oen9/full-stack-zio/HEAD/js/src/main/resources/front-res/img/logos/bootstrap.png -------------------------------------------------------------------------------- /js/src/main/resources/front-res/img/flappy/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oen9/full-stack-zio/HEAD/js/src/main/resources/front-res/img/flappy/background.png -------------------------------------------------------------------------------- /jvm/src/main/resources/db/migration/V1__InitSetup.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE scoreboard ( 2 | id SERIAL PRIMARY KEY, 3 | name VARCHAR NOT NULL, 4 | score SMALLINT NOT NULL 5 | ); 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "license": "UNLICENSED", 4 | "dependencies": { 5 | "react-dom": "16.13.1", 6 | "react": "16.13.1" 7 | }, 8 | "devDependencies": { 9 | "webpack": "4.43.0" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/model/SqlData.scala: -------------------------------------------------------------------------------- 1 | package example.model 2 | 3 | object SqlData { 4 | case class User( 5 | id: Option[Long] = None, 6 | name: String, 7 | password: String, 8 | token: String 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/model/ChatData.scala: -------------------------------------------------------------------------------- 1 | package example.model 2 | 3 | import example.shared.Dto 4 | import fs2.concurrent.Queue 5 | import zio._ 6 | 7 | object ChatData { 8 | case class User(id: Int, name: String, out: Queue[Task[*], Dto.ChatDto]) 9 | } 10 | -------------------------------------------------------------------------------- /jvm/src/test/scala/example/DummyTest.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import zio.test._ 4 | 5 | object DummyTest extends DefaultRunnableSpec { 6 | def spec = suite("dummy suite")( 7 | test("dummy hello test") { 8 | assert("hello")(Assertion.equalTo("hello")) 9 | } 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /jvm/src/main/resources/db/migration/V1.1__AddUsersTable.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE users ( 2 | id SERIAL PRIMARY KEY, 3 | name VARCHAR UNIQUE NOT NULL, 4 | password VARCHAR NOT NULL, 5 | token VARCHAR NOT NULL 6 | ); 7 | 8 | INSERT INTO users (name, password, token) 9 | VALUES ('test', '$2a$10$yHNtm9cSR4fJn6hQNiLW3uPfrJ.Dz3zK53AfuKePD8cm25iIur9oW', 'test'); 10 | -------------------------------------------------------------------------------- /jvm/src/test/resources/application.conf: -------------------------------------------------------------------------------- 1 | http { 2 | host = "0.0.0.0" 3 | port = 8080 4 | } 5 | 6 | mongo { 7 | uri = "empty" 8 | } 9 | 10 | sqldb { 11 | driver = "org.h2.Driver" 12 | url = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1" 13 | username = "sa" 14 | } 15 | 16 | encryption { 17 | salt = "token-secret" 18 | bcrypt-log-rounds = 10 19 | } 20 | 21 | assets = "assets" 22 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 2.4.2 2 | project.git = true 3 | maxColumn = 120 4 | align = more 5 | assumeStandardLibraryStripMargin = true 6 | rewrite.rules = [AvoidInfix, SortImports, RedundantBraces, RedundantParens, SortModifiers] 7 | rewrite.redundantBraces.stringInterpolation = true 8 | spaces.afterTripleEquals = true 9 | continuationIndent.defnSite = 2 10 | includeCurlyBraceInSelectChains = false 11 | -------------------------------------------------------------------------------- /js/src/main/scala/example/bridges/PathToRegexp.scala: -------------------------------------------------------------------------------- 1 | package example.bridges 2 | 3 | import scala.scalajs.js 4 | import scala.scalajs.js.annotation.JSImport 5 | 6 | @js.native 7 | @JSImport("path-to-regexp", JSImport.Default) 8 | object PathToRegexp extends js.Object { 9 | @js.native 10 | trait ToPathData extends js.Object 11 | def compile(str: String): js.Function1[ToPathData, String] = js.native 12 | } 13 | -------------------------------------------------------------------------------- /js/src/main/scala/example/components/BlueButton.scala: -------------------------------------------------------------------------------- 1 | package example.components 2 | 3 | import slinky.core.annotations.react 4 | import slinky.core.FunctionalComponent 5 | import slinky.web.html._ 6 | 7 | @react object BlueButton { 8 | case class Props(text: String, onClick: () => Unit) 9 | val component = FunctionalComponent[Props] { props => 10 | button(className := "btn btn-primary", onClick := props.onClick, props.text) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /jvm/src/test/resources/db/fulldb/V1000__FillDb.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO scoreboard (name, score) VALUES ('unknown1', 10); 2 | INSERT INTO scoreboard (name, score) VALUES ('unknown2', 20); 3 | INSERT INTO scoreboard (name, score) VALUES ('unknown3', 15); 4 | INSERT INTO scoreboard (name, score) VALUES ('aaa', 5); 5 | INSERT INTO scoreboard (name, score) VALUES ('bbb', 5); 6 | 7 | INSERT INTO users (name, password, token) VALUES ('user2', '!user2Password', 'user2Token'); 8 | -------------------------------------------------------------------------------- /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("ch.epfl.scala" % "sbt-scalajs-bundler" % "0.20.0") 4 | 5 | addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.8.1") 6 | addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1") 7 | addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.19") 8 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.2") 9 | addSbtPlugin("com.github.ghostdogpr" % "caliban-codegen-sbt" % "0.9.2") 10 | -------------------------------------------------------------------------------- /js/src/main/scala/example/modules/DynamicPage.scala: -------------------------------------------------------------------------------- 1 | package example.modules 2 | import slinky.core.annotations.react 3 | import slinky.core.FunctionalComponent 4 | import slinky.web.html._ 5 | import example.bridges.reactrouter.ReactRouterDOM 6 | 7 | @react object DynamicPage { 8 | type Props = Unit 9 | 10 | val component = FunctionalComponent[Props] { _ => 11 | val params = ReactRouterDOM.useParams().toMap 12 | div( 13 | h4("Feel free to change args in url (...#/dyn/{foo}/{bar})"), 14 | h5("Dynamic page params: " + params) 15 | ) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /js/src/main/scala/example/bridges/reactrouter/ReactRouterDOM.scala: -------------------------------------------------------------------------------- 1 | package example.bridges.reactrouter 2 | 3 | import scala.scalajs.js 4 | import scala.scalajs.js.annotation.JSImport 5 | 6 | @JSImport("react-router-dom", JSImport.Default) 7 | @js.native 8 | object ReactRouterDOM extends js.Object { 9 | def useParams(): js.Dictionary[String] = js.native 10 | def useLocation(): Location = js.native 11 | 12 | trait Location extends js.Object { 13 | val key: String 14 | val pathname: String 15 | val search: String 16 | val hash: String 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /jvm/src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | http { 2 | host = "0.0.0.0" 3 | host = ${?HOST} 4 | port = 8080 5 | port = ${?PORT} 6 | } 7 | 8 | mongo { 9 | uri = "mongodb://root:secret@localhost:27017/admin" 10 | uri = ${?MONGO_URL_FULL_STACK_ZIO} 11 | } 12 | 13 | sqldb { 14 | driver = "org.postgresql.Driver" 15 | url = "jdbc:postgresql://localhost:5432/fullstackzio?user=root&password=secret" 16 | url = ${?DATABASE_URL_FULL_STACK_ZIO} 17 | } 18 | 19 | encryption { 20 | salt = "token-secret" 21 | bcrypt-log-rounds = 10 22 | } 23 | 24 | assets = ${PWD} 25 | assets = ${?assets} 26 | -------------------------------------------------------------------------------- /js/src/main/resources/front-res/css/style.css: -------------------------------------------------------------------------------- 1 | .todo-size { 2 | width: 40rem; 3 | } 4 | 5 | .width-100 { 6 | width: 100%; 7 | } 8 | 9 | .scoreboard-size { 10 | width: 60rem; 11 | } 12 | 13 | .gold { 14 | color: gold; 15 | } 16 | 17 | .silver { 18 | color: silver; 19 | } 20 | 21 | .brown { 22 | color: brown; 23 | } 24 | 25 | .green { 26 | color: lawngreen; 27 | } 28 | 29 | .max-vh-50 { 30 | max-height: 50vh; 31 | } 32 | 33 | .vh-50 { 34 | height: 50vh; 35 | } 36 | 37 | .input-group>.input-group-prepend { 38 | flex: 0 0 15%; 39 | } 40 | .input-group .input-group-text { 41 | width: 100%; 42 | } 43 | -------------------------------------------------------------------------------- /js/src/main/scala/example/bridges/reactrouter/HashRouter.scala: -------------------------------------------------------------------------------- 1 | package example.bridges.reactrouter 2 | 3 | import slinky.core.ExternalComponent 4 | import slinky.core.annotations.react 5 | 6 | import scala.scalajs.js 7 | import scala.scalajs.js.UndefOr 8 | 9 | import slinky.reactrouter.ReactRouterDOM 10 | 11 | @react object HashRouter extends ExternalComponent { 12 | case class Props( 13 | basename: UndefOr[String] = js.undefined, 14 | getUserConfirmation: UndefOr[js.Function] = js.undefined, 15 | hashType: UndefOr[String] = js.undefined 16 | ) 17 | 18 | override val component = ReactRouterDOM.HashRouter 19 | } 20 | -------------------------------------------------------------------------------- /jvm/src/test/scala/example/Http4sTestHelper.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import cats.implicits._ 4 | import zio._ 5 | import zio.interop.catz._ 6 | 7 | import io.circe.Decoder 8 | import io.circe.parser.decode 9 | import org.http4s._ 10 | 11 | object Http4sTestHelper { 12 | def parseBody[R, A](resp: Option[Response[RIO[R, *]]])(implicit decoder: Decoder[A]): RIO[R, Option[A]] = 13 | for { 14 | body <- resp 15 | .map( 16 | _.body.compile.toVector 17 | .map(x => x.map(_.toChar).mkString("")) 18 | ) 19 | .sequence 20 | parsedBody = body.flatMap(b => decode[A](b).toOption) 21 | } yield parsedBody 22 | } 23 | -------------------------------------------------------------------------------- /.scalafix.conf: -------------------------------------------------------------------------------- 1 | rules = [ 2 | RemoveUnused 3 | DisableSyntax 4 | LeakingImplicitClassVal 5 | NoValInForComprehension 6 | ProcedureSyntax 7 | ] 8 | 9 | DisableSyntax.noVars = true 10 | DisableSyntax.noThrows = true 11 | DisableSyntax.noNulls = true 12 | DisableSyntax.noReturns = true 13 | DisableSyntax.noAsInstanceOf = false 14 | DisableSyntax.noIsInstanceOf = true 15 | DisableSyntax.noXml = true 16 | DisableSyntax.noFinalVal = true 17 | DisableSyntax.noFinalize = true 18 | DisableSyntax.noValPatterns = true 19 | DisableSyntax.regex = [ 20 | { 21 | id = noJodaTime 22 | pattern = "org\\.joda\\.time" 23 | message = "Use java.time instead" 24 | } 25 | ] 26 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/modules/services/RandomService.scala: -------------------------------------------------------------------------------- 1 | package example.modules.services 2 | 3 | import zio._ 4 | import zio.random._ 5 | 6 | object randomService { 7 | type RandomService = Has[RandomService.Service] 8 | 9 | object RandomService { 10 | trait Service { 11 | def getRandom: Task[Int] 12 | } 13 | val live: ZLayer[Random, Nothing, RandomService] = ZLayer.fromFunction(random => 14 | new Service { 15 | def getRandom: zio.Task[Int] = random.get.nextIntBetween(0, 9000) 16 | } 17 | ) 18 | } 19 | 20 | def getRandom: ZIO[RandomService, Throwable, Int] = 21 | ZIO.accessM[RandomService](_.get.getRandom) 22 | } 23 | -------------------------------------------------------------------------------- /js/src/main/resources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Full Stack ZIO 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /js/src/main/scala/example/services/handlers/SecuredTextHandler.scala: -------------------------------------------------------------------------------- 1 | package example.services.handlers 2 | 3 | import diode.data.Pot 4 | import diode.data.PotAction 5 | import diode.{ActionHandler, ModelRW} 6 | import example.services.AjaxClient 7 | import example.services.TryGetSecuredText 8 | 9 | class SecuredTextHandler[M](modelRW: ModelRW[M, Pot[String]]) extends ActionHandler(modelRW) { 10 | import scala.concurrent.ExecutionContext.Implicits.global 11 | 12 | override def handle = { 13 | case action: TryGetSecuredText => 14 | val updateF = action.effect(AjaxClient.getAuthSecured(action.token))(identity) 15 | action.handleWith(this, updateF)(PotAction.handler()) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /jvm/src/test/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | trace 7 | 8 | true 9 | 10 | %yellow([%date]) %highlight([%-5level]) %green([%thread]) %cyan([%logger]) - %magenta(%msg) %n 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /js/src/main/scala/example/ScalaJSExample.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import org.scalajs.dom.document 4 | import scala.scalajs.js 5 | import scala.scalajs.js.annotation.JSImport 6 | import slinky.web.ReactDOM 7 | import scala.scalajs.LinkingInfo 8 | import example.modules.MainRouter 9 | import example.bridges.reactrouter.HashRouter 10 | 11 | object ScalaJSExample { 12 | 13 | @JSImport("bootstrap", JSImport.Default) 14 | @js.native 15 | object Bootstrap extends js.Object 16 | 17 | def main(args: Array[String]): Unit = { 18 | val target = document.getElementById("main") 19 | 20 | Bootstrap 21 | 22 | if (LinkingInfo.developmentMode) { 23 | println("dev mode") 24 | } 25 | 26 | ReactDOM.render(HashRouter(MainRouter()), target) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /js/src/main/scala/example/bridges/reactrouter/NavLink.scala: -------------------------------------------------------------------------------- 1 | package example.bridges.reactrouter 2 | 3 | import slinky.core.ExternalComponent 4 | import slinky.core.annotations.react 5 | import slinky.reactrouter.ReactRouterDOM 6 | 7 | import scala.scalajs.js 8 | import scala.scalajs.js.{|, UndefOr} 9 | 10 | case class To( 11 | pathname: Option[String] = None, 12 | search: Option[String] = None, 13 | hash: Option[String] = None, 14 | state: Option[js.Object] 15 | ) 16 | 17 | @react object NavLink extends ExternalComponent { 18 | case class Props( 19 | to: String | To, 20 | exact: UndefOr[Boolean] = js.undefined, 21 | activeClassName: UndefOr[String] = js.undefined, 22 | activeStyle: UndefOr[js.Dynamic] = js.undefined 23 | ) 24 | override val component = ReactRouterDOM.NavLink 25 | } 26 | -------------------------------------------------------------------------------- /js/src/main/scala/example/bridges/reactrouter/RouteProps.scala: -------------------------------------------------------------------------------- 1 | package example.bridges.reactrouter 2 | 3 | import scala.scalajs.js 4 | import scala.scalajs.js.{|, UndefOr} 5 | 6 | case class Match(params: js.Object, isExact: Boolean, path: String, url: String) 7 | case class Location(key: UndefOr[String], pathname: UndefOr[String], search: UndefOr[String], hash: UndefOr[String]) 8 | case class History( 9 | length: Int, 10 | action: String, 11 | location: Location, 12 | push: js.Function1[String, Unit] | js.Function2[String, js.Object, Unit], 13 | replace: js.Function1[String, Unit] | js.Function2[String, js.Object, Unit], 14 | go: js.Function1[Int, Unit], 15 | goBack: js.Function, 16 | goForward: js.Function, 17 | block: js.Function 18 | ) 19 | case class RouteProps(`match`: Match, location: Location, history: History) 20 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/model/Errors.scala: -------------------------------------------------------------------------------- 1 | package example.model 2 | 3 | object Errors { 4 | sealed trait ErrorInfo extends Product with Serializable 5 | case class NotFound(msg: String) extends ErrorInfo 6 | case class BadRequest(msg: String) extends ErrorInfo 7 | case class UnknownError(msg: String) extends ErrorInfo 8 | case class Unauthorized(msg: String) extends ErrorInfo 9 | case class Conflict(msg: String) extends ErrorInfo 10 | 11 | case class TodoTaskNotFound(msg: String) extends Exception(msg) 12 | case class WrongMongoId(msg: String) extends Exception(msg) 13 | 14 | case class TokenNotFound(msg: String) extends Exception(msg) 15 | case class UserExists(msg: String) extends Exception(msg) 16 | case class AuthenticationError(msg: String) extends Exception(msg) 17 | } 18 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/endpoints/ChatEndpoints.scala: -------------------------------------------------------------------------------- 1 | package example.endpoints 2 | 3 | import example.modules.services.chatFlowBuilder 4 | import example.modules.services.chatFlowBuilder.ChatFlowBuilder 5 | import org.http4s.dsl.Http4sDsl 6 | import org.http4s.HttpRoutes 7 | import org.http4s.server.websocket.WebSocketBuilder 8 | import zio._ 9 | import zio.interop.catz._ 10 | import zio.logging._ 11 | 12 | object ChatEndpoints { 13 | 14 | def routes[R <: Logging with ChatFlowBuilder]: HttpRoutes[RIO[R, *]] = { 15 | val dsl = Http4sDsl[RIO[R, *]] 16 | import dsl._ 17 | HttpRoutes.of { 18 | case request @ GET -> Root / "chat" => 19 | for { 20 | flowData <- chatFlowBuilder.build[R]() 21 | ws <- WebSocketBuilder[RIO[R, *]].build( 22 | flowData.out, 23 | flowData.in, 24 | onClose = flowData.onClose 25 | ) 26 | } yield ws 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /js/src/main/scala/example/services/ReactDiode.scala: -------------------------------------------------------------------------------- 1 | package example.services 2 | 3 | import diode.Circuit 4 | import diode.Dispatcher 5 | import diode.ModelR 6 | import slinky.core.facade.Hooks._ 7 | import slinky.core.facade.React 8 | import slinky.core.facade.ReactContext 9 | 10 | object ReactDiode { 11 | val diodeContext = React.createContext[Circuit[RootModel]](AppCircuit) 12 | 13 | def useDiode[T](selector: ModelR[RootModel, T]): (T, Dispatcher) = 14 | useDiode(diodeContext, selector) 15 | 16 | def useDiode[M <: AnyRef, T, S <: Circuit[M]](context: ReactContext[S], selector: ModelR[M, T]): (T, Dispatcher) = { 17 | val circuit = useContext(context) 18 | val (state, setState) = useState[T](default = selector()) 19 | 20 | useEffect(() => { 21 | val unsubscription = circuit.subscribe(selector)(state => setState(state.value)) 22 | () => unsubscription() 23 | }, Seq()) 24 | 25 | (state, circuit) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /js/src/main/scala/example/services/handlers/ExampleHandlers.scala: -------------------------------------------------------------------------------- 1 | package example.services.handlers 2 | 3 | import diode.data.Pot 4 | import diode.data.PotAction 5 | import diode.{ActionHandler, ModelRW} 6 | import example.services.AjaxClient 7 | import example.services.Clicks 8 | import example.services.IncreaseClicks 9 | import example.services.TryGetRandom 10 | import example.shared.Dto.Foo 11 | 12 | class ClicksHandler[M](modelRW: ModelRW[M, Clicks]) extends ActionHandler(modelRW) { 13 | override def handle = { 14 | case IncreaseClicks => updated(value.copy(count = value.count + 1)) 15 | } 16 | } 17 | 18 | class RandomNumberHandler[M](modelRW: ModelRW[M, Pot[Foo]]) extends ActionHandler(modelRW) { 19 | import scala.concurrent.ExecutionContext.Implicits.global 20 | override def handle = { 21 | case action: TryGetRandom => 22 | val updateF = action.effect(AjaxClient.getRandom)(identity _) 23 | action.handleWith(this, updateF)(PotAction.handler()) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /js/src/main/scala/example/components/ScoreboardList.scala: -------------------------------------------------------------------------------- 1 | package example.components 2 | 3 | import slinky.core.annotations.react 4 | import slinky.web.html._ 5 | import slinky.core.FunctionalComponent 6 | import example.shared.Dto.ScoreboardRecord 7 | 8 | @react object ScoreboardList { 9 | case class Props(scores: Vector[ScoreboardRecord]) 10 | 11 | val component = FunctionalComponent[Props] { props => 12 | ul( 13 | className := "list-group list-group-flush", 14 | li( 15 | className := "list-group-item", 16 | div( 17 | className := "row", 18 | div(className := "col text-center", "score"), 19 | div(className := "col text-center", "name"), 20 | div(className := "col text-center", "trophy") 21 | ) 22 | ), 23 | div(className := "overflow-auto max-vh-50", props.scores.zipWithIndex.map { 24 | case (s, pos) => ScoreboardLi(s, pos).withKey(s.id.getOrElse(-1).toString()) 25 | }) 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/model/MongoData.scala: -------------------------------------------------------------------------------- 1 | package example.model 2 | 3 | import reactivemongo.api.bson.BSONObjectID 4 | import reactivemongo.api.bson.Macros 5 | import reactivemongo.api.bson.Macros.Annotations.Key 6 | 7 | object MongoData { 8 | sealed trait TodoStatus 9 | case object Done extends TodoStatus 10 | case object Pending extends TodoStatus 11 | 12 | case class TodoTask(@Key("_id") id: BSONObjectID, value: String, status: TodoStatus) 13 | 14 | object TodoStatus { 15 | import reactivemongo.api.bson.MacroOptions.{\/, AutomaticMaterialization, UnionType} 16 | type PredefinedTodoStatus = UnionType[Done.type \/ Pending.type] with AutomaticMaterialization 17 | implicit val predefinedTodoStatus = Macros.handlerOpts[TodoStatus, PredefinedTodoStatus] 18 | } 19 | 20 | implicit val todoTaskHandler = Macros.handler[TodoTask] 21 | 22 | def switchStatus(oldStatus: TodoStatus): TodoStatus = oldStatus match { 23 | case Pending => Done 24 | case Done => Pending 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /js/src/main/scala/example/services/handlers/GlobalNameHandler.scala: -------------------------------------------------------------------------------- 1 | package example.services.handlers 2 | 3 | import diode.ActionHandler 4 | import diode.Effect 5 | import diode.ModelRW 6 | import example.services.ChangeMyChatName 7 | import example.services.RefreshGlobalName 8 | import example.services.SetGlobalName 9 | import scala.concurrent.ExecutionContext.Implicits.global 10 | 11 | // flow: 12 | // signin - sync all names 13 | // -> SetGloablName -> SetChatName 14 | // change globalName - this result in different names (signin name != globalName) 15 | // -> SetGlobalName -> SetChatName 16 | class GlobalNameHandler[M](modelRW: ModelRW[M, String]) extends ActionHandler(modelRW) { 17 | 18 | override def handle = { 19 | 20 | case SetGlobalName(newName) if newName.trim.nonEmpty => 21 | updated(newName, Effect.action(ChangeMyChatName(newName))) 22 | 23 | case SetGlobalName(_) => 24 | noChange 25 | 26 | case RefreshGlobalName => 27 | effectOnly(Effect.action(ChangeMyChatName(value))) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /js/src/main/resources/index-dev.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Full Stack ZIO 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /js/src/main/scala/example/components/ScoreboardLi.scala: -------------------------------------------------------------------------------- 1 | package example.components 2 | 3 | import slinky.core.annotations.react 4 | import slinky.web.html._ 5 | import slinky.core.FunctionalComponent 6 | import example.shared.Dto.ScoreboardRecord 7 | 8 | @react object ScoreboardLi { 9 | case class Props(score: ScoreboardRecord, pos: Int) 10 | 11 | val component = FunctionalComponent[Props] { props => 12 | li( 13 | className := "list-group-item", 14 | div( 15 | className := "row", 16 | div(className := "col text-center", s"${props.score.score}"), 17 | div(className := "col text-center", s"${props.score.name}"), 18 | div( 19 | className := "col text-center", 20 | props.pos match { 21 | case 0 => i(className := "fas fa-trophy gold") 22 | case 1 => i(className := "fas fa-trophy silver") 23 | case 2 => i(className := "fas fa-trophy brown") 24 | case _ => i(className := "fas fa-kiwi-bird green") 25 | } 26 | ) 27 | ) 28 | ) 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /js/src/main/scala/example/services/handlers/GenericHandlers.scala: -------------------------------------------------------------------------------- 1 | package example.services.handlers 2 | 3 | import diode.{ActionHandler} 4 | import diode.data._ 5 | import scala.concurrent.ExecutionContext.Implicits.global 6 | import diode.Effect 7 | import diode.ActionType 8 | 9 | object GenericHandlers { 10 | def withOnReady[A, M, P <: PotAction[A, P], D: ActionType](onReady: PotAction[A, P] => D) = 11 | (action: PotAction[A, P], handler: ActionHandler[M, Pot[A]], updateEffect: Effect) => { 12 | import PotState._ 13 | import handler._ 14 | 15 | action.state match { 16 | case PotEmpty => 17 | updated(value.pending(), updateEffect) 18 | case PotPending => 19 | noChange 20 | case PotReady => 21 | val eff = Effect.action(onReady(action)) 22 | updated(action.potResult, eff) 23 | case PotUnavailable => 24 | updated(value.unavailable()) 25 | case PotFailed => 26 | val ex = action.result.failed.get 27 | updated(value.fail(ex)) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 oen9 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /js/src/main/scala/example/components/chat/ChatUserList.scala: -------------------------------------------------------------------------------- 1 | package example.components.chat 2 | 3 | import example.services.AppCircuit 4 | import example.services.ReactDiode 5 | import slinky.core.annotations.react 6 | import slinky.core.FunctionalComponent 7 | import slinky.web.html._ 8 | import example.shared.Dto 9 | 10 | @react object ChatUserList { 11 | type Props = Unit 12 | 13 | val component = FunctionalComponent[Props] { _ => 14 | val (users, _) = ReactDiode.useDiode(AppCircuit.zoomTo(_.chatConn.users)) 15 | val (me, _) = ReactDiode.useDiode(AppCircuit.zoomTo(_.chatConn.user)) 16 | 17 | def prettyUser(u: Dto.ChatUser) = { 18 | val color = if (u.id == me.id) "primary" else "secondary" 19 | 20 | div( 21 | className := s"alert alert-$color", 22 | span(u.name), 23 | span(className := s"ml-2 badge badge-$color", u.id) 24 | ) 25 | } 26 | 27 | div( 28 | className := "vh-50 overflow-auto mb-3 bg-light", 29 | users.value.zipWithIndex.map { 30 | case (user, idx) => 31 | div(key := idx.toString, prettyUser(user)) 32 | } 33 | ) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/modules/services/ChatFlowBuilder.scala: -------------------------------------------------------------------------------- 1 | package example.modules.services 2 | 3 | import example.modules.services.chatService.ChatService 4 | import fs2.{Pipe, Stream} 5 | import org.http4s.websocket.WebSocketFrame 6 | import zio._ 7 | import zio.logging.Logger 8 | import zio.logging.Logging 9 | 10 | object chatFlowBuilder { 11 | type ChatFlowBuilder = Has[ChatFlowBuilder.Service] 12 | 13 | object ChatFlowBuilder { 14 | case class ChatClientFlow[R]( 15 | out: Stream[Task[*], WebSocketFrame], 16 | in: Pipe[RIO[R, *], WebSocketFrame, Unit], 17 | onClose: Task[Unit] 18 | ) 19 | 20 | trait Service { 21 | def build[R](): RIO[R, ChatClientFlow[R]] 22 | } 23 | 24 | val live: ZLayer[ChatService with Logging, Nothing, ChatFlowBuilder] = 25 | ZLayer.fromServices[ChatService.Service, Logger[String], ChatFlowBuilder.Service] { (chatService, logger) => 26 | new ChatFlowBuilderLive(chatService, logger) 27 | } 28 | } 29 | 30 | def build[R](): ZIO[R with ChatFlowBuilder, Throwable, ChatFlowBuilder.ChatClientFlow[R]] = 31 | ZIO.accessM[R with ChatFlowBuilder](_.get.build[R]()) 32 | } 33 | -------------------------------------------------------------------------------- /jvm/src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | TRACE 5 | 6 | true 7 | 8 | %yellow([%date]) %highlight([%-5level]) %green([%thread]) %cyan([%logger]) - %magenta(%msg) %n 9 | 10 | 11 | 12 | 13 | 14 | /var/tmp/oen/full-stack-zio-%d{yyyy-MM-dd}.%i.log 15 | 100MB 16 | 60 17 | 2GB 18 | 19 | true 20 | 21 | [%date] [%-5level] [%thread] [%logger] - %msg %n 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Scala CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/sample-config/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/openjdk:11-jdk-node 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | # - image: circleci/postgres:9.4 16 | 17 | working_directory: ~/repo 18 | 19 | environment: 20 | # Customize the JVM maximum heap limit 21 | JVM_OPTS: -Xmx8G 22 | TERM: dumb 23 | 24 | steps: 25 | - checkout 26 | 27 | # Download and cache dependencies 28 | - restore_cache: 29 | keys: 30 | - v1-dependencies-{{ checksum "build.sbt" }} 31 | # fallback to using the latest cache if no exact match is found 32 | - v1-dependencies- 33 | 34 | - run: cat /dev/null | sbt test:compile 35 | 36 | - save_cache: 37 | paths: 38 | - ~/.m2 39 | key: v1-dependencies--{{ checksum "build.sbt" }} 40 | 41 | # run tests! 42 | - run: cat /dev/null | sbt appJS/test 43 | - run: cat /dev/null | sbt appJVM/test 44 | 45 | -------------------------------------------------------------------------------- /js/src/main/scala/example/services/handlers/GraphQLHandlers.scala: -------------------------------------------------------------------------------- 1 | package example.services.handlers 2 | 3 | import diode.ActionResult 4 | import diode.data.Pot 5 | import diode.data.PotAction 6 | import diode.{ActionHandler, ModelRW} 7 | import example.services.GetGQLItem 8 | import example.services.GetGQLItems 9 | import example.services.GraphQLClient 10 | import example.services.GraphQLClient.ItemBaseView 11 | import example.services.GraphQLClient.ItemFullView 12 | import scala.concurrent.ExecutionContext.Implicits.global 13 | 14 | object GraphQLHandlers { 15 | class ItemsHandler[M](modelRW: ModelRW[M, Pot[List[ItemBaseView]]]) extends ActionHandler(modelRW) { 16 | override protected def handle: PartialFunction[Any, ActionResult[M]] = { 17 | case action: GetGQLItems => 18 | val updateF = action.effect(GraphQLClient.getItems())(identity _) 19 | action.handleWith(this, updateF)(PotAction.handler()) 20 | } 21 | } 22 | 23 | class ItemHandler[M](modelRW: ModelRW[M, Pot[Option[ItemFullView]]]) extends ActionHandler(modelRW) { 24 | override protected def handle: PartialFunction[Any, ActionResult[M]] = { 25 | case action: GetGQLItem => 26 | val updateF = action.effect(GraphQLClient.getItem(action.name))(identity _) 27 | action.handleWith(this, updateF)(PotAction.handler()) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /js/src/main/scala/example/services/GraphQLClientData.scala: -------------------------------------------------------------------------------- 1 | package example.services 2 | 3 | import caliban.client._ 4 | import caliban.client.FieldBuilder._ 5 | import caliban.client.Operations._ 6 | import caliban.client.SelectionBuilder._ 7 | 8 | object GraphQLClientData { 9 | 10 | type Feature 11 | object Feature { 12 | def name: SelectionBuilder[Feature, String] = Field("name", Scalar()) 13 | def value: SelectionBuilder[Feature, Int] = Field("value", Scalar()) 14 | def description: SelectionBuilder[Feature, String] = Field("description", Scalar()) 15 | } 16 | 17 | type Item 18 | object Item { 19 | def name: SelectionBuilder[Item, String] = Field("name", Scalar()) 20 | def amount: SelectionBuilder[Item, Int] = Field("amount", Scalar()) 21 | def features[A](innerSelection: SelectionBuilder[Feature, A]): SelectionBuilder[Item, List[A]] = 22 | Field("features", ListOf(Obj(innerSelection))) 23 | } 24 | 25 | type Queries = RootQuery 26 | object Queries { 27 | def items[A](innerSelection: SelectionBuilder[Item, A]): SelectionBuilder[RootQuery, List[A]] = 28 | Field("items", ListOf(Obj(innerSelection))) 29 | def item[A](name: String)(innerSelection: SelectionBuilder[Item, A]): SelectionBuilder[RootQuery, Option[A]] = 30 | Field("item", OptionOf(Obj(innerSelection)), arguments = List(Argument("name", name))) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /js/src/main/scala/example/components/AuthLastError.scala: -------------------------------------------------------------------------------- 1 | package example.components 2 | 3 | import cats.implicits._ 4 | import diode.data.PotState.PotFailed 5 | import example.services.AppCircuit 6 | import example.services.ReactDiode 7 | import example.services.SignOut 8 | import slinky.core.annotations.react 9 | import slinky.core.facade.ReactElement 10 | import slinky.core.FunctionalComponent 11 | import slinky.web.html._ 12 | 13 | @react object AuthLastError { 14 | type Props = Unit 15 | 16 | val component = FunctionalComponent[Props] { props => 17 | val (auth, dispatch) = ReactDiode.useDiode(AppCircuit.zoom(_.auth)) 18 | val clean = () => dispatch(SignOut) 19 | 20 | def lastErrorMsg() = 21 | auth.exceptionOption 22 | .fold("unknown error")(msg => s"Last auth error: ${msg.getMessage()}") 23 | 24 | auth.state match { 25 | case PotFailed => 26 | div( 27 | className := "alert alert-danger", 28 | role := "alert", 29 | div( 30 | className := "row align-items-center", 31 | div(className := "col", lastErrorMsg()), 32 | div( 33 | className := "col text-right", 34 | button(className := "btn btn-secondary text-right", "clean", onClick := clean) 35 | ) 36 | ) 37 | ).some 38 | case _ => none[ReactElement] 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/modules/db/DoobieTransactor.scala: -------------------------------------------------------------------------------- 1 | package example.modules.db 2 | 3 | import cats.effect.Blocker 4 | import cats.implicits._ 5 | import doobie.hikari.HikariTransactor 6 | import doobie.util.transactor.Transactor 7 | import example.modules.appConfig 8 | import example.modules.appConfig.AppConfig 9 | import example.modules.db.flywayHandler.FlywayHandler 10 | import zio._ 11 | import zio.blocking.Blocking 12 | import zio.interop.catz._ 13 | 14 | object doobieTransactor { 15 | type DoobieTransactor = Has[Transactor[Task]] 16 | 17 | val live: ZLayer[Blocking with AppConfig with FlywayHandler, Throwable, DoobieTransactor] = ZLayer.fromManaged { 18 | ZIO.runtime[Blocking].toManaged_.flatMap { implicit rt => 19 | for { 20 | _ <- flywayHandler.initDb.toManaged_ 21 | cfg <- appConfig.load.toManaged_ 22 | blockingEC <- ZIO.accessM[Blocking](b => ZIO.succeed(b.get.blockingExecutor.asEC)).toManaged_ 23 | connectEC = rt.platform.executor.asEC 24 | transactor <- HikariTransactor 25 | .newHikariTransactor[Task]( 26 | cfg.sqldb.driver, 27 | cfg.sqldb.url, 28 | cfg.sqldb.username, 29 | cfg.sqldb.password, 30 | connectEC, 31 | Blocker.liftExecutionContext(blockingEC) 32 | ) 33 | .toManaged 34 | } yield transactor 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /js/src/main/scala/example/components/PotScoreboardList.scala: -------------------------------------------------------------------------------- 1 | package example.components 2 | 3 | import diode.data.Pot 4 | import diode.data.PotState.PotEmpty 5 | import diode.data.PotState.PotFailed 6 | import diode.data.PotState.PotPending 7 | import diode.data.PotState.PotReady 8 | import example.shared.Dto.ScoreboardRecord 9 | import slinky.core.annotations.react 10 | import slinky.core.FunctionalComponent 11 | import slinky.web.html._ 12 | import slinky.core.facade.ReactElement 13 | 14 | @react object PotScoreboardList { 15 | case class Props(scores: Pot[Vector[ScoreboardRecord]]) 16 | 17 | val component = FunctionalComponent[Props] { props => 18 | props.scores.state match { 19 | case PotEmpty => 20 | "nothing here" 21 | case PotPending => 22 | div( 23 | className := "row justify-content-center", 24 | div( 25 | className := "row justify-content-center spinner-border text-primary", 26 | role := "status", 27 | span(className := "sr-only", "Loading...") 28 | ) 29 | ) 30 | case PotFailed => 31 | props.scores.exceptionOption.fold("unknown error")(msg => " error: " + msg.getMessage()) 32 | case PotReady => 33 | props.scores.fold( 34 | div("nothing here yet"): ReactElement 35 | )(scoreList => ul(className := "list-group list-group-flush", ScoreboardList(scoreList))) 36 | case _ => div("unexpected state") 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/modules/services/ChatService.scala: -------------------------------------------------------------------------------- 1 | package example.modules.services 2 | 3 | import cats.implicits._ 4 | import example.model.ChatData.User 5 | import example.shared.Dto 6 | import fs2.concurrent.Queue 7 | import zio._ 8 | import zio.logging.Logger 9 | import zio.logging.Logging 10 | 11 | object chatService { 12 | type ChatService = Has[ChatService.Service] 13 | 14 | object ChatService { 15 | trait Service { 16 | def createUser(out: Queue[Task[*], Dto.ChatDto]): Task[Dto.ChatUser] 17 | def handleUserMsg(userId: Int, msg: Dto.ClientMsg): Task[Unit] 18 | def handleServerMsg(msg: Dto.ServerMsg): Task[Unit] 19 | } 20 | 21 | val live: ZLayer[Any with Logging, Throwable, ChatService] = 22 | ZLayer.fromServiceM[Logger[String], Any, Throwable, ChatService.Service] { logger => 23 | for { 24 | users <- Ref.make(Vector[User]()) 25 | idCounter <- Ref.make(1) 26 | } yield new ChatServiceLive(users, idCounter, logger) 27 | } 28 | } 29 | 30 | def createUser(out: Queue[Task[*], Dto.ChatDto]): ZIO[ChatService, Throwable, Dto.ChatUser] = 31 | ZIO.accessM[ChatService](_.get.createUser(out)) 32 | def handleUserMsg(userId: Int, msg: Dto.ClientMsg): ZIO[ChatService, Throwable, Unit] = 33 | ZIO.accessM[ChatService](_.get.handleUserMsg(userId, msg)) 34 | def handleServerMsg(msg: Dto.ServerMsg): ZIO[ChatService, Throwable, Unit] = 35 | ZIO.accessM[ChatService](_.get.handleServerMsg(msg)) 36 | } 37 | -------------------------------------------------------------------------------- /js/src/main/scala/example/modules/flappybird/Hood.scala: -------------------------------------------------------------------------------- 1 | package example.modules.flappybird 2 | 3 | import com.github.oen9.slinky.bridge.reactkonva.Group 4 | import com.github.oen9.slinky.bridge.reactkonva.Text 5 | import example.modules.flappybird.GameLogic.GameState 6 | import slinky.core.annotations.react 7 | import slinky.core.FunctionalComponent 8 | 9 | @react object Hood { 10 | case class Props(gs: GameState) 11 | 12 | val component = FunctionalComponent[Props] { props => 13 | Group( 14 | Text( 15 | text = s"fps: ${props.gs.fps}", 16 | x = 20, 17 | y = 20, 18 | fontSize = 14 19 | ), 20 | Text( 21 | text = s"score: ${props.gs.score}", 22 | x = 20, 23 | y = 40, 24 | fontSize = 14 25 | ), 26 | if (props.gs.opt.debug) { 27 | Group( 28 | Text( 29 | text = s"${props.gs.bird.toString()}", 30 | x = 20, 31 | y = 60, 32 | fill = "grey", 33 | fontSize = 14 34 | ), 35 | Text( 36 | text = s"${props.gs.pipe1.toString()}", 37 | x = 20, 38 | y = 80, 39 | fill = "grey", 40 | fontSize = 14 41 | ), 42 | Text( 43 | text = s"${props.gs.pipe2.toString()}", 44 | x = 20, 45 | y = 100, 46 | fill = "grey", 47 | fontSize = 14 48 | ) 49 | ) 50 | } else Group() 51 | ) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /js/src/main/scala/example/modules/GraphQL.scala: -------------------------------------------------------------------------------- 1 | package example.modules 2 | 3 | import example.components.graphql.ItemDetails 4 | import example.components.graphql.ItemsList 5 | import example.services.AppCircuit 6 | import example.services.GetGQLItems 7 | import org.scalajs.dom.{html, Event} 8 | import slinky.core.annotations.react 9 | import slinky.core.FunctionalComponent 10 | import slinky.core.SyntheticEvent 11 | import slinky.web.html._ 12 | 13 | @react object GraphQL { 14 | type Props = Unit 15 | 16 | val component = FunctionalComponent[Props] { _ => 17 | def handleRefresh(e: SyntheticEvent[html.Form, Event]): Unit = { 18 | e.preventDefault() 19 | AppCircuit.dispatch(GetGQLItems()) 20 | } 21 | 22 | div( 23 | className := "card", 24 | div( 25 | className := "card-header", 26 | div( 27 | className := "row", 28 | div(className := "col", div("GraphQL showcase")) 29 | ) 30 | ), 31 | div( 32 | className := "card-body", 33 | form( 34 | className := "mb-2", 35 | onSubmit := (handleRefresh(_)), 36 | button(`type` := "submit", className := "btn btn-success", "refresh data") 37 | ), 38 | div( 39 | className := "row", 40 | div( 41 | className := "col-md", 42 | ItemsList() 43 | ), 44 | div( 45 | className := "col-md", 46 | ItemDetails() 47 | ) 48 | ) 49 | ) 50 | ) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /js/src/main/scala/example/modules/Home.scala: -------------------------------------------------------------------------------- 1 | package example.modules 2 | 3 | import slinky.core.annotations.react 4 | import slinky.core.FunctionalComponent 5 | import slinky.web.html._ 6 | 7 | @react object Home { 8 | type Props = Unit 9 | val component = FunctionalComponent[Props] { _ => 10 | div( 11 | className := "card", 12 | div(className := "card-header", "Home"), 13 | div( 14 | className := "card-body", 15 | h5( 16 | className := "card-title text-center mb-2", 17 | "Full stack app example with databases, api documentation and more." 18 | ), 19 | div( 20 | className := "row align-items-center", 21 | div(className := "col-12 col-sm-4", img(className := "img-fluid", src := "front-res/img/logos/docker.png")), 22 | div(className := "col-12 col-sm-4", img(className := "img-fluid", src := "front-res/img/logos/postgres.png")), 23 | div(className := "col-12 col-sm-4", img(className := "img-fluid", src := "front-res/img/logos/mongodb.png")) 24 | ), 25 | div( 26 | className := "row align-items-center", 27 | div(className := "col-12 col-sm-4", img(className := "img-fluid", src := "front-res/img/logos/scala-js.svg")), 28 | div(className := "col-12 col-sm-4", img(className := "img-fluid", src := "front-res/img/logos/react.png")), 29 | div(className := "col-12 col-sm-4", img(className := "img-fluid", src := "front-res/img/logos/bootstrap.png")) 30 | ) 31 | ) 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/model/GQLData.scala: -------------------------------------------------------------------------------- 1 | package example.model 2 | 3 | import caliban.GraphQL.graphQL 4 | import caliban.RootResolver 5 | 6 | object GQLData { 7 | case class Item(name: String, amount: Int, features: Vector[Feature] = Vector()) 8 | case class Feature(name: String, value: Int, description: String) 9 | 10 | def getItems = exampleItems 11 | def getItem(name: String): Option[Item] = exampleItems.find(_.name == name) 12 | 13 | val exampleItems = List( 14 | Item( 15 | name = "expensive laptop", 16 | amount = 100, 17 | features = Vector( 18 | Feature(name = "speed", value = 100, description = "very fast"), 19 | Feature(name = "color", value = 4, description = "blue") 20 | ) 21 | ), 22 | Item( 23 | name = "cheap laptop", 24 | amount = 130, 25 | features = Vector( 26 | Feature(name = "speed", value = 20, description = "fast enough"), 27 | Feature(name = "color", value = 1, description = "red") 28 | ) 29 | ), 30 | Item( 31 | name = "chair", 32 | amount = 4, 33 | features = Vector( 34 | Feature(name = "material", value = 47, description = "wood"), 35 | Feature(name = "pillows", value = 2, description = "soft chair") 36 | ) 37 | ) 38 | ) 39 | 40 | case class ItemArgs(name: String) 41 | case class Queries(items: List[Item], item: ItemArgs => Option[Item]) 42 | 43 | val queries = Queries(getItems, args => getItem(args.name)) 44 | val api = graphQL(RootResolver(queries)) 45 | } 46 | -------------------------------------------------------------------------------- /jvm/src/test/scala/example/endpoints/RestEndpointsTest.scala: -------------------------------------------------------------------------------- 1 | package example.endpoints 2 | 3 | import cats.implicits._ 4 | import zio._ 5 | import zio.interop.catz._ 6 | import zio.test._ 7 | import zio.test.Assertion._ 8 | import zio.test.environment.TestEnvironment 9 | import zio.test.environment.TestRandom 10 | 11 | import io.circe.generic.extras.auto._ 12 | import org.http4s._ 13 | import org.http4s.implicits._ 14 | 15 | import example.endpoints.RestEndpoints 16 | import example.Http4sTestHelper 17 | import example.modules.services.randomService.RandomService 18 | import example.shared.Dto._ 19 | 20 | object RestEndpointsTest extends DefaultRunnableSpec { 21 | type TestEnv = TestEnvironment with RandomService 22 | 23 | def spec = suite("RestEndpoints")( 24 | testM("GET /json/random") { 25 | val expected = Vector(Foo(3), Foo(5), Foo(7), Foo(11)) 26 | val req = Request[RIO[TestEnv, *]](Method.GET, uri"/json/random") 27 | 28 | def reqRandom() = 29 | for { 30 | resp <- RestEndpoints.routes[TestEnv].run(req).value 31 | parsedBody <- Http4sTestHelper.parseBody[TestEnv, Foo](resp) 32 | } yield parsedBody 33 | 34 | val randomTest = for { 35 | _ <- TestRandom.feedInts(expected.map(_.i): _*) 36 | responses <- (0 until expected.size).toVector 37 | .map(_ => reqRandom()) 38 | .sequence 39 | .map(_.flatten) 40 | } yield assert(responses)(equalTo(expected)) 41 | 42 | randomTest.provideLayer( 43 | TestEnvironment.any ++ RandomService.live 44 | ) 45 | } 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/endpoints/StaticEndpoints.scala: -------------------------------------------------------------------------------- 1 | package example.endpoints 2 | 3 | import cats.effect.Blocker 4 | import zio._ 5 | import zio.interop.catz._ 6 | 7 | import org.http4s.dsl.Http4sDsl 8 | import org.http4s.{HttpRoutes, Request, StaticFile} 9 | 10 | object StaticEndpoints { 11 | 12 | def routes[R](assetsPath: String, catsBlocker: Blocker, gqlSchema: String): HttpRoutes[RIO[R, *]] = { 13 | val dsl = Http4sDsl[RIO[R, *]] 14 | import dsl._ 15 | 16 | def static(file: String, catsBlocker: Blocker, request: Request[RIO[R, *]]) = 17 | StaticFile.fromResource("/" + file, catsBlocker, Some(request)).getOrElseF(NotFound()) 18 | 19 | def staticAssets(file: String, catsBlocker: Blocker, request: Request[RIO[R, *]]) = 20 | StaticFile.fromString(s"$assetsPath/$file", catsBlocker, Some(request)).getOrElseF(NotFound()) 21 | 22 | HttpRoutes.of[RIO[R, *]] { 23 | case request @ GET -> Root => 24 | static("index.html", catsBlocker, request) 25 | 26 | case request @ GET -> Root / path if List(".js", ".css", ".map", ".html", ".ico").exists(path.endsWith) => 27 | static(path, catsBlocker, request) 28 | 29 | case request @ GET -> "front-res" /: path => 30 | val fullPath = "front-res/" + path.toList.mkString("/") 31 | static(fullPath, catsBlocker, request) 32 | 33 | case request @ GET -> "assets" /: path => 34 | val fullPath = path.toList.mkString("/") 35 | staticAssets(fullPath, catsBlocker, request) 36 | 37 | case GET -> Root / "api" / "schema.graphql" => 38 | Ok(gqlSchema) 39 | } 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /js/src/main/scala/example/services/Validator.scala: -------------------------------------------------------------------------------- 1 | package example.services 2 | 3 | import cats.implicits._ 4 | import cats.kernel.Eq 5 | 6 | object Validator { 7 | object Names { 8 | val username = "username" 9 | val password = "password" 10 | val rePassword = "repeat password" 11 | } 12 | 13 | def nonBlank(name: String, value: String): Either[Vector[String], String] = 14 | value.asRight.ensure(Vector(s"$name cannot be blank"))(_.trim.nonEmpty) 15 | 16 | def same[T: Eq](t1Name: String, t1: T, t2Name: String, t2: T): Either[Vector[String], T] = 17 | t1.asRight.ensure(Vector(s"$t1Name must be same as $t2Name"))(_ === t2) 18 | 19 | def validateTryAuth(username: String, password: String) = 20 | ( 21 | nonBlank(Names.username, username).toValidated, 22 | nonBlank(Names.password, password).toValidated 23 | ).mapN(TryAuth(_, _)) 24 | 25 | def validatePassword(password: String, rePassword: String): Either[Vector[String], String] = { 26 | val baseValidation = ( 27 | nonBlank(Names.password, password).toValidated, 28 | nonBlank(Names.rePassword, rePassword).toValidated 29 | ).mapN((_, _)).toEither 30 | 31 | for { 32 | tup <- baseValidation 33 | (p, reP) = tup 34 | finalP <- same( 35 | Names.password, 36 | p, 37 | Names.rePassword, 38 | reP 39 | ) 40 | } yield finalP 41 | } 42 | 43 | def validateTryRegister(username: String, password: String, rePassword: String) = 44 | ( 45 | nonBlank(Names.username, username).toValidated, 46 | validatePassword(password, rePassword).toValidated 47 | ).mapN(TryRegister(_, _)) 48 | } 49 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/modules/db/MongoConn.scala: -------------------------------------------------------------------------------- 1 | package example.modules.db 2 | 3 | import example.modules.appConfig.AppConfig 4 | import example.modules.AppConfigData 5 | import reactivemongo.api.AsyncDriver 6 | import reactivemongo.api.DB 7 | import reactivemongo.api.FailoverStrategy 8 | import reactivemongo.api.MongoConnection 9 | import scala.concurrent.duration._ 10 | import zio._ 11 | 12 | object MongoConn { 13 | case class MongoConn(conn: MongoConnection, defaultDb: DB) 14 | 15 | def createDriver() = ZIO.effect(new AsyncDriver) 16 | def makeConnection(mongoUri: String) = 17 | for { 18 | driver <- createDriver() 19 | mongoConnection <- ZIO.fromFuture(_ => driver.connect(mongoUri)) 20 | } yield mongoConnection 21 | 22 | def loadDefaultDb(mongoConnection: MongoConnection, appConfigData: AppConfigData): ZIO[Any, Throwable, DB] = 23 | for { 24 | uri <- ZIO.fromFuture(implicit ec => MongoConnection.fromString(appConfigData.mongo.uri)) 25 | dbName <- ZIO.fromOption(uri.db).mapError(_ => new Exception("Can't read default db name")) 26 | defaultDb <- ZIO.fromFuture(implicit ec => mongoConnection.database(dbName, FailoverStrategy(retries = 20))) // (retries = 20) == 32 seconds 27 | } yield defaultDb 28 | 29 | val live: ZLayer[AppConfig, Throwable, Has[MongoConn]] = ZLayer.fromFunctionManaged { appCfg => 30 | Managed.make( 31 | for { 32 | cfg <- appCfg.get.load 33 | conn <- makeConnection(cfg.mongo.uri) 34 | defaultDb <- loadDefaultDb(conn, cfg) 35 | } yield MongoConn(conn, defaultDb) 36 | )(mConn => UIO(mConn.conn.close()(5.second))) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /js/src/main/scala/example/services/handlers/AuthHandler.scala: -------------------------------------------------------------------------------- 1 | package example.services.handlers 2 | 3 | import diode.Action 4 | import diode.data.Empty 5 | import diode.data.Pot 6 | import diode.data.PotAction 7 | import diode.NoAction 8 | import diode.{ActionHandler, ModelRW} 9 | import example.services.AjaxClient 10 | import example.services.Auth 11 | import example.services.SetGlobalName 12 | import example.services.SignOut 13 | import example.services.TryAuth 14 | import example.services.TryRegister 15 | import example.shared.Dto.AuthCredentials 16 | 17 | class AuthHandler[M](modelRW: ModelRW[M, Pot[Auth]]) extends ActionHandler(modelRW) { 18 | import scala.concurrent.ExecutionContext.Implicits.global 19 | 20 | override def handle = { 21 | case action: TryAuth => 22 | val cred = AuthCredentials(action.username, action.passwd) 23 | val updateF = action.effect(AjaxClient.postAuth(cred))(u => Auth(u.name, u.token)) 24 | 25 | val onReady: PotAction[Auth, TryAuth] => Action = 26 | _.potResult.fold(NoAction: Action)(auth => SetGlobalName(auth.username)) 27 | action.handleWith(this, updateF)(GenericHandlers.withOnReady(onReady)) 28 | 29 | case SignOut => 30 | updated(Empty) 31 | 32 | case action: TryRegister => 33 | val cred = AuthCredentials(action.username, action.passwd) 34 | val updateF = action.effect(AjaxClient.postAuthUser(cred))(u => Auth(u.name, u.token)) 35 | 36 | val onReady: PotAction[Auth, TryRegister] => Action = 37 | _.potResult.fold(NoAction: Action)(auth => SetGlobalName(auth.username)) 38 | action.handleWith(this, updateF)(GenericHandlers.withOnReady(onReady)) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/endpoints/RestEndpoints.scala: -------------------------------------------------------------------------------- 1 | package example.endpoints 2 | 3 | import cats.implicits._ 4 | import zio._ 5 | import zio.interop.catz._ 6 | 7 | import io.circe.generic.extras.auto._ 8 | import org.http4s.HttpRoutes 9 | import sttp.tapir._ 10 | import sttp.tapir.json.circe._ 11 | import sttp.tapir.server.http4s._ 12 | 13 | import example.modules.services.randomService.getRandom 14 | import example.modules.services.randomService.RandomService 15 | import example.shared.Dto._ 16 | 17 | object RestEndpoints { 18 | val getUserEndpoint = endpoint.get 19 | .in("json" / "random") 20 | .out(jsonBody[Foo]) 21 | 22 | val getHelloEndpoint = endpoint.get 23 | .in("hello") 24 | .out(jsonBody[String]) 25 | 26 | val echoEndpoint = endpoint.get 27 | .in("echo" / path[String]("echo text").example("hi!")) 28 | .out(stringBody) 29 | 30 | def endpoints = List( 31 | getUserEndpoint, 32 | getHelloEndpoint, 33 | echoEndpoint 34 | ) 35 | 36 | def getUserRoute[R <: RandomService]: HttpRoutes[RIO[R, *]] = getUserEndpoint.toRoutes { _ => 37 | for { 38 | randomNumber <- getRandom 39 | response = Foo(randomNumber) 40 | } yield response.asRight[Unit] 41 | } 42 | 43 | def getHelloRoute[R <: RandomService]: HttpRoutes[RIO[R, *]] = getHelloEndpoint.toRoutes { _ => 44 | ZIO.succeed("Hello, World!".asRight[Unit]) 45 | } 46 | 47 | def echoRoute[R <: RandomService]: HttpRoutes[RIO[R, *]] = echoEndpoint.toRoutes { value => 48 | ZIO.succeed(value.asRight[Unit]) 49 | } 50 | 51 | def routes[R <: RandomService]: HttpRoutes[RIO[R, *]] = 52 | getUserRoute[R] <+> 53 | getHelloRoute <+> 54 | echoRoute 55 | } 56 | -------------------------------------------------------------------------------- /js/src/main/scala/example/services/handlers/ScoreboardHandler.scala: -------------------------------------------------------------------------------- 1 | package example.services.handlers 2 | 3 | import diode.data.Pot 4 | import diode.data.PotAction 5 | import diode.data.Ready 6 | import diode.Effect 7 | import diode.{ActionHandler, ModelRW} 8 | import example.services.AddNewScore 9 | import example.services.AjaxClient 10 | import example.services.ClearScoreboard 11 | import example.services.ScoreAdded 12 | import example.services.ScoreboardCleared 13 | import example.services.TryGetScoreboard 14 | import example.shared.Dto.ScoreboardRecord 15 | 16 | class ScoreboardHandler[M](modelRW: ModelRW[M, Pot[Vector[ScoreboardRecord]]]) extends ActionHandler(modelRW) { 17 | import scala.concurrent.ExecutionContext.Implicits.global 18 | override def handle = { 19 | case action: TryGetScoreboard => 20 | val updateF = action.effect(AjaxClient.getScoreboard)(identity _) 21 | action.handleWith(this, updateF)(PotAction.handler()) 22 | 23 | case AddNewScore(newScore) => 24 | val addEffect = Effect(AjaxClient.postScore(newScore).map(ScoreAdded)) 25 | effectOnly(addEffect) 26 | case ScoreAdded(newScore) => 27 | val newValue = value.fold(value)(scores => 28 | Ready( 29 | (scores :+ newScore) 30 | .sortBy(_.name) 31 | .sortBy(_.score) 32 | .reverse 33 | ) 34 | ) 35 | updated(newValue) 36 | 37 | case ClearScoreboard => 38 | val deleteEffect = Effect(AjaxClient.deleteAllScores().map(_ => ScoreboardCleared)) 39 | effectOnly(deleteEffect) 40 | case ScoreboardCleared => 41 | val newValue = value.fold(value)(_ => Ready(Vector())) 42 | updated(newValue) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /js/src/main/scala/example/components/GlobalName.scala: -------------------------------------------------------------------------------- 1 | package example.components 2 | 3 | import cats.implicits._ 4 | import org.scalajs.dom.{html, Event} 5 | import slinky.core.annotations.react 6 | import slinky.core.facade.Hooks._ 7 | import slinky.core.FunctionalComponent 8 | import slinky.core.SyntheticEvent 9 | import slinky.web.html._ 10 | import example.services.ReactDiode 11 | import example.services.AppCircuit 12 | import example.services.SetGlobalName 13 | 14 | @react object GlobalName { 15 | type Props = Unit 16 | 17 | val component = FunctionalComponent[Props] { _ => 18 | val (name, setName) = useState("unknown") 19 | val (globalName, dispatch) = ReactDiode.useDiode(AppCircuit.zoomTo(_.globalName)) 20 | 21 | useEffect(() => setName(globalName), Seq()) 22 | 23 | def onChangeName(e: SyntheticEvent[html.Input, Event]): Unit = setName(e.target.value) 24 | 25 | def handleSend(e: SyntheticEvent[html.Form, Event]): Unit = { 26 | e.preventDefault() 27 | if (name.trim.nonEmpty) 28 | dispatch(SetGlobalName(name)) 29 | else 30 | setName(globalName) 31 | } 32 | 33 | form( 34 | onSubmit := (handleSend(_)), 35 | div( 36 | className := "input-group mb-1", 37 | div(className := "input-group-prepend", span(className := "input-group-text", "Username:")), 38 | input(className := "form-control", value := name, onChange := (onChangeName(_))), 39 | div( 40 | className := "input-group-append", 41 | button( 42 | `type` := "submit", 43 | className := "btn btn-success", 44 | disabled := name == globalName, 45 | "accept" 46 | ) 47 | ) 48 | ) 49 | ) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /shared/src/main/scala/example/shared/Dto.scala: -------------------------------------------------------------------------------- 1 | package example.shared 2 | 3 | object Dto { 4 | sealed trait Event 5 | case class Foo(i: Int) extends Event 6 | case class Bar(s: String) extends Event 7 | case class Baz(c: Char) extends Event 8 | case class Qux(values: List[String]) extends Event 9 | 10 | sealed trait TodoStatus 11 | case object Done extends TodoStatus 12 | case object Pending extends TodoStatus 13 | case class TodoTask(id: Option[String] = None, value: String = "todo value", status: TodoStatus = Pending) 14 | 15 | case class ScoreboardRecord(id: Option[Long] = None, name: String = "foo", score: Int = 0) 16 | 17 | case class AuthCredentials(name: String, password: String) 18 | type Token = String 19 | case class User(id: Long, name: String, token: Token) 20 | 21 | sealed trait ChatDto 22 | case class ChatUser(id: Int = 0, name: String = "unknown") extends ChatDto 23 | case class ChatUsers(value: Set[ChatUser] = Set()) extends ChatDto 24 | 25 | sealed trait ClientMsg extends ChatDto 26 | case class ChatMsg(user: Option[ChatUser] = None, msg: String) extends ClientMsg 27 | case class ChangeChatName(oldUser: Option[ChatUser] = None, newName: String) extends ClientMsg 28 | case class UnknownData(data: String) extends ClientMsg 29 | 30 | sealed trait ServerMsg extends ChatDto 31 | case class NewChatUser(u: ChatUser) extends ServerMsg 32 | case class ChatUserLeft(u: ChatUser) extends ServerMsg 33 | 34 | import io.circe.generic.extras.Configuration 35 | implicit val circeConfig = Configuration.default.withDiscriminator("eventType").withDefaults 36 | } 37 | -------------------------------------------------------------------------------- /js/src/main/scala/example/components/TodoLi.scala: -------------------------------------------------------------------------------- 1 | package example.components 2 | 3 | import slinky.core.annotations.react 4 | import slinky.web.html._ 5 | import slinky.core.FunctionalComponent 6 | import example.shared.Dto.TodoTask 7 | import example.shared.Dto.Done 8 | import example.shared.Dto.Pending 9 | import example.services.ReactDiode 10 | import example.services.AppCircuit 11 | import example.services.SwitchTodoStatus 12 | 13 | @react object TodoLi { 14 | case class Props(todoTask: TodoTask, onDelete: () => Unit) 15 | 16 | val component = FunctionalComponent[Props] { props => 17 | val (_, dispatch) = ReactDiode.useDiode(AppCircuit.zoom(identity)) 18 | 19 | val onSwitchStatus = () => dispatch(SwitchTodoStatus(props.todoTask.id.getOrElse(""))) 20 | 21 | li( 22 | className := "list-group-item", 23 | div( 24 | className := "row align-items-center", 25 | div(className := "col-sm col-md-8", props.todoTask.status match { 26 | case Done => s(props.todoTask.value) 27 | case Pending => props.todoTask.value 28 | }), 29 | div( 30 | className := "col-sm col-md-4 text-right", 31 | props.todoTask.status match { 32 | case Done => 33 | button(className := "btn btn-warning", i(className := "fas fa-backspace"), onClick := onSwitchStatus) 34 | case Pending => 35 | button(className := "btn btn-success", i(className := "fas fa-check-circle"), onClick := onSwitchStatus) 36 | }, 37 | button( 38 | className := "btn btn-danger", 39 | data - "toggle" := "modal", 40 | data - "target" := "#deleteModal", 41 | onClick := props.onDelete, 42 | i(className := "fas fa-trash") 43 | ) 44 | ) 45 | ) 46 | ) 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /js/src/main/scala/example/services/GraphQLClient.scala: -------------------------------------------------------------------------------- 1 | package example.services 2 | 3 | import caliban.client.CalibanClientError 4 | import caliban.client.Operations 5 | import caliban.client.SelectionBuilder 6 | import GraphQLClientData._ 7 | import org.scalajs.dom 8 | import scala.concurrent.ExecutionContext.Implicits.global 9 | import scala.concurrent.Future 10 | import scala.scalajs.LinkingInfo 11 | import sttp.client3._ 12 | 13 | object GraphQLClient { 14 | lazy val sttpBackend = FetchBackend() 15 | val baseUrl = if (LinkingInfo.developmentMode) "http://localhost:8080" else dom.window.location.origin 16 | val uri = uri"$baseUrl/api/graphql" 17 | 18 | case class ItemBaseView(name: String) 19 | case class ItemFullView(name: String, amount: Int, features: List[FeatureFullView]) 20 | case class FeatureFullView(name: String, value: Int, description: String) 21 | 22 | def getItemQuery(name: String) = Queries.item(name) { 23 | (Item.name 24 | ~ Item.amount 25 | ~ Item.features { 26 | ( 27 | Feature.name 28 | ~ Feature.value 29 | ~ Feature.description 30 | ).mapN(FeatureFullView) 31 | }).mapN(ItemFullView) 32 | } 33 | 34 | val getItemsQuery = Queries.items { 35 | Item.name.map(ItemBaseView) 36 | } 37 | 38 | def getItems() = runRequest(getItemsQuery) 39 | def getItem(name: String) = runRequest(getItemQuery(name)) 40 | 41 | private def runRequest[A](query: SelectionBuilder[Operations.RootQuery, A]) = 42 | sttpBackend 43 | .send(query.toRequest(uri)) 44 | .map(_.body) 45 | .flatMap(handleError) 46 | 47 | private def handleError[A](value: Either[CalibanClientError, A]): Future[A] = value match { 48 | case Left(error) => Future.failed(error) 49 | case Right(succ) => Future.successful(succ) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /js/src/main/scala/example/services/handlers/ChatHandler.scala: -------------------------------------------------------------------------------- 1 | package example.services.handlers 2 | 3 | import cats.implicits._ 4 | import com.softwaremill.quicklens._ 5 | import diode.ActionHandler 6 | import diode.ModelRW 7 | import example.services.AddNewMsg 8 | import example.services.AddUser 9 | import example.services.ChangeMyChatName 10 | import example.services.ChangeUser 11 | import example.services.ChatConnection 12 | import example.services.ChatWebsock 13 | import example.services.InitChatUsers 14 | import example.services.RemoveUser 15 | import example.shared.Dto 16 | import example.shared.Dto.ChangeChatName 17 | import scala.concurrent.ExecutionContext.Implicits.global 18 | 19 | class ChatHandler[M](modelRW: ModelRW[M, ChatConnection]) extends ActionHandler(modelRW) { 20 | 21 | override def handle = { 22 | case InitChatUsers(users) => 23 | val newValue = value.modify(_.users).setTo(users) 24 | updated(newValue) 25 | 26 | case AddUser(ncu) => 27 | val newValue = value.modify(_.users.value).using(_ + ncu.u) 28 | updated(newValue) 29 | 30 | case RemoveUser(cul) => 31 | val newValue = value.modify(_.users.value).using(_ - cul.u) 32 | updated(newValue) 33 | 34 | case AddNewMsg(msg) => 35 | val newValue = value.modify(_.msgs).using(_ :+ msg) 36 | updated(newValue) 37 | 38 | case ChangeMyChatName(newName) => 39 | value.ws.fold(noChange) { ws => 40 | val data = ChangeChatName(newName = newName) 41 | effectOnly(ChatWebsock.sendAsEffect(ws, data)) 42 | } 43 | 44 | case ChangeUser(ChangeChatName(oldUser, newName)) => 45 | val userPred: Dto.ChatUser => Boolean = _.some == oldUser 46 | val newValue = value 47 | .modify( 48 | _.users.value.eachWhere(userPred).name 49 | ) 50 | .setTo(newName) 51 | updated(newValue) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /js/src/main/scala/example/modules/About.scala: -------------------------------------------------------------------------------- 1 | package example.modules 2 | import slinky.core.annotations.react 3 | import slinky.core.FunctionalComponent 4 | import slinky.web.html._ 5 | 6 | @react object About { 7 | type Props = Unit 8 | 9 | val component = FunctionalComponent[Props] { _ => 10 | div( 11 | className := "card", 12 | div(className := "card-header", "About"), 13 | div( 14 | className := "card-body", 15 | table( 16 | className := "table table-striped", 17 | tbody( 18 | tr( 19 | td("author"), 20 | td("oen") 21 | ), 22 | tr( 23 | td("github"), 24 | td( 25 | a( 26 | target := "_blank", 27 | href := "https://github.com/oen9/full-stack-zio", 28 | "https://github.com/oen9/full-stack-zio" 29 | ) 30 | ) 31 | ), 32 | tr( 33 | td("heroku"), 34 | td( 35 | a( 36 | target := "_blank", 37 | href := "https://full-stack-zio.herokuapp.com", 38 | "https://full-stack-zio.herokuapp.com" 39 | ) 40 | ) 41 | ), 42 | tr( 43 | td("api documentation (swagger)"), 44 | td( 45 | a( 46 | target := "_blank", 47 | href := "https://full-stack-zio.herokuapp.com/docs/index.html?url=/docs/docs.yaml", 48 | "https://full-stack-zio.herokuapp.com/docs/index.html?url=/docs/docs.yaml" 49 | ) 50 | ) 51 | ), 52 | tr( 53 | td("use"), 54 | td("do whatever you want!") 55 | ) 56 | ) 57 | ) 58 | ) 59 | ) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /js/src/main/scala/example/modules/flappybird/Ground.scala: -------------------------------------------------------------------------------- 1 | package example.modules.flappybird 2 | 3 | import com.github.oen9.slinky.bridge.konva.KonvaHelper 4 | import com.github.oen9.slinky.bridge.reactkonva.Group 5 | import com.github.oen9.slinky.bridge.reactkonva.Image 6 | import com.github.oen9.slinky.bridge.reactkonva.Rect 7 | import com.github.oen9.slinky.bridge.useimage.UseImage._ 8 | import slinky.core.annotations.react 9 | import slinky.core.facade.Hooks._ 10 | import slinky.core.FunctionalComponent 11 | 12 | @react object Ground { 13 | case class Props(groundShift: Int, debug: Boolean = false) 14 | 15 | val groundWidth = 18 16 | val groundNum = (GameLogic.width / groundWidth.toDouble).ceil.toInt 17 | 18 | val component = FunctionalComponent[Props] { props => 19 | val (groundImg, _) = useImage("front-res/img/flappy/ground.png") 20 | val (debugRect, setDebugRect) = useState(KonvaHelper.IRect()) 21 | 22 | useLayoutEffect( 23 | () => 24 | if (props.debug) { 25 | val rect = KonvaHelper.IRect( 26 | x = 0, 27 | y = GameLogic.groundY, 28 | width = GameLogic.width, 29 | height = GameLogic.height 30 | ) 31 | setDebugRect(rect) 32 | }, 33 | Seq(props) 34 | ) 35 | 36 | Group( 37 | (0 to groundNum).map { i => 38 | Image( 39 | image = groundImg, 40 | x = i * groundWidth + props.groundShift, 41 | y = GameLogic.groundY, 42 | scaleX = 0.5, 43 | scaleY = 0.5 44 | ).withKey(i.toString()) 45 | }, 46 | if (props.debug) { 47 | Rect( 48 | x = debugRect.x.toInt, 49 | y = debugRect.y.toInt, 50 | width = debugRect.width.toInt, 51 | height = debugRect.height.toInt, 52 | stroke = "brown" 53 | ) 54 | } else Group() 55 | ) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/modules/db/FlywayHandler.scala: -------------------------------------------------------------------------------- 1 | package example.modules.db 2 | 3 | import example.modules.appConfig.AppConfig 4 | import org.flywaydb.core.Flyway 5 | import zio._ 6 | 7 | object flywayHandler { 8 | type FlywayHandler = Has[FlywayHandler.Service] 9 | 10 | object FlywayHandler { 11 | trait Service { 12 | def initDb: Task[Unit] 13 | } 14 | 15 | val live: ZLayer[AppConfig, Throwable, FlywayHandler] = ZLayer.fromFunction { appCfg => 16 | new FlywayHandler.Service { 17 | def initDb: Task[Unit] = 18 | for { 19 | cfgData <- appCfg.get.load 20 | _ <- Task.effect { 21 | Flyway 22 | .configure() 23 | .dataSource( 24 | cfgData.sqldb.url, 25 | cfgData.sqldb.username, 26 | cfgData.sqldb.password 27 | ) 28 | .load() 29 | .migrate() 30 | } 31 | } yield () 32 | } 33 | } 34 | 35 | def test(locations: Seq[String] = Seq()): ZLayer[AppConfig, Throwable, FlywayHandler] = ZLayer.fromFunction { 36 | appCfg => 37 | new FlywayHandler.Service { 38 | def initDb: Task[Unit] = 39 | for { 40 | cfgData <- appCfg.get.load 41 | _ <- Task.effect { 42 | val flyway = Flyway 43 | .configure() 44 | .locations(locations: _*) 45 | .dataSource( 46 | cfgData.sqldb.url, 47 | cfgData.sqldb.username, 48 | cfgData.sqldb.password 49 | ) 50 | .load() 51 | flyway.clean() 52 | flyway.migrate() 53 | } 54 | } yield () 55 | } 56 | } 57 | 58 | } 59 | 60 | def initDb: ZIO[FlywayHandler, Throwable, Unit] = 61 | ZIO.accessM[FlywayHandler](_.get.initDb) 62 | } 63 | -------------------------------------------------------------------------------- /js/src/main/scala/example/modules/Secured.scala: -------------------------------------------------------------------------------- 1 | package example.modules 2 | 3 | import cats.implicits._ 4 | import diode.data.PotState.PotFailed 5 | import diode.data.PotState.PotPending 6 | import diode.data.PotState.PotReady 7 | import example.services.AppCircuit 8 | import example.services.ReactDiode 9 | import example.services.TryGetSecuredText 10 | import slinky.core.annotations.react 11 | import slinky.core.facade.Hooks._ 12 | import slinky.core.facade.ReactElement 13 | import slinky.core.FunctionalComponent 14 | import slinky.web.html._ 15 | 16 | @react object Secured { 17 | type Props = Unit 18 | 19 | val component = FunctionalComponent[Props] { _ => 20 | val (auth, dispatch) = ReactDiode.useDiode(AppCircuit.zoom(_.auth)) 21 | val (secretText, _) = ReactDiode.useDiode(AppCircuit.zoom(_.securedText)) 22 | 23 | useEffect( 24 | () => 25 | auth.state match { 26 | case PotReady => dispatch(TryGetSecuredText(auth.get.token)) 27 | case _ => () 28 | }, 29 | Seq() 30 | ) 31 | 32 | div( 33 | className := "card", 34 | div(className := "card-header", "Secured"), 35 | div( 36 | className := "card-body", 37 | div("This page is available only after signing in."), 38 | div( 39 | secretText.state match { 40 | case PotPending => 41 | div( 42 | className := "spinner-border text-primary", 43 | role := "status", 44 | span(className := "sr-only", "Loading...") 45 | ).some 46 | case PotFailed => 47 | div( 48 | secretText.exceptionOption 49 | .fold("unknown error")(msg => s"error: ${msg.getMessage()}") 50 | ) 51 | case PotReady => 52 | secretText.fold("unknown error")(s => s"Secured text: $s").some 53 | case _ => none[ReactElement] 54 | } 55 | ) 56 | ) 57 | ) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /js/src/main/scala/example/components/chat/ChatView.scala: -------------------------------------------------------------------------------- 1 | package example.components.chat 2 | 3 | import example.services.AppCircuit 4 | import example.services.ReactDiode 5 | import org.scalajs.dom.html 6 | import slinky.core.annotations.react 7 | import slinky.core.facade.Hooks._ 8 | import slinky.core.facade.React 9 | import slinky.core.FunctionalComponent 10 | import slinky.web.html._ 11 | import example.shared.Dto 12 | 13 | @react object ChatView { 14 | case class Props(autoscroll: Boolean = true) 15 | 16 | val component = FunctionalComponent[Props] { props => 17 | val (msgs, _) = ReactDiode.useDiode(AppCircuit.zoomTo(_.chatConn.msgs)) 18 | val (me, _) = ReactDiode.useDiode(AppCircuit.zoomTo(_.chatConn.user)) 19 | 20 | val chatRef = React.createRef[html.Div] 21 | 22 | useLayoutEffect( 23 | () => 24 | if (props.autoscroll) { 25 | val chatDiv = chatRef.current 26 | chatDiv.scrollTop = chatDiv.scrollHeight 27 | }, 28 | Seq(msgs, props.autoscroll) 29 | ) 30 | 31 | def prettyMsg(m: Dto.ChatMsg) = { 32 | val color = m match { 33 | case Dto.ChatMsg(Some(u), msg) if u.id == me.id => "primary" 34 | case Dto.ChatMsg(None, msg) => "warning" 35 | case _ => "secondary" 36 | } 37 | 38 | val formattedMsg = m match { 39 | case Dto.ChatMsg(Some(u), msg) => 40 | div( 41 | span(u.name), 42 | span(className := s"ml-1 badge badge-$color", u.id), 43 | span(className := s"ml-1", msg) 44 | ) 45 | case Dto.ChatMsg(None, msg) => div(msg) 46 | } 47 | 48 | div( 49 | className := s"alert alert-$color", 50 | formattedMsg 51 | ) 52 | } 53 | 54 | div( 55 | className := "vh-50 overflow-auto mb-3 bg-light", 56 | ref := chatRef, 57 | msgs.zipWithIndex.map { 58 | case (msg, idx) => 59 | div(key := idx.toString, prettyMsg(msg)) 60 | } 61 | ) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | 4 | web: 5 | image: oracle/graalvm-ce:20.0.0-java11 6 | ports: 7 | - 8080:8080 8 | - 8000:8000 9 | env_file: 10 | - /etc/environment 11 | environment: 12 | MONGO_URL_FULL_STACK_ZIO: mongodb://root:secret@mongo:27017/admin 13 | DATABASE_URL_FULL_STACK_ZIO: jdbc:postgresql://postgres:5432/fullstackzio?user=root&password=secret 14 | JAVA_OPTS: "-Xdebug -Xrunjdwp:server=y,transport=dt_socket,suspend=n,address=8000" 15 | links: 16 | - mongo 17 | - postgres 18 | depends_on: 19 | - mongo 20 | - postgres 21 | volumes: 22 | - ./target/universal/stage/:/home/full-stack-zio 23 | command: /home/full-stack-zio/bin/app 24 | 25 | mongo: 26 | image: mongo:3.6 27 | restart: always 28 | command: --smallfiles 29 | environment: 30 | MONGO_INITDB_ROOT_USERNAME: root 31 | MONGO_INITDB_ROOT_PASSWORD: secret 32 | volumes: 33 | - ./cache/mongodata:/data/db 34 | ports: 35 | - 27017:27017 36 | 37 | mongo-express: 38 | image: mongo-express 39 | restart: always 40 | ports: 41 | - 8081:8081 42 | environment: 43 | ME_CONFIG_MONGODB_ADMINUSERNAME: root 44 | ME_CONFIG_MONGODB_ADMINPASSWORD: secret 45 | depends_on: 46 | - mongo 47 | 48 | # docker-compose run postgres bash 49 | # psql -h postgres -d fullstackzio -U root 50 | postgres: 51 | image: postgres:12.2 52 | ports: 53 | - "5432:5432" 54 | environment: 55 | POSTGRES_DB: fullstackzio 56 | POSTGRES_USER: root 57 | POSTGRES_PASSWORD: secret 58 | volumes: 59 | - ./cache/postgres:/var/lib/postgresql/data 60 | 61 | # mkdir -p cache/pgadmin 62 | # chown -R 5050:5050 cache/pgadmin 63 | pgadmin: 64 | image: dpage/pgadmin4:latest 65 | ports: 66 | - "8082:80" 67 | depends_on: 68 | - postgres 69 | environment: 70 | PGADMIN_DEFAULT_EMAIL: root@root.com 71 | PGADMIN_DEFAULT_PASSWORD: secret 72 | volumes: 73 | - ./cache/pgadmin:/var/lib/pgadmin 74 | -------------------------------------------------------------------------------- /js/src/main/scala/example/components/DeleteDialog.scala: -------------------------------------------------------------------------------- 1 | package example.components 2 | 3 | import slinky.core.annotations.react 4 | import slinky.web.html._ 5 | import slinky.core.FunctionalComponent 6 | import slinky.core.facade.ReactElement 7 | 8 | @react object DeleteDialog { 9 | case class Props( 10 | onDelete: () => Unit = () => (), 11 | onCancel: () => Unit = () => (), 12 | content: ReactElement = div() 13 | ) 14 | 15 | val component = FunctionalComponent[Props] { props => 16 | div( 17 | className := "modal fade", 18 | id := "deleteModal", 19 | data - "backdrop" := "static", 20 | tabIndex := -1, 21 | role := "dialog", 22 | aria - "labelledby" := "deleteModalCenterTitle", 23 | aria - "hidden" := "true", 24 | div( 25 | className := "modal-dialog modal-dialog-centered", 26 | role := "document", 27 | div( 28 | className := "modal-content", 29 | div( 30 | className := "modal-header", 31 | h5(className := "modal-title", id := "deleteModalCenterTitle", s"Delete"), 32 | button( 33 | `type` := "button", 34 | className := "close", 35 | data - "dismiss" := "modal", 36 | aria - "label" := "Close", 37 | span(aria - "hidden" := "true", "×", onClick := props.onCancel) 38 | ) 39 | ), 40 | div(className := "modal-body", props.content), 41 | div( 42 | className := "modal-footer", 43 | button( 44 | `type` := "button", 45 | className := "btn btn-secondary", 46 | data - "dismiss" := "modal", 47 | onClick := props.onCancel, 48 | "cancel" 49 | ), 50 | button( 51 | `type` := "button", 52 | className := "btn btn-danger", 53 | data - "dismiss" := "modal", 54 | "delete", 55 | onClick := props.onDelete 56 | ) 57 | ) 58 | ) 59 | ) 60 | ) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/modules/AppConfig.scala: -------------------------------------------------------------------------------- 1 | package example.modules 2 | 3 | import com.softwaremill.quicklens._ 4 | import pureconfig.ConfigSource 5 | import pureconfig.generic.auto._ 6 | import zio._ 7 | 8 | case class Http(port: Int, host: String) 9 | case class Mongo(uri: String) 10 | case class SQLDB(url: String, driver: String, username: String = "", password: String = "") 11 | case class Encryption(salt: String, bcryptLogRounds: Int) 12 | case class AppConfigData(http: Http, mongo: Mongo, sqldb: SQLDB, encryption: Encryption, assets: String) 13 | 14 | object appConfig { 15 | type AppConfig = Has[AppConfig.Service] 16 | 17 | object AppConfig { 18 | trait Service { 19 | def load: Task[AppConfigData] 20 | } 21 | 22 | val live: Layer[Nothing, AppConfig] = ZLayer.succeed(new Service { 23 | def load: ZIO[Any, Throwable, AppConfigData] = Task.effect(ConfigSource.default.loadOrThrow[AppConfigData]) 24 | }) 25 | 26 | // actually we want to use application.conf from test so `live` for tests is better 27 | val test: Layer[Nothing, AppConfig] = ZLayer.succeed(new Service { 28 | def load: ZIO[Any, Throwable, AppConfigData] = 29 | ZIO.effectTotal( 30 | AppConfigData( 31 | Http(8080, "localhost"), 32 | Mongo("mongo://test:test@localhost/test"), 33 | SQLDB(url = "postgres://test:test@localhost:5432/test", driver = "postgres"), 34 | Encryption(salt = "super-secret", bcryptLogRounds = 10), 35 | "/tmp" 36 | ) 37 | ) 38 | }) 39 | 40 | def test(h2DbName: String = "test"): Layer[Nothing, AppConfig] = 41 | live >>> ZLayer.fromService[AppConfig.Service, AppConfig.Service](appConfig => 42 | new Service { 43 | def load: zio.Task[AppConfigData] = 44 | appConfig.load.map( 45 | _.modify(_.sqldb.url).setTo( 46 | s"jdbc:h2:mem:$h2DbName;DB_CLOSE_DELAY=-1" 47 | ) 48 | ) 49 | } 50 | ) 51 | } 52 | 53 | def load: ZIO[AppConfig, Throwable, AppConfigData] = 54 | ZIO.accessM[AppConfig](_.get.load) 55 | 56 | } 57 | -------------------------------------------------------------------------------- /jvm/src/test/scala/example/TestEnvs.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import example.model.MongoData.TodoTask 4 | import example.modules.appConfig.AppConfig 5 | import example.modules.db.doobieTransactor 6 | import example.modules.db.flywayHandler 7 | import example.modules.db.scoreboardRepository 8 | import example.modules.db.todoRepository 9 | import example.modules.db.userRepository 10 | import example.modules.services.auth.authService 11 | import example.modules.services.cryptoService.CryptoService 12 | import example.modules.services.scoreboardService 13 | import example.modules.services.todoService 14 | import zio.blocking.Blocking 15 | import zio.logging.Logging 16 | 17 | object TestEnvs { 18 | sealed trait SqlInit 19 | case object SqlEmpty extends SqlInit 20 | case object SqlFull extends SqlInit 21 | 22 | val appConf = AppConfig.live 23 | val logging = Logging.ignore 24 | val cryptoServ = CryptoService.test 25 | 26 | def appConf(h2DbName: String = "test") = AppConfig.test(h2DbName) 27 | 28 | def testDoobieTransactor(initdb: SqlInit, h2DbName: String) = { 29 | val migrations = initdb match { 30 | case SqlEmpty => Seq("db/migration") 31 | case SqlFull => Seq("db/migration", "db/fulldb") 32 | } 33 | val testAppConf = appConf(h2DbName) 34 | val flyway = testAppConf >>> flywayHandler.FlywayHandler.test(migrations) 35 | (Blocking.any ++ testAppConf ++ flyway) >>> doobieTransactor.live 36 | } 37 | 38 | def testScoreboardService(initdb: SqlInit, h2DbName: String = "scoreboardDB") = { 39 | val scoreboardRepo = testDoobieTransactor(initdb, h2DbName) >>> scoreboardRepository.ScoreboardRepository.live 40 | (scoreboardRepo ++ logging) >>> scoreboardService.ScoreboardService.live 41 | } 42 | 43 | def testAuthService(h2DbName: String = "authDB") = { 44 | val userRepo = testDoobieTransactor(SqlFull, h2DbName) >>> userRepository.UserRepository.live 45 | (cryptoServ ++ userRepo ++ logging) >>> authService.AuthService.live 46 | } 47 | 48 | def todoRepo(initData: Vector[TodoTask] = Vector()) = todoRepository.TodoRepository.test(initData) 49 | def todoServ(initData: Vector[TodoTask] = Vector()) = todoRepo(initData) >>> todoService.TodoService.live 50 | } 51 | -------------------------------------------------------------------------------- /js/src/main/scala/example/modules/SimpleExamples.scala: -------------------------------------------------------------------------------- 1 | package example.modules 2 | 3 | import diode.data.PotState.PotEmpty 4 | import diode.data.PotState.PotFailed 5 | import diode.data.PotState.PotPending 6 | import diode.data.PotState.PotReady 7 | import slinky.core.annotations.react 8 | import slinky.core.facade.Fragment 9 | import slinky.core.FunctionalComponent 10 | import slinky.web.html._ 11 | 12 | import example.components.BlueButton 13 | import example.services.AppCircuit 14 | import example.services.IncreaseClicks 15 | import example.services.ReactDiode 16 | import example.services.TryGetRandom 17 | import example.shared.HelloShared 18 | 19 | @react object SimpleExamples { 20 | type Props = Unit 21 | val component = FunctionalComponent[Props] { _ => 22 | val (clicks, dispatch) = ReactDiode.useDiode(AppCircuit.zoomTo(_.clicks)) 23 | val (randomNumber, _) = ReactDiode.useDiode(AppCircuit.zoomTo(_.randomNumber)) 24 | 25 | Fragment( 26 | div(className := "text-center", "compile-time shared string between js and jvm: " + HelloShared.TEST_STR), 27 | div( 28 | className := "row mt-2", 29 | div(className := "col text-right", BlueButton("more clicks", () => dispatch(IncreaseClicks))), 30 | div(className := "col", " clicks: " + clicks.count) 31 | ), 32 | div( 33 | className := "row mt-2", 34 | div(className := "col text-right", BlueButton("new random", () => dispatch(TryGetRandom()))), 35 | div( 36 | className := "col", 37 | " random: ", 38 | randomNumber.state match { 39 | case PotEmpty => 40 | div("nothing here") 41 | case PotPending => 42 | div( 43 | className := "spinner-border text-primary", 44 | role := "status", 45 | span(className := "sr-only", "Loading...") 46 | ) 47 | case PotFailed => 48 | randomNumber.exceptionOption.fold("unknown error")(msg => " error: " + msg.getMessage()) 49 | case PotReady => 50 | randomNumber.fold("unknown error")(_.i.toString) 51 | case _ => div() 52 | } 53 | ) 54 | ) 55 | ) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /js/src/main/scala/example/modules/flappybird/Scoreboard.scala: -------------------------------------------------------------------------------- 1 | package example.modules.flappybird 2 | 3 | import com.github.oen9.slinky.bridge.konva.Konva.KonvaEventObject 4 | import com.github.oen9.slinky.bridge.reactkonva.Group 5 | import com.github.oen9.slinky.bridge.reactkonva.Image 6 | import com.github.oen9.slinky.bridge.reactkonva.Text 7 | import com.github.oen9.slinky.bridge.useimage.UseImage._ 8 | import org.scalajs.dom.raw.Event 9 | import slinky.core.annotations.react 10 | import slinky.core.FunctionalComponent 11 | 12 | @react object Scoreboard { 13 | case class Props( 14 | gameOver: Boolean, 15 | score: Int, 16 | bestScore: Int, 17 | onClickRestart: KonvaEventObject[Event] => Unit 18 | ) 19 | 20 | val component = FunctionalComponent[Props] { props => 21 | val (restartImg, _) = useImage("front-res/img/flappy/restart.png") 22 | val (scoreImg, _) = useImage("front-res/img/flappy/score.png") 23 | 24 | if (props.gameOver) { 25 | Group( 26 | Image( 27 | image = scoreImg, 28 | x = GameLogic.width / 2 - 43, 29 | y = GameLogic.height / 2 - 100, 30 | scaleX = 0.5, 31 | scaleY = 0.5 32 | ), 33 | Image( 34 | image = restartImg, 35 | x = GameLogic.width / 2 - 54, 36 | y = GameLogic.height / 2 + 50, 37 | scaleX = 0.5, 38 | scaleY = 0.5, 39 | onClick = props.onClickRestart, 40 | onTap = props.onClickRestart 41 | ), 42 | Text( 43 | x = GameLogic.width / 2 - 43, 44 | y = GameLogic.height / 2 - 125, 45 | width = 86, 46 | height = 144, 47 | align = "center", 48 | verticalAlign = "middle", 49 | text = s"${props.score}", 50 | fontSize = 24, 51 | fill = "black" 52 | ), 53 | Text( 54 | x = GameLogic.width / 2 - 43, 55 | y = GameLogic.height / 2 - 80, 56 | width = 86, 57 | height = 144, 58 | align = "center", 59 | verticalAlign = "middle", 60 | text = s"${props.bestScore}", 61 | fontSize = 24, 62 | fill = "black" 63 | ) 64 | ) 65 | } else { 66 | Group() 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /js/src/main/scala/example/services/handlers/TodosHandler.scala: -------------------------------------------------------------------------------- 1 | package example.services.handlers 2 | 3 | import cats.implicits._ 4 | import com.softwaremill.quicklens._ 5 | import diode.data.Pot 6 | import diode.data.PotAction 7 | import diode.data.Ready 8 | import diode.Effect 9 | import diode.{ActionHandler, ModelRW} 10 | import example.services.AddNewTodo 11 | import example.services.AjaxClient 12 | import example.services.DeleteTodo 13 | import example.services.SwitchTodoStatus 14 | import example.services.TodoAdded 15 | import example.services.TodoDeleted 16 | import example.services.TodoStatusSwitched 17 | import example.services.TryGetTodos 18 | import example.shared.Dto.TodoTask 19 | 20 | class TodosHandler[M](modelRW: ModelRW[M, Pot[Vector[TodoTask]]]) extends ActionHandler(modelRW) { 21 | import scala.concurrent.ExecutionContext.Implicits.global 22 | override def handle = { 23 | case action: TryGetTodos => 24 | val updateF = action.effect(AjaxClient.getTodos)(identity _) 25 | action.handleWith(this, updateF)(PotAction.handler()) 26 | 27 | case AddNewTodo(newTodo) => 28 | val addEffect = Effect(AjaxClient.postTodo(newTodo).map(newId => TodoAdded(newTodo.copy(id = newId.some)))) 29 | effectOnly(addEffect) 30 | case TodoAdded(newTodo) => 31 | val newValue = value.fold(value)(todos => Ready(todos :+ newTodo)) 32 | updated(newValue) 33 | 34 | case SwitchTodoStatus(idToSwitch) => 35 | val switchTodoEffect = Effect( 36 | AjaxClient.switchStatus(idToSwitch).map(newStatus => TodoStatusSwitched(idToSwitch, newStatus)) 37 | ) 38 | effectOnly(switchTodoEffect) 39 | case TodoStatusSwitched(id, newStatus) => 40 | val idPred = (todo: TodoTask) => todo.id === id.some 41 | val newValue = value.fold(value)((todos: Vector[TodoTask]) => 42 | Ready { 43 | todos.modify(_.eachWhere(idPred).status).setTo(newStatus) 44 | } 45 | ) 46 | updated(newValue) 47 | 48 | case DeleteTodo(id) => 49 | val deleteEffect = Effect(AjaxClient.deleteTodo(id).map(_ => TodoDeleted(id))) 50 | effectOnly(deleteEffect) 51 | case TodoDeleted(id) => 52 | val newValue = value.fold(value)((todos: Vector[TodoTask]) => 53 | Ready { 54 | todos.filter(_.id =!= id.some) 55 | } 56 | ) 57 | updated(newValue) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/modules/db/ScoreboardRepository.scala: -------------------------------------------------------------------------------- 1 | package example.modules.db 2 | 3 | import cats.implicits._ 4 | import doobie._ 5 | import doobie.implicits._ 6 | import example.modules.db.doobieTransactor.DoobieTransactor 7 | import example.shared.Dto.ScoreboardRecord 8 | import zio._ 9 | import zio.interop.catz._ 10 | 11 | object scoreboardRepository { 12 | type ScoreboardRepository = Has[ScoreboardRepository.Service] 13 | 14 | object ScoreboardRepository { 15 | trait Service { 16 | def insert(record: ScoreboardRecord): UIO[ScoreboardRecord] 17 | def getAll(): UIO[Vector[ScoreboardRecord]] 18 | def deleteAll(): UIO[Int] 19 | } 20 | 21 | val live: ZLayer[DoobieTransactor, Throwable, ScoreboardRepository] = ZLayer.fromService { xa => 22 | new Service { 23 | def insert(record: ScoreboardRecord): zio.UIO[ScoreboardRecord] = 24 | SQL 25 | .insert(record) 26 | .withUniqueGeneratedKeys[ScoreboardRecord]("id", "name", "score") 27 | .transact(xa) 28 | .orDie 29 | 30 | def getAll(): UIO[Vector[ScoreboardRecord]] = 31 | SQL.selectAllSortedByScore 32 | .to[Vector] 33 | .transact(xa) 34 | .orDie 35 | 36 | def deleteAll(): UIO[Int] = 37 | SQL.deleteAll.run 38 | .transact(xa) 39 | .orDie 40 | } 41 | } 42 | } 43 | 44 | def insert(record: ScoreboardRecord): ZIO[ScoreboardRepository, Throwable, ScoreboardRecord] = 45 | ZIO.accessM[ScoreboardRepository](_.get.insert(record)) 46 | def getAll(): ZIO[ScoreboardRepository, Throwable, Vector[ScoreboardRecord]] = 47 | ZIO.accessM[ScoreboardRepository](_.get.getAll()) 48 | def deleteAll(): ZIO[ScoreboardRepository, Throwable, Int] = 49 | ZIO.accessM[ScoreboardRepository](_.get.deleteAll()) 50 | 51 | object SQL { 52 | def insert(record: ScoreboardRecord): Update0 = sql""" 53 | INSERT INTO scoreboard (name, score) 54 | VALUES (${record.name}, ${record.score}) 55 | """.update 56 | 57 | val selectAllSortedByScore: Query0[ScoreboardRecord] = sql""" 58 | SELECT * 59 | FROM scoreboard 60 | ORDER BY (score, name) DESC 61 | """.query[ScoreboardRecord] 62 | 63 | val deleteAll: Update0 = sql""" 64 | DELETE 65 | FROM scoreboard 66 | """.update 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /js/src/main/scala/example/modules/Flappy.scala: -------------------------------------------------------------------------------- 1 | package example.modules 2 | 3 | import example.components.DeleteDialog 4 | import example.components.GlobalName 5 | import example.components.PotScoreboardList 6 | import example.modules.flappybird.FlappyBird 7 | import example.services.AddNewScore 8 | import example.services.AppCircuit 9 | import example.services.ClearScoreboard 10 | import example.services.ReactDiode 11 | import example.services.TryGetScoreboard 12 | import example.shared.Dto.ScoreboardRecord 13 | import slinky.core.annotations.react 14 | import slinky.core.facade.Fragment 15 | import slinky.core.facade.Hooks._ 16 | import slinky.core.FunctionalComponent 17 | import slinky.web.html._ 18 | 19 | @react object Flappy { 20 | type Props = Unit 21 | val component = FunctionalComponent[Props] { _ => 22 | val (scores, dispatch) = ReactDiode.useDiode(AppCircuit.zoomTo(_.scores)) 23 | val (score, setScore) = useState(0) 24 | val (name, _) = ReactDiode.useDiode(AppCircuit.zoomTo(_.globalName)) 25 | 26 | useEffect(() => dispatch(TryGetScoreboard()), Seq()) 27 | 28 | val submitDeleteAll = () => dispatch(ClearScoreboard) 29 | def addNewScoreboardRecord(score: Int): Unit = 30 | dispatch(AddNewScore(ScoreboardRecord(name = name, score = score))) 31 | 32 | Fragment( 33 | DeleteDialog( 34 | onDelete = submitDeleteAll, 35 | content = div("Are you sure you want to delete all record?!") 36 | ), 37 | div( 38 | className := "row justify-content-center", 39 | div( 40 | className := "scoreboard-size mb-2", 41 | GlobalName() 42 | ), 43 | FlappyBird(setScore = addNewScoreboardRecord), 44 | div( 45 | className := "card scoreboard-size mt-2", 46 | div(className := "card-header", "scoreboard"), 47 | div( 48 | className := "card-body", 49 | PotScoreboardList(scores), 50 | div( 51 | className := "row", 52 | button( 53 | className := "btn btn-danger w-100", 54 | data - "toggle" := "modal", 55 | data - "target" := "#deleteModal", 56 | "delete all saved scores", 57 | i(className := "ml-2 fas fa-trash") 58 | ) 59 | ) 60 | ) 61 | ) 62 | ) 63 | ) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /js/src/main/scala/example/services/handlers/WebsockLifecycleHandler.scala: -------------------------------------------------------------------------------- 1 | package example.services.handlers 2 | 3 | import cats.implicits._ 4 | import com.softwaremill.quicklens._ 5 | import diode.ActionHandler 6 | import diode.Effect 7 | import diode.ModelRW 8 | import example.services.ChatConnection 9 | import example.services.ChatWebsock 10 | import example.services.Connect 11 | import example.services.Connected 12 | import example.services.Disconnect 13 | import example.services.Disconnected 14 | import example.services.ReConnect 15 | import example.services.RefreshGlobalName 16 | import example.shared.Dto 17 | import scala.concurrent.duration.DurationInt 18 | import scala.concurrent.ExecutionContext.Implicits.global 19 | 20 | class WebsockLifecycleHandler[M](modelRW: ModelRW[M, ChatConnection]) extends ActionHandler(modelRW) { 21 | 22 | override def handle = { 23 | case Connected(user) => 24 | // format: off 25 | val newValue = value 26 | .modify(_.user).setTo(user) 27 | .modify(_.msgs).using(_ :+ Dto.ChatMsg(msg = "connected")) 28 | // format: on 29 | val refreshName = Effect.action(RefreshGlobalName) 30 | updated(newValue, refreshName) 31 | 32 | case Disconnected => 33 | import diode.Implicits.runAfterImpl 34 | // format: off 35 | val newValue = value 36 | .modify(_.users).setTo(Dto.ChatUsers()) 37 | .modify(_.msgs).using(_ :+ Dto.ChatMsg(msg = "disconnected")) 38 | .modify(_.msgs).using(_ :+ Dto.ChatMsg(msg = "reconnecting in 5 seconds")) 39 | // format: on 40 | updated(newValue, Effect.action(ReConnect).after(5.second)) 41 | 42 | case ReConnect => 43 | value.ws.fold(noChange)(_ => effectOnly(Effect.action(Connect))) 44 | 45 | case Connect => 46 | // format: off 47 | val newValue = value 48 | .modify(_.ws).setTo(ChatWebsock.connect().some) 49 | .modify(_.msgs).using(_ :+ Dto.ChatMsg(msg = "connecting ...")) 50 | // format: on 51 | updated(newValue) 52 | 53 | case Disconnect => 54 | value.ws.fold(()) { ws => 55 | ws.onclose = _ => () 56 | ws.close() 57 | } 58 | 59 | // format: off 60 | val newValue = value 61 | .modify(_.ws).setTo(none) 62 | .modify(_.user).setTo(Dto.ChatUser()) 63 | .modify(_.users).setTo(Dto.ChatUsers()) 64 | .modify(_.msgs).using(_ :+ Dto.ChatMsg(msg = "disconnected")) 65 | // format: on 66 | updated(newValue) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /js/src/main/scala/example/modules/flappybird/Bird.scala: -------------------------------------------------------------------------------- 1 | package example.modules.flappybird 2 | 3 | import com.github.oen9.slinky.bridge.konva.KonvaHelper 4 | import com.github.oen9.slinky.bridge.reactkonva.Group 5 | import com.github.oen9.slinky.bridge.reactkonva.Operations 6 | import com.github.oen9.slinky.bridge.reactkonva.Rect 7 | import com.github.oen9.slinky.bridge.reactkonva.Sprite 8 | import com.github.oen9.slinky.bridge.useimage.UseImage._ 9 | import scalajs.js 10 | import slinky.core.annotations.react 11 | import slinky.core.facade.Hooks._ 12 | import slinky.core.facade.ReactRef 13 | import slinky.core.FunctionalComponent 14 | 15 | @react object Bird { 16 | case class Props( 17 | angle: Int, 18 | y: Int, 19 | ref: ReactRef[Operations.SpriteRef], 20 | debug: Boolean = false 21 | ) 22 | 23 | val birdWidth = 92 24 | val birdHeight = 64 25 | val angleWidthFix = -17 26 | val angleHeightFix = 1 27 | 28 | val animations = js.Dynamic.literal( 29 | // format: off 30 | idle = js.Array( 31 | 0 * birdWidth, 0, birdWidth, birdHeight, 32 | 1 * birdWidth, 0, birdWidth, birdHeight, 33 | 2 * birdWidth, 0, birdWidth, birdHeight 34 | ) 35 | // format: on 36 | ) 37 | 38 | val component = FunctionalComponent[Props] { props => 39 | val (birdImg, _) = useImage("front-res/img/flappy/bird.png") 40 | val (debugRect, setDebugRect) = useState(KonvaHelper.IRect()) 41 | 42 | useLayoutEffect(() => props.ref.current.rotation(props.angle), Seq(props.angle)) 43 | 44 | useLayoutEffect( 45 | () => 46 | if (props.debug) { 47 | setDebugRect(props.ref.current.getClientRect()) 48 | }, 49 | Seq(props) 50 | ) 51 | 52 | Group( 53 | Sprite( 54 | x = 50, 55 | y = props.y, 56 | width = birdWidth + angleWidthFix, 57 | height = birdHeight + angleHeightFix, 58 | image = birdImg, 59 | animations = animations, 60 | animation = "idle", 61 | frameRate = 8, 62 | frameIndex = 0, 63 | offsetX = birdWidth / 2, 64 | offsetY = birdHeight / 2, 65 | scaleX = 0.5, 66 | scaleY = 0.5 67 | ).withRef(props.ref), 68 | if (props.debug) { 69 | Rect( 70 | x = debugRect.x.toInt, 71 | y = debugRect.y.toInt, 72 | width = debugRect.width.toInt, 73 | height = debugRect.height.toInt, 74 | stroke = "yellow" 75 | ) 76 | } else Group() 77 | ) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/modules/services/CryptoService.scala: -------------------------------------------------------------------------------- 1 | package example.modules.services 2 | 3 | import example.modules.appConfig 4 | import example.modules.appConfig.AppConfig 5 | import example.shared.Dto 6 | import org.mindrot.jbcrypt.BCrypt 7 | import org.reactormonk.CryptoBits 8 | import org.reactormonk.PrivateKey 9 | import zio._ 10 | import zio.clock.Clock 11 | 12 | object cryptoService { 13 | 14 | type CryptoService = Has[CryptoService.Service] 15 | 16 | object CryptoService { 17 | trait Service { 18 | def generateToken(s: String): Task[Dto.Token] 19 | def hashPassword(password: String): String 20 | def chkPassword(password: String, hashed: String): Boolean 21 | } 22 | 23 | val live: ZLayer[AppConfig with Clock, Throwable, CryptoService] = 24 | ZLayer.fromServiceM[Clock.Service, AppConfig, Throwable, CryptoService.Service] { clock => 25 | appConfig.load.map(cfg => 26 | new Service { 27 | val key = PrivateKey(scala.io.Codec.toUTF8(cfg.encryption.salt)) 28 | val crypto = CryptoBits(key) 29 | val testUsername = "test" 30 | 31 | def generateToken(s: String): Task[Dto.Token] = 32 | clock.nanoTime.map(nanos => 33 | if (s == testUsername) testUsername 34 | else crypto.signToken(s, nanos.toString()) 35 | ) 36 | 37 | def hashPassword(password: String): String = 38 | BCrypt.hashpw(password, BCrypt.gensalt(cfg.encryption.bcryptLogRounds)) 39 | 40 | def chkPassword(password: String, hashed: String): Boolean = 41 | BCrypt.checkpw(password, hashed) 42 | } 43 | ) 44 | } 45 | 46 | val test: Layer[Nothing, CryptoService] = ZLayer.succeed(new Service { 47 | def generateToken(s: String): Task[Dto.Token] = ZIO.succeed("generatedToken") 48 | def hashPassword(password: String): String = s"!$password" 49 | def chkPassword(password: String, hashed: String): Boolean = password == hashed.substring(1) 50 | }) 51 | } 52 | 53 | def generateToken(s: String): ZIO[CryptoService, Throwable, Dto.Token] = 54 | ZIO.accessM[CryptoService](_.get.generateToken(s)) 55 | def hashPassword(password: String): ZIO[CryptoService, Throwable, String] = 56 | ZIO.access[CryptoService](_.get.hashPassword(password)) 57 | def chkPassword(password: String, hashed: String): ZIO[CryptoService, Throwable, Boolean] = 58 | ZIO.access[CryptoService](_.get.chkPassword(password, hashed)) 59 | } 60 | -------------------------------------------------------------------------------- /js/src/main/scala/example/services/ChatWebsock.scala: -------------------------------------------------------------------------------- 1 | package example.services 2 | 3 | import diode.{Effect, NoAction} 4 | import example.shared.Dto 5 | import example.shared.Dto._ 6 | import io.circe.generic.extras.auto._ 7 | import io.circe.parser._ 8 | import io.circe.syntax._ 9 | import org.scalajs.dom 10 | import org.scalajs.dom.{CloseEvent, Event, MessageEvent, WebSocket} 11 | import scala.concurrent.ExecutionContext 12 | import scala.scalajs.js 13 | import scala.scalajs.LinkingInfo 14 | 15 | object ChatWebsock { 16 | val protocol = dom.window.location.protocol match { 17 | case "http:" | "file:" => "ws://" 18 | case _ => "wss://" 19 | } 20 | val baseUrl = if (LinkingInfo.developmentMode) "localhost:8080" else dom.window.location.host 21 | val url = protocol + baseUrl + "/chat" 22 | 23 | def connect(): WebSocket = { 24 | def onopen(e: Event): Unit = {} 25 | 26 | def onmessage(e: MessageEvent): Unit = 27 | decode[Dto.ChatDto](e.data.toString) 28 | .fold( 29 | err => println(s"error: $err : ${e.data.toString()}"), { 30 | case u: Dto.ChatUser => AppCircuit.dispatch(Connected(u)) 31 | case u: Dto.ChatUsers => AppCircuit.dispatch(InitChatUsers(u)) 32 | case u: Dto.NewChatUser => AppCircuit.dispatch(AddUser(u)) 33 | case u: Dto.ChatUserLeft => AppCircuit.dispatch(RemoveUser(u)) 34 | case m: Dto.ChatMsg => AppCircuit.dispatch(AddNewMsg(m)) 35 | case m: Dto.ChangeChatName => AppCircuit.dispatch(ChangeUser(m)) 36 | case unknown => println(s"[ws] unsupported data: $unknown") 37 | } 38 | ) 39 | 40 | def onerror(e: Event): Unit = { 41 | val msg: String = e 42 | .asInstanceOf[js.Dynamic] 43 | .message 44 | .asInstanceOf[js.UndefOr[String]] 45 | .fold(s"error occurred!")("error occurred: " + _) 46 | println(s"[ws] $msg") 47 | } 48 | 49 | def onclose(e: CloseEvent): Unit = 50 | AppCircuit.dispatch(Disconnected) 51 | 52 | val ws = new WebSocket(url) 53 | ws.onopen = onopen _ 54 | ws.onclose = onclose _ 55 | ws.onmessage = onmessage _ 56 | ws.onerror = onerror _ 57 | ws 58 | } 59 | 60 | def send(ws: dom.WebSocket, data: Dto.ClientMsg): Unit = 61 | if (ws.readyState == 1) { 62 | val msg = data.asJson.noSpaces 63 | ws.send(msg) 64 | } 65 | 66 | def sendAsEffect(ws: dom.WebSocket, data: Dto.ClientMsg)(implicit ec: ExecutionContext): Effect = Effect.action { 67 | send(ws, data) 68 | NoAction 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /jvm/src/test/scala/example/modules/services/ScoreboardServiceTest.scala: -------------------------------------------------------------------------------- 1 | package example.modules.services 2 | 3 | import cats.implicits._ 4 | import example.shared.Dto.ScoreboardRecord 5 | import example.TestEnvs 6 | import zio.test._ 7 | import zio.test.Assertion._ 8 | import zio.test.TestAspect._ 9 | 10 | object ScoreboardServiceTest extends DefaultRunnableSpec { 11 | def spec = 12 | suite("scoreboardService test")( 13 | testM("listScores() with full db") { 14 | val expected = Vector( 15 | ScoreboardRecord(2L.some, "unknown2", 20), 16 | ScoreboardRecord(3L.some, "unknown3", 15), 17 | ScoreboardRecord(1L.some, "unknown1", 10), 18 | ScoreboardRecord(5L.some, "bbb", 5), 19 | ScoreboardRecord(4L.some, "aaa", 5) 20 | ) 21 | 22 | val program = for { 23 | result <- scoreboardService.listScores() 24 | } yield assert(result)(equalTo(expected)) 25 | 26 | program.provideLayer { 27 | TestEnvs.testScoreboardService(TestEnvs.SqlFull) 28 | } 29 | }, 30 | testM("listScores() with empty db") { 31 | val expected = Vector() 32 | 33 | val program = for { 34 | result <- scoreboardService.listScores() 35 | } yield assert(result)(equalTo(expected)) 36 | 37 | program.provideLayer { 38 | TestEnvs.testScoreboardService(TestEnvs.SqlEmpty) 39 | } 40 | }, 41 | testM("addNew()") { 42 | val toInsert = ScoreboardRecord(name = "foo", score = 42) 43 | 44 | val program = for { 45 | addNewResult <- scoreboardService.addNew(toInsert) 46 | expectedRecord = toInsert.copy(id = addNewResult.id) 47 | allRecords <- scoreboardService.listScores() 48 | } yield assert(addNewResult)(equalTo(expectedRecord)) && 49 | assert(addNewResult.id)(isSome(anything)) && 50 | assert(allRecords)(exists(equalTo(expectedRecord))) 51 | 52 | program.provideLayer { 53 | TestEnvs.testScoreboardService(TestEnvs.SqlEmpty) 54 | } 55 | }, 56 | testM("deletaAll()") { 57 | val program = 58 | for { 59 | preDelete <- scoreboardService.listScores() 60 | _ <- scoreboardService.deleteAll() 61 | postDelete <- scoreboardService.listScores() 62 | } yield assert(preDelete)(Assertion.hasSize(equalTo(5))) && 63 | assert(postDelete)(isEmpty) 64 | 65 | program.provideLayer { 66 | TestEnvs.testScoreboardService(TestEnvs.SqlFull) 67 | } 68 | } 69 | ) @@ sequential 70 | } 71 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/modules/db/UserRepository.scala: -------------------------------------------------------------------------------- 1 | package example.modules.db 2 | 3 | import doobie._ 4 | import doobie.implicits._ 5 | import example.model.SqlData 6 | import example.modules.db.doobieTransactor.DoobieTransactor 7 | import zio._ 8 | import zio.interop.catz._ 9 | 10 | object userRepository { 11 | type UserRepository = Has[UserRepository.Service] 12 | 13 | object UserRepository { 14 | trait Service { 15 | def getUserByToken(token: String): UIO[Option[SqlData.User]] 16 | def getUserByName(name: String): UIO[Option[SqlData.User]] 17 | def insert(record: SqlData.User): UIO[Option[SqlData.User]] 18 | def updateToken(id: Long, newToken: String): Task[SqlData.User] 19 | } 20 | 21 | val live: ZLayer[DoobieTransactor, Throwable, UserRepository] = ZLayer.fromService { xa => 22 | new Service { 23 | def getUserByToken(token: String): UIO[Option[SqlData.User]] = 24 | SQL 25 | .selectUserByToken(token) 26 | .unique 27 | .transact(xa) 28 | .option 29 | 30 | def getUserByName(name: String): UIO[Option[SqlData.User]] = 31 | SQL 32 | .selectUserByName(name) 33 | .unique 34 | .transact(xa) 35 | .option 36 | 37 | def insert(record: SqlData.User): UIO[Option[SqlData.User]] = 38 | SQL 39 | .insert(record) 40 | .withUniqueGeneratedKeys[SqlData.User]("id", "name", "password", "token") 41 | .transact(xa) 42 | .option 43 | 44 | def updateToken(id: Long, newToken: String): Task[SqlData.User] = 45 | SQL 46 | .updateToken(id, newToken) 47 | .withUniqueGeneratedKeys[SqlData.User]("id", "name", "password", "token") 48 | .transact(xa) 49 | 50 | } 51 | } 52 | } 53 | 54 | object SQL { 55 | def selectUserByName(name: String): Query0[SqlData.User] = sql""" 56 | SELECT * 57 | FROM users 58 | WHERE name = $name 59 | """.query[SqlData.User] 60 | 61 | def selectUserByToken(token: String): Query0[SqlData.User] = sql""" 62 | SELECT * 63 | FROM users 64 | WHERE token = $token 65 | """.query[SqlData.User] 66 | 67 | def insert(record: SqlData.User): Update0 = sql""" 68 | INSERT INTO users (name, password, token) 69 | VALUES (${record.name}, ${record.password}, ${record.token}) 70 | """.update 71 | 72 | def updateToken(id: Long, newToken: String): Update0 = sql""" 73 | UPDATE users 74 | SET token = $newToken 75 | WHERE id = $id 76 | """.update 77 | } 78 | 79 | } 80 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/modules/services/ChatFlowBuilderLive.scala: -------------------------------------------------------------------------------- 1 | package example.modules.services 2 | 3 | import cats.implicits._ 4 | import example.modules.services.chatFlowBuilder.ChatFlowBuilder 5 | import example.modules.services.chatFlowBuilder.ChatFlowBuilder.ChatClientFlow 6 | import example.modules.services.chatService.ChatService 7 | import example.shared.Dto 8 | import example.shared.Dto._ 9 | import fs2.concurrent.Queue 10 | import fs2.Pipe 11 | import io.circe.generic.extras.auto._ 12 | import io.circe.parser._ 13 | import io.circe.syntax._ 14 | import org.http4s.websocket.WebSocketFrame 15 | import zio._ 16 | import zio.interop.catz._ 17 | import zio.logging.Logger 18 | import zio.logging.LogLevel 19 | 20 | class ChatFlowBuilderLive( 21 | chatService: ChatService.Service, 22 | logger: Logger[String] 23 | ) extends ChatFlowBuilder.Service { 24 | 25 | def build[R](): RIO[R, ChatClientFlow[R]] = 26 | for { 27 | in <- Queue.unbounded[RIO[R, *], WebSocketFrame] 28 | inEndChannel <- Queue.unbounded[Task[*], Option[Unit]] 29 | out <- Queue.unbounded[Task[*], Dto.ChatDto] 30 | 31 | user <- chatService.createUser(out) 32 | _ <- logger.log(LogLevel.Trace)(s"userId ${user.id}: connected") 33 | 34 | qinLogic = in.dequeue 35 | .through(handleMsg[R](user.id)) 36 | .merge(inEndChannel.dequeue) 37 | .unNoneTerminate 38 | _ <- (for { 39 | res <- qinLogic.compile.drain 40 | _ <- logger.log(LogLevel.Trace)(s"userId ${user.id}: disconnected") 41 | } yield ()).forkDaemon 42 | 43 | outStream = out.dequeue.map(toWsFrame) 44 | } yield ChatClientFlow(outStream, in.enqueue, onClose(user, inEndChannel)) 45 | 46 | def onClose(u: Dto.ChatUser, inEndChannel: fs2.concurrent.Queue[Task[*], Option[Unit]]) = 47 | for { 48 | _ <- inEndChannel.enqueue1(none) 49 | _ <- chatService.handleServerMsg(Dto.ChatUserLeft(u)) 50 | } yield () 51 | 52 | def handleMsg[R](userId: Int): Pipe[RIO[R, *], WebSocketFrame, Option[Unit]] = 53 | _.collect { 54 | case WebSocketFrame.Text(msg, _) => fromWsFrame(msg) 55 | }.evalMap(msg => 56 | for { 57 | _ <- logger.log(LogLevel.Trace)(s"userId : $userId -> msg : $msg") 58 | _ <- chatService.handleUserMsg(userId, msg) 59 | } yield msg 60 | ) 61 | .dropWhile(_ => true) 62 | .map(_ => ().some) 63 | 64 | def toWsFrame(dto: Dto.ChatDto): WebSocketFrame = WebSocketFrame.Text(dto.asJson.noSpaces) 65 | def fromWsFrame(msg: String): Dto.ClientMsg = decode[Dto.ClientMsg](msg).fold(_ => UnknownData(msg), identity) 66 | 67 | } 68 | -------------------------------------------------------------------------------- /js/src/main/scala/example/modules/flappybird/PipeElem.scala: -------------------------------------------------------------------------------- 1 | package example.modules.flappybird 2 | 3 | import com.github.oen9.slinky.bridge.konva.KonvaHelper 4 | import com.github.oen9.slinky.bridge.reactkonva.Group 5 | import com.github.oen9.slinky.bridge.reactkonva.Image 6 | import com.github.oen9.slinky.bridge.reactkonva.Operations 7 | import com.github.oen9.slinky.bridge.reactkonva.Rect 8 | import com.github.oen9.slinky.bridge.useimage.UseImage._ 9 | import slinky.core.annotations.react 10 | import slinky.core.facade.Hooks._ 11 | import slinky.core.facade.ReactRef 12 | import slinky.core.FunctionalComponent 13 | 14 | @react object PipeElem { 15 | case class Props( 16 | pipe: GameLogic.Pipe, 17 | holeSize: Int, 18 | upperPipeRef: ReactRef[Operations.ShapeRef], 19 | lowerPipeRef: ReactRef[Operations.ShapeRef], 20 | debug: Boolean = false 21 | ) 22 | 23 | val component = FunctionalComponent[Props] { props => 24 | val (pipeImg, _) = useImage("front-res/img/flappy/pipe.png") 25 | val (debugPipe1Rect, setDebugPipe1Rect) = useState(KonvaHelper.IRect()) 26 | val (debugPipe2Rect, setDebugPipe2Rect) = useState(KonvaHelper.IRect()) 27 | 28 | useLayoutEffect(() => props.upperPipeRef.current.rotation(180), Seq()) 29 | 30 | useLayoutEffect( 31 | () => 32 | if (props.debug) { 33 | setDebugPipe1Rect(props.upperPipeRef.current.getClientRect()) 34 | setDebugPipe2Rect(props.lowerPipeRef.current.getClientRect()) 35 | }, 36 | Seq(props) 37 | ) 38 | 39 | Group( 40 | Image( 41 | image = pipeImg, 42 | x = props.pipe.x, 43 | y = props.pipe.y, 44 | scaleX = 0.5, 45 | scaleY = 0.5, 46 | offsetX = GameLogic.pipeWidth * 2 47 | ).withRef(props.upperPipeRef), 48 | Image( 49 | image = pipeImg, 50 | x = props.pipe.x, 51 | y = props.pipe.y + props.holeSize, 52 | scaleX = 0.5, 53 | scaleY = 0.5 54 | ).withRef(props.lowerPipeRef), 55 | if (props.debug) { 56 | Group( 57 | Rect( 58 | x = debugPipe1Rect.x.toInt, 59 | y = debugPipe1Rect.y.toInt, 60 | width = debugPipe1Rect.width.toInt, 61 | height = debugPipe1Rect.height.toInt, 62 | stroke = "green" 63 | ), 64 | Rect( 65 | x = debugPipe2Rect.x.toInt, 66 | y = debugPipe2Rect.y.toInt, 67 | width = debugPipe2Rect.width.toInt, 68 | height = debugPipe2Rect.height.toInt, 69 | stroke = "green" 70 | ) 71 | ) 72 | } else Group() 73 | ) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /js/src/main/scala/example/components/graphql/ItemsList.scala: -------------------------------------------------------------------------------- 1 | package example.components.graphql 2 | 3 | import diode.data.PotState.PotEmpty 4 | import diode.data.PotState.PotFailed 5 | import diode.data.PotState.PotPending 6 | import diode.data.PotState.PotReady 7 | import example.services.AppCircuit 8 | import example.services.GetGQLItem 9 | import example.services.GraphQLClient.ItemBaseView 10 | import example.services.ReactDiode 11 | import org.scalajs.dom.html 12 | import slinky.core.annotations.react 13 | import slinky.core.facade.ReactElement 14 | import slinky.core.FunctionalComponent 15 | import slinky.web.html._ 16 | import slinky.web.SyntheticMouseEvent 17 | 18 | @react object ItemsList { 19 | type Props = Unit 20 | 21 | val component = FunctionalComponent[Props] { _ => 22 | val (items, dispatch) = ReactDiode.useDiode(AppCircuit.zoomTo(_.graphQLItems.items)) 23 | 24 | def selectItem(name: String)(e: SyntheticMouseEvent[html.Button]): Unit = { 25 | e.preventDefault() 26 | println(s"selected: $name") 27 | dispatch(GetGQLItem(name = name)) 28 | } 29 | 30 | def prettyItems(items: List[ItemBaseView]) = 31 | ul( 32 | className := "list-group", 33 | items.map { item => 34 | li( 35 | key := item.name, 36 | className := "list-group-item", 37 | div( 38 | className := "row align-items-center", 39 | div(className := "col-sm", item.name), 40 | div( 41 | className := "col-sm text-right", 42 | button( 43 | `type` := "button", 44 | className := "btn btn-primary", 45 | onClick := (selectItem(item.name)(_)), 46 | "show full", 47 | i(className := "ml-2 fas fa-chevron-circle-right") 48 | ) 49 | ) 50 | ) 51 | ) 52 | } 53 | ) 54 | 55 | items.state match { 56 | case PotEmpty => 57 | "nothing here (click refresh button?)" 58 | case PotPending => 59 | div( 60 | className := "row justify-content-center", 61 | div( 62 | className := "spinner-border text-primary", 63 | role := "status", 64 | span(className := "sr-only", "Loading...") 65 | ) 66 | ) 67 | case PotFailed => 68 | items.exceptionOption.fold("unknown error")(msg => " error: " + msg.getMessage()) 69 | case PotReady => 70 | items.fold( 71 | div("nothing here (click refresh button?)"): ReactElement 72 | )(prettyItems) 73 | case _ => div("unexpected state") 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/modules/services/ScoreboardService.scala: -------------------------------------------------------------------------------- 1 | package example.modules.services 2 | 3 | import cats.implicits._ 4 | import example.modules.db.scoreboardRepository.ScoreboardRepository 5 | import example.shared.Dto.ScoreboardRecord 6 | import zio._ 7 | import zio.logging.Logger 8 | import zio.logging.Logging 9 | import zio.logging.LogLevel 10 | 11 | object scoreboardService { 12 | type ScoreboardService = Has[ScoreboardService.Service] 13 | 14 | object ScoreboardService { 15 | trait Service { 16 | def addNew(newRecord: ScoreboardRecord): Task[ScoreboardRecord] 17 | def listScores(): Task[Vector[ScoreboardRecord]] 18 | def deleteAll(): Task[Unit] 19 | } 20 | 21 | val live: ZLayer[ScoreboardRepository with Logging, Nothing, ScoreboardService] = 22 | ZLayer.fromServices[ScoreboardRepository.Service, Logger[String], ScoreboardService.Service] { 23 | (scoreboardRepository, logger) => 24 | new Service { 25 | def addNew(newRecord: ScoreboardRecord): Task[ScoreboardRecord] = 26 | for { 27 | inserted <- scoreboardRepository.insert(newRecord) 28 | _ <- logger.log(LogLevel.Trace)(s"new score record added: $inserted") 29 | } yield inserted 30 | 31 | def listScores(): Task[Vector[ScoreboardRecord]] = 32 | for { 33 | allScores <- scoreboardRepository.getAll() 34 | _ <- logger.log(LogLevel.Trace)(s"got: '${allScores.size}' scores") 35 | } yield allScores 36 | 37 | def deleteAll(): Task[Unit] = 38 | for { 39 | deleteResult <- scoreboardRepository.deleteAll() 40 | _ <- logger.log(LogLevel.Trace)(s"deleted: '$deleteResult' scores") 41 | } yield () 42 | } 43 | } 44 | 45 | def test(data: Vector[ScoreboardRecord] = Vector()) = 46 | ZLayer.succeed(new Service { 47 | def addNew(newRecord: ScoreboardRecord): Task[ScoreboardRecord] = ZIO.succeed(newRecord.copy(id = 42L.some)) 48 | def listScores(): Task[Vector[ScoreboardRecord]] = ZIO.succeed(data) 49 | def deleteAll(): Task[Unit] = ZIO.unit 50 | }) 51 | } 52 | 53 | def addNew(newRecord: ScoreboardRecord): ZIO[ScoreboardService, Throwable, ScoreboardRecord] = 54 | ZIO.accessM[ScoreboardService](_.get.addNew(newRecord)) 55 | def listScores(): ZIO[ScoreboardService, Throwable, Vector[ScoreboardRecord]] = 56 | ZIO.accessM[ScoreboardService](_.get.listScores()) 57 | def deleteAll(): ZIO[ScoreboardService, Throwable, Unit] = 58 | ZIO.accessM[ScoreboardService](_.get.deleteAll()) 59 | } 60 | -------------------------------------------------------------------------------- /js/src/main/scala/example/components/graphql/ItemDetails.scala: -------------------------------------------------------------------------------- 1 | package example.components.graphql 2 | 3 | import diode.data.PotState.PotEmpty 4 | import diode.data.PotState.PotFailed 5 | import diode.data.PotState.PotPending 6 | import diode.data.PotState.PotReady 7 | import example.services.AppCircuit 8 | import example.services.GraphQLClient.ItemFullView 9 | import example.services.ReactDiode 10 | import slinky.core.annotations.react 11 | import slinky.core.facade.ReactElement 12 | import slinky.core.FunctionalComponent 13 | import slinky.web.html._ 14 | 15 | @react object ItemDetails { 16 | type Props = Unit 17 | 18 | val component = FunctionalComponent[Props] { _ => 19 | val (item, dispatch) = ReactDiode.useDiode(AppCircuit.zoomTo(_.graphQLItems.selectedItem)) 20 | 21 | def prettyMaybeItem(maybeItem: Option[ItemFullView]) = 22 | maybeItem.fold( 23 | div("Sorry but this item doesn't exist") 24 | )(prettyItem) 25 | 26 | def prettyItem(item: ItemFullView) = 27 | div( 28 | className := "card", 29 | div( 30 | className := "card-header", 31 | div( 32 | className := "row", 33 | div(className := "col", div(s"${item.name} details")) 34 | ) 35 | ), 36 | div( 37 | className := "card-body", 38 | h5(className := "card-title", s"Amount: ${item.amount}"), 39 | div( 40 | className := "row", 41 | div(className := "col", b("name")), 42 | div(className := "col", b("value")), 43 | div(className := "col", b("description")) 44 | ), 45 | hr(), 46 | item.features.map { feat => 47 | div( 48 | key := feat.name, 49 | className := "row", 50 | div(className := "col", feat.name), 51 | div(className := "col", feat.value), 52 | div(className := "col", feat.description) 53 | ) 54 | } 55 | ) 56 | ) 57 | 58 | item.state match { 59 | case PotEmpty => 60 | "First refresh data and then select item" 61 | case PotPending => 62 | div( 63 | className := "row justify-content-center", 64 | div( 65 | className := "spinner-border text-primary", 66 | role := "status", 67 | span(className := "sr-only", "Loading...") 68 | ) 69 | ) 70 | case PotFailed => 71 | item.exceptionOption.fold("unknown error")(msg => " error: " + msg.getMessage()) 72 | case PotReady => 73 | item.fold( 74 | div("selected item doesn't exist"): ReactElement 75 | )(prettyMaybeItem) 76 | case _ => div("unexpected state") 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/endpoints/ScoreboardEndpoints.scala: -------------------------------------------------------------------------------- 1 | package example.endpoints 2 | 3 | import cats.implicits._ 4 | import zio._ 5 | import zio.interop.catz._ 6 | import zio.logging._ 7 | 8 | import io.circe.generic.extras.auto._ 9 | import org.http4s.HttpRoutes 10 | import sttp.model.StatusCode 11 | import sttp.tapir._ 12 | import sttp.tapir.json.circe._ 13 | import sttp.tapir.server.http4s._ 14 | 15 | import example.model.Errors._ 16 | import example.modules.services.scoreboardService 17 | import example.modules.services.scoreboardService.ScoreboardService 18 | import example.shared.Dto._ 19 | 20 | object ScoreboardEndpoints { 21 | val unexpectedError = oneOf( 22 | statusMapping(StatusCode.InternalServerError, jsonBody[UnknownError].description("unknown default error")) 23 | ) 24 | 25 | val listScores = endpoint.get 26 | .description("List all scores sorted by score") 27 | .in("scoreboard") 28 | .errorOut(unexpectedError) 29 | .out( 30 | jsonBody[Vector[ScoreboardRecord]].example( 31 | Vector(ScoreboardRecord(id = 2L.some, name = "bar", 100), ScoreboardRecord(id = 1L.some, name = "foo", 50)) 32 | ) 33 | ) 34 | 35 | val createNew = endpoint.post 36 | .description("Create new score record") 37 | .in("scoreboard") 38 | .in(jsonBody[ScoreboardRecord].example(ScoreboardRecord())) 39 | .errorOut(unexpectedError) 40 | .out(jsonBody[ScoreboardRecord].example(ScoreboardRecord(id = 3L.some))) 41 | .out(statusCode(StatusCode.Created)) 42 | 43 | val deleteAll = endpoint.delete 44 | .description("Delete all scores") 45 | .in("scoreboard") 46 | .errorOut(unexpectedError) 47 | .out(statusCode(StatusCode.NoContent)) 48 | 49 | def endpoints = List( 50 | listScores, 51 | createNew, 52 | deleteAll 53 | ) 54 | 55 | // format: off 56 | def routes[R <: ScoreboardService with Logging]: HttpRoutes[RIO[R, *]] = 57 | ( 58 | listScores.toRoutes { _ => 59 | handleUnexpectedError(scoreboardService.listScores()) 60 | }: HttpRoutes[RIO[R, *]] // TODO find a better way 61 | ) <+> createNew.toRoutes { toCreate => 62 | handleUnexpectedError(scoreboardService.addNew(toCreate)) 63 | } <+> deleteAll.toRoutes { _ => 64 | handleUnexpectedError(scoreboardService.deleteAll()) 65 | } 66 | // format: on 67 | 68 | private def handleUnexpectedError[R <: Logging, A](result: ZIO[R, Throwable, A]): URIO[R, Either[UnknownError, A]] = 69 | result.foldM( 70 | { 71 | case unknown => 72 | for { 73 | _ <- log.throwable("unknown error", unknown) 74 | } yield UnknownError(s"Something went wrong. Check logs for more info").asLeft 75 | }, 76 | succ => ZIO.succeed(succ.asRight) 77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # full-stack-zio 2 | 3 | full-stack-zio 4 | 5 | [![Build Status](https://travis-ci.org/oen9/full-stack-zio.svg?branch=master)](https://travis-ci.org/oen9/full-stack-zio) 6 | [![CircleCI](https://circleci.com/gh/oen9/full-stack-zio.svg?style=svg)](https://circleci.com/gh/oen9/full-stack-zio) 7 | 8 | ![alt text](https://raw.githubusercontent.com/oen9/full-stack-zio/master/img/web.png "web") 9 | 10 | ## Features 11 | 12 | 1. TODO list - MongoDB 13 | 1. Small Flappy Bird game with scoreboard - Postgres\ 14 | The game is badly optimised because of not working directly with canvas. 15 | It is just my demo focusing on react with scalajs. 16 | 1. Auth example (register, singin, signout, secured endpoint) - Postgres 17 | 1. API documentation - Swagger 18 | 1. Small chat with websockets 19 | 1. GraphQL example 20 | 21 | ## Live 22 | 23 | https://full-stack-zio.herokuapp.com/ 24 | 25 | ## Libs 26 | 27 | ### backend 28 | 29 | 1. scala 30 | 1. ZIO 31 | 1. cats-core 32 | 1. http4s 33 | 1. pureconfig 34 | 1. circe 35 | 1. swagger 36 | 1. reactivemongo 37 | 1. doobie 38 | 1. flyway 39 | 1. caliban 40 | 41 | ### frontend 42 | 43 | 1. scalajs 44 | 1. slinky (react) 45 | 1. diode 46 | 1. bootstrap 47 | 1. circe 48 | 49 | ## in progress 50 | 51 | 1. ??? 52 | 53 | ## soon 54 | 55 | 1. more? 56 | 57 | ## Production 58 | 59 | ### docker 60 | 61 | 1. `sbt stage` 62 | 1. `docker-compose up -d web` 63 | 1. open `http://localhost:8080` in browser 64 | 65 | ### standalone 66 | 67 | 1. `sbt stage` 68 | 1. set `MONGO_URL_FULL_STACK_ZIO` env variable\ 69 | example: `MONGO_URL_FULL_STACK_ZIO=mongodb://test:test@localhost:27017/test` 70 | 1. set `DATABASE_URL_FULL_STACK_ZIO` env variable\ 71 | example: `DATABASE_URL_FULL_STACK_ZIO="jdbc:postgresql://localhost:5432/fullstackzio?user=test&password=test"` 72 | 1. run `./target/universal/stage/bin/app` 73 | 1. open `http://localhost:8080` in browser 74 | 75 | ## DEV 76 | 77 | ### required services 78 | 79 | - docker\ 80 | run `docker-compose up -d mongo postgres` 81 | 82 | - other\ 83 | set `MONGO_URL_FULL_STACK_ZIO` env variable\ 84 | example: `MONGO_URL_FULL_STACK_ZIO=mongodb://test:test@localhost:27017/test`\ 85 | set `DATABASE_URL_FULL_STACK_ZIO` env variable\ 86 | example: `DATABASE_URL_FULL_STACK_ZIO="jdbc:postgresql://localhost:5432/fullstackzio?user=test&password=test"` 87 | 88 | ### js 89 | 90 | `fastOptJS::webpack`\ 91 | `~fastOptJS`\ 92 | open `js/src/main/resources/index-dev.html` in browser 93 | 94 | ### server 95 | 96 | `reStart`\ 97 | http://localhost:8080/ 98 | 99 | ### js + server (dev conf) 100 | 101 | Run server normally `reStart`.\ 102 | Run js: `fastOptJS::webpack` and `fastOptJS`.\ 103 | Open `js/src/main/resources/index-dev.html` in browser.\ 104 | When server changed run `reStart`.\ 105 | When js changed run `fastOptJS`. 106 | -------------------------------------------------------------------------------- /js/src/main/scala/example/modules/flappybird/GameLogic.scala: -------------------------------------------------------------------------------- 1 | package example.modules.flappybird 2 | 3 | import com.github.oen9.slinky.bridge.konva.Konva.IFrame 4 | import com.softwaremill.quicklens._ 5 | import scala.util.Random 6 | 7 | object GameLogic { 8 | val width = 480 9 | val height = 640 10 | val groundY = 576 11 | val pipeWidth = 69 12 | 13 | val defOpt = GameOptions() 14 | case class GameOptions( 15 | holeSize: Int = 200, 16 | distanceBetweenPipes: Int = 250, 17 | pipeStartAtX: Int = 500, 18 | gravitationStep: Int = 5, 19 | upStep: Int = 20, 20 | rotationAngle: Int = 30, 21 | groundSpeed: Int = 2, 22 | upSlowdown: Int = 2, 23 | debug: Boolean = false 24 | ) 25 | case class Pipe(x: Int, y: Int) 26 | case class Bird( 27 | y: Int = height / 2, 28 | angle: Int = 0, 29 | upAcceleration: Int = 0 30 | ) 31 | case class GameState( 32 | gameOver: Boolean = false, 33 | fps: Double = 0, 34 | score: Int = 0, 35 | groundShift: Int = 0, 36 | bird: Bird = Bird(), 37 | pipe1: Pipe = generateNewPipe(defOpt.pipeStartAtX, defOpt.holeSize), 38 | pipe2: Pipe = generateNewPipe(defOpt.pipeStartAtX + defOpt.distanceBetweenPipes, defOpt.holeSize), 39 | opt: GameOptions = defOpt 40 | ) 41 | 42 | def generateNewPipe(startX: Int, holeSize: Int): Pipe = { 43 | val holePosition = Random.nextInt(groundY - holeSize) 44 | Pipe(x = startX, y = holePosition) 45 | } 46 | 47 | def movePipe(pipe: Pipe, opts: GameOptions): (Pipe, Int) = 48 | if (pipe.x <= -pipeWidth) 49 | (generateNewPipe(opts.pipeStartAtX, opts.holeSize), 1) 50 | else 51 | (pipe.copy(x = pipe.x - opts.groundSpeed), 0) 52 | 53 | def loop(frame: IFrame, gs: GameState): GameState = { 54 | val newUpAcc = Some(gs.bird.upAcceleration - gs.opt.upSlowdown).filter(_ > 0).getOrElse(0) 55 | 56 | val currentAcc = gs.opt.gravitationStep - newUpAcc 57 | val newBirdY = gs.bird.y + currentAcc 58 | 59 | val newAngle = 60 | if (currentAcc > 0) gs.opt.rotationAngle 61 | else if (currentAcc < 0) -gs.opt.rotationAngle 62 | else 0 63 | 64 | val newGroundShift = if (gs.groundShift < (-15)) 0 else gs.groundShift - gs.opt.groundSpeed 65 | 66 | val (newPipe1, pipe1Score) = movePipe(gs.pipe1, gs.opt) 67 | val (newPipe2, pipe2Score) = movePipe(gs.pipe2, gs.opt) 68 | 69 | val newScore = gs.score + pipe1Score + pipe2Score 70 | 71 | // format: off 72 | gs.modify(_.bird.angle).setTo(newAngle) 73 | .modify(_.bird.upAcceleration).setTo(newUpAcc) 74 | .modify(_.bird.y).setTo(newBirdY) 75 | .modify(_.fps).setTo((1000 / frame.timeDiff).toInt) 76 | .modify(_.groundShift).setTo(newGroundShift) 77 | .modify(_.score).setTo(newScore) 78 | .modify(_.pipe1).setTo(newPipe1) 79 | .modify(_.pipe2).setTo(newPipe2) 80 | // format: on 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /jvm/src/test/scala/example/endpoints/ScoreboardEndpointsTest.scala: -------------------------------------------------------------------------------- 1 | package example.endpoints 2 | 3 | import cats.implicits._ 4 | import zio._ 5 | import zio.logging.Logging 6 | import zio.test._ 7 | import zio.test.Assertion._ 8 | 9 | import io.circe.generic.extras.auto._ 10 | import io.circe.syntax._ 11 | import org.http4s._ 12 | import org.http4s.circe._ 13 | import org.http4s.implicits._ 14 | 15 | import example.Http4sTestHelper 16 | import example.modules.services.scoreboardService.ScoreboardService 17 | import example.shared.Dto._ 18 | import example.TestEnvs 19 | 20 | object ScoreboardEndpointsTest extends DefaultRunnableSpec { 21 | type TestEnv = ScoreboardService with Logging 22 | 23 | val initData = Vector( 24 | ScoreboardRecord(id = 1L.some, name = "foo", score = 10), 25 | ScoreboardRecord(id = 2L.some, name = "bar", score = 20), 26 | ScoreboardRecord(id = 3L.some, name = "baz", score = 5) 27 | ) 28 | 29 | def spec = suite("ScoreboardEndpoints")( 30 | testM("GET /scoreboard") { 31 | val req = Request[RIO[TestEnv, *]](Method.GET, uri"/scoreboard") 32 | 33 | val program = for { 34 | response <- ScoreboardEndpoints.routes[TestEnv].run(req).value 35 | parsedBody <- Http4sTestHelper.parseBody[TestEnv, Vector[ScoreboardRecord]](response) 36 | } yield assert(parsedBody)(isSome(equalTo(initData))) 37 | 38 | program.provideLayer( 39 | TestEnvs.logging ++ ScoreboardService.test(initData) 40 | ) 41 | }, 42 | testM("POST /scoreboard") { 43 | val postData = ScoreboardRecord(id = None, name = "foo", score = 10) 44 | val req = Request[RIO[TestEnv, *]](Method.POST, uri"/scoreboard") 45 | .withEntity(postData.asJson) 46 | 47 | val program = for { 48 | response <- ScoreboardEndpoints.routes[TestEnv].run(req).value 49 | parsedBody <- Http4sTestHelper.parseBody[TestEnv, ScoreboardRecord](response) 50 | expected = postData.copy(id = parsedBody.flatMap(_.id)) 51 | } yield assert(response.map(_.status))(isSome(equalTo(Status.Created))) && 52 | assert(parsedBody.flatMap(_.id))(isSome(anything)) && 53 | assert(parsedBody)(isSome(equalTo(expected))) 54 | 55 | program.provideLayer( 56 | TestEnvs.logging ++ ScoreboardService.test() 57 | ) 58 | }, 59 | testM("POST /scoreboard bad request") { 60 | val req = Request[RIO[TestEnv, *]](Method.POST, uri"/scoreboard") 61 | .withEntity("some req") 62 | 63 | val program = for { 64 | response <- ScoreboardEndpoints.routes[TestEnv].run(req).value 65 | } yield assert(response.map(_.status))(isSome(equalTo(Status.BadRequest))) 66 | 67 | program.provideLayer( 68 | TestEnvs.logging ++ ScoreboardService.test() 69 | ) 70 | }, 71 | testM("DELETE /scoreboard") { 72 | val req = Request[RIO[TestEnv, *]](Method.DELETE, uri"/scoreboard") 73 | 74 | val program = for { 75 | response <- ScoreboardEndpoints.routes[TestEnv].run(req).value 76 | } yield assert(response.map(_.status))(isSome(equalTo(Status.NoContent))) 77 | 78 | program.provideLayer( 79 | TestEnvs.logging ++ ScoreboardService.test() 80 | ) 81 | } 82 | ) 83 | } 84 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/modules/services/auth/AuthService.scala: -------------------------------------------------------------------------------- 1 | package example.modules.services.auth 2 | 3 | import example.model.Errors.AuthenticationError 4 | import example.model.Errors.UserExists 5 | import example.modules.db.userRepository.UserRepository 6 | import example.modules.services.cryptoService.CryptoService 7 | import example.shared.Dto 8 | import io.scalaland.chimney.dsl._ 9 | import zio._ 10 | import zio.logging.Logger 11 | import zio.logging.Logging 12 | 13 | object authService { 14 | type AuthService = Has[AuthService.Service] 15 | 16 | object AuthService { 17 | trait Service { 18 | def getUser(token: Dto.Token): Task[Dto.User] 19 | def generateNewToken(cred: Dto.AuthCredentials): Task[Dto.User] 20 | def createUser(cred: Dto.AuthCredentials): Task[Dto.User] 21 | def secretText(user: Dto.User): Task[String] 22 | } 23 | 24 | val live: ZLayer[UserRepository with Logging with CryptoService, Nothing, AuthService] = 25 | ZLayer.fromServices[UserRepository.Service, Logger[String], CryptoService.Service, AuthService.Service] { 26 | (userRepository, logger, cryptoService) => new AuthServiceLive(userRepository, logger, cryptoService) 27 | } 28 | 29 | def test( 30 | data: Vector[Dto.User] = Vector(), 31 | newToken: String = "newToken", 32 | secretReturn: String = "Super secret text" 33 | ) = 34 | ZLayer.succeed(new Service { 35 | 36 | def getUser(token: Dto.Token): Task[Dto.User] = 37 | ZIO 38 | .fromOption( 39 | data 40 | .find(_.token == token) 41 | ) 42 | .mapError(_ => AuthenticationError("wrong token")) 43 | 44 | def generateNewToken(cred: Dto.AuthCredentials): Task[Dto.User] = 45 | ZIO 46 | .fromOption( 47 | data 48 | .find(_.name == cred.name) 49 | .map(_.copy(token = newToken)) 50 | ) 51 | .mapError(_ => AuthenticationError("wrong name/password")) 52 | 53 | def createUser(cred: Dto.AuthCredentials): Task[Dto.User] = { 54 | val succ: ZIO[Any, Throwable, Dto.User] = ZIO.succeed( 55 | cred 56 | .into[Dto.User] 57 | .withFieldComputed(_.id, _ => 1L) 58 | .withFieldComputed(_.token, _ => newToken) 59 | .transform 60 | ) 61 | val fail = ZIO.fail(UserExists(s"user ${cred.name} exists")) 62 | data 63 | .find(_.name == cred.name) 64 | .fold(succ)(_ => fail) 65 | } 66 | 67 | def secretText(user: Dto.User): Task[String] = ZIO.succeed(secretReturn) 68 | }) 69 | } 70 | 71 | def getUser(token: Dto.Token): ZIO[AuthService, Throwable, Dto.User] = 72 | ZIO.accessM[AuthService](_.get.getUser(token)) 73 | def generateNewToken(cred: Dto.AuthCredentials): ZIO[AuthService, Throwable, Dto.User] = 74 | ZIO.accessM[AuthService](_.get.generateNewToken(cred)) 75 | def createUser(cred: Dto.AuthCredentials): ZIO[AuthService, Throwable, Dto.User] = 76 | ZIO.accessM[AuthService](_.get.createUser(cred)) 77 | def secretText(user: Dto.User): ZIO[AuthService, Throwable, String] = 78 | ZIO.accessM[AuthService](_.get.secretText(user)) 79 | } 80 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/modules/services/TodoService.scala: -------------------------------------------------------------------------------- 1 | package example.modules.services 2 | 3 | import cats.implicits._ 4 | import io.scalaland.chimney.dsl._ 5 | import zio._ 6 | 7 | import example.model.Errors.WrongMongoId 8 | import example.model.MongoData 9 | import example.modules.db.todoRepository.TodoRepository 10 | import example.shared.Dto 11 | import example.shared.Dto._ 12 | import reactivemongo.api.bson.BSONObjectID 13 | 14 | object todoService { 15 | type TodoService = Has[TodoService.Service] 16 | 17 | object TodoService { 18 | trait Service { 19 | def getAll: Task[List[TodoTask]] 20 | def createNew(todoTask: TodoTask): Task[String] 21 | def switchStatus(id: String): Task[TodoStatus] 22 | def deleteTodo(id: String): Task[Unit] 23 | } 24 | 25 | val live: ZLayer[TodoRepository, Nothing, Has[TodoService.Service]] = ZLayer.fromService { todoRepository => 26 | new Service { 27 | def getAll: Task[List[Dto.TodoTask]] = 28 | for { 29 | allTodos <- todoRepository.getAll 30 | dtos = allTodos.map { 31 | _.into[TodoTask] 32 | .withFieldComputed(_.id, _.id.stringify.some) 33 | .transform 34 | } 35 | } yield dtos 36 | 37 | def createNew(toCreate: Dto.TodoTask): Task[String] = { 38 | val newId = BSONObjectID.generate() 39 | val toInsert = toCreate 40 | .into[MongoData.TodoTask] 41 | .withFieldComputed(_.id, _ => newId) 42 | .transform 43 | for { 44 | _ <- todoRepository.insert(toInsert) 45 | } yield newId.stringify 46 | } 47 | 48 | def switchStatus(id: String): Task[TodoStatus] = 49 | for { 50 | bsonId <- strToBsonId(id) 51 | found <- todoRepository.findById(bsonId) 52 | newStatus = MongoData.switchStatus(found.status) 53 | _ <- todoRepository.updateStatus(bsonId, newStatus) 54 | dto = newStatus.into[TodoStatus].transform 55 | } yield dto 56 | 57 | def deleteTodo(id: String): zio.Task[Unit] = 58 | for { 59 | bsonId <- strToBsonId(id) 60 | found <- todoRepository.findById(bsonId) 61 | _ <- todoRepository.deleteById(bsonId) 62 | } yield () 63 | 64 | private def strToBsonId(id: String) = 65 | ZIO 66 | .fromTry(BSONObjectID.parse(id)) 67 | .mapError(e => WrongMongoId(e.getMessage())) 68 | } 69 | } 70 | 71 | def test(initData: List[TodoTask] = List()) = 72 | ZLayer.succeed(new Service { 73 | def getAll: Task[List[TodoTask]] = ZIO.succeed(initData) 74 | def createNew(todoTask: TodoTask): Task[String] = ZIO.succeed(BSONObjectID.generate().stringify) 75 | def switchStatus(id: String): Task[TodoStatus] = ZIO.succeed(Done) 76 | def deleteTodo(id: String): Task[Unit] = ZIO.unit 77 | }) 78 | } 79 | 80 | def getAll: ZIO[TodoService, Throwable, List[TodoTask]] = 81 | ZIO.accessM[TodoService](_.get.getAll) 82 | def createNew(toCreate: TodoTask): ZIO[TodoService, Throwable, String] = 83 | ZIO.accessM[TodoService](_.get.createNew(toCreate)) 84 | def switchStatus(id: String): ZIO[TodoService, Throwable, Dto.TodoStatus] = 85 | ZIO.accessM[TodoService](_.get.switchStatus(id)) 86 | def deleteTodo(id: String): ZIO[TodoService, Throwable, Unit] = 87 | ZIO.accessM[TodoService](_.get.deleteTodo(id)) 88 | } 89 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/endpoints/AuthEndpoints.scala: -------------------------------------------------------------------------------- 1 | package example.endpoints 2 | 3 | import cats.implicits._ 4 | import zio._ 5 | import zio.interop.catz._ 6 | import zio.logging._ 7 | 8 | import io.circe.generic.extras.auto._ 9 | import org.http4s.HttpRoutes 10 | import sttp.model.StatusCode 11 | import sttp.tapir._ 12 | import sttp.tapir.json.circe._ 13 | import sttp.tapir.server.http4s._ 14 | 15 | import example.model.Errors._ 16 | import example.shared.Dto._ 17 | import example.shared.Dto 18 | import example.modules.services.auth.authService.AuthService 19 | import example.modules.services.auth.authService 20 | 21 | object AuthEndpoints { 22 | val exampleToken = "test" 23 | 24 | val allErrorsOut = oneOf( 25 | statusMapping(StatusCode.Unauthorized, jsonBody[Unauthorized].description("wrong token/credentials")), 26 | statusMapping(StatusCode.Conflict, jsonBody[Conflict].description("conflict in request with existing data")), 27 | statusMapping(StatusCode.InternalServerError, jsonBody[UnknownError].description("unknown default error")) 28 | ) 29 | 30 | val apiKey = auth.apiKey(header[Dto.Token]("TOKEN").example(exampleToken)) 31 | val userDtoOut = jsonBody[Dto.User].example(Dto.User(1L, "foo", exampleToken)) 32 | val authDtoIn = jsonBody[Dto.AuthCredentials].example(Dto.AuthCredentials("foo", "bar")) 33 | 34 | val getUser = endpoint.get 35 | .description("get user info by token") 36 | .in("auth" / "user") 37 | .in(apiKey) 38 | .errorOut(allErrorsOut) 39 | .out(userDtoOut) 40 | 41 | val createUser = endpoint.post 42 | .description("create new user") 43 | .in("auth" / "user") 44 | .in(authDtoIn) 45 | .errorOut(allErrorsOut) 46 | .out(userDtoOut) 47 | .out(statusCode(StatusCode.Created)) 48 | 49 | val generateNewToken = endpoint.post 50 | .description("generate new token") 51 | .in("auth") 52 | .in(authDtoIn) 53 | .errorOut(allErrorsOut) 54 | .out(userDtoOut) 55 | 56 | val securedText = endpoint.get 57 | .description("get a super secure info for specific user") 58 | .in("auth" / "secured") 59 | .in(apiKey) 60 | .errorOut(allErrorsOut) 61 | .out(jsonBody[String].example("Secret text")) 62 | 63 | def endpoints = List( 64 | getUser, 65 | createUser, 66 | generateNewToken, 67 | securedText 68 | ) 69 | 70 | // format: off 71 | def routes[R <: AuthService with Logging]: HttpRoutes[RIO[R, *]] = 72 | ( 73 | getUser.toRoutes(token => handleError(authService.getUser(token))): HttpRoutes[RIO[R, *]] 74 | ) <+> createUser.toRoutes { cred => 75 | handleError(authService.createUser(cred)) 76 | } <+> generateNewToken.toRoutes { cred => 77 | handleError(authService.generateNewToken(cred)) 78 | } <+> securedText.toRoutes { token => 79 | handleError(authService.getUser(token) >>= authService.secretText) 80 | } 81 | // format: on 82 | 83 | private def handleError[R <: Logging, A](result: ZIO[R, Throwable, A]): URIO[R, Either[ErrorInfo, A]] = 84 | result.foldM( 85 | { 86 | case TokenNotFound(msg) => ZIO.succeed(Unauthorized(msg).asLeft) 87 | case AuthenticationError(msg) => ZIO.succeed(Unauthorized(msg).asLeft) 88 | case UserExists(msg) => ZIO.succeed(Conflict(msg).asLeft) 89 | 90 | case unknown => 91 | for { 92 | _ <- log.throwable("unknown error", unknown) 93 | } yield UnknownError(s"Something went wrong. Check logs for more info").asLeft 94 | }, 95 | succ => ZIO.succeed(succ.asRight) 96 | ) 97 | } 98 | -------------------------------------------------------------------------------- /jvm/src/test/scala/example/endpoints/TodoEndpointsTest.scala: -------------------------------------------------------------------------------- 1 | package example.endpoints 2 | 3 | import cats.implicits._ 4 | import zio._ 5 | import zio.logging.Logging 6 | import zio.test._ 7 | import zio.test.Assertion._ 8 | 9 | import io.circe.generic.extras.auto._ 10 | import io.circe.syntax._ 11 | import org.http4s._ 12 | import org.http4s.circe._ 13 | import org.http4s.implicits._ 14 | 15 | import example.Http4sTestHelper 16 | import example.modules.services.todoService.TodoService 17 | import example.shared.Dto._ 18 | import example.TestEnvs 19 | 20 | object TodoEndpointsTest extends DefaultRunnableSpec { 21 | type TestEnv = TodoService with Logging 22 | 23 | val initData = List( 24 | TodoTask(id = "5e7ca3231200001200268a81".some, value = "foo", status = Pending), 25 | TodoTask(id = "5e7ca3231200001200268a82".some, value = "bar", status = Done), 26 | TodoTask(id = "5e7ca3231200001200268a83".some, value = "baz", status = Pending) 27 | ) 28 | 29 | def spec = suite("TodoEndpoints")( 30 | testM("GET /todos") { 31 | val req = Request[RIO[TestEnv, *]](Method.GET, uri"/todos") 32 | 33 | val program = for { 34 | response <- TodoEndpoints.routes[TestEnv].run(req).value 35 | parsedBody <- Http4sTestHelper.parseBody[TestEnv, List[TodoTask]](response) 36 | } yield assert(parsedBody)(isSome(equalTo(initData))) 37 | 38 | program.provideLayer( 39 | TestEnvs.logging ++ TodoService.test(initData) 40 | ) 41 | }, 42 | testM("POST /todos") { 43 | val postData = TodoTask(id = None, value = "foo", status = Pending) 44 | val req = Request[RIO[TestEnv, *]](Method.POST, uri"/todos") 45 | .withEntity(postData.asJson) 46 | 47 | val program = for { 48 | response <- TodoEndpoints.routes[TestEnv].run(req).value 49 | parsedBody <- Http4sTestHelper.parseBody[TestEnv, String](response) 50 | } yield assert(response.map(_.status))(isSome(equalTo(Status.Created))) && 51 | assert(parsedBody)(isSome(isNonEmptyString)) 52 | 53 | program.provideLayer( 54 | TestEnvs.logging ++ TodoService.test() 55 | ) 56 | }, 57 | testM("POST /todos bad request") { 58 | val req = Request[RIO[TestEnv, *]](Method.POST, uri"/todos") 59 | .withEntity("some req") 60 | 61 | val program = for { 62 | response <- TodoEndpoints.routes[TestEnv].run(req).value 63 | } yield assert(response.map(_.status))(isSome(equalTo(Status.BadRequest))) 64 | 65 | program.provideLayer( 66 | TestEnvs.logging ++ TodoService.test() 67 | ) 68 | }, 69 | testM("GET /todos/{id}/switch") { 70 | val id = "5e7ca3231200001200268a81" 71 | val req = Request[RIO[TestEnv, *]](Method.GET, uri"/todos" / id / "switch") 72 | 73 | val program = for { 74 | response <- TodoEndpoints.routes[TestEnv].run(req).value 75 | parsedBody <- Http4sTestHelper.parseBody[TestEnv, TodoStatus](response) 76 | } yield assert(response.map(_.status))(isSome(equalTo(Status.Ok))) && 77 | assert(parsedBody)(isSome(isSubtype[TodoStatus](anything))) 78 | 79 | program.provideLayer( 80 | TestEnvs.logging ++ TodoService.test() 81 | ) 82 | }, 83 | testM("DELETE /todos/{id}") { 84 | val id = "5e7ca3231200001200268a81" 85 | val req = Request[RIO[TestEnv, *]](Method.DELETE, uri"/todos" / id) 86 | 87 | val program = for { 88 | response <- TodoEndpoints.routes[TestEnv].run(req).value 89 | } yield assert(response.map(_.status))(isSome(equalTo(Status.NoContent))) 90 | 91 | program.provideLayer( 92 | TestEnvs.logging ++ TodoService.test() 93 | ) 94 | } 95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/endpoints/TodoEndpoints.scala: -------------------------------------------------------------------------------- 1 | package example.endpoints 2 | 3 | import cats.implicits._ 4 | import zio._ 5 | import zio.interop.catz._ 6 | import zio.logging._ 7 | 8 | import io.circe.generic.extras.auto._ 9 | import org.http4s.HttpRoutes 10 | import sttp.model.StatusCode 11 | import sttp.tapir._ 12 | import sttp.tapir.json.circe._ 13 | import sttp.tapir.server.http4s._ 14 | 15 | import example.model.Errors._ 16 | import example.modules.services.todoService 17 | import example.modules.services.todoService.TodoService 18 | import example.shared.Dto._ 19 | 20 | object TodoEndpoints { 21 | val exampleId = "5e7ca3231200001200268a81" 22 | 23 | val allErrorsOut = oneOf( 24 | statusMapping(StatusCode.BadRequest, jsonBody[BadRequest].description("bad id")), 25 | statusMapping(StatusCode.NotFound, jsonBody[NotFound].description("not found")), 26 | statusMapping(StatusCode.InternalServerError, jsonBody[UnknownError].description("unknown default error")) 27 | ) 28 | 29 | val unexpectedError = oneOf( 30 | statusMapping(StatusCode.InternalServerError, jsonBody[UnknownError].description("unknown default error")) 31 | ) 32 | 33 | val getAllTodos = endpoint.get 34 | .in("todos") 35 | .errorOut(unexpectedError) 36 | .out(jsonBody[List[TodoTask]].example(List(TodoTask(exampleId.some), TodoTask(exampleId.some)))) 37 | 38 | val createNew = endpoint.post 39 | .in("todos") 40 | .in(jsonBody[TodoTask].example(TodoTask())) 41 | .errorOut(unexpectedError) 42 | .out(jsonBody[String].example(exampleId)) 43 | .out(statusCode(StatusCode.Created)) 44 | 45 | val switchStatus = endpoint.get 46 | .in("todos" / path[String]("id").example(exampleId) / "switch") 47 | .errorOut(allErrorsOut) 48 | .out(jsonBody[TodoStatus].example(Pending)) 49 | 50 | val deleteTodo = endpoint.delete 51 | .in("todos" / path[String]("id").example(exampleId)) 52 | .errorOut(allErrorsOut) 53 | .out(statusCode(StatusCode.NoContent)) 54 | 55 | def endpoints = List( 56 | getAllTodos, 57 | createNew, 58 | switchStatus, 59 | deleteTodo 60 | ) 61 | 62 | // format: off 63 | def routes[R <: TodoService with Logging]: HttpRoutes[RIO[R, *]] = 64 | ( 65 | getAllTodos.toRoutes { _ => 66 | handleUnexpectedError(todoService.getAll) 67 | }: HttpRoutes[RIO[R, *]] // TODO find a better way 68 | ) <+> createNew.toRoutes { toCreate => 69 | handleUnexpectedError(todoService.createNew(toCreate)) 70 | }<+> switchStatus.toRoutes { 71 | id => handleError(todoService.switchStatus(id)) 72 | } <+> deleteTodo.toRoutes(id => handleError(todoService.deleteTodo(id))) 73 | // format: on 74 | 75 | private def handleError[R <: Logging, A](result: ZIO[R, Throwable, A]): URIO[R, Either[ErrorInfo, A]] = 76 | result.foldM( 77 | { 78 | case WrongMongoId(msg) => ZIO.succeed(BadRequest(msg).asLeft) 79 | case TodoTaskNotFound(msg) => ZIO.succeed(NotFound(msg).asLeft) 80 | 81 | case unknown => 82 | for { 83 | _ <- log.throwable("unknown error", unknown) 84 | } yield UnknownError(s"Something went wrong. Check logs for more info").asLeft 85 | }, 86 | succ => ZIO.succeed(succ.asRight) 87 | ) 88 | 89 | private def handleUnexpectedError[R <: Logging, A](result: ZIO[R, Throwable, A]): URIO[R, Either[UnknownError, A]] = 90 | result.foldM( 91 | { 92 | case unknown => 93 | for { 94 | _ <- log.throwable("unknown error", unknown) 95 | } yield UnknownError(s"Something went wrong. Check logs for more info").asLeft 96 | }, 97 | succ => ZIO.succeed(succ.asRight) 98 | ) 99 | } 100 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/modules/services/auth/AuthServiceLive.scala: -------------------------------------------------------------------------------- 1 | package example.modules.services.auth 2 | 3 | import example.model.Errors.AuthenticationError 4 | import example.model.Errors.TokenNotFound 5 | import example.model.Errors.UserExists 6 | import example.model.SqlData 7 | import example.modules.db.userRepository.UserRepository 8 | import example.modules.services.auth.authService.AuthService 9 | import example.modules.services.cryptoService.CryptoService 10 | import example.shared.Dto 11 | import io.scalaland.chimney.dsl._ 12 | import zio._ 13 | import zio.logging.Logger 14 | import zio.logging.LogLevel 15 | 16 | class AuthServiceLive( 17 | userRepository: UserRepository.Service, 18 | logger: Logger[String], 19 | cryptoService: CryptoService.Service 20 | ) extends AuthService.Service { 21 | 22 | def getUser(token: Dto.Token): Task[Dto.User] = 23 | for { 24 | maybeUser <- userRepository.getUserByToken(token) 25 | user <- ZIO.fromOption(maybeUser).flatMapError(_ => tokenNotFoundError("getUser", token)) 26 | dtoUser = toDto(user) 27 | _ <- logger.log(LogLevel.Trace)(s"getUser: $dtoUser") 28 | } yield dtoUser 29 | 30 | def generateNewToken(cred: Dto.AuthCredentials): Task[Dto.User] = 31 | for { 32 | maybeUser <- userRepository.getUserByName(cred.name) 33 | maybeAuthenticatedUser = maybeUser 34 | .filter(u => cryptoService.chkPassword(cred.password, u.password)) 35 | user <- ZIO 36 | .fromOption(maybeAuthenticatedUser) 37 | .flatMapError(_ => authenticationError("generateNewToken", cred.name)) 38 | newToken <- cryptoService.generateToken(user.name) 39 | updatedUser <- userRepository.updateToken(user.id.get, newToken) 40 | 41 | dtoUser = toDto(updatedUser) 42 | _ <- logger.log(LogLevel.Trace)(s"generateNewToken: from: '${user.token}' to '$dtoUser'") 43 | } yield dtoUser 44 | 45 | def createUser(cred: Dto.AuthCredentials): Task[Dto.User] = 46 | for { 47 | newToken <- cryptoService.generateToken(cred.name) 48 | hashedPasswd = cryptoService.hashPassword(cred.password) 49 | toInsert = cred 50 | .into[SqlData.User] 51 | .withFieldComputed(_.password, c => hashedPasswd) 52 | .withFieldComputed(_.token, _ => newToken) 53 | .transform 54 | maybeInsertedUser <- userRepository.insert(toInsert) 55 | insertedUser <- ZIO 56 | .fromOption(maybeInsertedUser) 57 | .flatMapError(_ => userExistsError("createUser", cred.name)) 58 | 59 | newUser = toDto(insertedUser) 60 | _ <- logger.log(LogLevel.Trace)(s"createUser: $newUser") 61 | } yield newUser 62 | 63 | def secretText(user: Dto.User): Task[String] = 64 | ZIO.succeed(s"This is super secure message for ${user.name}!") 65 | 66 | def toDto(sqlUser: SqlData.User): Dto.User = 67 | sqlUser 68 | .into[Dto.User] 69 | .withFieldComputed(_.id, _.id.getOrElse(0L)) 70 | .transform 71 | 72 | def tokenNotFoundMsg(t: Dto.Token) = s"Token '$t' not found" 73 | def userExistsMsg(name: String) = s"User '$name' probably already exists" 74 | def unauthMsg(name: String) = s"Wrong name/password for user '$name'" 75 | 76 | def tokenNotFoundError(errName: String, token: String) = { 77 | val msg = tokenNotFoundMsg(token) 78 | logger.log(LogLevel.Trace)(s"$errName: $msg").as(TokenNotFound(msg)) 79 | } 80 | 81 | def authenticationError(errName: String, username: String) = { 82 | val msg = unauthMsg(username) 83 | logger.log(LogLevel.Trace)(s"$errName: $msg").as(AuthenticationError(msg)) 84 | } 85 | 86 | def userExistsError(errName: String, username: String) = { 87 | val msg = userExistsMsg(username) 88 | logger.log(LogLevel.Trace)(s"createUser: $msg").as(UserExists(msg)) 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /js/src/main/scala/example/modules/MainRouter.scala: -------------------------------------------------------------------------------- 1 | package example.modules 2 | 3 | import diode.data.PotState.PotReady 4 | import scalajs.js 5 | import slinky.core.annotations.react 6 | import slinky.core.FunctionalComponent 7 | import slinky.core.ReactComponentClass 8 | import slinky.reactrouter.Redirect 9 | import slinky.reactrouter.Route 10 | import slinky.reactrouter.Switch 11 | 12 | import example.bridges.PathToRegexp 13 | import example.services.AppCircuit 14 | import example.services.ReactDiode 15 | 16 | @react object MainRouter { 17 | type Props = Unit 18 | 19 | val component = FunctionalComponent[Props] { _ => 20 | val (auth, _) = ReactDiode.useDiode(AppCircuit.zoom(_.auth)) 21 | 22 | def securedRoute(path: String, component: ReactComponentClass[_]) = { 23 | val securedComp: ReactComponentClass[_] = auth.state match { 24 | case PotReady => component 25 | case _ => FunctionalComponent[Unit](_ => Redirect(to = Loc.signIn)) 26 | } 27 | Route(exact = true, path = path, component = securedComp) 28 | } 29 | 30 | val routerSwitch = Switch( 31 | Route(exact = true, path = Loc.home, component = Home.component), 32 | Route(exact = true, path = Loc.simpleExamples, component = SimpleExamples.component), 33 | Route(exact = true, path = Loc.dynPage, component = DynamicPage.component), 34 | Route(exact = true, path = Loc.chat, component = Chat.component), 35 | Route(exact = true, path = Loc.graphQL, component = GraphQL.component), 36 | Route(exact = true, path = Loc.todos, component = Todos.component), 37 | Route(exact = true, path = Loc.flappy, component = Flappy.component), 38 | securedRoute(path = Loc.secured, component = Secured.component), 39 | Route(exact = true, path = Loc.signIn, component = SignIn.component), 40 | Route(exact = true, path = Loc.register, component = Register.component), 41 | Route(exact = true, path = Loc.about, component = About.component) 42 | ) 43 | ReactDiode.diodeContext.Provider(AppCircuit)( 44 | Layout(routerSwitch) 45 | ) 46 | } 47 | 48 | sealed trait MenuItemType 49 | case class RegularMenuItem(idx: String, label: String, location: String) extends MenuItemType 50 | case class DropDownMenuItems(idx: String, label: String, items: Seq[RegularMenuItem]) extends MenuItemType 51 | 52 | object Loc { 53 | val home = "/" 54 | val simpleExamples = "/simple-examples" 55 | val dynPage = "/dyn/:foo(\\d+)/:bar(.*)" 56 | val chat = "/chat" 57 | val graphQL = "/graphql" 58 | val todos = "/todos" 59 | val flappy = "/flappy" 60 | val secured = "/secured" 61 | val about = "/about" 62 | val signIn = "/sign-in" 63 | val register = "/register" 64 | } 65 | val menuItems: Seq[MenuItemType] = Seq( 66 | DropDownMenuItems( 67 | "100", 68 | "Databases", 69 | Seq( 70 | RegularMenuItem("101", "MongoDB todos", Loc.todos), 71 | RegularMenuItem("102", "Postgres flappy", Loc.flappy) 72 | ) 73 | ), 74 | DropDownMenuItems( 75 | "200", 76 | "Auth", 77 | Seq( 78 | RegularMenuItem("201", "Secured page", Loc.secured), 79 | RegularMenuItem("202", "Sign in", Loc.signIn), 80 | RegularMenuItem("203", "Register", Loc.register) 81 | ) 82 | ), 83 | DropDownMenuItems( 84 | "300", 85 | "Other", 86 | Seq( 87 | RegularMenuItem("301", "Simple examples", Loc.simpleExamples), 88 | RegularMenuItem("302", "Dynamic page", pathToDynPage(678, "a/b/c")), 89 | RegularMenuItem("303", "Chat", Loc.chat), 90 | RegularMenuItem("304", "GraphQL", Loc.graphQL) 91 | ) 92 | ), 93 | RegularMenuItem("1000", "About", Loc.about) 94 | ) 95 | 96 | def pathToDynPage(foo: Int, bar: String): String = { 97 | val compiled = PathToRegexp.compile(Loc.dynPage) 98 | compiled( 99 | js.Dynamic 100 | .literal( 101 | foo = foo, 102 | bar = bar 103 | ) 104 | .asInstanceOf[PathToRegexp.ToPathData] 105 | ) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/modules/services/ChatServiceLive.scala: -------------------------------------------------------------------------------- 1 | package example.modules.services 2 | 3 | import cats.implicits._ 4 | import example.model.ChatData.User 5 | import example.modules.services.chatService.ChatService 6 | import example.shared.Dto 7 | import example.shared.Dto.ChangeChatName 8 | import example.shared.Dto.ChatMsg 9 | import example.shared.Dto.ChatUserLeft 10 | import example.shared.Dto.NewChatUser 11 | import example.shared.Dto.UnknownData 12 | import fs2.concurrent.Queue 13 | import io.scalaland.chimney.dsl._ 14 | import zio._ 15 | import zio.logging.Logger 16 | import zio.logging.LogLevel 17 | 18 | class ChatServiceLive(users: Ref[Vector[User]], idCounter: Ref[Int], logger: Logger[String]) 19 | extends ChatService.Service { 20 | def getUsers(): zio.Task[Dto.ChatUsers] = 21 | for { 22 | users <- users.get 23 | dtoUsers = users.map(_.into[Dto.ChatUser].transform) 24 | } yield Dto.ChatUsers(dtoUsers.toSet) 25 | 26 | def createUser(out: Queue[Task[*], Dto.ChatDto]): Task[Dto.ChatUser] = 27 | for { 28 | id <- idCounter.modify(id => (id, id + 1)) 29 | u = User(id = id, name = "unknown", out = out) 30 | dtoU = u.into[Dto.ChatUser].transform 31 | 32 | _ <- out.enqueue1(dtoU) 33 | _ <- handleServerMsg(Dto.NewChatUser(dtoU)) 34 | 35 | _ <- users.update(_ :+ u) 36 | dtoUsers <- getUsers() 37 | _ <- out.enqueue1(dtoUsers) 38 | } yield dtoU 39 | 40 | def handleUserMsg(userId: Int, msg: Dto.ClientMsg): Task[Unit] = msg match { 41 | case chatMsg: ChatMsg => 42 | for { 43 | maybeUser <- users.map(_.find(_.id == userId)).get 44 | _ <- maybeUser 45 | .fold( 46 | logError("ChatMsg", s"userId: $userId not found") 47 | )( 48 | broadcast(_, userDto => chatMsg.copy(user = userDto)) 49 | ) 50 | } yield () 51 | 52 | case cn: ChangeChatName => 53 | for { 54 | maybeOldUser <- users.modify(changeUserName(userId, cn.newName, _)) 55 | _ <- maybeOldUser 56 | .fold( 57 | logError("ChangeChatName", s"userId: $userId not found") 58 | )( 59 | broadcast(_, userDto => cn.copy(oldUser = userDto)) 60 | ) 61 | } yield () 62 | 63 | case ud: UnknownData => 64 | for { 65 | users <- users.get 66 | maybeUser = users.find(_.id == userId) 67 | _ <- maybeUser.fold(zioUnit)(_.out.enqueue1(ud)) 68 | } yield () 69 | } 70 | 71 | def handleServerMsg(msg: Dto.ServerMsg): zio.Task[Unit] = msg match { 72 | case nu: NewChatUser => 73 | broadcast(nu) 74 | case ul @ ChatUserLeft(u) => 75 | for { 76 | maybeRemovedUser <- users.modify(removeUser(u.id, _)) 77 | _ <- maybeRemovedUser 78 | .fold( 79 | logError("ChatUserLeft", s"userId: ${u.id} not found") 80 | )( 81 | broadcast(_, userDto => ul.copy(u = userDto.get)) 82 | ) 83 | } yield () 84 | } 85 | 86 | def zioUnit: ZIO[Any, Throwable, Unit] = ZIO.unit // Nothing -> Throwable 87 | def broadcast(msg: Dto.ChatDto): ZIO[Any, Throwable, Unit] = 88 | for { 89 | users <- users.get 90 | _ <- users.foldLeft(zioUnit)((acc, rcvU) => acc *> rcvU.out.enqueue1(msg)) 91 | } yield () 92 | 93 | def broadcast(u: User, createMsg: Option[Dto.ChatUser] => Dto.ChatDto): ZIO[Any, Throwable, Unit] = { 94 | val dtoU = u.into[Dto.ChatUser].transform 95 | val msgToBroadcast = createMsg(dtoU.some) 96 | broadcast(msgToBroadcast) 97 | } 98 | 99 | def changeUserName(userId: Int, newName: String, users: Vector[User]) = { 100 | import com.softwaremill.quicklens._ 101 | val uIdPred: User => Boolean = _.id == userId 102 | 103 | val maybeOldUser = users.find(uIdPred) 104 | val updatedUsers = users.modify(_.eachWhere(uIdPred).name).setTo(newName) 105 | 106 | (maybeOldUser, updatedUsers) 107 | } 108 | 109 | def removeUser(userId: Int, users: Vector[User]) = { 110 | val maybeRemovedUser = users.find(_.id == userId) 111 | val updatedUsers = users.filter(_.id != userId) 112 | 113 | (maybeRemovedUser, updatedUsers) 114 | } 115 | 116 | def logError(caller: String, msg: String): ZIO[Any, Throwable, Unit] = logger.log(LogLevel.Error)(s"$caller -> $msg") 117 | } 118 | -------------------------------------------------------------------------------- /js/src/main/scala/example/modules/Layout.scala: -------------------------------------------------------------------------------- 1 | package example.modules 2 | 3 | import diode.data.Pot 4 | import diode.data.PotState.PotReady 5 | import slinky.core.annotations.react 6 | import slinky.core.facade.Fragment 7 | import slinky.core.facade.ReactElement 8 | import slinky.core.FunctionalComponent 9 | import slinky.reactrouter.Link 10 | import slinky.web.html._ 11 | 12 | import example.bridges.reactrouter.NavLink 13 | import example.bridges.reactrouter.ReactRouterDOM 14 | import example.modules.MainRouter.DropDownMenuItems 15 | import example.modules.MainRouter.Loc 16 | import example.modules.MainRouter.RegularMenuItem 17 | import example.services.AppCircuit 18 | import example.services.Auth 19 | import example.services.ReactDiode 20 | import example.services.SignOut 21 | 22 | @react object Layout { 23 | case class Props(content: ReactElement) 24 | 25 | def createRegularMenuItem(idx: String, label: String, location: String) = 26 | li(key := idx, className := "nav-item", NavLink(exact = true, to = location)(className := "nav-link", label)) 27 | 28 | def createDropDownMenuItems(currentPath: String, idx: String, label: String, items: Seq[RegularMenuItem]) = 29 | li( 30 | key := idx, 31 | if (items.exists(_.location == currentPath)) className := "nav-item dropdown active" 32 | else className := "nav-item dropdown", 33 | a( 34 | className := "nav-link dropdown-toggle", 35 | href := "#", 36 | id := "navbarDropdown", 37 | role := "button", 38 | data - "toggle" := "dropdown", 39 | aria - "haspopup" := "true", 40 | aria - "expanded" := "false", 41 | label 42 | ), 43 | div( 44 | className := "dropdown-menu", 45 | aria - "labelledby" := "navbarDropdown", 46 | items.map(item => 47 | NavLink(exact = true, to = item.location)(className := "dropdown-item", key := item.idx, item.label) 48 | ) 49 | ) 50 | ) 51 | 52 | def nav(props: Props, currentPath: String, auth: Pot[Auth], onSignOut: () => Unit) = 53 | div( 54 | className := "navbar navbar-expand-md navbar-dark bg-dark", 55 | Link(to = Loc.home)( 56 | className := "navbar-brand", 57 | img(src := "front-res/img/logo-mini.png"), 58 | " full-stack-zio" 59 | ), 60 | button( 61 | className := "navbar-toggler", 62 | `type` := "button", 63 | data - "toggle" := "collapse", 64 | data - "target" := "#navbarNav", 65 | aria - "controls" := "navbarNav", 66 | aria - "expanded" := "false", 67 | aria - "label" := "Toggle navigation", 68 | span(className := "navbar-toggler-icon") 69 | ), 70 | div( 71 | className := "collapse navbar-collapse", 72 | id := "navbarNav", 73 | ul( 74 | className := "navbar-nav mr-auto", 75 | MainRouter.menuItems.map(_ match { 76 | case RegularMenuItem(idx, label, location) => createRegularMenuItem(idx, label, location) 77 | case DropDownMenuItems(idx, label, items) => createDropDownMenuItems(currentPath, idx, label, items) 78 | }) 79 | ), 80 | auth.state match { 81 | case PotReady => 82 | Fragment( 83 | span(className := "navbar-text mr-2", auth.get.username), 84 | button(className := "btn btn-secondary d-lg-inline-block", "Sign Out", onClick := onSignOut) 85 | ) 86 | case _ => 87 | NavLink(exact = true, to = MainRouter.Loc.signIn)( 88 | className := "btn btn-secondary d-lg-inline-block", 89 | "Sign In" 90 | ) 91 | } 92 | ) 93 | ) 94 | 95 | def contentBody(props: Props) = props.content 96 | 97 | def footer(props: Props) = 98 | div(className := "footer bg-dark text-white d-flex justify-content-center mt-auto py-3", "© 2020 oen") 99 | 100 | val component = FunctionalComponent[Props] { props => 101 | val (auth, dispatch) = ReactDiode.useDiode(AppCircuit.zoomTo(_.auth)) 102 | val location = ReactRouterDOM.useLocation() 103 | 104 | Fragment( 105 | nav(props, location.pathname, auth, () => dispatch(SignOut)), 106 | div(className := "container", div(className := "main-content mt-5", role := "main", contentBody(props))), 107 | footer(props) 108 | ) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /js/src/main/scala/example/modules/SignIn.scala: -------------------------------------------------------------------------------- 1 | package example.modules 2 | 3 | import cats.implicits._ 4 | import diode.data.PotState.PotPending 5 | import diode.data.PotState.PotReady 6 | import org.scalajs.dom.{html, Event} 7 | import slinky.core.annotations.react 8 | import slinky.core.facade.Hooks._ 9 | import slinky.core.facade.ReactElement 10 | import slinky.core.FunctionalComponent 11 | import slinky.core.SyntheticEvent 12 | import slinky.web.html._ 13 | 14 | import example.bridges.reactrouter.NavLink 15 | import example.components.AuthLastError 16 | import example.services.AppCircuit 17 | import example.services.ReactDiode 18 | import example.services.TryAuth 19 | import example.services.Validator 20 | 21 | @react object SignIn { 22 | type Props = Unit 23 | 24 | val component = FunctionalComponent[Props] { _ => 25 | val (auth, dispatch) = ReactDiode.useDiode(AppCircuit.zoom(_.auth)) 26 | val (username, setUsername) = useState("test") 27 | val (password, setPassword) = useState("test") 28 | val (errorMsgs, setErrorMsgs) = useState(Vector[String]()) 29 | 30 | def handleUsername(e: SyntheticEvent[html.Input, Event]): Unit = setUsername(e.target.value) 31 | def handlePassword(e: SyntheticEvent[html.Input, Event]): Unit = setPassword(e.target.value) 32 | 33 | def signIn(tryAuth: TryAuth) = { 34 | dispatch(tryAuth) 35 | setUsername("") 36 | setPassword("") 37 | setErrorMsgs(Vector()) 38 | } 39 | 40 | def handleSignIn(e: SyntheticEvent[html.Form, Event]): Unit = { 41 | e.preventDefault() 42 | Validator 43 | .validateTryAuth(username, password) 44 | .fold(setErrorMsgs, signIn) 45 | } 46 | 47 | def signInForm() = form( 48 | onSubmit := (handleSignIn(_)), 49 | div( 50 | className := "input-group mb-3", 51 | div( 52 | className := "input-group-prepend", 53 | span(className := "input-group-text", "username", id := "form-username-label") 54 | ), 55 | input( 56 | `type` := "text", 57 | className := "form-control", 58 | placeholder := "Username", 59 | aria - "label" := "Username", 60 | aria - "describedby" := "form-username-label", 61 | value := username, 62 | onChange := (handleUsername(_)) 63 | ) 64 | ), 65 | div( 66 | className := "input-group mb-3", 67 | div( 68 | className := "input-group-prepend", 69 | span(className := "input-group-text", "password", id := "form-password-label") 70 | ), 71 | input( 72 | `type` := "password", 73 | className := "form-control", 74 | placeholder := "Password", 75 | aria - "label" := "Password", 76 | aria - "describedby" := "form-password-label", 77 | value := password, 78 | onChange := (handlePassword(_)) 79 | ) 80 | ), 81 | errorMsgs.zipWithIndex.map { 82 | case (msg, idx) => 83 | div(key := idx.toString, className := "alert alert-danger", role := "alert", msg) 84 | }, 85 | div( 86 | className := "row", 87 | div(className := "col", button(`type` := "submit", className := "btn btn-secondary", "Sign In")), 88 | div(className := "col", small("you can use: test/test")), 89 | div(className := "col text-right", NavLink(exact = true, to = MainRouter.Loc.register)("register")) 90 | ) 91 | ) 92 | 93 | div( 94 | className := "card", 95 | div(className := "card-header", "Sign In"), 96 | div( 97 | className := "card-body", 98 | h5( 99 | className := "card-title", 100 | auth.state match { 101 | case PotPending => 102 | div( 103 | className := "spinner-border text-primary", 104 | role := "status", 105 | span(className := "sr-only", "Loading...") 106 | ) 107 | case PotReady => 108 | auth.fold("unknown error")(a => s"Logged as ${a.username.toString}") 109 | case _ => 110 | "Sign In!" 111 | } 112 | ), 113 | AuthLastError(), 114 | auth.state match { 115 | case PotReady => none[ReactElement] 116 | case _ => signInForm().some 117 | } 118 | ) 119 | ) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /js/src/main/scala/example/services/AjaxClient.scala: -------------------------------------------------------------------------------- 1 | package example.services 2 | 3 | import example.shared.Dto.Foo 4 | import org.scalajs.dom.ext.Ajax 5 | import org.scalajs.dom.raw.XMLHttpRequest 6 | import scala.util.Failure 7 | import scala.util.Success 8 | import scala.util.Try 9 | 10 | import example.shared.Dto._ 11 | import io.circe.Decoder 12 | import io.circe.generic.extras.auto._ 13 | import io.circe.parser.decode 14 | import io.circe.syntax._ 15 | import org.scalajs.dom.ext.AjaxException 16 | import scala.scalajs.LinkingInfo 17 | 18 | object AjaxClient { 19 | val JSON_TYPE = Map("Content-Type" -> "application/json") 20 | def authHeader(token: String) = Map("TOKEN" -> token) 21 | val baseUrl = if (LinkingInfo.developmentMode) "http://localhost:8080" else "" 22 | 23 | import scala.concurrent.ExecutionContext.Implicits.global 24 | 25 | def getRandom = 26 | Ajax 27 | .get( 28 | url = s"$baseUrl/json/random", 29 | headers = JSON_TYPE 30 | ) 31 | .transform(decodeAndHandleErrors[Foo]) 32 | 33 | def getTodos = 34 | Ajax 35 | .get( 36 | url = s"$baseUrl/todos", 37 | headers = JSON_TYPE 38 | ) 39 | .transform(decodeAndHandleErrors[Vector[TodoTask]]) 40 | 41 | def postTodo(newTodo: TodoTask) = 42 | Ajax 43 | .post( 44 | url = s"$baseUrl/todos", 45 | data = newTodo.asJson.noSpaces, 46 | headers = JSON_TYPE 47 | ) 48 | .transform(decodeAndHandleErrors[String]) 49 | 50 | def switchStatus(todoId: String) = 51 | Ajax 52 | .get( 53 | url = s"$baseUrl/todos/$todoId/switch", 54 | headers = JSON_TYPE 55 | ) 56 | .transform(decodeAndHandleErrors[TodoStatus]) 57 | 58 | def deleteTodo(todoId: String) = 59 | Ajax 60 | .delete( 61 | url = s"$baseUrl/todos/$todoId", 62 | headers = JSON_TYPE 63 | ) 64 | .transform(_.responseText, onFailure) 65 | 66 | def getScoreboard = 67 | Ajax 68 | .get( 69 | url = s"$baseUrl/scoreboard", 70 | headers = JSON_TYPE 71 | ) 72 | .transform(decodeAndHandleErrors[Vector[ScoreboardRecord]]) 73 | 74 | def postScore(newScore: ScoreboardRecord) = 75 | Ajax 76 | .post( 77 | url = s"$baseUrl/scoreboard", 78 | data = newScore.asJson.noSpaces, 79 | headers = JSON_TYPE 80 | ) 81 | .transform(decodeAndHandleErrors[ScoreboardRecord]) 82 | 83 | def deleteAllScores() = 84 | Ajax 85 | .delete( 86 | url = s"$baseUrl/scoreboard", 87 | headers = JSON_TYPE 88 | ) 89 | .transform(_.responseText, onFailure) 90 | 91 | def postAuth(cred: AuthCredentials) = 92 | Ajax 93 | .post( 94 | url = s"$baseUrl/auth", 95 | data = cred.asJson.noSpaces, 96 | headers = JSON_TYPE 97 | ) 98 | .transform(decodeAndHandleErrors[User]) 99 | 100 | def postAuthUser(cred: AuthCredentials) = 101 | Ajax 102 | .post( 103 | url = s"$baseUrl/auth/user", 104 | data = cred.asJson.noSpaces, 105 | headers = JSON_TYPE 106 | ) 107 | .transform(decodeAndHandleErrors[User]) 108 | 109 | def getAuthSecured(token: String) = 110 | Ajax 111 | .get( 112 | url = s"$baseUrl/auth/secured", 113 | headers = JSON_TYPE ++ authHeader(token) 114 | ) 115 | .transform(decodeAndHandleErrors[String]) 116 | 117 | private[this] def decodeAndHandleErrors[A: Decoder](t: Try[XMLHttpRequest]): Try[A] = t match { 118 | case Success(req) => decode[A](req.responseText).toTry 119 | case Failure(e) => Failure(onFailure(e)) 120 | } 121 | 122 | private[this] def onFailure: Throwable => Throwable = t => { 123 | t.printStackTrace() 124 | t match { 125 | case ex: AjaxException => 126 | val msgEx: Exception = 127 | decode[GenericMsgException](ex.xhr.responseText).getOrElse(AjaxErrorException(ex.xhr.responseText)) 128 | ex.xhr.status match { 129 | case 401 | 409 => msgEx 130 | case _ => AjaxClient.AjaxErrorException(s"Connection error.") 131 | } 132 | case unknown => AjaxClient.UnknownErrorException 133 | } 134 | } 135 | 136 | case object UnknownErrorException extends Exception("unknown error") 137 | case class AjaxErrorException(s: String) extends Exception(s) 138 | case class GenericMsgException(msg: String) extends Exception(msg) 139 | } 140 | -------------------------------------------------------------------------------- /jvm/src/test/scala/example/modules/services/auth/AuthServiceTest.scala: -------------------------------------------------------------------------------- 1 | package example.modules.services.auth 2 | 3 | import example.model.Errors.TokenNotFound 4 | import example.modules.services.auth.authService 5 | import example.shared.Dto.User 6 | import example.TestEnvs 7 | import zio.test._ 8 | import zio.test.Assertion._ 9 | import zio.test.TestAspect._ 10 | import example.shared.Dto.AuthCredentials 11 | import example.model.Errors.AuthenticationError 12 | import example.model.Errors.UserExists 13 | 14 | object AuthServiceTest extends DefaultRunnableSpec { 15 | val testUser = User(1L, "test", "test") 16 | val testUser2 = User(2L, "user2", "user2Token") 17 | val testUser2Cred = AuthCredentials("user2", "user2Password") 18 | 19 | def spec = 20 | suite("authService test")( 21 | suite("getUser")( 22 | testM("with correct token") { 23 | val program = for { 24 | result <- authService.getUser(testUser.token) 25 | } yield assert(result)(equalTo(testUser)) 26 | 27 | program.provideLayer(TestEnvs.testAuthService()) 28 | }, 29 | testM("with wrong token") { 30 | val program = authService.getUser("wrong token") 31 | val programWithLayers = program.provideLayer(TestEnvs.testAuthService()) 32 | assertM(programWithLayers.run)(fails(isSubtype[TokenNotFound](anything))) 33 | } 34 | ), 35 | suite("generateToken")( 36 | testM("correct credentials full flow") { 37 | val expected = testUser2.copy(token = "generatedToken") 38 | val program = for { 39 | oldUserData <- authService.getUser(testUser2.token) 40 | result <- authService.generateNewToken(testUser2Cred) 41 | newUserData <- authService.getUser(result.token) 42 | } yield assert(oldUserData)(equalTo(testUser2)) && 43 | assert(result)(equalTo(expected)) && 44 | assert(newUserData)(equalTo(expected)) 45 | 46 | program.provideLayer(TestEnvs.testAuthService()) 47 | }, 48 | testM("deactivate old token") { 49 | val program = for { 50 | _ <- authService.getUser(testUser2.token) 51 | _ <- authService.generateNewToken(testUser2Cred) 52 | _ <- authService.getUser(testUser2.token) 53 | } yield () 54 | 55 | val programWithLayers = program.provideLayer(TestEnvs.testAuthService()) 56 | assertM(programWithLayers.run)(fails(isSubtype[TokenNotFound](anything))) 57 | }, 58 | testM("with wrong password") { 59 | val wrongCredentials = testUser2Cred.copy(password = "wrong password") 60 | val program = authService.generateNewToken(wrongCredentials) 61 | 62 | val programWithLayers = program.provideLayer(TestEnvs.testAuthService()) 63 | assertM(programWithLayers.run)(fails(isSubtype[AuthenticationError](anything))) 64 | }, 65 | testM("with wrong name") { 66 | val wrongCredentials = testUser2Cred.copy(name = "wrong name") 67 | val program = authService.generateNewToken(wrongCredentials) 68 | 69 | val programWithLayers = program.provideLayer(TestEnvs.testAuthService()) 70 | assertM(programWithLayers.run)(fails(isSubtype[AuthenticationError](anything))) 71 | } 72 | ), 73 | suite("createUser")( 74 | testM("create correct user full flow") { 75 | val newCred = AuthCredentials("newUserName", "somePassword") 76 | val program = for { 77 | created <- authService.createUser(newCred) 78 | userData <- authService.getUser(created.token) 79 | } yield assert(created.id)(not(isNull)) && 80 | assert(created.name)(equalTo(newCred.name)) && 81 | assert(created.token)(isNonEmptyString) && 82 | assert(userData)(equalTo(created)) 83 | 84 | program.provideLayer(TestEnvs.testAuthService()) 85 | }, 86 | testM("user exists") { 87 | val newCred = testUser2Cred.copy(password = "some passwd") 88 | val program = authService.createUser(newCred) 89 | 90 | val programWithLayers = program.provideLayer(TestEnvs.testAuthService()) 91 | assertM(programWithLayers.run)(fails(isSubtype[UserExists](anything))) 92 | } 93 | ), 94 | testM("get secret text") { 95 | val program = for { 96 | result <- authService.secretText(testUser) 97 | } yield assert(result)(isNonEmptyString) 98 | 99 | program.provideLayer(TestEnvs.testAuthService()) 100 | } 101 | ) @@ sequential 102 | } 103 | -------------------------------------------------------------------------------- /js/src/main/scala/example/modules/Chat.scala: -------------------------------------------------------------------------------- 1 | package example.modules 2 | 3 | import cats.implicits._ 4 | import example.components.chat.ChatUserList 5 | import example.components.chat.ChatView 6 | import example.services.AppCircuit 7 | import example.services.ChatWebsock 8 | import example.services.Connect 9 | import example.services.Disconnect 10 | import example.services.ReactDiode 11 | import example.shared.Dto 12 | import org.scalajs.dom.{html, Event} 13 | import slinky.core.annotations.react 14 | import slinky.core.facade.Fragment 15 | import slinky.core.facade.Hooks._ 16 | import slinky.core.FunctionalComponent 17 | import slinky.core.SyntheticEvent 18 | import slinky.web.html._ 19 | import example.components.GlobalName 20 | 21 | @react object Chat { 22 | type Props = Unit 23 | 24 | val component = FunctionalComponent[Props] { _ => 25 | val (newMsg, setNewMsg) = useState("") 26 | val (errors, setErrors) = useState(Vector[String]()) 27 | val (autoscroll, setAutoscroll) = useState(true) 28 | val (maybeWs, dispatch) = ReactDiode.useDiode(AppCircuit.zoomTo(_.chatConn.ws)) 29 | val (msgs, _) = ReactDiode.useDiode(AppCircuit.zoomTo(_.chatConn.msgs)) 30 | 31 | def handleNewMsg(e: SyntheticEvent[html.Input, Event]): Unit = setNewMsg(e.target.value) 32 | def handleSetAutoscroll(e: SyntheticEvent[html.Input, Event]): Unit = setAutoscroll(e.currentTarget.checked) 33 | 34 | def handleSend(e: SyntheticEvent[html.Form, Event]): Unit = { 35 | e.preventDefault() 36 | if (newMsg.nonEmpty) { 37 | maybeWs.fold(())(ws => ChatWebsock.send(ws, Dto.ChatMsg(msg = newMsg))) 38 | setNewMsg("") 39 | } 40 | } 41 | 42 | val connect = () => dispatch(Connect) 43 | val disconnect = () => dispatch(Disconnect) 44 | 45 | def chatForm() = Fragment( 46 | form( 47 | onSubmit := (handleSend(_)), 48 | div( 49 | className := "form-group form-check", 50 | input( 51 | `type` := "checkbox", 52 | id := "autoscroll-checkbox", 53 | className := "form-check-input", 54 | aria - "label" := "autoscroll", 55 | aria - "describedby" := "form-autoscroll-label", 56 | checked := autoscroll, 57 | onChange := (handleSetAutoscroll(_)) 58 | ), 59 | label( 60 | id := "form-autoscroll-label", 61 | className := "form-check-label", 62 | htmlFor := "autoscroll-checkbox", 63 | "autoscroll" 64 | ) 65 | ), 66 | div( 67 | className := "row", 68 | div(className := "col-12 order-2 col-sm-8 order-sm-1", ChatView(autoscroll), autoscroll), 69 | div(className := "col-12 order-1 col-sm-4 order-sm-2", ChatUserList()) 70 | ), 71 | div( 72 | className := "input-group mb-3", 73 | div( 74 | className := "input-group-prepend", 75 | span(className := "input-group-text", "Message:", id := "form-message-label") 76 | ), 77 | input( 78 | `type` := "text", 79 | className := "form-control", 80 | placeholder := "Message", 81 | aria - "label" := "Message", 82 | aria - "describedby" := "form-message-label", 83 | value := newMsg, 84 | onChange := (handleNewMsg(_)) 85 | ) 86 | ), 87 | errors.zipWithIndex.map { 88 | case (msg, idx) => 89 | div(key := idx.toString, className := "alert alert-danger", role := "alert", msg) 90 | }, 91 | div( 92 | className := "row", 93 | div(className := "col", button(`type` := "submit", className := "btn btn-secondary w-100", "send")) 94 | ) 95 | ) 96 | ) 97 | 98 | div( 99 | className := "card", 100 | div( 101 | className := "card-header", 102 | div( 103 | className := "row", 104 | div(className := "col", div("Chat")), 105 | div( 106 | className := "col", 107 | div( 108 | className := "text-right", 109 | button(className := "btn btn-primary", disabled := maybeWs.isDefined, onClick := connect, "connect"), 110 | button(className := "btn btn-danger", disabled := maybeWs.isEmpty, onClick := disconnect, "disconnect") 111 | ) 112 | ) 113 | ) 114 | ), 115 | div( 116 | className := "card-body", 117 | h5(className := "card-title", "Open in new tab/window to test"), 118 | GlobalName(), 119 | chatForm() 120 | ) 121 | ) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /jvm/src/test/scala/example/modules/services/TodoServiceTest.scala: -------------------------------------------------------------------------------- 1 | package example.modules.services 2 | 3 | import cats.implicits._ 4 | import com.softwaremill.quicklens._ 5 | import example.model.Errors.TodoTaskNotFound 6 | import example.model.MongoData 7 | import example.shared.Dto 8 | import example.TestEnvs 9 | import io.scalaland.chimney.dsl._ 10 | import reactivemongo.api.bson.BSONObjectID 11 | import zio.test._ 12 | import zio.test.Assertion._ 13 | 14 | object TodoServiceTest extends DefaultRunnableSpec { 15 | val initData = Vector( 16 | MongoData.TodoTask(id = BSONObjectID.generate(), value = "foo", status = MongoData.Done), 17 | MongoData.TodoTask(id = BSONObjectID.generate(), value = "bar", status = MongoData.Pending) 18 | ) 19 | 20 | val initDataAsDto = initData 21 | .map( 22 | _.into[Dto.TodoTask] 23 | .withFieldComputed(_.id, _.id.stringify.some) 24 | .transform 25 | ) 26 | .toList 27 | 28 | def spec = suite("todoService test")( 29 | testM("getAll with full db") { 30 | val program = for { 31 | result <- todoService.getAll 32 | } yield assert(result)(equalTo(initDataAsDto)) 33 | 34 | program.provideLayer(TestEnvs.todoServ(initData)) 35 | }, 36 | testM("getAll with empty db") { 37 | val expected = List[Dto.TodoTask]() 38 | 39 | val program = for { 40 | result <- todoService.getAll 41 | } yield assert(result)(equalTo(expected)) 42 | 43 | program.provideLayer(TestEnvs.todoServ()) 44 | }, 45 | testM("createNew") { 46 | val toCreate = Dto.TodoTask(value = "foo1", status = Dto.Pending) 47 | 48 | val program = 49 | for { 50 | createdId <- todoService.createNew(toCreate) 51 | allTodos <- todoService.getAll 52 | expected = toCreate.modify(_.id).setTo(createdId.some) 53 | } yield assert(createdId)(isNonEmptyString) && 54 | assert(allTodos)(exists(equalTo(expected))) 55 | 56 | program.provideLayer(TestEnvs.todoServ(initData)) 57 | }, 58 | testM("createNew with ignoring incoming id") { 59 | val toCreate = initDataAsDto.get(0).get 60 | 61 | val program = 62 | for { 63 | createdId <- todoService.createNew(toCreate) 64 | allTodos <- todoService.getAll 65 | expected = toCreate.modify(_.id).setTo(createdId.some) 66 | } yield assert(createdId)(not(equalTo(toCreate.id.get))) && 67 | assert(allTodos)(exists(equalTo(expected))) 68 | 69 | program.provideLayer(TestEnvs.todoServ(initData)) 70 | }, 71 | testM("switchStatus from Done") { 72 | val toSwitch = initDataAsDto.get(0).get 73 | 74 | val program = 75 | for { 76 | switched <- todoService.switchStatus(toSwitch.id.get) 77 | allTodos <- todoService.getAll 78 | expected = toSwitch.modify(_.status).setTo(Dto.Pending) 79 | } yield assert(switched)(equalTo(Dto.Pending)) && 80 | assert(allTodos)(exists(equalTo(expected))) 81 | 82 | program.provideLayer(TestEnvs.todoServ(initData)) 83 | }, 84 | testM("switchStatus from Pending") { 85 | val toSwitch = initDataAsDto.get(1).get 86 | 87 | val program = 88 | for { 89 | switched <- todoService.switchStatus(toSwitch.id.get) 90 | allTodos <- todoService.getAll 91 | expected = toSwitch.modify(_.status).setTo(Dto.Done) 92 | } yield assert(switched)(equalTo(Dto.Done)) && 93 | assert(allTodos)(exists(equalTo(expected))) 94 | 95 | program.provideLayer(TestEnvs.todoServ(initData)) 96 | }, 97 | testM("switchStatus NotFound") { 98 | val id = BSONObjectID.generate().stringify 99 | val program = todoService.switchStatus(id) 100 | 101 | val programWithLayers = program.provideLayer(TestEnvs.todoServ(initData)) 102 | assertM(programWithLayers.run)(fails(isSubtype[TodoTaskNotFound](anything))) 103 | }, 104 | testM("deleteTodo") { 105 | val expected = initDataAsDto.drop(1) 106 | 107 | val program = for { 108 | allTodos <- todoService.getAll 109 | _ <- todoService.deleteTodo(allTodos.get(0).get.id.get) 110 | actual <- todoService.getAll 111 | } yield assert(actual)(equalTo(expected)) 112 | 113 | program.provideLayer(TestEnvs.todoServ(initData)) 114 | }, 115 | testM("deleteTodo NotFound") { 116 | val id = BSONObjectID.generate().stringify 117 | val program = todoService.deleteTodo(id) 118 | 119 | val programWithLayers = program.provideLayer(TestEnvs.todoServ(initData)) 120 | assertM(programWithLayers.run)(fails(isSubtype[TodoTaskNotFound](anything))) 121 | } 122 | ) 123 | } 124 | -------------------------------------------------------------------------------- /js/src/main/scala/example/modules/Todos.scala: -------------------------------------------------------------------------------- 1 | package example.modules 2 | 3 | import cats.implicits._ 4 | 5 | import example.components.DeleteDialog 6 | import example.components.TodoLi 7 | import example.services.AddNewTodo 8 | import example.services.AppCircuit 9 | import example.services.DeleteTodo 10 | import example.services.ReactDiode 11 | import example.services.TryGetTodos 12 | import example.shared.Dto.TodoTask 13 | 14 | import diode.data.PotState.PotEmpty 15 | import diode.data.PotState.PotFailed 16 | import diode.data.PotState.PotPending 17 | import diode.data.PotState.PotReady 18 | import org.scalajs.dom.{html, Event} 19 | import slinky.core.annotations.react 20 | import slinky.core.facade.Fragment 21 | import slinky.core.facade.Hooks._ 22 | import slinky.core.facade.ReactElement 23 | import slinky.core.FunctionalComponent 24 | import slinky.core.SyntheticEvent 25 | import slinky.web.html._ 26 | 27 | @react object Todos { 28 | type Props = Unit 29 | val component = FunctionalComponent[Props] { _ => 30 | val (todos, dispatch) = ReactDiode.useDiode(AppCircuit.zoomTo(_.todos)) 31 | val (toDelete, setToDelete) = useState(none[TodoTask]) 32 | val (toAdd, setToAdd) = useState("") 33 | 34 | useEffect(() => dispatch(TryGetTodos()), Seq()) 35 | 36 | val clearToDelete = () => setToDelete(none) 37 | val submitDelete = () => dispatch(DeleteTodo(toDelete.flatMap(_.id).getOrElse(""))) 38 | def onDelete(todoTask: TodoTask) = () => setToDelete(todoTask.some) 39 | def onChangeAddNew(e: SyntheticEvent[html.Input, Event]): Unit = setToAdd(e.target.value) 40 | def onClickAddNew(e: SyntheticEvent[html.Button, Event]): Unit = { 41 | e.preventDefault() 42 | if (toAdd.trim().nonEmpty) { 43 | val todoTask = TodoTask(value = toAdd) 44 | val action = AddNewTodo(todoTask) 45 | dispatch(action) 46 | setToAdd("") 47 | } 48 | } 49 | 50 | Fragment( 51 | DeleteDialog( 52 | onDelete = submitDelete, 53 | onCancel = clearToDelete, 54 | content = toDelete.fold( 55 | div("error: can't find selected item'"): ReactElement 56 | ) { todo => 57 | table( 58 | className := "table table-striped", 59 | tbody( 60 | tr( 61 | td("id"), 62 | td(todo.id.fold("unknown")(identity)) 63 | ), 64 | tr( 65 | td("value"), 66 | td(todo.value) 67 | ), 68 | tr( 69 | td("status"), 70 | td(todo.status.toString) 71 | ) 72 | ) 73 | ) 74 | } 75 | ), 76 | div( 77 | className := "row justify-content-center", 78 | div( 79 | className := "todo-size", 80 | form( 81 | div( 82 | className := "input-group", 83 | input(className := "form-control width-100", value := toAdd, onChange := (onChangeAddNew(_))), 84 | span( 85 | className := "input-group-append", 86 | button( 87 | className := "btn btn-primary", 88 | onClick := (onClickAddNew(_)), 89 | i(className := "fas fa-plus-circle"), 90 | " add new" 91 | ) 92 | ) 93 | ) 94 | ) 95 | ), 96 | div( 97 | className := "card todo-size mt-2", 98 | div(className := "card-header", "TODO list"), 99 | div( 100 | className := "card-body", 101 | todos.state match { 102 | case PotEmpty => 103 | "nothing here" 104 | case PotPending => 105 | div( 106 | className := "row justify-content-center", 107 | div( 108 | className := "spinner-border text-primary", 109 | role := "status", 110 | span(className := "sr-only", "Loading...") 111 | ) 112 | ) 113 | case PotFailed => 114 | todos.exceptionOption.fold("unknown error")(msg => " error: " + msg.getMessage()) 115 | case PotReady => 116 | todos.fold( 117 | div("nothing here yet"): ReactElement 118 | )(ts => 119 | ul( 120 | className := "list-group list-group-flush", 121 | ts.map(t => TodoLi(todoTask = t, onDelete = onDelete(t)).withKey(t.id.getOrElse("error-key"))) 122 | ) 123 | ) 124 | case _ => div("unexpected state") 125 | } 126 | ) 127 | ) 128 | ) 129 | ) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /js/src/main/scala/example/modules/Register.scala: -------------------------------------------------------------------------------- 1 | package example.modules 2 | 3 | import cats.implicits._ 4 | import diode.data.PotState.PotPending 5 | import diode.data.PotState.PotReady 6 | import org.scalajs.dom.{html, Event} 7 | import slinky.core.annotations.react 8 | import slinky.core.facade.Hooks._ 9 | import slinky.core.facade.ReactElement 10 | import slinky.core.FunctionalComponent 11 | import slinky.core.SyntheticEvent 12 | import slinky.web.html._ 13 | 14 | import example.components.AuthLastError 15 | import example.services.AppCircuit 16 | import example.services.ReactDiode 17 | import example.services.TryRegister 18 | import example.services.Validator 19 | 20 | @react object Register { 21 | type Props = Unit 22 | 23 | val component = FunctionalComponent[Props] { _ => 24 | val (auth, dispatch) = ReactDiode.useDiode(AppCircuit.zoom(_.auth)) 25 | val (username, setUsername) = useState("") 26 | val (password, setPassword) = useState("") 27 | val (rePassword, setRePassword) = useState("") 28 | val (errorMsgs, setErrorMsgs) = useState(Vector[String]()) 29 | 30 | def handleUsername(e: SyntheticEvent[html.Input, Event]): Unit = setUsername(e.target.value) 31 | def handlePassword(e: SyntheticEvent[html.Input, Event]): Unit = setPassword(e.target.value) 32 | def handleRePassword(e: SyntheticEvent[html.Input, Event]): Unit = setRePassword(e.target.value) 33 | 34 | def register(tryRegister: TryRegister) = { 35 | dispatch(tryRegister) 36 | setUsername("") 37 | setPassword("") 38 | setRePassword("") 39 | setErrorMsgs(Vector()) 40 | } 41 | 42 | def handleRegister(e: SyntheticEvent[html.Form, Event]): Unit = { 43 | e.preventDefault() 44 | Validator 45 | .validateTryRegister(username, password, rePassword) 46 | .fold(setErrorMsgs, register) 47 | } 48 | 49 | def registerForm() = form( 50 | onSubmit := (handleRegister(_)), 51 | div( 52 | className := "input-group mb-3", 53 | div( 54 | className := "input-group-prepend", 55 | span(className := "input-group-text", "username", id := "form-username-label") 56 | ), 57 | input( 58 | `type` := "text", 59 | className := "form-control", 60 | placeholder := "Username", 61 | aria - "label" := "Username", 62 | aria - "describedby" := "form-username-label", 63 | value := username, 64 | onChange := (handleUsername(_)) 65 | ) 66 | ), 67 | div( 68 | className := "input-group mb-3", 69 | div( 70 | className := "input-group-prepend", 71 | span(className := "input-group-text", "password", id := "form-password-label") 72 | ), 73 | input( 74 | `type` := "password", 75 | className := "form-control", 76 | placeholder := "Password", 77 | aria - "label" := "Password", 78 | aria - "describedby" := "form-password-label", 79 | value := password, 80 | onChange := (handlePassword(_)) 81 | ) 82 | ), 83 | div( 84 | className := "input-group mb-3", 85 | div( 86 | className := "input-group-prepend", 87 | span(className := "input-group-text", "repeat password", id := "form-re-password-label") 88 | ), 89 | input( 90 | `type` := "password", 91 | className := "form-control", 92 | placeholder := "Repeat Password", 93 | aria - "label" := "Repeat password", 94 | aria - "describedby" := "form-re-password-label", 95 | value := rePassword, 96 | onChange := (handleRePassword(_)) 97 | ) 98 | ), 99 | errorMsgs.zipWithIndex.map { 100 | case (msg, idx) => 101 | div(key := idx.toString, className := "alert alert-danger", role := "alert", msg) 102 | }, 103 | button(`type` := "submit", className := "btn btn-secondary", "Register and sign-in instantly") 104 | ) 105 | 106 | div( 107 | className := "card", 108 | div(className := "card-header", "Register (simple and free)"), 109 | div( 110 | className := "card-body", 111 | h5( 112 | className := "card-title", 113 | auth.state match { 114 | case PotPending => 115 | div( 116 | className := "spinner-border text-primary", 117 | role := "status", 118 | span(className := "sr-only", "Loading...") 119 | ) 120 | case PotReady => 121 | auth.fold("unknown error")(a => s"You are successfully registered and logged as ${a.username.toString}") 122 | case _ => 123 | "Register" 124 | } 125 | ), 126 | AuthLastError(), 127 | auth.state match { 128 | case PotReady => none[ReactElement] 129 | case _ => registerForm().some 130 | } 131 | ) 132 | ) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/modules/db/TodoRepository.scala: -------------------------------------------------------------------------------- 1 | package example.modules.db 2 | 3 | import cats.implicits._ 4 | import example.model.Errors.TodoTaskNotFound 5 | import example.model.MongoData._ 6 | import example.modules.db.MongoConn.MongoConn 7 | import reactivemongo.api.bson.BSONDocument 8 | import reactivemongo.api.bson.BSONObjectID 9 | import reactivemongo.api.bson.collection.BSONCollection 10 | import reactivemongo.api.Cursor 11 | import zio._ 12 | import zio.logging.Logger 13 | import zio.logging.LogLevel 14 | 15 | object todoRepository { 16 | type TodoRepository = Has[TodoRepository.Service] 17 | 18 | object TodoRepository { 19 | trait Service { 20 | def getAll: Task[List[TodoTask]] 21 | def insert(todoTask: TodoTask): Task[Option[Int]] 22 | def findById(id: BSONObjectID): Task[TodoTask] 23 | def updateStatus(id: BSONObjectID, newStatus: TodoStatus): Task[Option[Int]] 24 | def deleteById(id: BSONObjectID): Task[Option[Int]] 25 | } 26 | 27 | def createNotFoundMsg(id: BSONObjectID) = s"TodoTask '${id.stringify}' not found" 28 | 29 | val live = ZLayer.fromServices[MongoConn, Logger[String], TodoRepository.Service]((mongoConn, logger) => 30 | new Service { 31 | val collection: BSONCollection = mongoConn.defaultDb.collection("todos") 32 | 33 | def getAll: Task[List[TodoTask]] = ZIO.fromFuture { implicit ec => 34 | collection 35 | .find(BSONDocument(), Option.empty) 36 | .cursor[TodoTask]() 37 | .collect[List](-1, Cursor.FailOnError[List[TodoTask]]()) 38 | } 39 | 40 | def insert(todoTask: TodoTask): Task[Option[Int]] = ZIO.fromFuture { implicit ec => 41 | collection.insert.one(todoTask).map(_.code) 42 | } 43 | 44 | def findById(id: BSONObjectID): Task[TodoTask] = { 45 | val query = BSONDocument("_id" -> id) 46 | for { 47 | maybeFound <- ZIO.fromFuture(implicit ec => collection.find(query, Option.empty).one[TodoTask]) 48 | found <- ZIO.fromOption(maybeFound).flatMapError { _ => 49 | val msg = createNotFoundMsg(id) 50 | logger.log(LogLevel.Trace)(s"findById $msg").as(TodoTaskNotFound(msg)) 51 | } 52 | _ <- logger.log(LogLevel.Trace)(s"findById '$id' found: $found") 53 | } yield found 54 | } 55 | 56 | def updateStatus(id: BSONObjectID, newStatus: TodoStatus): Task[Option[Int]] = { 57 | val query = BSONDocument("_id" -> id) 58 | val update = BSONDocument("$set" -> BSONDocument("status" -> newStatus)) 59 | for { 60 | updateResult <- ZIO.fromFuture(implicit ec => collection.update.one(q = query, u = update)) 61 | _ <- logger.log(LogLevel.Trace)(s"updateStatus '$id' to $newStatus: $updateResult") 62 | } yield updateResult.code 63 | } 64 | 65 | def deleteById(id: BSONObjectID): Task[Option[Int]] = { 66 | val query = BSONDocument("_id" -> id) 67 | for { 68 | removeResult <- ZIO.fromFuture(implicit ec => collection.delete.one(query)) 69 | _ <- logger.log(LogLevel.Trace)(s"deleteById '$id' delete: $removeResult") 70 | } yield removeResult.code 71 | } 72 | } 73 | ) 74 | 75 | def test(initData: Vector[TodoTask] = Vector()) = ZLayer.fromEffect { 76 | import com.softwaremill.quicklens._ 77 | 78 | val defaultResult = ZIO.succeed(none[Int]) 79 | 80 | for { 81 | ref <- Ref.make(initData) 82 | } yield new Service { 83 | def getAll: Task[List[TodoTask]] = 84 | ref.get.map(_.toList) 85 | 86 | def insert(todoTask: TodoTask): Task[Option[Int]] = 87 | ref.update(_ :+ todoTask) *> defaultResult 88 | 89 | def findById(id: BSONObjectID): Task[TodoTask] = 90 | for { 91 | maybeFound <- ref.get.map(_.find(_.id == id)) 92 | found <- ZIO.fromOption(maybeFound).mapError(_ => TodoTaskNotFound(createNotFoundMsg(id))) 93 | } yield found 94 | 95 | def updateStatus(id: BSONObjectID, newStatus: TodoStatus): Task[Option[Int]] = 96 | ref.update(_.map { tt => 97 | if (tt.id == id) tt.modify(_.status).setTo(newStatus) 98 | else tt 99 | }) *> defaultResult 100 | 101 | def deleteById(id: BSONObjectID): Task[Option[Int]] = 102 | ref.update(_.filter(_.id != id)) *> defaultResult 103 | } 104 | } 105 | } 106 | 107 | def getAll: ZIO[TodoRepository, Throwable, List[TodoTask]] = 108 | ZIO.accessM[TodoRepository](_.get.getAll) 109 | def insert(todoTask: TodoTask): ZIO[TodoRepository, Throwable, Option[Int]] = 110 | ZIO.accessM[TodoRepository](_.get.insert(todoTask)) 111 | def findById(id: BSONObjectID): ZIO[TodoRepository, Throwable, TodoTask] = 112 | ZIO.accessM[TodoRepository](_.get.findById(id)) 113 | def updateStatus(id: BSONObjectID, newStatus: TodoStatus): ZIO[TodoRepository, Throwable, Option[Int]] = 114 | ZIO.accessM[TodoRepository](_.get.updateStatus(id, newStatus)) 115 | def deleteById(id: BSONObjectID): ZIO[TodoRepository, Throwable, Option[Int]] = 116 | ZIO.accessM[TodoRepository](_.get.deleteById(id)) 117 | } 118 | -------------------------------------------------------------------------------- /jvm/src/main/scala/example/Hello.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import cats.implicits._ 4 | import zio._ 5 | import zio.blocking.Blocking 6 | import zio.interop.catz._ 7 | import zio.logging._ 8 | 9 | import caliban.Http4sAdapter 10 | import org.http4s.implicits._ 11 | import org.http4s.server.blaze.BlazeServerBuilder 12 | import org.http4s.server.middleware.CORS 13 | import org.http4s.server.middleware.CORSConfig 14 | import org.http4s.server.Router 15 | import sttp.tapir.docs.openapi._ 16 | import sttp.tapir.openapi.circe.yaml._ 17 | import sttp.tapir.swagger.http4s.SwaggerHttp4s 18 | 19 | import example.endpoints.AuthEndpoints 20 | import example.endpoints.ChatEndpoints 21 | import example.endpoints.RestEndpoints 22 | import example.endpoints.ScoreboardEndpoints 23 | import example.endpoints.StaticEndpoints 24 | import example.endpoints.TodoEndpoints 25 | import example.model.GQLData 26 | import example.modules.appConfig 27 | import example.modules.db.doobieTransactor 28 | import example.modules.db.flywayHandler 29 | import example.modules.db.MongoConn 30 | import example.modules.db.scoreboardRepository 31 | import example.modules.db.todoRepository 32 | import example.modules.db.userRepository 33 | import example.modules.services.auth.authService 34 | import example.modules.services.chatFlowBuilder 35 | import example.modules.services.chatService 36 | import example.modules.services.cryptoService 37 | import example.modules.services.randomService 38 | import example.modules.services.scoreboardService 39 | import example.modules.services.todoService 40 | import java.io.PrintWriter 41 | import java.io.StringWriter 42 | import scala.concurrent.duration._ 43 | import scala.concurrent.ExecutionContext 44 | import zio.clock.Clock 45 | 46 | object Hello extends App { 47 | type AppEnv = ZEnv 48 | with appConfig.AppConfig 49 | with randomService.RandomService 50 | with todoService.TodoService 51 | with scoreboardService.ScoreboardService 52 | with authService.AuthService 53 | with chatService.ChatService 54 | with chatFlowBuilder.ChatFlowBuilder 55 | with Logging 56 | 57 | type AppTask[A] = ZIO[AppEnv, Throwable, A] 58 | 59 | def run(args: List[String]): ZIO[zio.ZEnv, Nothing, ExitCode] = 60 | app().provideCustomLayer { 61 | val appConf = appConfig.AppConfig.live 62 | val logging = slf4j.Slf4jLogger.make((_, msg) => msg) 63 | val randomServ = randomService.RandomService.live 64 | 65 | val mongoConf = appConf >>> MongoConn.live 66 | val todoRepo = (mongoConf ++ logging) >>> todoRepository.TodoRepository.live 67 | val todoServ = todoRepo >>> todoService.TodoService.live 68 | 69 | val flyway = appConf >>> flywayHandler.FlywayHandler.live 70 | val doobieTran = (Blocking.any ++ appConf ++ flyway) >>> doobieTransactor.live 71 | 72 | val scoreboardRepo = doobieTran >>> scoreboardRepository.ScoreboardRepository.live 73 | val scoreServ = (scoreboardRepo ++ logging) >>> scoreboardService.ScoreboardService.live 74 | 75 | val cryptoServ = (appConf ++ Clock.any) >>> cryptoService.CryptoService.live 76 | val userRepo = doobieTran >>> userRepository.UserRepository.live 77 | val authServ = (userRepo ++ logging ++ cryptoServ) >>> authService.AuthService.live 78 | 79 | val chatServ = logging >>> chatService.ChatService.live 80 | val chatFlowBuil = (chatServ ++ logging) >>> chatFlowBuilder.ChatFlowBuilder.live 81 | 82 | logging ++ 83 | appConf ++ 84 | todoServ ++ 85 | scoreServ ++ 86 | authServ ++ 87 | chatServ ++ 88 | chatFlowBuil ++ 89 | randomServ 90 | }.flatMapError { 91 | case e: Throwable => 92 | val sw = new StringWriter 93 | e.printStackTrace(new PrintWriter(sw)) 94 | zio.console.putStrLnErr(sw.toString()).orElse(ZIO.unit) 95 | }.fold(_ => ExitCode.failure, _ => ExitCode.success) 96 | 97 | def app(): ZIO[AppEnv, Throwable, Unit] = 98 | for { 99 | conf <- appConfig.load 100 | 101 | originConfig = CORSConfig(anyOrigin = true, allowCredentials = false, maxAge = 1.day.toSeconds) 102 | 103 | ec <- ZIO.accessM[Blocking](b => ZIO.succeed(b.get.blockingExecutor.asEC)) 104 | catsBlocker = cats.effect.Blocker.liftExecutionContext(ec) 105 | 106 | gqlInterpreter <- GQLData.api.interpreter 107 | yamlDocs = ( 108 | RestEndpoints.endpoints 109 | ++ TodoEndpoints.endpoints 110 | ++ ScoreboardEndpoints.endpoints 111 | ++ AuthEndpoints.endpoints 112 | ).toOpenAPI("full-stack-zio", "0.1.0").toYaml 113 | 114 | httpApp = ( 115 | RestEndpoints.routes[AppEnv] 116 | <+> TodoEndpoints.routes[AppEnv] 117 | <+> ScoreboardEndpoints.routes[AppEnv] 118 | <+> AuthEndpoints.routes[AppEnv] 119 | <+> new SwaggerHttp4s(yamlDocs).routes[RIO[AppEnv, *]] 120 | <+> ChatEndpoints.routes[AppEnv] 121 | <+> StaticEndpoints.routes[AppEnv](conf.assets, catsBlocker, GQLData.api.render) 122 | <+> Router("/api/graphql" -> Http4sAdapter.makeHttpService(gqlInterpreter)) 123 | ).orNotFound 124 | 125 | server <- ZIO.runtime[AppEnv].flatMap { implicit rts => 126 | BlazeServerBuilder[AppTask](ExecutionContext.global) 127 | .bindHttp(conf.http.port, conf.http.host) 128 | .withHttpApp(CORS(httpApp, originConfig)) 129 | .serve 130 | .compile 131 | .drain 132 | } 133 | } yield server 134 | } 135 | --------------------------------------------------------------------------------