├── 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 | [](https://travis-ci.org/oen9/full-stack-zio)
6 | [](https://circleci.com/gh/oen9/full-stack-zio)
7 |
8 | 
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 |
--------------------------------------------------------------------------------