├── web_client ├── public │ └── .gitkeep ├── src │ └── main │ │ ├── resources │ │ └── MainPage.css │ │ ├── graphql │ │ ├── allproduct.graphql │ │ ├── example_mutation.graphql │ │ └── example.graphql │ │ └── scala │ │ ├── com │ │ └── mypackage │ │ │ ├── util │ │ │ └── UrlUtils.scala │ │ │ ├── product │ │ │ ├── Products.scala │ │ │ ├── ProductDisplay.scala │ │ │ └── ProductForm.scala │ │ │ ├── App.scala │ │ │ ├── MainPage.scala │ │ │ ├── Bootstrap.scala │ │ │ └── authentication │ │ │ └── Login.scala │ │ ├── reactrouter │ │ ├── Redirect.scala │ │ ├── BrowserRouter.scala │ │ ├── NavLink.scala │ │ └── Route.scala │ │ └── antd │ │ ├── Message.scala │ │ ├── Button.scala │ │ ├── Input.scala │ │ ├── Grid.scala │ │ ├── Card.scala │ │ ├── Layout.scala │ │ ├── Menu.scala │ │ └── Form.scala └── webpack │ ├── scalajs-entry.js │ ├── webpack-opt.config.js │ ├── fastopt-loader.js │ ├── webpack-fastopt.config.js │ └── webpack-core.config.js ├── project ├── build.properties ├── ProjectVersionManager.scala ├── Schema.scala ├── Packaging.scala ├── PlayUtils.scala ├── plugins.sbt ├── WebpackUtils.scala ├── QuickCompiler.scala ├── Server.scala ├── WebClient.scala ├── Shared.scala ├── GraphqlUtils.scala └── DatabaseUtils.scala ├── server ├── conf │ ├── messages │ ├── routes │ ├── application.conf │ ├── logback.xml │ └── evolutions │ │ └── default │ │ └── 1.sql ├── app │ ├── auth │ │ ├── User.scala │ │ ├── AuthEnvironment.scala │ │ ├── UserIdentityService.scala │ │ ├── ManualUserGenerator.scala │ │ ├── UserSessionRepository.scala │ │ └── PasswordInfoRepository.scala │ ├── views │ │ ├── index.scala.html │ │ ├── main.scala.html │ │ └── graphiql.scala.html │ ├── repositories │ │ ├── PictureRepo.scala │ │ └── ProductRepo.scala │ ├── infrastructure │ │ ├── JodaAwareSourceCodeGenerator.scala │ │ └── ApplyEvolutions.scala │ ├── controllers │ │ ├── HomeController.scala │ │ ├── AuthenticationController.scala │ │ └── GraphQLController.scala │ ├── Bootstrap.scala │ └── di │ │ └── DiModule.scala └── public │ └── images │ ├── nutella.jpeg │ ├── cheesecake1.jpeg │ ├── cheesecake2.jpeg │ └── default_item.jpg ├── schema └── src │ └── main │ └── scala │ └── com │ └── mypackage │ ├── RequestContext.scala │ ├── PictureRepoLike.scala │ ├── ProductRepoLike.scala │ └── GraphQLSchema.scala ├── shared └── src │ └── main │ └── scala │ └── com │ └── mypackage │ └── Domain.scala ├── .gitignore ├── docker-compose.yaml ├── LICENSE.md └── README.md /web_client/public/.gitkeep: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.2.8 2 | -------------------------------------------------------------------------------- /server/conf/messages: -------------------------------------------------------------------------------- 1 | # https://www.playframework.com/documentation/latest/ScalaI18N 2 | -------------------------------------------------------------------------------- /web_client/src/main/resources/MainPage.css: -------------------------------------------------------------------------------- 1 | .app-title { 2 | color: #0F0; 3 | } 4 | -------------------------------------------------------------------------------- /server/app/auth/User.scala: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import com.mohiva.play.silhouette.api.Identity 4 | 5 | case class User() extends Identity 6 | -------------------------------------------------------------------------------- /server/public/images/nutella.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/espinhogr/scala-graphql-fullstack-seed/HEAD/server/public/images/nutella.jpeg -------------------------------------------------------------------------------- /server/public/images/cheesecake1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/espinhogr/scala-graphql-fullstack-seed/HEAD/server/public/images/cheesecake1.jpeg -------------------------------------------------------------------------------- /server/public/images/cheesecake2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/espinhogr/scala-graphql-fullstack-seed/HEAD/server/public/images/cheesecake2.jpeg -------------------------------------------------------------------------------- /server/public/images/default_item.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/espinhogr/scala-graphql-fullstack-seed/HEAD/server/public/images/default_item.jpg -------------------------------------------------------------------------------- /schema/src/main/scala/com/mypackage/RequestContext.scala: -------------------------------------------------------------------------------- 1 | package com.mypackage 2 | 3 | trait RequestContext { 4 | 5 | def productRepo: ProductRepoLike 6 | 7 | def pictureRepo: PictureRepoLike 8 | } 9 | -------------------------------------------------------------------------------- /web_client/src/main/graphql/allproduct.graphql: -------------------------------------------------------------------------------- 1 | query AllProducts { 2 | products { 3 | id 4 | name 5 | description 6 | pictures(size: 500) { 7 | width 8 | height 9 | url 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /web_client/src/main/graphql/example_mutation.graphql: -------------------------------------------------------------------------------- 1 | mutation AddProduct($name: String!, $description: String!) { 2 | addProduct(name: $name, description: $description) { 3 | id 4 | name 5 | description 6 | } 7 | } -------------------------------------------------------------------------------- /web_client/src/main/graphql/example.graphql: -------------------------------------------------------------------------------- 1 | query MyProduct($id: String!, $size: Int!) { 2 | product(id: $id) { 3 | name 4 | pictures(size: $size) { 5 | width 6 | height 7 | url 8 | } 9 | } 10 | } 11 | 12 | -------------------------------------------------------------------------------- /schema/src/main/scala/com/mypackage/PictureRepoLike.scala: -------------------------------------------------------------------------------- 1 | package com.mypackage 2 | 3 | import com.mypackage.Domain.Picture 4 | 5 | import scala.concurrent.Future 6 | 7 | trait PictureRepoLike { 8 | 9 | def picturesByProduct(id: String): Future[Seq[Picture]] 10 | 11 | } 12 | -------------------------------------------------------------------------------- /server/app/auth/AuthEnvironment.scala: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import com.mohiva.play.silhouette.api.Env 4 | import com.mohiva.play.silhouette.impl.authenticators.BearerTokenAuthenticator 5 | 6 | trait AuthEnvironment extends Env { 7 | type A = BearerTokenAuthenticator 8 | type I = User 9 | } 10 | -------------------------------------------------------------------------------- /shared/src/main/scala/com/mypackage/Domain.scala: -------------------------------------------------------------------------------- 1 | package com.mypackage 2 | 3 | object Domain { 4 | 5 | trait Identifiable { 6 | def id: String 7 | } 8 | 9 | case class Picture(width: Int, height: Int, url: Option[String]) 10 | 11 | case class Product(id: String, name: String, description: String) extends Identifiable 12 | 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *\.ZILESAVE 3 | *\.tmp 4 | *\.log 5 | *\.out 6 | /\.classpath 7 | /\.project 8 | /\.settings 9 | /\.idea 10 | /\.idea_modules 11 | /\.lib 12 | *\.iml 13 | \.classpath_nb 14 | \.ensime 15 | \.ensime_lucene 16 | \.ensime_cache/ 17 | target/ 18 | dist/ 19 | lib_managed/ 20 | src_managed/ 21 | project/boot/ 22 | project/plugins/project/ 23 | .history 24 | .cache 25 | .lib/ 26 | .idea/ 27 | logs/ 28 | lib/ 29 | tmp/ 30 | -------------------------------------------------------------------------------- /schema/src/main/scala/com/mypackage/ProductRepoLike.scala: -------------------------------------------------------------------------------- 1 | package com.mypackage 2 | 3 | import com.mypackage.Domain.{Product => RetailProduct} 4 | 5 | import scala.concurrent.Future 6 | 7 | trait ProductRepoLike { 8 | 9 | def product(id: String): Future[Option[RetailProduct]] 10 | 11 | def products: Future[Seq[RetailProduct]] 12 | 13 | def addProduct(name: String, description: String): Future[RetailProduct] 14 | 15 | } 16 | -------------------------------------------------------------------------------- /server/app/views/index.scala.html: -------------------------------------------------------------------------------- 1 | @(devMode: Boolean) 2 | 3 | @main("Welcome to Play with Scala.js and React")(Html("")) { 4 | 7 |
8 | 9 | } 10 | -------------------------------------------------------------------------------- /web_client/webpack/scalajs-entry.js: -------------------------------------------------------------------------------- 1 | if (process.env.NODE_ENV === "production") { 2 | const opt = require("./web_client-opt.js"); 3 | opt.entrypoint.main(); 4 | module.exports = opt; 5 | } else { 6 | var exports = window; 7 | exports.require = require("./web_client-fastopt-entrypoint.js").require; 8 | window.global = window; 9 | 10 | const fastOpt = require("./web_client-fastopt.js"); 11 | fastOpt.entrypoint.main() 12 | module.exports = fastOpt; 13 | 14 | if (module.hot) { 15 | module.hot.accept(); 16 | } 17 | } -------------------------------------------------------------------------------- /server/app/auth/UserIdentityService.scala: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import com.mohiva.play.silhouette.api.LoginInfo 4 | import com.mohiva.play.silhouette.api.services.IdentityService 5 | 6 | import scala.concurrent.Future 7 | 8 | class UserIdentityService extends IdentityService[User] { 9 | 10 | /** 11 | * This is not implemented as I'm not saving any data related to the user, 12 | * this is useful to save things like email, username, etc etc. 13 | */ 14 | override def retrieve(loginInfo: LoginInfo): Future[Option[User]] = { 15 | Future.successful(Some(User())) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /web_client/webpack/webpack-opt.config.js: -------------------------------------------------------------------------------- 1 | var merge = require('webpack-merge'); 2 | var core = require('./webpack-core.config.js') 3 | var webpack = require("webpack"); 4 | 5 | var generatedConfig = require("./scalajs.webpack.config.js"); 6 | const entries = {}; 7 | entries[Object.keys(generatedConfig.entry)[0]] = "scalajs"; 8 | 9 | module.exports = merge(core, { 10 | mode: "production", 11 | devtool: "source-map", 12 | entry: entries, 13 | plugins: [ 14 | new webpack.DefinePlugin({ 15 | 'process.env': { 16 | NODE_ENV: JSON.stringify('production') 17 | } 18 | }) 19 | ] 20 | }) -------------------------------------------------------------------------------- /server/app/views/main.scala.html: -------------------------------------------------------------------------------- 1 | @* 2 | * This template is called from the `index` template. This template 3 | * handles the rendering of the page header and body tags. It takes 4 | * two arguments, a `String` for the title of the page and an `Html` 5 | * object to insert into the body of the page. 6 | *@ 7 | @(title: String)(headers: Html)(content: Html) 8 | 9 | 10 | 11 | 12 | 13 | 14 | @headers 15 | @title 16 | 17 | 18 | @content 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /server/app/repositories/PictureRepo.scala: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import com.mypackage.Domain.Picture 4 | import com.mypackage.PictureRepoLike 5 | import database.Tables 6 | import javax.inject.Inject 7 | import slick.jdbc.MySQLProfile.api._ 8 | 9 | import scala.concurrent.ExecutionContext.Implicits.global 10 | import scala.concurrent.Future 11 | 12 | class PictureRepo @Inject()(db: Database) extends PictureRepoLike { 13 | 14 | override def picturesByProduct(id: String): Future[Seq[Picture]] = { 15 | val action = Tables.Picture.filter(_.productid === id.toInt).result 16 | db.run(action).map(_.map(p => Picture(p.width, p.height, p.url))) 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | 4 | # postgres: 5 | # image: postgres:11.3 6 | # restart: always 7 | # environment: 8 | # POSTGRES_USER: admin 9 | # POSTGRES_PASSWORD: admin 10 | # ports: 11 | # - 5432:5432 12 | # volumes: 13 | # - postgres:/var/lib/postgresql/data 14 | 15 | mysql: 16 | image: mysql:5.7.26 17 | environment: 18 | MYSQL_DATABASE: test 19 | MYSQL_ROOT_PASSWORD: root 20 | MYSQL_USER: admin 21 | MYSQL_PASSWORD: admin 22 | ports: 23 | - 3306:3306 24 | volumes: 25 | - mysql:/var/lib/mysql 26 | restart: always 27 | 28 | volumes: 29 | postgres: ~ 30 | mysql: ~ 31 | -------------------------------------------------------------------------------- /web_client/webpack/fastopt-loader.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Processes the source file before it's bundled and it resolves the path of the 3 | * map file. 4 | */ 5 | const fs = require("fs"); 6 | const path = require("path"); 7 | module.exports = function(source) { 8 | const sourceMapPath = source.substring( 9 | source.lastIndexOf("//# sourceMappingURL=") + "//# sourceMappingURL=".length 10 | ).trim(); 11 | 12 | const map = JSON.parse(fs.readFileSync(sourceMapPath, { encoding: "utf-8" })); 13 | map.sources = map.sources.map(s => { 14 | if (s.startsWith("http://") || s.startsWith("https://")) { 15 | return s; 16 | } else { 17 | return "file://" + path.resolve(s); 18 | } 19 | }) 20 | 21 | this.callback(null, 22 | source, 23 | JSON.stringify(map) 24 | ); 25 | }; -------------------------------------------------------------------------------- /server/conf/routes: -------------------------------------------------------------------------------- 1 | # Routes 2 | # This file defines all application routes (Higher priority routes first) 3 | # https://www.playframework.com/documentation/latest/ScalaRouting 4 | # ~~~~ 5 | 6 | POST /authenticate controllers.AuthenticationController.authenticate 7 | 8 | POST /graphql controllers.GraphQLController.graphql 9 | 10 | GET /graphiql controllers.GraphQLController.graphiQL 11 | 12 | GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset) 13 | 14 | GET / controllers.HomeController.index() 15 | 16 | # This route has to be the last one because it catches all the paths 17 | GET /*dynamic controllers.HomeController.dynamicPath(dynamic) 18 | -------------------------------------------------------------------------------- /web_client/src/main/scala/com/mypackage/util/UrlUtils.scala: -------------------------------------------------------------------------------- 1 | package com.mypackage.util 2 | 3 | import scala.scalajs.js 4 | 5 | object UrlUtils { 6 | 7 | /** 8 | * Extracts the base url form the address bar. 9 | * Returns only the part with the protocol, the domain and the port 10 | * 11 | */ 12 | def getBaseUrl: String = { 13 | val currentPageUrl: String = js.Dynamic.global.window.location.href.asInstanceOf[String] 14 | val protocolSlashIndex = currentPageUrl.indexOf('/') 15 | val baseUrlSlashIndex = currentPageUrl.indexOf('/', protocolSlashIndex + 2) 16 | 17 | 18 | currentPageUrl.substring(0, baseUrlSlashIndex) 19 | } 20 | 21 | def navigate(queryString: String) = { 22 | js.Dynamic.global.window.location.href = getBaseUrl + queryString 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /web_client/src/main/scala/reactrouter/Redirect.scala: -------------------------------------------------------------------------------- 1 | package reactrouter 2 | 3 | import slinky.core.ExternalComponent 4 | import slinky.core.ExternalComponentWithAttributes 5 | import slinky.web.html.a 6 | 7 | import scala.scalajs.js 8 | import scala.scalajs.js.UndefOr 9 | import scala.scalajs.js.annotation.JSImport 10 | import scala.scalajs.js.| 11 | 12 | @JSImport("react-router-dom", JSImport.Default) 13 | @js.native 14 | object ReactRedirect extends js.Object { 15 | val Redirect: js.Object = js.native 16 | } 17 | 18 | object Redirect extends ExternalComponent { 19 | case class Props(push: Boolean = false, 20 | exact: Boolean = false, 21 | strict: Boolean = false, 22 | to: String | js.Object) 23 | 24 | override val component = ReactRedirect.Redirect 25 | } 26 | -------------------------------------------------------------------------------- /web_client/src/main/scala/reactrouter/BrowserRouter.scala: -------------------------------------------------------------------------------- 1 | package 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 | import scala.scalajs.js.annotation.JSImport 9 | 10 | @JSImport("react-router-dom", JSImport.Default) 11 | @js.native 12 | object ReactBrowserRouter extends js.Object { 13 | val BrowserRouter: js.Object = js.native 14 | } 15 | 16 | @react object BrowserRouter extends ExternalComponent { 17 | case class Props(basename: String = "/", 18 | getUserConfirmation: UndefOr[js.Function0[Boolean]] = js.undefined, 19 | forceRefresh: Boolean = false, 20 | keyLength: Int = 6) 21 | 22 | override val component = ReactBrowserRouter.BrowserRouter 23 | } 24 | -------------------------------------------------------------------------------- /web_client/webpack/webpack-fastopt.config.js: -------------------------------------------------------------------------------- 1 | // Webpack-merge is used to merge this configuration with the core one 2 | var merge = require('webpack-merge'); 3 | var core = require('./webpack-core.config.js') 4 | 5 | var generatedConfig = require("./scalajs.webpack.config.js"); 6 | const entries = {}; 7 | entries[Object.keys(generatedConfig.entry)[0]] = "scalajs"; 8 | 9 | module.exports = merge(core, { 10 | devtool: "cheap-module-eval-source-map", 11 | entry: entries, 12 | devServer: { 13 | hot: true 14 | }, 15 | module: { 16 | noParse: (content) => { 17 | return content.endsWith("-fastopt.js"); 18 | }, 19 | rules: [ 20 | // Loader rule 21 | { 22 | test: /\-fastopt.js\$/, 23 | use: [ require.resolve('./fastopt-loader.js') ] 24 | } 25 | ] 26 | }, 27 | output: { 28 | filename: '[name]-bundle.js' 29 | } 30 | }) -------------------------------------------------------------------------------- /project/ProjectVersionManager.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | import sbt.Keys._ 3 | 4 | object ProjectVersionManager { 5 | def writeConfig(projectId: String, projectName: String, projectPort: Int) = Def.task { 6 | val content =s""" 7 | |package version.util 8 | | 9 | |object Version { 10 | | val projectId = "$projectId" 11 | | val projectName = "$projectName" 12 | | val projectPort = $projectPort 13 | | 14 | | val version = "${version.value}" 15 | |} 16 | |""".stripMargin.trim 17 | 18 | val file = (sourceManaged in Compile).value / "version" / "util" / "Version.scala" 19 | val current = if(file.exists) { IO.read(file) } else { "" } 20 | if(current != content) { IO.write(file, content) } 21 | Seq(file) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /project/Schema.scala: -------------------------------------------------------------------------------- 1 | import sbt.Keys._ 2 | import sbt._ 3 | 4 | /** 5 | * Project to hold the GraphQL schema. 6 | * This is not in shared because there are some parts used only by the server 7 | * and some used only by the client. This is mainly to the fact that the schema 8 | * is defined using Sangria DSL, then a schema is produced programmatically and 9 | * it's used in the client to generate the types. 10 | */ 11 | object Schema { 12 | 13 | val schemaObject = settingKey[String]("Fully qualified name of the object containing the schema.") 14 | 15 | private[this] val schemaSettings = Shared.commonSettings ++ Seq( 16 | libraryDependencies ++= Seq( 17 | "org.sangria-graphql" %% "sangria" % "1.4.2", 18 | ), 19 | 20 | schemaObject := "com.mypackage.GraphQLSchema", 21 | 22 | ) 23 | 24 | lazy val schema = (project in file("schema")) 25 | .settings(schemaSettings: _*) 26 | .dependsOn(Shared.sharedJvm) 27 | } 28 | 29 | 30 | -------------------------------------------------------------------------------- /server/app/auth/ManualUserGenerator.scala: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import com.mohiva.play.silhouette.api.LoginInfo 4 | import com.mohiva.play.silhouette.impl.providers.CredentialsProvider 5 | import di.DiModule._ 6 | import slick.jdbc.JdbcBackend.Database 7 | 8 | import scala.concurrent.Await 9 | import scala.concurrent.duration._ 10 | 11 | /** 12 | * This class is used in case you want to register a user manually. 13 | * It inserts a record in the database with the user credentials. 14 | */ 15 | object ManualUserGenerator extends App { 16 | 17 | val username = "admin" 18 | val password = "admin" 19 | 20 | val database = Database.forConfig(databaseConfigPath) 21 | val passwordInfoRepository = new PasswordInfoRepository(database) 22 | 23 | val passwordInfo = hasher.hash(password) 24 | val loginInfo = LoginInfo(CredentialsProvider.ID, username) 25 | println( 26 | Await.result(passwordInfoRepository.add(loginInfo, passwordInfo), 5 seconds) 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /server/app/infrastructure/JodaAwareSourceCodeGenerator.scala: -------------------------------------------------------------------------------- 1 | package infrastructure 2 | 3 | import slick.codegen.SourceCodeGenerator 4 | import slick.model.Model 5 | 6 | /** 7 | * Slick source code generator which uses joda types instead of sql.Timestamp 8 | */ 9 | class JodaAwareSourceCodeGenerator(model: Model) extends SourceCodeGenerator(model) { 10 | 11 | // This inserts the imports in any table, even if the table doesn't use DateTime, can be optimised. 12 | override def codePerTable: Map[String, String] = super.codePerTable.mapValues { v => 13 | """import com.github.tototoshi.slick.MySQLJodaSupport._ 14 | |import org.joda.time.DateTime 15 | |""".stripMargin + v 16 | } 17 | 18 | override def Table = new Table(_) { 19 | override def Column = new Column(_) { 20 | 21 | override def rawType = model.tpe match { 22 | case "java.sql.Timestamp" => "DateTime" 23 | case _ => super.rawType 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /server/app/controllers/HomeController.scala: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import javax.inject._ 4 | import play.Environment 5 | import play.api.mvc._ 6 | 7 | /** 8 | * This controller creates an `Action` to handle HTTP requests to the 9 | * application's home page. 10 | */ 11 | @Singleton 12 | class HomeController @Inject()(cc: ControllerComponents, env: Environment) extends AbstractController(cc) { 13 | 14 | /** 15 | * Create an Action to render an HTML page. 16 | * 17 | * The configuration in the `routes` file means that this method 18 | * will be called when the application receives a `GET` request with 19 | * a path of `/`. 20 | */ 21 | def index() = dynamicPath("") 22 | 23 | /** 24 | * This is used to be able to have single page applications on the javascript side 25 | * so that if they dynamically change the route, it's always the index being served. 26 | */ 27 | def dynamicPath(dynamic: String) = Action { implicit request: Request[AnyContent] => 28 | Ok(views.html.index(env.isDev)) 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /web_client/src/main/scala/com/mypackage/product/Products.scala: -------------------------------------------------------------------------------- 1 | package com.mypackage.product 2 | 3 | import antd.AntForm 4 | import antd.Col 5 | import antd.LayoutContent 6 | import antd.Row 7 | import slinky.core.ReactComponentClass._ 8 | import slinky.core.StatelessComponent 9 | import slinky.core.annotations.react 10 | import slinky.core.facade.React 11 | import slinky.web.html.h1 12 | import slinky.web.html.hr 13 | import slinky.web.html.style 14 | 15 | import scala.scalajs.js 16 | 17 | @react class Products extends StatelessComponent { 18 | type Props = Unit 19 | 20 | def render() = { 21 | val wrappedProductForm = AntForm.Form.create()(wrapperToClass(ProductForm)) 22 | 23 | LayoutContent(LayoutContent.Props())(style := js.Dynamic.literal(padding = "50px"))( 24 | ProductDisplay(), 25 | hr(style := js.Dynamic.literal(margin = "30px")), 26 | Row( 27 | Col(Col.Props(span = 24))( 28 | h1("Insert a new product"), 29 | React.createElement(wrappedProductForm, js.Dictionary()) 30 | ) 31 | ) 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Rasnabit ltd 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 14 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 15 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 16 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 17 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 18 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 19 | OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /server/app/repositories/ProductRepo.scala: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import com.mypackage.Domain.Product 4 | import com.mypackage.ProductRepoLike 5 | import database.Tables 6 | import javax.inject.Inject 7 | import slick.jdbc.MySQLProfile.api._ 8 | 9 | import scala.concurrent.ExecutionContext.Implicits.global 10 | import scala.concurrent.Future 11 | 12 | class ProductRepo @Inject()(db: Database) extends ProductRepoLike { 13 | 14 | def product(id: String): Future[Option[Product]] = { 15 | val action = Tables.Product.filter(_.id === id.toInt ).result 16 | db.run(action).map(_.headOption.map(p => Product(p.id.toString, p.name, p.description))) 17 | } 18 | 19 | def products: Future[Seq[Product]] = { 20 | val action = Tables.Product.result 21 | db.run(action).map(_.map(p => Product(p.id.toString, p.name, p.description))) 22 | } 23 | 24 | def addProduct(name: String, description: String): Future[Product] = { 25 | val action = Tables.Product.map(p => (p.name, p.description)) += ((name, description)) 26 | db.run(action).map(id => Product(id.toString, name, description)) 27 | } 28 | } 29 | 30 | -------------------------------------------------------------------------------- /web_client/src/main/scala/reactrouter/NavLink.scala: -------------------------------------------------------------------------------- 1 | package reactrouter 2 | 3 | import slinky.core.ExternalComponent 4 | import slinky.core.ExternalComponentWithAttributes 5 | import slinky.web.html.a 6 | 7 | import scala.scalajs.js 8 | import scala.scalajs.js.UndefOr 9 | import scala.scalajs.js.annotation.JSImport 10 | import scala.scalajs.js.| 11 | 12 | @JSImport("react-router-dom", JSImport.Default) 13 | @js.native 14 | object ReactNavLink extends js.Object { 15 | val NavLink: js.Object = js.native 16 | } 17 | 18 | object NavLink extends ExternalComponentWithAttributes[a.tag.type] { 19 | case class Props(activeClassName: UndefOr[String] = js.undefined, 20 | activeStyle: UndefOr[js.Object] = js.undefined, 21 | exact: Boolean = false, 22 | strict: Boolean = false, 23 | isActive: UndefOr[js.Function2[js.Object, js.Object, Boolean]] = js.undefined, 24 | location: UndefOr[js.Object] = js.undefined, 25 | `aria-current`: String = "page", 26 | to: String | js.Object, 27 | replace: Boolean = false) 28 | 29 | override val component = ReactNavLink.NavLink 30 | } 31 | -------------------------------------------------------------------------------- /server/app/Bootstrap.scala: -------------------------------------------------------------------------------- 1 | import play.api._ 2 | import play.core.server.Server 3 | import play.core.server.ProdServerStart 4 | import play.core.server.RealServerProcess 5 | import play.core.server.ServerConfig 6 | import play.core.server.ServerProvider 7 | 8 | object Bootstrap { 9 | def main(args: Array[String]): Unit = if (args.isEmpty) { 10 | startServer(new RealServerProcess(args)) 11 | } else { 12 | throw new IllegalStateException("Unable to parse arguments.") 13 | } 14 | 15 | def startServer(process: RealServerProcess): Server = { 16 | val config: ServerConfig = ProdServerStart.readServerConfigSettings(process) 17 | val application: Application = { 18 | val environment = Environment(config.rootDir, process.classLoader, Mode.Prod) 19 | val context = ApplicationLoader.Context.create(environment) 20 | val loader = ApplicationLoader(context) 21 | loader.load(context) 22 | } 23 | Play.start(application) 24 | 25 | val serverProvider: ServerProvider = ServerProvider.fromConfiguration(process.classLoader, config.configuration) 26 | val server = serverProvider.createServer(config, application) 27 | process.addShutdownHook(server.stop()) 28 | server 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /project/Packaging.scala: -------------------------------------------------------------------------------- 1 | import com.typesafe.sbt.packager.Keys._ 2 | import com.typesafe.sbt.packager.docker.DockerPlugin.autoImport.{Docker, dockerExposedPorts, dockerExposedVolumes} 3 | import com.typesafe.sbt.packager.universal.UniversalPlugin.autoImport.{Universal, useNativeZip} 4 | import sbt.Keys._ 5 | import sbt._ 6 | 7 | object Packaging { 8 | private[this] def isConf(x: (File, String)) = x._1.getAbsolutePath.contains("conf/") 9 | 10 | val settings = useNativeZip ++ Seq( 11 | topLevelDirectory in Universal := None, 12 | packageSummary := description.value, 13 | packageDescription := description.value, 14 | 15 | mappings in Universal := (mappings in Universal).value.filterNot(isConf), 16 | 17 | // Docker 18 | dockerExposedPorts := Seq(Shared.projectPort), 19 | dockerLabels ++= Map("project" -> Shared.projectId), 20 | dockerUpdateLatest := true, 21 | defaultLinuxInstallLocation in Docker := s"/opt/${Shared.projectId}", 22 | packageName in Docker := packageName.value, 23 | dockerExposedVolumes := Seq(s"/opt/${Shared.projectId}"), 24 | version in Docker := version.value, 25 | dockerUsername := Some(Shared.projectId), 26 | 27 | javaOptions in Universal ++= Seq("-J-Xmx2g", "-J-Xms256m", s"-Dproject=${Shared.projectId}") 28 | ) 29 | } -------------------------------------------------------------------------------- /project/PlayUtils.scala: -------------------------------------------------------------------------------- 1 | import java.io.Closeable 2 | 3 | import play.sbt.PlayNonBlockingInteractionMode 4 | import sbt.util.{Level, Logger} 5 | 6 | object PlayUtils { 7 | object NonBlockingInteractionMode extends PlayNonBlockingInteractionMode { 8 | object NullLogger extends Logger { 9 | def trace(t: => Throwable): Unit = () 10 | def success(message: => String): Unit = () 11 | def log(level: Level.Value, message: => String): Unit = () 12 | } 13 | 14 | private var runningServers: Set[Closeable] = scala.collection.immutable.HashSet.empty 15 | 16 | override def start(server: => Closeable): Unit = synchronized { 17 | val theServer = server 18 | if (runningServers(theServer)) { 19 | println("Noop: This server was already started") 20 | } else { 21 | runningServers += theServer 22 | } 23 | } 24 | 25 | override def stop(): Unit = synchronized { 26 | if (runningServers.size > 1) { 27 | println("Stopping all servers") 28 | } else if (runningServers.size == 1) { 29 | println("Stopping server") 30 | } else { 31 | println("No running server to stop") 32 | } 33 | runningServers.foreach(_.close()) 34 | runningServers = scala.collection.immutable.HashSet.empty 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /web_client/src/main/scala/antd/Message.scala: -------------------------------------------------------------------------------- 1 | package antd 2 | 3 | import slinky.core.facade.ReactElement 4 | 5 | import scala.scalajs.js 6 | import scala.scalajs.js.annotation.JSImport 7 | import scala.scalajs.js.| 8 | 9 | @JSImport("antd", JSImport.Default) 10 | @js.native 11 | object AntMessage extends js.Object { 12 | val message: MessageInterface = js.native 13 | } 14 | 15 | @js.native 16 | trait MessageInterface extends js.Object { 17 | def success(content: String | ReactElement, 18 | duration: js.UndefOr[Double] = 1.5, 19 | onClose: js.UndefOr[js.Function0[js.Any]] = js.undefined): Unit = js.native 20 | def error(content: String | ReactElement, 21 | duration: js.UndefOr[Double] = 1.5, 22 | onClose: js.UndefOr[js.Function0[js.Any]] = js.undefined): Unit = js.native 23 | def info(content: String | ReactElement, 24 | duration: js.UndefOr[Double] = 1.5, 25 | onClose: js.UndefOr[js.Function0[js.Any]] = js.undefined): Unit = js.native 26 | def warning(content: String | ReactElement, 27 | duration: js.UndefOr[Double] = 1.5, 28 | onClose: js.UndefOr[js.Function0[js.Any]] = js.undefined): Unit = js.native 29 | def loading(content: String | ReactElement, 30 | duration: js.UndefOr[Double] = 1.5, 31 | onClose: js.UndefOr[js.Function0[js.Any]] = js.undefined): Unit = js.native 32 | } 33 | 34 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | scalacOptions ++= Seq( "-unchecked", "-deprecation" ) 2 | 3 | resolvers += Resolver.typesafeRepo("releases") 4 | 5 | 6 | resolvers += Resolver.url("jetbrains-bintray", url("http://dl.bintray.com/jetbrains/sbt-plugins/"))(Resolver.ivyStylePatterns) 7 | 8 | 9 | // The Play plugin 10 | addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.7.0") 11 | 12 | // SBT-Web plugins 13 | addSbtPlugin("com.typesafe.sbt" % "sbt-web" % "1.4.4") 14 | 15 | libraryDependencies += "org.webjars.npm" % "source-map" % "0.7.3" 16 | 17 | // Scala.js 18 | addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "0.6.0") 19 | 20 | addSbtPlugin("org.scala-js" % "sbt-scalajs" % "0.6.26") 21 | 22 | addSbtPlugin("com.vmunier" % "sbt-web-scalajs" % "1.0.8-0.6" exclude("org.scala-js", "sbt-scalajs")) 23 | 24 | addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % "0.14.0") 25 | 26 | addSbtPlugin("ch.epfl.scala" % "sbt-web-scalajs-bundler" % "0.14.0") 27 | 28 | addSbtPlugin("com.typesafe.sbt" % "sbt-digest" % "1.1.3") // Checksum on sbt-web files 29 | 30 | addSbtPlugin("com.typesafe.sbt" % "sbt-gzip" % "1.0.2") 31 | 32 | // Dependency Resolution 33 | addSbtPlugin("io.get-coursier" % "sbt-coursier" % "1.0.0") 34 | 35 | // App Packaging 36 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.9") 37 | 38 | // Utilities 39 | addSbtPlugin("net.virtual-void" % "sbt-dependency-graph" % "0.9.0") // dependencyGraph 40 | 41 | addSbtPlugin("com.orrsella" % "sbt-stats" % "1.0.7") // stats -------------------------------------------------------------------------------- /web_client/src/main/scala/antd/Button.scala: -------------------------------------------------------------------------------- 1 | package antd 2 | 3 | import org.scalajs.dom.Event 4 | import slinky.core.ExternalComponent 5 | import slinky.core.annotations.react 6 | 7 | import scala.scalajs.js 8 | import scala.scalajs.js.UndefOr 9 | import scala.scalajs.js.annotation.JSImport 10 | import scala.scalajs.js.| 11 | 12 | @JSImport("antd", JSImport.Default) 13 | @js.native 14 | object AntButton extends js.Object { 15 | val Button: js.Object = js.native 16 | } 17 | 18 | @js.native 19 | trait Delay extends js.Object { 20 | val delay: Int = js.native 21 | } 22 | object Delay { 23 | def apply(delay: Int) = js.Dynamic.literal(delay = delay).asInstanceOf[Delay] 24 | } 25 | 26 | 27 | @react object Button extends ExternalComponent { 28 | case class Props(disabled: Boolean = false, 29 | ghost: Boolean = false, 30 | href: UndefOr[String] = js.undefined, 31 | htmlType: String = "button", 32 | icon: UndefOr[String] = js.undefined, 33 | loading: Boolean | Delay = false, 34 | shape: UndefOr[String] = js.undefined, 35 | size: String = "default", 36 | target: UndefOr[String] = js.undefined, 37 | `type`: String = "default", 38 | onClick: UndefOr[Event => Unit] = js.undefined, 39 | block: Boolean = false) 40 | 41 | override val component = AntButton.Button 42 | } 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /server/conf/application.conf: -------------------------------------------------------------------------------- 1 | # https://www.playframework.com/documentation/latest/Configuration 2 | 3 | play.filters { 4 | headers.contentSecurityPolicy = "default-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline' cdn.jsdelivr.net; script-src 'self' 'unsafe-inline' 'unsafe-eval' cdn.jsdelivr.net" 5 | 6 | // Not suggested for production 7 | hosts.allowed = ["."] 8 | 9 | // Not suggested for production 10 | disabled += "play.filters.csrf.CSRFFilter" 11 | } 12 | 13 | // Not suggested for production 14 | play.filters.disabled += "play.filters.csrf.CSRFFilter" 15 | 16 | // Dependency Injection module 17 | play.modules.enabled += "di.DiModule" 18 | 19 | slick.dbs { 20 | postgres { 21 | profile="slick.jdbc.PostgresProfile$" 22 | db { 23 | driver = "org.postgresql.Driver" 24 | url = "jdbc:postgresql://localhost:5432/test" 25 | databaseName = "test" 26 | user = "admin" 27 | password = "admin" 28 | } 29 | codegen { 30 | package = "database" 31 | outputDir = "slick" 32 | } 33 | } 34 | mysql { 35 | profile="slick.jdbc.MySQLProfile$" 36 | db { 37 | driver="com.mysql.cj.jdbc.Driver" 38 | url = "jdbc:mysql://localhost/test?nullNamePatternMatchesAll=true&useSSL=false" 39 | databaseName = "test" 40 | user = "admin" 41 | password = "admin" 42 | } 43 | codegen { 44 | package = "database" 45 | outputDir = "slick" 46 | } 47 | } 48 | default = ${slick.dbs.mysql} 49 | } 50 | -------------------------------------------------------------------------------- /web_client/src/main/scala/antd/Input.scala: -------------------------------------------------------------------------------- 1 | package antd 2 | 3 | import org.scalajs.dom.Event 4 | import slinky.core.ExternalComponentWithAttributes 5 | import slinky.core.annotations.react 6 | import slinky.core.facade.ReactElement 7 | import slinky.web.html.input 8 | 9 | import scala.scalajs.js 10 | import scala.scalajs.js.UndefOr 11 | import scala.scalajs.js.annotation.JSImport 12 | import scala.scalajs.js.| 13 | 14 | @JSImport("antd", JSImport.Default) 15 | @js.native 16 | object AntInput extends js.Object { 17 | val Input: js.Object = js.native 18 | } 19 | 20 | @react object Input extends ExternalComponentWithAttributes[input.tag.type] { 21 | case class Props(addonAfter: UndefOr[String | ReactElement] = js.undefined, 22 | addonBefore: UndefOr[String | ReactElement] = js.undefined, 23 | defaultValue: UndefOr[String] = js.undefined, 24 | disabled: Boolean = false, 25 | id: UndefOr[String] = js.undefined, 26 | prefix: UndefOr[String | ReactElement] = js.undefined, 27 | size: String = "default", 28 | suffix: UndefOr[String | ReactElement] = js.undefined, 29 | `type`: String = "text", 30 | value: UndefOr[String] = js.undefined, 31 | onChange: UndefOr[Event => Unit] = js.undefined, 32 | onPressEnter: UndefOr[Event => Unit] = js.undefined, 33 | allowClear: Boolean = false 34 | ) 35 | 36 | override val component = AntInput.Input 37 | } 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /web_client/src/main/scala/reactrouter/Route.scala: -------------------------------------------------------------------------------- 1 | package reactrouter 2 | 3 | import slinky.core.ExternalComponent 4 | import slinky.core.ReactComponentClass 5 | import slinky.core.annotations.react 6 | import slinky.core.facade.ReactElement 7 | 8 | import scala.scalajs.js 9 | import scala.scalajs.js.UndefOr 10 | import scala.scalajs.js.annotation.JSImport 11 | import scala.scalajs.js.| 12 | 13 | @JSImport("react-router-dom", JSImport.Default) 14 | @js.native 15 | object ReactRoute extends js.Object { 16 | val Route: js.Object = js.native 17 | } 18 | 19 | @js.native 20 | trait RouteProps extends js.Object { 21 | val `match`: js.Object = js.native 22 | val location: js.Object = js.native 23 | val history: History = js.native 24 | } 25 | 26 | @js.native 27 | trait History extends js.Object { 28 | def push(path: String, state: js.UndefOr[js.Object] = js.undefined): Unit = js.native 29 | def replace(path: String, state: js.UndefOr[js.Object] = js.undefined): Unit = js.native 30 | } 31 | 32 | @react object Route extends ExternalComponent { 33 | case class Props(component: UndefOr[ReactComponentClass[_]] = js.undefined, 34 | render: UndefOr[js.Function1[RouteProps, ReactElement]] = js.undefined, 35 | // children -> TODO 36 | path: String | js.Array[String] = "/", 37 | exact: Boolean = false, 38 | strict: Boolean = false, 39 | location: UndefOr[js.Object] = js.undefined, 40 | sensitive: Boolean = false) 41 | 42 | override val component = ReactRoute.Route 43 | } 44 | -------------------------------------------------------------------------------- /server/app/infrastructure/ApplyEvolutions.scala: -------------------------------------------------------------------------------- 1 | package infrastructure 2 | 3 | import java.io.File 4 | 5 | import com.typesafe.config.ConfigFactory 6 | import play.api.Configuration 7 | import play.api.Environment 8 | import play.api.Mode 9 | import play.api.db.evolutions.OfflineEvolutions 10 | import play.api.db.slick.DefaultSlickApi 11 | import play.api.db.slick.evolutions.SlickDBApi 12 | import play.api.inject.DefaultApplicationLifecycle 13 | import scala.concurrent.ExecutionContext.Implicits.global 14 | 15 | /** 16 | * This class applies Evolutions without the need to start the play application. 17 | * 18 | * It is used because the first time you check-out the code and you compile it, the 19 | * database is empty and therefore no slick code generation can be done. An empty 20 | * database leads to a empty code generation and therefore compilation errors. 21 | * 22 | * This is run before the code generation so that the database is initialized with 23 | * the evolutions and the code generation can take place. 24 | */ 25 | object ApplyEvolutions { 26 | 27 | def main(argv: Array[String]) = { 28 | val baseDir = argv(0) 29 | val config = ConfigFactory.load("application.conf") 30 | val environment = Environment(new File(baseDir), this.getClass.getClassLoader, Mode.Dev) 31 | 32 | val dbApi = SlickDBApi(new DefaultSlickApi( 33 | environment, 34 | Configuration(config), 35 | new DefaultApplicationLifecycle 36 | )) 37 | 38 | OfflineEvolutions.applyScript( 39 | new File("."), 40 | this.getClass.getClassLoader, 41 | dbApi, 42 | dbName = "default" 43 | ) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /server/conf/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ${application.home:-.}/logs/application.log 8 | 9 | %date [%level] from %logger in %thread - %message%n%xException 10 | 11 | 12 | 13 | 14 | 15 | %coloredLevel %logger{15} - %message%n%xException{10} 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /web_client/src/main/scala/com/mypackage/App.scala: -------------------------------------------------------------------------------- 1 | package com.mypackage 2 | 3 | import antd.AntForm 4 | import reactrouter.BrowserRouter 5 | import reactrouter.Redirect 6 | import reactrouter.Route 7 | import slinky.core.Component 8 | import slinky.core.StatelessComponent 9 | import slinky.core.annotations.react 10 | import slinky.core.ReactComponentClass._ 11 | import slinky.core.facade.ReactElement 12 | import slinky.web.html.h1 13 | import authentication.Login 14 | import org.scalajs.dom.ext.LocalStorage 15 | import reactrouter.RouteProps 16 | import slinky.core.facade.React 17 | import slinky.web.svg.path 18 | 19 | import scala.scalajs.js 20 | 21 | @react class App extends Component { 22 | type Props = Unit 23 | 24 | case class State(isLoggingIn: Boolean, isLoggedIn: Boolean) 25 | 26 | override def initialState: State = State(false, false) 27 | 28 | val loginComponent: js.Function1[RouteProps, ReactElement] = routeProps => { 29 | val wrappedLoginPage = AntForm.Form.create()(wrapperToClass(Login)) 30 | React.createElement(wrappedLoginPage, js.Dictionary("history" -> routeProps.history)) 31 | } 32 | val rootPath: js.Function1[RouteProps, ReactElement] = p => Redirect(Redirect.Props(to = "/app")) 33 | val renderAppIfLoggedIn: js.Function1[RouteProps, ReactElement] = p => 34 | if (LocalStorage("bearerToken").isEmpty) 35 | Redirect(Redirect.Props(to = "/login")) 36 | else 37 | MainPage() 38 | 39 | 40 | def render() = { 41 | 42 | BrowserRouter( 43 | Route(Route.Props(path = "/login", exact = true, render = loginComponent)), 44 | Route(Route.Props(path = "/app", render = renderAppIfLoggedIn)), 45 | Route(Route.Props(path = "/", exact = true, render = rootPath)) 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /web_client/webpack/webpack-core.config.js: -------------------------------------------------------------------------------- 1 | var path = require("path"); 2 | var CopyWebpackPlugin = require('copy-webpack-plugin'); 3 | var CleanWebpackPlugin = require('clean-webpack-plugin'); 4 | var webpack = require('webpack'); 5 | 6 | var pathsToClean = ['dist']; 7 | var cleanOptions = { 8 | verbose: true 9 | }; 10 | 11 | module.exports = { 12 | mode: "development", 13 | resolve: { 14 | alias: { 15 | "resources": path.resolve(__dirname, "../../../../src/main/resources"), 16 | "scalajs": path.resolve(__dirname, "./scalajs-entry.js") 17 | }, 18 | modules: [ path.resolve(__dirname, 'node_modules') ] 19 | }, 20 | module: { 21 | rules: [ 22 | // Config coming from HMR webpack 23 | { 24 | test: /\.css$/, 25 | use: [ 'style-loader', 'css-loader' ] 26 | }, 27 | // "file" loader for svg 28 | { 29 | test: /\.svg\$/, 30 | use: [ 31 | { 32 | loader: 'file-loader', 33 | query: { 34 | name: 'static/media/[name].[hash:8].[ext]' 35 | } 36 | } 37 | ] 38 | } 39 | ] 40 | }, 41 | plugins: [ 42 | new CleanWebpackPlugin(pathsToClean, cleanOptions), 43 | new CopyWebpackPlugin([ 44 | { from: path.resolve(__dirname, "../../../../public") } 45 | ]), 46 | new webpack.HotModuleReplacementPlugin() 47 | ], 48 | output: { 49 | devtoolModuleFilenameTemplate: (f) => { 50 | if (f.resourcePath.startsWith("http://") || 51 | f.resourcePath.startsWith("https://") || 52 | f.resourcePath.startsWith("file://")) { 53 | return f.resourcePath; 54 | } else { 55 | return "webpack://" + f.namespace + "/" + f.resourcePath; 56 | } 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /server/conf/evolutions/default/1.sql: -------------------------------------------------------------------------------- 1 | -- !Ups 2 | 3 | CREATE TABLE product ( 4 | id int NOT NULL AUTO_INCREMENT, 5 | name varchar(255) NOT NULL, 6 | description varchar(255) NOT NULL, 7 | PRIMARY KEY (id) 8 | ); 9 | 10 | CREATE TABLE picture ( 11 | id int NOT NULL AUTO_INCREMENT, 12 | productId int NOT NULL, 13 | width int NOT NULL, 14 | height int NOT NULL, 15 | url varchar(255), 16 | PRIMARY KEY (id) 17 | ); 18 | 19 | INSERT INTO picture (productId, width, height, url) VALUES 20 | (1, 50, 50, '/assets/images/cheesecake1.jpeg'), 21 | (1, 50, 50, '/assets/images/cheesecake2.jpeg'), 22 | (2, 60, 30, '/assets/images/nutella.jpeg'); 23 | 24 | INSERT INTO product (name, description) VALUES 25 | ('Cheesecake', 'Tasty'), 26 | ('Health Potion', '+50 HP'); 27 | 28 | CREATE TABLE loginInfo ( 29 | id varchar(255) NOT NULL, 30 | hasher varchar(255) NOT NULL, 31 | password varchar(255) NOT NULL, 32 | salt varchar(255), 33 | PRIMARY KEY (id) 34 | ); 35 | 36 | -- 37 | -- This record adds by default the user with credentials: 38 | -- username: admin 39 | -- password: admin 40 | -- should not be kept in production 41 | -- 42 | INSERT INTO loginInfo (id, hasher, password) values 43 | ('credentials:admin', 'bcrypt', '$2a$10$QpRYc9HiCvQ2FKhe7SO8vuyy9ZJInVJAj6i3f5l1z9gYzjh9pNMEy'); 44 | 45 | CREATE TABLE userSession( 46 | id varchar(256) NOT NULL, 47 | loginInfoId varchar(255) NOT NULL, 48 | loginInfoKey varchar(255) NOT NULL, 49 | lastUsed datetime NOT NULL, 50 | expiration datetime NOT NULL, 51 | idleTimeout int, 52 | creation datetime DEFAULT CURRENT_TIMESTAMP, 53 | PRIMARY KEY (id) 54 | ); 55 | 56 | -- !Downs 57 | 58 | DROP TABLE userSession; 59 | DROP TABLE loginInfo; 60 | DROP TABLE picture; 61 | DROP TABLE product; 62 | -------------------------------------------------------------------------------- /project/WebpackUtils.scala: -------------------------------------------------------------------------------- 1 | import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport._ 2 | import sbt.Keys._ 3 | import sbt._ 4 | import scalajsbundler.sbtplugin.ScalaJSBundlerPlugin.autoImport._ 5 | 6 | object WebpackUtils { 7 | 8 | val deleteWebpackCache = taskKey[Unit]("Deletes the cache used by webpack if there are changes to the monitored files") 9 | 10 | val wiringTasks = Seq( 11 | deleteWebpackCache := { 12 | val webpackMonitored = (Compile / fastOptJS / webpackMonitoredFiles).value 13 | val webpackCacheDir = (Compile / fastOptJS / webpack / streams).value.cacheDirectory 14 | val cacheDirectory = streams.value.cacheDirectory / "webpack-cache" 15 | val logger = streams.value.log 16 | 17 | /* 18 | * Only deletes the cache if a monitored file has changed. 19 | * 20 | * This has been done because webpack does not monitor the output file 21 | * created by fastOptJS (i.e. web_client-fastopt.js). So when the output file 22 | * changes, in LibraryTasks.bundle no bundle is created because all the 23 | * rest of the monitored files stay the same. 24 | */ 25 | val actionToPerform = FileFunction.cached(cacheDirectory, inStyle = FileInfo.hash) {_ => 26 | // Deleting cache directory to make webpack run every time 27 | // TODO: Improve with webpackDevServer 28 | val dirToDelete = webpackCacheDir / "fastOptJS-webpack-libraries" 29 | logger.info("Deleting " + dirToDelete) 30 | IO.delete(dirToDelete) 31 | Set() 32 | } 33 | 34 | actionToPerform(webpackMonitored.toSet) 35 | 36 | }, 37 | (Compile / fastOptJS / webpack) := { 38 | streams.value.log.info("Running webpack") 39 | deleteWebpackCache.value 40 | (Compile / fastOptJS / webpack).value 41 | } 42 | ) 43 | 44 | } 45 | -------------------------------------------------------------------------------- /web_client/src/main/scala/com/mypackage/product/ProductDisplay.scala: -------------------------------------------------------------------------------- 1 | package com.mypackage.product 2 | 3 | import antd.Card 4 | import antd.CardMeta 5 | import antd.Col 6 | import antd.Row 7 | import com.apollographql.scalajs.react.Query 8 | import com.mypackage.AllProductsQuery 9 | import slinky.core.StatelessComponent 10 | import slinky.core.annotations.react 11 | import slinky.web.html.div 12 | import slinky.web.html.h1 13 | import slinky.web.html.img 14 | import slinky.web.html.span 15 | import slinky.web.html.src 16 | import slinky.web.html.style 17 | import slinky.core.WithAttrs._ 18 | import slinky.core.BuildingComponent._ 19 | import slinky.core.TagMod 20 | 21 | import scala.scalajs.js 22 | 23 | @react class ProductDisplay extends StatelessComponent { 24 | type Props = Unit 25 | 26 | override def render() = 27 | Query(AllProductsQuery) { result => 28 | if (result.loading) { 29 | h1("Loading!") 30 | } else if (result.error.isDefined) { 31 | h1("Error: " + result.error.get.message) 32 | } else { 33 | div( 34 | h1("Products display"), 35 | Row(Row.Props(gutter = 16, justify = "space-around", align = "middle"))( 36 | result.data.get.products.map { product => 37 | Col(Col.Props(span = 6))( 38 | renderProductCard(product) 39 | ): TagMod[Object] 40 | }: _* 41 | ) 42 | ) 43 | } 44 | } 45 | 46 | private def renderProductCard(product: AllProductsQuery.Data.Product) = 47 | Card(Card.Props( 48 | cover = img(src := product.pictures.headOption.flatMap(_.url).getOrElse("/assets/images/default_item.jpg"))() 49 | ))( 50 | style := js.Dynamic.literal(maxWidth = "240px"), 51 | CardMeta( 52 | CardMeta.Props(title = span(product.name)(), description = span(product.description)()) 53 | ) 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /web_client/src/main/scala/antd/Grid.scala: -------------------------------------------------------------------------------- 1 | package antd 2 | 3 | import slinky.core.ExternalComponent 4 | import slinky.core.annotations.react 5 | import slinky.core.facade.ReactElement 6 | 7 | import scala.scalajs.js 8 | import scala.scalajs.js.UndefOr 9 | import scala.scalajs.js.annotation.JSImport 10 | import scala.scalajs.js.| 11 | 12 | @JSImport("antd", JSImport.Default) 13 | @js.native 14 | object AntGrid extends js.Object { 15 | val Row: js.Object = js.native 16 | val Col: js.Object = js.native 17 | } 18 | 19 | case class Gutter(xs: Int = 0, 20 | sm: Int = 0, 21 | md: Int = 0, 22 | lg: Int = 0, 23 | xl: Int = 0, 24 | xxl: Int = 0) 25 | 26 | case class ColProps(offset: Int = 0, 27 | order: Int = 0, 28 | pull: Int = 0, 29 | push: Int = 0, 30 | span: Int = 0) 31 | 32 | @react object Row extends ExternalComponent { 33 | case class Props(align: String = "top", 34 | gutter: Int | Gutter = 0, 35 | justify: String = "start", 36 | `type`: UndefOr[String] = js.undefined) 37 | 38 | override val component = AntGrid.Row 39 | } 40 | 41 | @react object Col extends ExternalComponent { 42 | case class Props(offset: Int = 0, 43 | order: Int = 0, 44 | pull: Int = 0, 45 | push: Int = 0, 46 | span: Int = 0, 47 | xs: UndefOr[Int | Gutter] = js.undefined, 48 | sm: UndefOr[Int | Gutter] = js.undefined, 49 | md: UndefOr[Int | Gutter] = js.undefined, 50 | lg: UndefOr[Int | Gutter] = js.undefined, 51 | xl: UndefOr[Int | Gutter] = js.undefined, 52 | xxl: UndefOr[Int | Gutter] = js.undefined) 53 | 54 | override val component = AntGrid.Col 55 | } 56 | 57 | -------------------------------------------------------------------------------- /schema/src/main/scala/com/mypackage/GraphQLSchema.scala: -------------------------------------------------------------------------------- 1 | package com.mypackage 2 | 3 | import sangria.macros.derive._ 4 | import sangria.schema._ 5 | import com.mypackage.Domain._ 6 | 7 | object GraphQLSchema { 8 | 9 | val IdentifiableType = InterfaceType( 10 | "Identifiable", 11 | "Entity that can be identified", 12 | 13 | fields[RequestContext, Identifiable]( 14 | Field("id", StringType, resolve = _.value.id))) 15 | 16 | val PictureType = 17 | deriveObjectType[Unit, Picture]( 18 | ObjectTypeDescription("The product picture"), 19 | DocumentField("url", "Picture CDN URL")) 20 | 21 | val ProductType = 22 | deriveObjectType[RequestContext, Product]( 23 | Interfaces(IdentifiableType), 24 | AddFields( 25 | Field("pictures", ListType(PictureType), 26 | arguments = Argument("size", IntType) :: Nil, 27 | resolve = c => c.ctx.pictureRepo.picturesByProduct(c.value.id)) 28 | ) 29 | ) 30 | 31 | val Id = Argument("id", StringType) 32 | 33 | val QueryType = ObjectType("Query", fields[RequestContext, Any]( 34 | Field("product", OptionType(ProductType), 35 | description = Some("Returns a product with specific `id`."), 36 | arguments = Id :: Nil, 37 | resolve = c ⇒ c.ctx.productRepo.product(c arg Id)), 38 | 39 | Field("products", ListType(ProductType), 40 | description = Some("Returns a list of all available products."), 41 | resolve = _.ctx.productRepo.products))) 42 | 43 | val NameArg = Argument("name", StringType) 44 | val DescriptionArg = Argument("description", StringType) 45 | 46 | val MutationType = ObjectType("Mutation", 47 | fields[RequestContext, Any]( 48 | Field("addProduct", ProductType, 49 | arguments = NameArg :: DescriptionArg :: Nil, 50 | resolve = req => req.ctx.productRepo.addProduct(req.arg(NameArg), req.arg(DescriptionArg)) 51 | ) 52 | ) 53 | ) 54 | 55 | val schema = Schema(QueryType, Some(MutationType)) 56 | 57 | } 58 | -------------------------------------------------------------------------------- /web_client/src/main/scala/com/mypackage/MainPage.scala: -------------------------------------------------------------------------------- 1 | package com.mypackage 2 | 3 | import antd.Layout 4 | import antd.LayoutFooter 5 | import antd.LayoutHeader 6 | import antd.LayoutSider 7 | import antd.Menu 8 | import antd.MenuItem 9 | import reactrouter.BrowserRouter 10 | import reactrouter.Route 11 | import slinky.core.ReactComponentClass._ 12 | import slinky.core._ 13 | import slinky.core.annotations.react 14 | import slinky.core.facade.ReactElement 15 | import slinky.web.html._ 16 | import product.Products 17 | import reactrouter.NavLink 18 | import reactrouter.RouteProps 19 | import version.util.Version 20 | 21 | import scala.scalajs.js 22 | import scala.scalajs.js.annotation.JSImport 23 | 24 | @JSImport("resources/MainPage.css", JSImport.Default) 25 | @js.native 26 | object MainPageCSS extends js.Object 27 | 28 | @react class MainPage extends StatelessComponent { 29 | type Props = Unit 30 | 31 | private val css = MainPageCSS 32 | 33 | def render() = { 34 | Layout(Layout.Props(className = "main-layout"))( 35 | renderHeader(), 36 | renderContent(), 37 | LayoutFooter( 38 | span(s"Version ${Version.version}") 39 | ) 40 | ) 41 | } 42 | 43 | private def renderContent() = { 44 | val testComponent: js.Function1[RouteProps, ReactElement] = p => h1("Test") 45 | Layout( 46 | renderSider(), 47 | Layout( 48 | Route(Route.Props(path = "/app", exact = true, component = wrapperToClass(Products))), 49 | Route(Route.Props(path = "/app/test", render = testComponent)) 50 | ) 51 | ) 52 | } 53 | 54 | private def renderHeader() = 55 | LayoutHeader( 56 | h1(className := "app-title")("Welcome to React (with Scala.js!) on Play") 57 | ) 58 | 59 | 60 | private def renderSider() = { 61 | LayoutSider( 62 | Menu( 63 | MenuItem( 64 | NavLink(NavLink.Props(to = "/app"))("Products") 65 | ), 66 | MenuItem( 67 | NavLink(NavLink.Props(to = "/app/test"))("Test") 68 | ) 69 | ) 70 | ) 71 | } 72 | } 73 | 74 | -------------------------------------------------------------------------------- /server/app/auth/UserSessionRepository.scala: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import com.github.tototoshi.slick.MySQLJodaSupport._ 4 | import com.mohiva.play.silhouette.api.LoginInfo 5 | import com.mohiva.play.silhouette.api.repositories.AuthenticatorRepository 6 | import com.mohiva.play.silhouette.impl.authenticators.BearerTokenAuthenticator 7 | import database.Tables 8 | import javax.inject.Inject 9 | import slick.jdbc.MySQLProfile.api._ 10 | 11 | import scala.concurrent.ExecutionContext.Implicits.global 12 | import scala.concurrent.Future 13 | import scala.concurrent.duration._ 14 | import scala.language.implicitConversions 15 | 16 | class UserSessionRepository @Inject()(db: Database) extends AuthenticatorRepository[BearerTokenAuthenticator] { 17 | 18 | override def find(id: String): Future[Option[BearerTokenAuthenticator]] = { 19 | val query = Tables.Usersession.filter(_.id === id).result.headOption 20 | db.run(query).map(_.map { a => 21 | val loginInfo = LoginInfo(a.logininfoid, a.logininfokey) 22 | BearerTokenAuthenticator(a.id, loginInfo, a.lastused, a.expiration, a.idletimeout.map(secondsToDuration)) 23 | }) 24 | } 25 | 26 | override def add(a: BearerTokenAuthenticator): Future[BearerTokenAuthenticator] = { 27 | val query = Tables.Usersession.map(us => (us.id, us.logininfoid, us.logininfokey, us.lastused, us.expiration, us.idletimeout)) += ( 28 | (a.id, a.loginInfo.providerID, a.loginInfo.providerKey, 29 | a.lastUsedDateTime, a.expirationDateTime, a.idleTimeout.map(durationToSeconds)) 30 | ) 31 | db.run(query).map(_ => a) 32 | } 33 | 34 | override def update(a: BearerTokenAuthenticator): Future[BearerTokenAuthenticator] = { 35 | val query = Tables.Usersession.filter(_.id === a.id) 36 | .map(us => (us.logininfoid, us.logininfokey, us.lastused, us.expiration, us.idletimeout)) 37 | .update((a.loginInfo.providerID, a.loginInfo.providerKey, 38 | a.lastUsedDateTime, a.expirationDateTime, a.idleTimeout.map(durationToSeconds))) 39 | db.run(query).map(_ => a) 40 | } 41 | 42 | override def remove(id: String): Future[Unit] = { 43 | val query = Tables.Usersession.filter(_.id === id).delete 44 | db.run(query).map(_ => ()) 45 | } 46 | 47 | private def secondsToDuration(s: Int): FiniteDuration = s seconds 48 | private def durationToSeconds(f: FiniteDuration): Int = f.toSeconds.toInt 49 | 50 | } 51 | -------------------------------------------------------------------------------- /server/app/controllers/AuthenticationController.scala: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import auth.AuthEnvironment 4 | import auth.UserIdentityService 5 | import com.mohiva.play.silhouette.api.Environment 6 | import com.mohiva.play.silhouette.api.util.Credentials 7 | import com.mohiva.play.silhouette.impl.exceptions.IdentityNotFoundException 8 | import com.mohiva.play.silhouette.impl.providers.CredentialsProvider 9 | import javax.inject.Inject 10 | import play.Logger 11 | import play.api.libs.json.Json 12 | import play.api.libs.json.Reads 13 | import play.api.mvc.AbstractController 14 | import play.api.mvc.ControllerComponents 15 | 16 | import scala.concurrent.ExecutionContext.Implicits.global 17 | import scala.concurrent.Await 18 | import scala.concurrent.Future 19 | import scala.util.Try 20 | 21 | class AuthenticationController @Inject()(cc: ControllerComponents, 22 | env: Environment[AuthEnvironment], 23 | credentialsProvider: CredentialsProvider, 24 | userIdentityService: UserIdentityService) extends AbstractController(cc) { 25 | 26 | def authenticate = Action.async(parse.json) { implicit request => 27 | request.body.validate[AuthCredentials].fold( 28 | errors => Future.successful(BadRequest(errors.mkString(","))), 29 | reqCredentials => { 30 | val credentials = Credentials(reqCredentials.username, reqCredentials.password) 31 | val bearerAuthenticatorToken = for { 32 | loginInfo <- credentialsProvider.authenticate(credentials) 33 | maybeUser <- userIdentityService.retrieve(loginInfo) 34 | authenticator <- maybeUser match { 35 | case None => Future.failed(new IdentityNotFoundException("Couldn't authenticate the user")) 36 | case Some(user) => env.authenticatorService.create(loginInfo) 37 | } 38 | token <- env.authenticatorService.init(authenticator) 39 | } yield token 40 | 41 | bearerAuthenticatorToken.map { token => 42 | Ok(s"$token") 43 | } recover { 44 | case t => Unauthorized("Unable to login") 45 | } 46 | } 47 | ) 48 | } 49 | 50 | } 51 | 52 | case class AuthCredentials(username: String, 53 | password: String) 54 | object AuthCredentials { 55 | implicit val fmt: Reads[AuthCredentials] = Json.reads[AuthCredentials] 56 | } 57 | -------------------------------------------------------------------------------- /server/app/auth/PasswordInfoRepository.scala: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import com.google.inject.Inject 4 | import com.mohiva.play.silhouette.api.LoginInfo 5 | import com.mohiva.play.silhouette.api.util.PasswordInfo 6 | import com.mohiva.play.silhouette.persistence.daos.DelegableAuthInfoDAO 7 | import database.Tables 8 | import slick.jdbc.MySQLProfile.api._ 9 | 10 | import scala.concurrent.ExecutionContext.Implicits.global 11 | import scala.concurrent.Future 12 | 13 | class PasswordInfoRepository @Inject()(db: Database) extends DelegableAuthInfoDAO[PasswordInfo] { 14 | 15 | override def find(loginInfo: LoginInfo): Future[Option[PasswordInfo]] = { 16 | val query = loginInfoById(loginInfo).result 17 | db.run(query).map(_.headOption.map(pi => PasswordInfo(pi.hasher, pi.password, pi.salt))) 18 | } 19 | 20 | 21 | override def add(loginInfo: LoginInfo, authInfo: PasswordInfo): Future[PasswordInfo] = 22 | runAndReturn(addQuery(loginInfo, authInfo), authInfo) 23 | 24 | private def addQuery = (loginInfo: LoginInfo, authInfo: PasswordInfo) => 25 | Tables.Logininfo.map(li => 26 | (li.id, li.hasher, li.password, li.salt) 27 | ) += ((idFromLoginInfo(loginInfo), authInfo.hasher, authInfo.password, authInfo.salt)) 28 | 29 | 30 | override def update(loginInfo: LoginInfo, authInfo: PasswordInfo): Future[PasswordInfo] = 31 | runAndReturn(updateQuery(loginInfo, authInfo), authInfo) 32 | 33 | private def updateQuery = (loginInfo: LoginInfo, authInfo: PasswordInfo) => 34 | loginInfoById(loginInfo) 35 | .map(li => (li.hasher, li.password, li.salt)) 36 | .update((authInfo.hasher, authInfo.password, authInfo.salt)) 37 | 38 | override def save(loginInfo: LoginInfo, authInfo: PasswordInfo): Future[PasswordInfo] = { 39 | val query = for { 40 | isLoginInfoPresent <- loginInfoById(loginInfo).exists.result 41 | queryToUse = if (isLoginInfoPresent) updateQuery else addQuery 42 | action <- queryToUse(loginInfo, authInfo) 43 | } yield action 44 | 45 | runAndReturn(query, authInfo) 46 | } 47 | 48 | override def remove(loginInfo: LoginInfo): Future[Unit] = runAndReturn(loginInfoById(loginInfo).delete, ()) 49 | 50 | private def idFromLoginInfo(loginInfo: LoginInfo) = loginInfo.providerID + ":" + loginInfo.providerKey 51 | private def loginInfoById(loginInfo: LoginInfo) = Tables.Logininfo.filter(_.id === idFromLoginInfo(loginInfo)) 52 | private def runAndReturn[T](action: DBIOAction[Int, NoStream, Nothing], ret: T) = db.run(action).map(_ => ret) 53 | } 54 | -------------------------------------------------------------------------------- /server/app/controllers/GraphQLController.scala: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import auth.AuthEnvironment 4 | import com.google.inject.Inject 5 | import com.mohiva.play.silhouette.api.Silhouette 6 | import com.mypackage.GraphQLSchema._ 7 | import com.mypackage.RequestContext 8 | import play.api.libs.json._ 9 | import play.api.mvc.AbstractController 10 | import play.api.mvc.ControllerComponents 11 | import play.twirl.api.Html 12 | import sangria.ast.Document 13 | import sangria.execution.ErrorWithResolver 14 | import sangria.execution.Executor 15 | import sangria.execution.QueryAnalysisError 16 | import sangria.marshalling.playJson._ 17 | import sangria.parser.QueryParser 18 | 19 | import scala.concurrent.Future 20 | import scala.util.Failure 21 | import scala.util.Success 22 | 23 | class GraphQLController @Inject()(cc: ControllerComponents, 24 | e: Silhouette[AuthEnvironment], 25 | requestContext: RequestContext) extends AbstractController(cc) { 26 | 27 | implicit val execution = cc.executionContext 28 | 29 | def graphql = e.SecuredAction.async(parse.json) { request ⇒ 30 | def parseVariables(variables: String) = 31 | if (variables.trim == "" || variables.trim == "null") Json.obj() else Json.parse(variables).as[JsObject] 32 | 33 | val query = (request.body \ "query").as[String] 34 | val operation = (request.body \ "operationName").asOpt[String] 35 | val variables = (request.body \ "variables").toOption.flatMap { 36 | case JsString(vars) ⇒ Some(parseVariables(vars)) 37 | case obj: JsObject ⇒ Some(obj) 38 | case _ ⇒ None 39 | } 40 | 41 | QueryParser.parse(query) match { 42 | // query parsed successfully, time to execute it! 43 | case Success(queryAst) ⇒ 44 | executeGraphQLQuery(queryAst, operation, variables.getOrElse(Json.obj())) 45 | 46 | // can't parse GraphQLController query, return error 47 | case Failure(error) ⇒ 48 | Future.successful(BadRequest(Json.obj("error" → error.getMessage))) 49 | } 50 | } 51 | 52 | def executeGraphQLQuery(query: Document, op: Option[String], vars: JsObject) = 53 | Executor.execute(schema, query, requestContext, operationName = op, variables = vars) 54 | .map(Ok(_)) 55 | .recover { 56 | case error: QueryAnalysisError ⇒ BadRequest(error.resolveError) 57 | case error: ErrorWithResolver ⇒ InternalServerError(error.resolveError) 58 | } 59 | 60 | def graphiQL = e.UnsecuredAction { 61 | Html 62 | Ok(views.html.graphiql()) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /web_client/src/main/scala/antd/Card.scala: -------------------------------------------------------------------------------- 1 | package antd 2 | 3 | import slinky.core.ExternalComponent 4 | import slinky.core.annotations.react 5 | import slinky.core.facade.ReactElement 6 | 7 | import scala.scalajs.js 8 | import scala.scalajs.js.UndefOr 9 | import scala.scalajs.js.annotation.JSImport 10 | import scala.scalajs.js.| 11 | 12 | @JSImport("antd", JSImport.Default) 13 | @js.native 14 | object AntCard extends js.Object { 15 | val Card: AntCardObj = js.native 16 | } 17 | 18 | @js.native 19 | trait AntCardObj extends js.Object { 20 | val Meta: js.Object = js.native 21 | val Grid: js.Object = js.native 22 | } 23 | 24 | @react object Card extends ExternalComponent { 25 | case class TabPaneHead(key: String, tab: ReactElement) 26 | 27 | case class Props(actions: UndefOr[Seq[ReactElement]] = js.undefined, 28 | activeTabKey: UndefOr[String] = js.undefined, 29 | headStyle: UndefOr[js.Object] = js.undefined, 30 | bodyStyle: UndefOr[js.Object] = js.undefined, 31 | bordered: Boolean = true, 32 | cover: UndefOr[ReactElement] = js.undefined, 33 | defaultActiveTabKey: UndefOr[String] = js.undefined, 34 | extra: UndefOr[String | ReactElement] = js.undefined, 35 | hoverable: Boolean = false, 36 | loading: Boolean = false, 37 | tabList: UndefOr[Seq[TabPaneHead]] = js.undefined, 38 | size: String = "default", 39 | title: UndefOr[String | ReactElement] = js.undefined, 40 | `type`: UndefOr[String] = js.undefined, 41 | onTabChange: UndefOr[String => Unit] = js.undefined 42 | // Needs to be verified if types align with JS. 43 | ) 44 | 45 | override val component = AntCard.Card 46 | } 47 | 48 | @react object CardMeta extends ExternalComponent { 49 | case class Props(avatar: UndefOr[ReactElement] = js.undefined, 50 | className: UndefOr[String] = js.undefined, 51 | description: UndefOr[ReactElement] = js.undefined, 52 | style: UndefOr[js.Object] = js.undefined, 53 | title: UndefOr[ReactElement] = js.undefined) 54 | 55 | override val component = AntCard.Card.Meta 56 | } 57 | 58 | @react object CardGrid extends ExternalComponent { 59 | case class Props(className: UndefOr[String] = js.undefined, 60 | style: UndefOr[js.Object] = js.undefined) 61 | 62 | override val component = AntCard.Card.Grid 63 | } 64 | -------------------------------------------------------------------------------- /web_client/src/main/scala/com/mypackage/Bootstrap.scala: -------------------------------------------------------------------------------- 1 | package com.mypackage 2 | 3 | import com.apollographql.scalajs.ApolloBoostClient 4 | import com.apollographql.scalajs.ApolloBoostClientOptions 5 | import com.apollographql.scalajs.ApolloError 6 | import com.apollographql.scalajs.link.Operation 7 | import com.apollographql.scalajs.react.ApolloProvider 8 | import com.mypackage.util.UrlUtils 9 | import org.scalajs.dom 10 | import org.scalajs.dom.ext.LocalStorage 11 | import slinky.hot 12 | import slinky.web.ReactDOM 13 | 14 | import scala.scalajs.LinkingInfo 15 | import scala.scalajs.js 16 | import scala.scalajs.js.Dictionary 17 | import scala.scalajs.js.annotation.JSExportTopLevel 18 | import scala.scalajs.js.annotation.JSImport 19 | import scala.scalajs.js.JSConverters._ 20 | 21 | @JSImport("antd/dist/antd.css", JSImport.Default) 22 | @js.native 23 | object AntdCSS extends js.Object 24 | 25 | object Bootstrap { 26 | 27 | val css = AntdCSS 28 | 29 | @JSExportTopLevel("entrypoint.main") 30 | def main(): Unit = { 31 | if (LinkingInfo.developmentMode) { 32 | hot.initialize() 33 | } 34 | 35 | val container = Option(dom.document.getElementById("root")).getOrElse { 36 | val elem = dom.document.createElement("div") 37 | elem.id = "root" 38 | dom.document.body.appendChild(elem) 39 | elem 40 | } 41 | 42 | val errorHandler: js.Function1[ApolloError, js.Any] = error => { 43 | if (error.networkError != null) { 44 | if (error.networkError.statusCode == 401) { 45 | LocalStorage.remove("bearerToken") 46 | UrlUtils.navigate("/login") 47 | } else { 48 | println(s"Status: ${error.networkError.statusCode} - Body: ${error.networkError.bodyText}") 49 | } 50 | } 51 | } 52 | 53 | val injectTokenInRequest: js.Function1[Operation, js.Promise[js.Any]] = operation => { 54 | js.Promise.resolve[js.Any]( 55 | LocalStorage("bearerToken").map { token => 56 | operation.setContext( 57 | Map( 58 | "headers" -> Map("X-Auth-Token" -> token).toJSDictionary 59 | ).toJSDictionary.asInstanceOf[Dictionary[js.Any]] 60 | ) 61 | js.Object() 62 | }.getOrElse(js.Object()).asInstanceOf[js.Any] 63 | ) 64 | } 65 | 66 | val client = new ApolloBoostClient( 67 | ApolloBoostClientOptions( 68 | uri = "/graphql", 69 | onError = errorHandler, 70 | request = injectTokenInRequest 71 | ) 72 | ) 73 | 74 | ReactDOM.render(ApolloProvider(client)(App()), container) 75 | 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /web_client/src/main/scala/antd/Layout.scala: -------------------------------------------------------------------------------- 1 | package antd 2 | 3 | import slinky.core.ExternalComponent 4 | import slinky.core.annotations.react 5 | import slinky.core.facade.ReactElement 6 | 7 | import scala.scalajs.js 8 | import scala.scalajs.js.UndefOr 9 | import scala.scalajs.js.annotation.JSImport 10 | import scala.scalajs.js.| 11 | 12 | @JSImport("antd", JSImport.Default) 13 | @js.native 14 | object AntLayout extends js.Object { 15 | val Layout: AntLayoutObj = js.native 16 | } 17 | 18 | @js.native 19 | trait AntLayoutObj extends js.Object { 20 | val Header: js.Object = js.native 21 | val Footer: js.Object = js.native 22 | val Content: js.Object = js.native 23 | val Sider: js.Object = js.native 24 | } 25 | 26 | @react object Layout extends ExternalComponent { 27 | case class Props(className: UndefOr[String] = js.undefined, 28 | hasSider: UndefOr[Boolean] = js.undefined, 29 | style: UndefOr[js.Object] = js.undefined) 30 | 31 | override val component = AntLayout.Layout 32 | } 33 | 34 | @react object LayoutHeader extends ExternalComponent { 35 | case class Props(className: UndefOr[String] = js.undefined, 36 | hasSider: UndefOr[Boolean] = js.undefined, 37 | style: UndefOr[js.Object] = js.undefined) 38 | 39 | override val component = AntLayout.Layout.Header 40 | } 41 | 42 | @react object LayoutFooter extends ExternalComponent { 43 | case class Props(className: UndefOr[String] = js.undefined, 44 | hasSider: UndefOr[Boolean] = js.undefined, 45 | style: UndefOr[js.Object] = js.undefined) 46 | 47 | override val component = AntLayout.Layout.Footer 48 | } 49 | 50 | @react object LayoutContent extends ExternalComponent { 51 | case class Props(className: UndefOr[String] = js.undefined, 52 | hasSider: UndefOr[Boolean] = js.undefined, 53 | style: UndefOr[js.Object] = js.undefined) 54 | 55 | override val component = AntLayout.Layout.Content 56 | } 57 | 58 | @react object LayoutSider extends ExternalComponent { 59 | case class Props(breakpoint: UndefOr[String] = js.undefined, 60 | className: UndefOr[String] = js.undefined, 61 | collapsed: UndefOr[Boolean] = js.undefined, 62 | collapsedWidth: Int = 80, 63 | collapsible: Boolean = false, 64 | defaultCollapsed: Boolean = false, 65 | reverseArrow: Boolean = false, 66 | style: UndefOr[js.Object] = js.undefined, 67 | theme: String = "dark", 68 | trigger: UndefOr[String | ReactElement] = js.undefined, 69 | width: Int | String = 200, 70 | onCollapse: UndefOr[js.Function2[js.Object, js.Object, js.Any]] = js.undefined, 71 | onBreakpoint: UndefOr[js.Function1[js.Object, js.Any]] = js.undefined) 72 | 73 | override val component = AntLayout.Layout.Sider 74 | } -------------------------------------------------------------------------------- /web_client/src/main/scala/com/mypackage/product/ProductForm.scala: -------------------------------------------------------------------------------- 1 | package com.mypackage.product 2 | 3 | import antd.Button 4 | import antd.FieldDecoratorOptions 5 | import antd.Form 6 | import antd.FormItem 7 | import antd.FormOps 8 | import antd.Input 9 | import antd.StaticProps 10 | import antd.ValidationRules 11 | import com.apollographql.scalajs.react.Mutation 12 | import com.apollographql.scalajs.react.Mutation.StaticMutationAsyncCallback 13 | import com.apollographql.scalajs.react.UpdateStrategy 14 | import com.mypackage.AddProductMutation 15 | import org.scalajs.dom.Event 16 | import slinky.core.StatelessComponent 17 | import slinky.core.annotations.react 18 | import slinky.core.facade.ReactElement 19 | import slinky.web.html.autoComplete 20 | import slinky.web.html.placeholder 21 | 22 | import scala.collection.mutable 23 | import scala.concurrent.ExecutionContext.Implicits.global 24 | import scala.scalajs.js.JSConverters._ 25 | import scala.scalajs.js.JSON 26 | import scala.scalajs.js.RegExp 27 | 28 | @react class ProductForm extends StatelessComponent { 29 | type Props = Unit 30 | 31 | def handleSubmit(e: Event, 32 | submitCall: StaticMutationAsyncCallback[AddProductMutation.type]): Unit = { 33 | e.preventDefault() 34 | form.validateFields((errors, values) => { 35 | if (errors != null) { 36 | println("Received errors " + JSON.stringify(errors)) 37 | } else { 38 | println("Received values " + JSON.stringify(values)) 39 | val valuesMap: mutable.Map[String, String] = values 40 | submitCall(AddProductMutation.Variables(valuesMap("productName"), valuesMap("productDescription"))).onComplete { _ => 41 | form.setFieldsValue(Map("productName" -> "", "productDescription" -> "").toJSDictionary) 42 | } 43 | } 44 | }) 45 | } 46 | 47 | override def render(): ReactElement = 48 | Mutation(AddProductMutation, UpdateStrategy(refetchQueries = Seq("AllProducts"))) { (addProduct, mutationStatus) => 49 | Form(Form.Props(onSubmit = (e: Event) => { 50 | handleSubmit(e, addProduct) 51 | }))( 52 | FormItem( 53 | form.getFieldDecorator("productName", FieldDecoratorOptions(rules = Seq(ValidationRules(required = true, message = "Cannot contain numbers or be empty.", pattern = RegExp("^[^0-9]+$")))))( 54 | Input(Input.Props())(placeholder := "Name", autoComplete := "off") 55 | ) 56 | ), 57 | FormItem( 58 | form.getFieldDecorator("productDescription", FieldDecoratorOptions(rules = Seq(ValidationRules(required = true, message = "Cannot be empty."))))( 59 | Input(Input.Props())(placeholder := "Description", autoComplete := "off") 60 | ) 61 | ), 62 | Button(Button.Props(`type` = "primary", htmlType = "submit"))("Add") 63 | ) 64 | } 65 | 66 | /** 67 | * This is needed as Antd forms are created as higher order component 68 | * and a form property is injected in the props by the framework. 69 | * This is a workaround to access that property. 70 | */ 71 | def form: FormOps = this.asInstanceOf[StaticProps].props.form 72 | } 73 | -------------------------------------------------------------------------------- /project/QuickCompiler.scala: -------------------------------------------------------------------------------- 1 | import java.util 2 | 3 | import sbt.File 4 | import sbt.internal.inc.CompilerCache 5 | import sbt.internal.util.ManagedLogger 6 | import xsbti.AnalysisCallback 7 | import xsbti.Position 8 | import xsbti.Severity 9 | import xsbti.UseScope 10 | import xsbti.api.ClassLike 11 | import xsbti.api.DependencyContext 12 | import xsbti.compile.Compilers 13 | import xsbti.compile.DependencyChanges 14 | 15 | /** 16 | * Code taken from https://github.com/etsy/sbt-compile-quick-plugin 17 | */ 18 | object QuickCompiler { 19 | 20 | def scalac(compilers: Compilers, 21 | sources: Seq[File], 22 | changes: DependencyChanges, 23 | classpath: Seq[File], 24 | outputDir: File, 25 | options: Seq[String], 26 | callback: AnalysisCallback, 27 | maxErrors: Int, 28 | log: ManagedLogger): Unit = { 29 | compilers.scalac() match { 30 | case c: sbt.internal.inc.AnalyzingCompiler => 31 | c.apply( 32 | sources.toArray, 33 | changes, 34 | classpath.toArray, 35 | outputDir, 36 | options.toArray, 37 | callback, 38 | maxErrors, 39 | new CompilerCache(5000), 40 | log 41 | ) 42 | case unknown_compiler => 43 | log.error("wrong compiler, expected 'sbt.internal.inc.AnalyzingCompiler' got: " + unknown_compiler.getClass.getName) 44 | } 45 | } 46 | 47 | // Indicates to the compiler that no files or dependencies have changed 48 | // This prevents compiling anything other than the requested file 49 | val noChanges: DependencyChanges = new xsbti.compile.DependencyChanges { 50 | def isEmpty = true 51 | 52 | def modifiedBinaries = Array() 53 | 54 | def modifiedClasses = Array() 55 | } 56 | 57 | // This discards the analysis produced by compiling one file, as it 58 | // isn't that useful 59 | object noopCallback extends xsbti.AnalysisCallback { 60 | override def startSource(source: File): Unit = {} 61 | 62 | override def mainClass(sourceFile: File, className: String): Unit = {} 63 | 64 | override def apiPhaseCompleted(): Unit = {} 65 | 66 | override def enabled(): Boolean = false 67 | 68 | override def binaryDependency(onBinaryEntry: File, onBinaryClassName: String, fromClassName: String, fromSourceFile: File, context: DependencyContext): Unit = {} 69 | 70 | override def generatedNonLocalClass(source: File, classFile: File, binaryClassName: String, srcClassName: String): Unit = {} 71 | 72 | override def problem(what: String, pos: Position, msg: String, severity: Severity, reported: Boolean): Unit = {} 73 | 74 | override def dependencyPhaseCompleted(): Unit = {} 75 | 76 | override def classDependency(onClassName: String, sourceClassName: String, context: DependencyContext): Unit = {} 77 | 78 | override def generatedLocalClass(source: File, classFile: File): Unit = {} 79 | 80 | override def api(sourceFile: File, classApi: ClassLike): Unit = {} 81 | 82 | override def usedName(className: String, name: String, useScopes: util.EnumSet[UseScope]): Unit = {} 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /web_client/src/main/scala/antd/Menu.scala: -------------------------------------------------------------------------------- 1 | package antd 2 | 3 | import slinky.core.ExternalComponent 4 | import slinky.core.annotations.react 5 | import slinky.core.facade.ReactElement 6 | 7 | import scala.scalajs.js 8 | import scala.scalajs.js.UndefOr 9 | import scala.scalajs.js.annotation.JSImport 10 | import scala.scalajs.js.| 11 | 12 | @JSImport("antd", JSImport.Default) 13 | @js.native 14 | object AntMenu extends js.Object { 15 | val Menu: AntMenuObject = js.native 16 | } 17 | 18 | @js.native 19 | trait AntMenuObject extends js.Object { 20 | val Item: js.Object = js.native 21 | val SubMenu: js.Object = js.native 22 | val ItemGroup: js.Object = js.native 23 | val Divider: js.Object = js.native 24 | } 25 | 26 | @react object Menu extends ExternalComponent { 27 | case class Props(defaultOpenKeys: UndefOr[js.Array[String]] = js.undefined, 28 | defaultSelectedKeys: UndefOr[js.Array[String]] = js.undefined, 29 | forceSubMenuRender: Boolean = false, 30 | inlineCollapsed: UndefOr[Boolean] = js.undefined, 31 | inlineIndent: Int = 24, 32 | mode: String = "vertical", 33 | multiple: Boolean = false, 34 | openKeys: UndefOr[js.Array[String]] = js.undefined, 35 | selectable: Boolean = true, 36 | selectedKeys: UndefOr[js.Array[String]] = js.undefined, 37 | style: UndefOr[js.Object] = js.undefined, 38 | subMenuCloseDelay: Double = 0.1, 39 | subMenuOpenDelay: Double = 0.0, 40 | theme: String = "light", 41 | onClick: UndefOr[js.Function3[js.Object, js.Object, js.Object, js.Any]] = js.undefined, 42 | onDeselect: UndefOr[js.Function3[js.Object, js.Object, js.Object, js.Any]] = js.undefined, 43 | onOpenChange: UndefOr[js.Function1[js.Array[String], js.Any]] = js.undefined, 44 | onSelect: UndefOr[js.Function3[js.Object, js.Object, js.Object, js.Any]] = js.undefined) 45 | 46 | override val component = AntMenu.Menu 47 | } 48 | 49 | @react object MenuItem extends ExternalComponent { 50 | case class Props(disabled: Boolean = false, 51 | key: UndefOr[String] = js.undefined, 52 | title: UndefOr[String] = js.undefined) 53 | 54 | override val component = AntMenu.Menu.Item 55 | } 56 | 57 | @react object SubMenu extends ExternalComponent { 58 | case class Props(disabled: Boolean = false, 59 | key: UndefOr[String] = js.undefined, 60 | title: UndefOr[String] = js.undefined, 61 | onTitleClick: UndefOr[js.Function2[js.Object, js.Object, js.Any]] = js.undefined) 62 | 63 | override val component = AntMenu.Menu.SubMenu 64 | } 65 | 66 | @react object ItemGroup extends ExternalComponent { 67 | case class Props(title: UndefOr[String | ReactElement] = js.undefined) 68 | 69 | override val component = AntMenu.Menu.ItemGroup 70 | } 71 | 72 | @react object Divider extends ExternalComponent { 73 | type Props = Unit 74 | 75 | override val component = AntMenu.Menu.Divider 76 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # README # 2 | 3 | Seed project for Play framework + ScalaJS using React, Apollo client and Sangria. 4 | Under the hood it uses: 5 | - GraphQL 6 | - Apollo Client 7 | - Play framework 8 | - Sangria 9 | - Silhouette (Authentication) 10 | - Slinky (React for Scala.js) 11 | - Antd (Components for React) 12 | - Slick with codegen and play-evolutions 13 | 14 | 15 | ## Development Guide 16 | 17 | ### Prerequisites 18 | 19 | 1. Docker installed on the dev box. 20 | See: [https://github.com/frgomes/bash-scripts/blob/master/sysadmin-install/install-docker-ce.sh](https://github.com/frgomes/bash-scripts/blob/master/sysadmin-install/install-docker-ce.sh) 21 | 22 | 2. Node installed on the dev box. 23 | See: [http://github.com/frgomes/bash-scripts/blob/master/user-install/install-node.sh](http://github.com/frgomes/bash-scripts/blob/master/user-install/install-node.sh) 24 | 25 | 3. SBT installed on the dev box 26 | See: [https://github.com/frgomes/bash-scripts/blob/master/user-install/install-scala.sh](https://github.com/frgomes/bash-scripts/blob/master/user-install/install-scala.sh) 27 | 28 | 4. Follow the instructions in the file `project/WebClient.scala`. 29 | 30 | 5. If an error at build time appears saying that module `style-loader` cannot be found, run `npm install -g style-loader css-loader` 31 | 32 | ### IDE support 33 | Follow the setup [here](https://slinky.shadaj.me/docs/installation/) for IntelliJ support. 34 | 35 | ### Build instructions 36 | 37 | * Make sure you have already built and published locally ``apollo-scalajs`` according to instructions at `project/WebClient.scala`. 38 | 39 | * Start a MySQL container via Docker Compose. 40 | ```bash 41 | $ docker-compose up -d 42 | ``` 43 | 44 | **Note**: Only MySQL is supported at the moment. In order to support other databases, we should have multiple sets of DDLs and evolutions per database vendor. 45 | 46 | * Run test cases: 47 | ```bash 48 | $ sbt clean test 49 | ``` 50 | 51 | * Runs the server and live reloads changes: 52 | ``` 53 | $ sbt run 54 | ``` 55 | 56 | * Generates a fat jar: 57 | ``` 58 | $ sbt assembly 59 | ``` 60 | 61 | ## Code generation 62 | 63 | Generates schema.graphql and query/mutation objects for the client. 64 | You shouldn't need to use this explicitly. 65 | ``` 66 | $ sbt web_client/Compile/managedSources 67 | ``` 68 | 69 | ## Usage 70 | 71 | The default login for the user interface is: 72 | - username: admin 73 | - password: admin 74 | 75 | It is created via evolutions, remove it from there if you want to change it. Also if you want to create another one manually there is an app called _ManualUserGenerator_ to do that. 76 | 77 | ## Problems 78 | 79 | - WebpackDevServer is not used at the moment 80 | - When a new dependency is added to the npmDependencies the bundle.js is not rebuilt. 81 | - The build is slow because some files are not cached (static query generation and two rounds of js bundling) and the dist directory is deleted. 82 | - Add test framework for front-end 83 | - Refetching the queries instead of changing the apollo cache. 84 | 85 | ## Credits 86 | 87 | https://github.com/shadaj/create-react-scala-app.g8/tree/master/src/main/g8 88 | 89 | https://github.com/boosh/play-scalajs-seed/blob/master/build.sbt 90 | 91 | https://github.com/KyleU/boilerplay 92 | -------------------------------------------------------------------------------- /project/Server.scala: -------------------------------------------------------------------------------- 1 | import com.typesafe.config.ConfigFactory 2 | import com.typesafe.sbt.jse.JsEngineImport.JsEngineKeys 3 | import com.typesafe.sbt.packager.archetypes.JavaAppPackaging 4 | import com.typesafe.sbt.packager.docker.DockerPlugin 5 | import com.typesafe.sbt.packager.jdkpackager.JDKPackagerPlugin 6 | import com.typesafe.sbt.packager.universal.UniversalPlugin 7 | import com.typesafe.sbt.web.Import._ 8 | import com.typesafe.sbt.web.SbtWeb 9 | import play.routes.compiler.InjectedRoutesGenerator 10 | import play.sbt.PlayImport 11 | import play.sbt.PlayImport.PlayKeys 12 | import play.sbt.routes.RoutesKeys 13 | import sbt.Keys._ 14 | import sbt._ 15 | import sbtassembly.AssemblyPlugin.autoImport._ 16 | import scalajsbundler.sbtplugin.WebScalaJSBundlerPlugin 17 | import webscalajs.WebScalaJS.autoImport._ 18 | 19 | object Server { 20 | 21 | val playSlickV = "4.0.0" 22 | val slickV = "3.3.0" 23 | val silhouetteV = "6.0.0-SNAPSHOT" 24 | 25 | private[this] val dependencies = { 26 | Seq( 27 | PlayImport.guice, 28 | PlayImport.evolutions, 29 | "net.codingwell" %% "scala-guice" % "4.2.3", 30 | "org.sangria-graphql" %% "sangria-play-json" % "1.0.5", 31 | "com.typesafe.play" %% "play-slick" % playSlickV, 32 | "com.typesafe.play" %% "play-slick-evolutions" % playSlickV, 33 | "com.typesafe.slick" %% "slick" % slickV, 34 | "com.typesafe.slick" %% "slick-codegen" % slickV, 35 | "mysql" % "mysql-connector-java" % "6.0.6", 36 | "org.postgresql" % "postgresql" % "42.2.5", 37 | "com.mohiva" %% "play-silhouette" % silhouetteV, 38 | "com.mohiva" %% "play-silhouette-password-bcrypt" % silhouetteV, 39 | "com.mohiva" %% "play-silhouette-crypto-jca" % silhouetteV, 40 | "com.mohiva" %% "play-silhouette-persistence" % silhouetteV, 41 | "com.github.tototoshi" %% "slick-joda-mapper" % "2.4.0" 42 | ) 43 | } 44 | 45 | 46 | private[this] lazy val serverSettings = Shared.commonSettings ++ DatabaseUtils.settings ++ Seq( 47 | name := Shared.projectId, 48 | description := Shared.projectName, 49 | 50 | resolvers ++= Seq( 51 | Resolver.jcenterRepo, 52 | Resolver.bintrayRepo("stanch", "maven"), 53 | "Sonatype OSS Snapshots" at "https://oss.sonatype.org/content/repositories/snapshots/" 54 | ), 55 | 56 | libraryDependencies ++= dependencies, 57 | 58 | // Play 59 | RoutesKeys.routesGenerator := InjectedRoutesGenerator, 60 | PlayKeys.externalizeResources := false, 61 | PlayKeys.devSettings := Seq("play.server.akka.requestTimeout" -> "infinite"), 62 | PlayKeys.playDefaultPort := Shared.projectPort, 63 | PlayKeys.playInteractionMode := PlayUtils.NonBlockingInteractionMode, 64 | 65 | // Scala.js 66 | scalaJSProjects := Seq(WebClient.web_client), 67 | 68 | // Sbt-Web 69 | JsEngineKeys.engineType := JsEngineKeys.EngineType.Node, 70 | pipelineStages in Assets := Seq(scalaJSPipeline), 71 | 72 | // Fat-Jar Assembly 73 | assemblyJarName in assembly := Shared.projectId + ".jar", 74 | assemblyMergeStrategy in assembly := { 75 | case "play/reference-overrides.conf" => MergeStrategy.concat 76 | case x => (assemblyMergeStrategy in assembly).value(x) 77 | }, 78 | mainClass in assembly := Some("Bootstrap"), 79 | fullClasspath in assembly += Attributed.blank(PlayKeys.playPackageAssets.value), 80 | ) 81 | 82 | lazy val server = (project in file("server")) 83 | .enablePlugins( 84 | SbtWeb, play.sbt.PlayScala, JavaAppPackaging, 85 | UniversalPlugin, DockerPlugin, JDKPackagerPlugin, WebScalaJSBundlerPlugin 86 | ) 87 | .settings(serverSettings: _*) 88 | .settings(Packaging.settings: _*) 89 | .dependsOn(Shared.sharedJvm, WebClient.web_client, Schema.schema) 90 | } 91 | -------------------------------------------------------------------------------- /project/WebClient.scala: -------------------------------------------------------------------------------- 1 | import org.portablescala.sbtplatformdeps.PlatformDepsPlugin.autoImport._ 2 | import org.scalajs.sbtplugin.ScalaJSPlugin 3 | import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport._ 4 | import sbt.Keys._ 5 | import sbt._ 6 | import scalajsbundler.sbtplugin.ScalaJSBundlerPlugin 7 | import scalajsbundler.sbtplugin.ScalaJSBundlerPlugin.autoImport._ 8 | import webscalajs.ScalaJSWeb 9 | 10 | object WebClient { 11 | 12 | val slinkyVer = "0.6.0" 13 | val reactVer = "16.5.2" 14 | 15 | /* 16 | * Some dependencies needed to make this project work are in the master branch, still to be released. 17 | * 18 | * In order to make this project work, follow these steps: 19 | * 20 | * 1. Clone the repo https://github.com/frgomes/apollo-scalajs 21 | * ``` 22 | * $ git clone https://github.com/frgomes/apollo-scalajs 23 | * ``` 24 | * 2. Run SBT, publishLocal and obtain the published version information: 25 | * ``` 26 | * $ cd apollo-scalajs 27 | * $ sbt 28 | * > publishLocal 29 | * > show core/version 30 | * 0.7.0+26-5cd49197+20190616-1445 31 | * > quit 32 | * ``` 33 | * 3. update variable ``apolloScalaJsVer`` so that it contains the version information just obtained. 34 | */ 35 | val apolloScalaJsVer = "0.7.0+27-0badbdb2" 36 | 37 | 38 | private[this] val clientSettings = 39 | Shared.commonSettings ++ WebpackUtils.wiringTasks ++ GraphqlUtils.generateTasks ++ Seq( 40 | scalaJSUseMainModuleInitializer := true, // Starts scalajs from a main function 41 | mainClass in Compile := Some("com.mypackage.Bootstrap"), 42 | 43 | resolvers ++= Seq( 44 | "Apollo Bintray" at "https://dl.bintray.com/apollographql/maven/", 45 | Resolver.bintrayRepo("hmil", "maven") 46 | ), 47 | 48 | libraryDependencies ++= Seq( 49 | "me.shadaj" %%% "slinky-web" % slinkyVer, // React DOM, HTML and SVG tags 50 | "me.shadaj" %%% "slinky-hot" % slinkyVer, // Hot loading, requires react-proxy package 51 | "me.shadaj" %%% "slinky-scalajsreact-interop" % slinkyVer, // Interop with japgolly/scalajs-react 52 | "com.apollographql" %%% "apollo-scalajs-core" % apolloScalaJsVer, 53 | "com.apollographql" %%% "apollo-scalajs-react" % apolloScalaJsVer, 54 | "org.sangria-graphql" %% "sangria-circe" % "1.1.0", 55 | "fr.hmil" %%% "roshttp" % "2.2.4" 56 | ), 57 | Compile / npmDependencies ++= Seq( 58 | "react" -> reactVer, 59 | "react-dom" -> reactVer, 60 | "react-proxy" -> "1.1.8", 61 | "react-router-dom" -> "5.0.0", 62 | "apollo-boost" -> "0.1.16", 63 | "apollo-cache-inmemory" -> "1.4.3", 64 | "react-apollo" -> "2.2.2", 65 | "graphql-tag" -> "2.10.0", 66 | "graphql" -> "14.0.2", 67 | "antd" -> "3.13.0" 68 | ), 69 | 70 | Compile / npmDevDependencies ++= Seq( 71 | "file-loader" -> "2.0.0", 72 | "style-loader" -> "0.23.1", 73 | "css-loader" -> "1.0.0", 74 | "copy-webpack-plugin" -> "4.5.4", 75 | "clean-webpack-plugin" -> "1.0.0", 76 | "webpack-merge" -> "4.1.4", 77 | "apollo" -> "2.5.3" 78 | ), 79 | 80 | webpack / version := "4.21.0", 81 | startWebpackDevServer / version := "3.1.14", 82 | 83 | webpackResources := baseDirectory.value / "webpack" * "*", 84 | fastOptJS / webpackConfigFile := Some(baseDirectory.value / "webpack" / "webpack-fastopt.config.js"), 85 | fullOptJS / webpackConfigFile := Some(baseDirectory.value / "webpack" / "webpack-opt.config.js"), 86 | 87 | fastOptJS / webpackBundlingMode := BundlingMode.LibraryOnly(), 88 | 89 | scalacOptions ++= Seq("-P:scalajs:sjsDefinedByDefault", "-P:scalajs:suppressExportDeprecations"), 90 | 91 | GraphqlUtils.npmUpdateTask := (Compile / npmInstallDependencies).value, 92 | 93 | ) 94 | 95 | lazy val web_client = (project in file("web_client")) 96 | .settings(clientSettings: _*) 97 | // .settings(addCommandAlias("dev", ";fastOptJS::startWebpackDevServer;~fastOptJS"): _*) 98 | .enablePlugins(ScalaJSPlugin, ScalaJSWeb, ScalaJSBundlerPlugin) 99 | .dependsOn(Shared.sharedJs, Schema.schema) 100 | } 101 | 102 | 103 | -------------------------------------------------------------------------------- /project/Shared.scala: -------------------------------------------------------------------------------- 1 | import sbt.Keys._ 2 | import sbt._ 3 | import sbtassembly.AssemblyPlugin.autoImport._ 4 | import sbtcrossproject.CrossPlugin.autoImport.{crossProject, CrossType, _} 5 | import scalajscrossproject.ScalaJSCrossPlugin.autoImport._ 6 | import webscalajs.ScalaJSWeb 7 | 8 | object Shared { 9 | val projectId = "scala-graphql-fullstack-seed" 10 | val projectName = "scala-graphql-fullstack-seed" 11 | val projectPort = 9000 12 | 13 | object Versions { 14 | val app = "1.0.0" 15 | val scala = "2.12.8" 16 | } 17 | 18 | private[this] val profilingEnabled = false 19 | 20 | val compileOptions = Seq( 21 | "-target:jvm-1.8", "-encoding", "UTF-8", "-feature", "-deprecation", "-explaintypes", "-unchecked", 22 | "–Xcheck-null", "-Xfatal-warnings", /* "-Xlint", */ "-Xcheckinit", "-Xfuture", "-Yrangepos", "-Ypartial-unification", 23 | "-Yno-adapted-args", "-Ywarn-inaccessible", "-Ywarn-nullary-override", "-Ywarn-numeric-widen", "-Ywarn-infer-any", 24 | "-language:postfixOps" 25 | ) ++ (if (profilingEnabled) { 26 | "-Ystatistics:typer" +: Seq("no-profiledb", "show-profiles", "generate-macro-flamegraph").map(s => s"-P:scalac-profiling:$s") 27 | } else { Nil }) 28 | 29 | lazy val commonSettings = Seq( 30 | version := Shared.Versions.app, 31 | scalaVersion := Shared.Versions.scala, 32 | 33 | scalacOptions ++= compileOptions, 34 | scalacOptions in (Compile, console) ~= (_.filterNot(Set("-Ywarn-unused:imports", "-Xfatal-warnings"))), 35 | scalacOptions in (Compile, doc) := Seq("-encoding", "UTF-8"), 36 | 37 | publishMavenStyle := false, 38 | 39 | evictionWarningOptions in update := EvictionWarningOptions.default.withWarnTransitiveEvictions(false).withWarnDirectEvictions(false), 40 | 41 | test in assembly := {}, 42 | assemblyMergeStrategy in assembly := { 43 | case PathList("javax", "servlet", _ @ _*) => MergeStrategy.first 44 | case PathList("javax", "xml", _ @ _*) => MergeStrategy.first 45 | case PathList(p @ _*) if p.last.contains("about_jetty-") => MergeStrategy.discard 46 | case PathList("org", "apache", "commons", "logging", _ @ _*) => MergeStrategy.first 47 | case PathList("org", "w3c", "dom", _ @ _*) => MergeStrategy.first 48 | case PathList("org", "w3c", "dom", "events", _ @ _*) => MergeStrategy.first 49 | case PathList("javax", "annotation", _ @ _*) => MergeStrategy.first 50 | case PathList("net", "jcip", "annotations", _ @ _*) => MergeStrategy.first 51 | case PathList("play", "api", "libs", "ws", _ @ _*) => MergeStrategy.first 52 | case PathList("META-INF", "io.netty.versions.properties") => MergeStrategy.first 53 | case PathList("sqlj", _ @ _*) => MergeStrategy.first 54 | case PathList("play", "reference-overrides.conf") => MergeStrategy.first 55 | case PathList("version","util", "Version$.class") => MergeStrategy.first 56 | case "module-info.class" => MergeStrategy.discard 57 | case "messages" => MergeStrategy.concat 58 | case "pom.xml" => MergeStrategy.discard 59 | case "JS_DEPENDENCIES" => MergeStrategy.discard 60 | case "NPM_DEPENDENCIES" => MergeStrategy.discard 61 | case "pom.properties" => MergeStrategy.discard 62 | case "application.conf" => MergeStrategy.concat 63 | case x => (assemblyMergeStrategy in assembly).value(x) 64 | }, 65 | 66 | // Macros 67 | addCompilerPlugin("org.scalamacros" % "paradise" % "2.1.0" cross CrossVersion.full), 68 | 69 | ) ++ (if(profilingEnabled) { 70 | Seq(addCompilerPlugin("ch.epfl.scala" %% "scalac-profiling" % "1.0.0")) 71 | } else { 72 | Nil 73 | }) 74 | 75 | lazy val sharedSettings = commonSettings ++ Seq( 76 | Compile / sourceGenerators += ProjectVersionManager.writeConfig(projectId, projectName, projectPort).taskValue, 77 | libraryDependencies ++= Seq() 78 | ) 79 | 80 | lazy val shared = (crossProject(JSPlatform, JVMPlatform) 81 | .withoutSuffixFor(JVMPlatform) 82 | .crossType(CrossType.Pure) in file("shared")) 83 | .settings(sharedSettings: _*) 84 | .jvmSettings(libraryDependencies += "org.scala-js" %% "scalajs-stubs" % "0.6.26" % "provided") 85 | 86 | lazy val sharedJs = shared.js.enablePlugins(ScalaJSWeb) 87 | 88 | lazy val sharedJvm = shared.jvm 89 | } 90 | -------------------------------------------------------------------------------- /web_client/src/main/scala/com/mypackage/authentication/Login.scala: -------------------------------------------------------------------------------- 1 | package com.mypackage.authentication 2 | 3 | import antd.AntMessage 4 | import antd.Button 5 | import antd.Col 6 | import antd.FieldDecoratorOptions 7 | import antd.Form 8 | import antd.FormItem 9 | import antd.FormOps 10 | import antd.Input 11 | import antd.Layout 12 | import antd.LayoutContent 13 | import antd.Row 14 | import antd.StaticProps 15 | import antd.ValidationRules 16 | import com.mypackage.AddProductMutation 17 | import com.mypackage.util.UrlUtils 18 | import fr.hmil.roshttp.HttpRequest 19 | import fr.hmil.roshttp.Method 20 | import fr.hmil.roshttp.body.Implicits._ 21 | import fr.hmil.roshttp.body.JSONBody._ 22 | import org.scalajs.dom.Event 23 | import slinky.core.StatelessComponent 24 | import slinky.core.annotations.react 25 | import slinky.web.html.autoComplete 26 | import slinky.web.html.h1 27 | import slinky.web.html.placeholder 28 | import slinky.web.html.style 29 | 30 | import scala.scalajs.js.JSConverters._ 31 | import monix.execution.Scheduler.Implicits.global 32 | import org.scalajs.dom.ext.LocalStorage 33 | import reactrouter.History 34 | import reactrouter.Redirect 35 | 36 | import scala.collection.mutable 37 | import scala.scalajs.js 38 | import scala.scalajs.js.JSON 39 | import scala.util.Failure 40 | import scala.util.Success 41 | 42 | @react class Login extends StatelessComponent { 43 | case class Props(history: History) 44 | 45 | val handleSubmit = (e: Event) => { 46 | e.preventDefault() 47 | 48 | form.validateFields((errors, values) => { 49 | if (errors != null) { 50 | println("Received errors " + JSON.stringify(errors)) 51 | } else { 52 | println("Received values " + JSON.stringify(values)) 53 | val valuesMap: mutable.Map[String, String] = values 54 | 55 | HttpRequest().withURL(UrlUtils.getBaseUrl + "/authenticate").post(JSONObject( 56 | "username" -> valuesMap("username"), 57 | "password" -> valuesMap("password") 58 | )).onComplete { result => 59 | form.setFieldsValue(Map("username" -> "", "password" -> "").toJSDictionary) 60 | 61 | result match { 62 | case Failure(exception) => 63 | AntMessage.message.error("Unable to login, retry!") 64 | println(exception.getMessage) 65 | case Success(result) => 66 | AntMessage.message.info("Login successful") 67 | LocalStorage.update("bearerToken", result.body) 68 | props.history.push("/") 69 | } 70 | } 71 | } 72 | }) 73 | } 74 | 75 | def render() = { 76 | if (LocalStorage("bearerToken").isEmpty) { 77 | Layout( 78 | LayoutContent( 79 | Row(Row.Props(`type` = "flex", align = "middle", justify = "center"))(style := js.Dynamic.literal(height = "100vh"))( 80 | Col(Col.Props(span = 6))( 81 | Form(Form.Props(onSubmit = handleSubmit))( 82 | FormItem( 83 | form.getFieldDecorator("username", FieldDecoratorOptions(rules = Seq(ValidationRules(required = true, message = "Cannot login without username."))))( 84 | Input(Input.Props())(placeholder := "Username") 85 | ) 86 | ), 87 | FormItem( 88 | form.getFieldDecorator("password", FieldDecoratorOptions(rules = Seq(ValidationRules(required = true, message = "Cannot login without password."))))( 89 | Input(Input.Props(`type` = "password"))(placeholder := "Password") 90 | ) 91 | ), 92 | Button(Button.Props(`type` = "primary", htmlType = "submit"))("Login") 93 | ) 94 | ) 95 | ) 96 | ) 97 | ) 98 | } else { 99 | Redirect(Redirect.Props(to = "/")) 100 | } 101 | } 102 | 103 | /** 104 | * This is needed as Antd forms are created as higher order component 105 | * and a form property is injected in the props by the framework. 106 | * This is a workaround to access that property. 107 | */ 108 | def form: FormOps = this.asInstanceOf[StaticProps].props.form 109 | } 110 | -------------------------------------------------------------------------------- /project/GraphqlUtils.scala: -------------------------------------------------------------------------------- 1 | import sbt.Keys._ 2 | import sbt._ 3 | import sbt.internal.inc.classpath.ClasspathUtilities 4 | 5 | /** 6 | * Tools to generate at build time the SDL for graphql from the defined schema in Sangria-Scala. 7 | * 8 | * To be replaced with sbt-graphql. After a fast test i've noticed that when the task in sbt-graphql used 9 | * to generate the schema is added to the (Compile / resourceGenerators) task list, when the project is 10 | * built, sbt enters infinite loop. 11 | */ 12 | object GraphqlUtils { 13 | 14 | val generateGraphqlSdlFromScala = taskKey[Seq[File]]("Generates the SDL from the defined schema in Sangria") 15 | val schemaSdlFileName = settingKey[String]("The name of the file generated when the graphql SDL is rendered") 16 | 17 | val generateGraphqlQueries = taskKey[Seq[File]]("Generates Scala classes definitions from queries defined in graphql") 18 | val queryClassesPackage = settingKey[String]("The package the generated Scala classes should belong to") 19 | val npmUpdateTask = taskKey[File]("Task that updates npm and returns the installation directory") 20 | 21 | val generateTasks = Seq( 22 | schemaSdlFileName := "schema.graphql", 23 | 24 | generateGraphqlSdlFromScala := { 25 | val classpath = (dependencyClasspath in Compile).value 26 | val schemaObject = (Schema.schema / Schema.schemaObject).value 27 | val outputDir = (Compile / resourceManaged).value 28 | val outputFile = (Compile / schemaSdlFileName).value 29 | val sdlFile = generateSdl(classpath, schemaObject, outputDir, outputFile) 30 | streams.value.log.info("Written graphql SDL in file " + sdlFile.getAbsolutePath) 31 | Seq(sdlFile) 32 | }, 33 | 34 | Compile / resourceGenerators += Compile / generateGraphqlSdlFromScala, 35 | 36 | queryClassesPackage := "com.mypackage", 37 | 38 | generateGraphqlQueries := { 39 | import scala.sys.process._ 40 | 41 | (Compile / generateGraphqlSdlFromScala).value 42 | 43 | val outputDirectory = (Compile / sourceManaged).value 44 | val outputFile = outputDirectory / "graphql.scala" 45 | 46 | outputDirectory.mkdirs() 47 | 48 | val apolloDirectory = npmUpdateTask.value / "node_modules" / "apollo" 49 | val queriesDirectory = ((Compile / sourceDirectory).value / "graphql").getAbsolutePath 50 | 51 | val apolloCommand = Seq( 52 | (apolloDirectory / "bin" / "run").getAbsolutePath, 53 | "codegen:generate", 54 | "--includes=" + queriesDirectory + "/*.graphql", 55 | "--localSchemaFile=" + ((Compile / resourceManaged).value / (Compile / schemaSdlFileName).value).getAbsolutePath, 56 | "--namespace=" + queryClassesPackage.value, 57 | "--target=scala", 58 | outputFile.getAbsolutePath 59 | ) 60 | 61 | apolloCommand.! 62 | 63 | streams.value.log.info("Running: " + apolloCommand) 64 | 65 | Seq(outputFile) 66 | }, 67 | 68 | Compile / sourceGenerators += Compile / generateGraphqlQueries, 69 | 70 | watchSources ++= ((sourceDirectory in Compile).value / "graphql" ** "*.graphql").get 71 | ) 72 | 73 | private def generateSdl(classpath: Classpath, schemaObjectFQN: String, outputDir: File, outputFile: String): File = { 74 | def scalaObjectInstance(clazz: Class[_]) = { 75 | val schemaObjectConstructor = clazz.getDeclaredConstructors.head 76 | schemaObjectConstructor.setAccessible(true) 77 | schemaObjectConstructor.newInstance() 78 | } 79 | 80 | def schemaDefinition(classLoader: ClassLoader) = { 81 | val schemaObjectRef = classLoader.loadClass(schemaObjectFQN + "$") 82 | val schemaObjectInstance = scalaObjectInstance(schemaObjectRef) 83 | val schemaField = schemaObjectRef.getMethod("schema") 84 | schemaField.invoke(schemaObjectInstance) 85 | } 86 | 87 | def renderSchema(classLoader: ClassLoader, schema: AnyRef): String = { 88 | val schemaRendererObjectRef = classLoader.loadClass("sangria.renderer.SchemaRenderer" + "$") 89 | val schemaClass = classLoader.loadClass("sangria.schema.Schema") 90 | val schemaRendererMethod = schemaRendererObjectRef.getMethod("renderSchema", schemaClass) 91 | val schemaRendererInstance = scalaObjectInstance(schemaRendererObjectRef) 92 | schemaRendererMethod.invoke(schemaRendererInstance, schema).asInstanceOf[String] 93 | } 94 | 95 | val classLoader = ClasspathUtilities.toLoader(classpath.map(_.data).map(_.getAbsoluteFile)) 96 | val schema = schemaDefinition(classLoader) 97 | val sdl = renderSchema(classLoader, schema) 98 | val sdlFile = new File(outputDir, outputFile) 99 | IO.write(sdlFile, sdl) 100 | 101 | sdlFile 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /server/app/di/DiModule.scala: -------------------------------------------------------------------------------- 1 | package di 2 | 3 | import auth.AuthEnvironment 4 | import auth.PasswordInfoRepository 5 | import auth.User 6 | import auth.UserIdentityService 7 | import auth.UserSessionRepository 8 | import com.google.inject.AbstractModule 9 | import com.google.inject.Provides 10 | import com.google.inject.Singleton 11 | import com.mohiva.play.silhouette.api.Environment 12 | import com.mohiva.play.silhouette.api.EventBus 13 | import com.mohiva.play.silhouette.api.Silhouette 14 | import com.mohiva.play.silhouette.api.SilhouetteProvider 15 | import com.mohiva.play.silhouette.api.repositories.AuthInfoRepository 16 | import com.mohiva.play.silhouette.api.repositories.AuthenticatorRepository 17 | import com.mohiva.play.silhouette.api.services.AuthenticatorService 18 | import com.mohiva.play.silhouette.api.services.IdentityService 19 | import com.mohiva.play.silhouette.api.util.Clock 20 | import com.mohiva.play.silhouette.api.util.IDGenerator 21 | import com.mohiva.play.silhouette.api.util.PasswordHasher 22 | import com.mohiva.play.silhouette.api.util.PasswordHasherRegistry 23 | import com.mohiva.play.silhouette.api.util.PasswordInfo 24 | import com.mohiva.play.silhouette.impl.authenticators.BearerTokenAuthenticator 25 | import com.mohiva.play.silhouette.impl.authenticators.BearerTokenAuthenticatorService 26 | import com.mohiva.play.silhouette.impl.authenticators.BearerTokenAuthenticatorSettings 27 | import com.mohiva.play.silhouette.impl.util.SecureRandomIDGenerator 28 | import com.mohiva.play.silhouette.password.BCryptPasswordHasher 29 | import com.mohiva.play.silhouette.persistence.daos.DelegableAuthInfoDAO 30 | import com.mohiva.play.silhouette.persistence.repositories.DelegableAuthInfoRepository 31 | import com.mypackage.PictureRepoLike 32 | import com.mypackage.ProductRepoLike 33 | import com.mypackage.RequestContext 34 | import net.codingwell.scalaguice.ScalaModule 35 | import play.api.Configuration 36 | import repositories.PictureRepo 37 | import repositories.ProductRepo 38 | import slick.jdbc.JdbcBackend.Database 39 | 40 | import scala.concurrent.ExecutionContext.Implicits.global 41 | import scala.concurrent.duration._ 42 | import DiModule._ 43 | 44 | class DiModule extends AbstractModule with ScalaModule { 45 | 46 | override def configure(): Unit = { 47 | bind[PictureRepoLike].to[PictureRepo] 48 | bind[ProductRepoLike].to[ProductRepo] 49 | bind[Silhouette[AuthEnvironment]].to[SilhouetteProvider[AuthEnvironment]] 50 | bind[IDGenerator].toInstance(new SecureRandomIDGenerator) 51 | bind[Clock].toInstance(Clock()) 52 | bind[EventBus].toInstance(EventBus()) 53 | bind[AuthenticatorRepository[BearerTokenAuthenticator]].to[UserSessionRepository].in(classOf[Singleton]) 54 | bind[IdentityService[User]].to[UserIdentityService].in(classOf[Singleton]) 55 | bind[DelegableAuthInfoDAO[PasswordInfo]].to[PasswordInfoRepository] 56 | bind[PasswordHasher].toInstance(hasher) 57 | } 58 | 59 | @Provides 60 | @Singleton 61 | def getDatabase(): Database = { 62 | Database.forConfig(databaseConfigPath) 63 | } 64 | 65 | @Provides 66 | @Singleton 67 | def getRequestContext(productRepository: ProductRepoLike, 68 | pictureRepository: PictureRepoLike): RequestContext = { 69 | new RequestContext { 70 | override def productRepo: ProductRepoLike = productRepository 71 | override def pictureRepo: PictureRepoLike = pictureRepository 72 | } 73 | } 74 | 75 | @Provides 76 | @Singleton 77 | def getEnvironment(is: IdentityService[User], 78 | as: AuthenticatorService[BearerTokenAuthenticator], 79 | eventBus: EventBus): Environment[AuthEnvironment] = 80 | Environment[AuthEnvironment](is, as, Seq(), eventBus) 81 | 82 | 83 | @Provides 84 | @Singleton 85 | def getAuthenticatorService(configuration: Configuration, 86 | authenticatorRepository: AuthenticatorRepository[BearerTokenAuthenticator], 87 | idGenerator: IDGenerator, 88 | clock: Clock): AuthenticatorService[BearerTokenAuthenticator] = { 89 | 90 | new BearerTokenAuthenticatorService( 91 | BearerTokenAuthenticatorSettings(authenticatorExpiry = 30 days), 92 | authenticatorRepository, 93 | idGenerator, 94 | clock) 95 | } 96 | 97 | @Provides 98 | @Singleton 99 | def getAuthInfoRepository(passwordInfoDAO: DelegableAuthInfoDAO[PasswordInfo]): AuthInfoRepository = 100 | new DelegableAuthInfoRepository(passwordInfoDAO) 101 | 102 | @Provides 103 | @Singleton 104 | def getPasswordHasherRegistry(passwordHasher: PasswordHasher) = 105 | PasswordHasherRegistry(passwordHasher) 106 | 107 | } 108 | 109 | object DiModule { 110 | def databaseConfigPath = "slick.dbs.default.db" 111 | val hasher = new BCryptPasswordHasher() 112 | } -------------------------------------------------------------------------------- /server/app/views/graphiql.scala.html: -------------------------------------------------------------------------------- 1 | @() 2 | 3 | @main("GraphiQL"){ 4 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | }{ 22 |
Loading...
23 | 24 | 117 | } 118 | -------------------------------------------------------------------------------- /project/DatabaseUtils.scala: -------------------------------------------------------------------------------- 1 | import com.typesafe.config.ConfigFactory 2 | import sbt.Keys._ 3 | import sbt._ 4 | 5 | /** 6 | * This utils are used to run the evolutions the first time the project is checked out or 7 | * in case the DB is whiped. This is necessary because if the DB is not initialized before 8 | * the slick code generation is started, the compilation task will fail due to the fact that 9 | * slick code generation task does not generate anything. 10 | */ 11 | object DatabaseUtils { 12 | val slickCodeGenTask = taskKey[Seq[File]]("Generates the table mapping classes to be used with slick") 13 | val slickOutputDir = settingKey[File]("The directory where the database mappings are outputted") 14 | val runEvolutions = taskKey[Seq[File]]("Runs the evolutions on the database") 15 | 16 | def generateSlickTables(baseDir: File, outputDir: File, classPath: Seq[File], runner: ScalaRun, stream: TaskStreams): Seq[File] = { 17 | val config = ConfigFactory.parseFile(new File(s"${baseDir.getAbsolutePath}/conf/application.conf")).resolve() 18 | val url = config.getString("slick.dbs.default.db.url") 19 | val jdbcDriver = config.getString("slick.dbs.default.db.driver") 20 | val slickProfile = config.getString("slick.dbs.default.profile").replace('$', ' ').trim 21 | val pkg = config.getString("slick.dbs.default.codegen.package") 22 | val user = config.getString("slick.dbs.default.db.user") 23 | val password = config.getString("slick.dbs.default.db.password") 24 | val ignoreInvalidDefaults = "true" 25 | val generatorClass = "infrastructure.JodaAwareSourceCodeGenerator" 26 | val outputMultipleFiles = "true" 27 | val generatorOptions = Array(slickProfile, jdbcDriver, url, outputDir.getPath, pkg, user, password, ignoreInvalidDefaults, generatorClass, outputMultipleFiles) 28 | runner.run("slick.codegen.SourceCodeGenerator", classPath, generatorOptions, stream.log).failed foreach (sys error _.getMessage) 29 | stream.log.info("Written slick mappings in directory " + outputDir) 30 | (outputDir / pkg.replace('.', '/')).listFiles() 31 | } 32 | 33 | /** 34 | * Copies the application.conf and the evolutions directory in the output directory. 35 | * This is used only when the project is checked out fresh, it is needed to run the 36 | * first time the evolutions. During the development this task is taken care by the 37 | * resourceGenerators 38 | */ 39 | def copyConfigAndEvolutions(resourcesDir: File, outputDir: File): Unit = { 40 | if (!(outputDir / "application.conf").exists()) { 41 | // Copy config 42 | val srcConf = resourcesDir / "application.conf" 43 | val dstConf = outputDir / "application.conf" 44 | IO.copyFile(srcConf, dstConf, CopyOptions(true, true, true)) 45 | 46 | // Copy evolutions 47 | val srcEvo = resourcesDir / "evolutions/default" 48 | val dstEvo = outputDir / "evolutions/default" 49 | IO.copyDirectory(srcEvo, dstEvo, CopyOptions(true, true, true)) 50 | } 51 | } 52 | 53 | 54 | val settings = Seq( 55 | 56 | runEvolutions := { 57 | // Compiling necessary class to run evolutions 58 | val srcDir = (Compile / sourceDirectory).value 59 | val compilers = Keys.compilers.value 60 | val classPath = (Compile / dependencyClasspath).value 61 | val filesToCompile = Seq( 62 | srcDir / "infrastructure/ApplyEvolutions.scala", 63 | srcDir / "infrastructure/JodaAwareSourceCodeGenerator.scala" 64 | ) 65 | val outputDir = (Compile / classDirectory).value 66 | val resourcesDir = (Compile / resourceDirectory).value 67 | val options = Shared.compileOptions 68 | val maxErrors = 1000 69 | val stream = streams.value 70 | 71 | copyConfigAndEvolutions(resourcesDir, outputDir) 72 | 73 | QuickCompiler.scalac( 74 | compilers, 75 | filesToCompile, 76 | QuickCompiler.noChanges, 77 | classPath.map(_.data), 78 | outputDir, 79 | options, 80 | QuickCompiler.noopCallback, 81 | maxErrors, 82 | stream.log 83 | ) 84 | 85 | // Running evolutions 86 | val baseDir = baseDirectory.value 87 | val runner = (Compile / Keys.runner).value 88 | 89 | runner.run("infrastructure.ApplyEvolutions", classPath.files.+:(outputDir), Array(baseDir.getAbsolutePath), stream.log).failed foreach (sys error _.getMessage) 90 | Seq() 91 | }, 92 | 93 | slickOutputDir := { 94 | val baseDir = baseDirectory.value 95 | val sourceManagedDir = sourceManaged.value 96 | val config = ConfigFactory.parseFile(new File(s"${baseDir.getAbsolutePath}/conf/application.conf")).resolve() 97 | 98 | sourceManagedDir / config.getString("slick.dbs.default.codegen.outputDir") 99 | }, 100 | 101 | slickCodeGenTask := { 102 | runEvolutions.value 103 | val dir = slickOutputDir.value 104 | val baseDir = baseDirectory.value 105 | val classPath = (Compile / dependencyClasspath).value.files :+ (Compile / classDirectory).value 106 | val runner = (Compile / Keys.runner).value 107 | val stream = streams.value 108 | generateSlickTables(baseDir, dir, classPath, runner, stream) 109 | }, 110 | 111 | Compile / managedSourceDirectories += slickOutputDir.value, 112 | Compile / sourceGenerators += slickCodeGenTask, 113 | 114 | ) 115 | } 116 | -------------------------------------------------------------------------------- /web_client/src/main/scala/antd/Form.scala: -------------------------------------------------------------------------------- 1 | package antd 2 | 3 | import antd.FormTypes.Value 4 | import org.scalajs.dom.Event 5 | import slinky.core.ExternalComponent 6 | import slinky.core.ReactComponentClass 7 | import slinky.core.annotations.react 8 | import slinky.core.facade.ReactElement 9 | 10 | import scala.scalajs.js 11 | import scala.scalajs.js.JSConverters._ 12 | import scala.scalajs.js.RegExp 13 | import scala.scalajs.js.UndefOr 14 | import scala.scalajs.js.annotation.JSImport 15 | import scala.scalajs.js.| 16 | 17 | /** 18 | * Forms follow the implementation at https://github.com/react-component/form 19 | * 20 | * This representation does not contain all the properties available in the 21 | * JS implementation, only the main have been ported. 22 | */ 23 | 24 | 25 | @JSImport("antd", JSImport.Default) 26 | @js.native 27 | object AntForm extends js.Object { 28 | val Form: FormObj = js.native 29 | } 30 | 31 | @js.native 32 | trait FormObj extends js.Object { 33 | val Item: js.Object = js.native 34 | 35 | def create(formOptions: FormOptions = FormOptions()): js.Function1[ReactComponentClass[_], js.Object] = js.native 36 | } 37 | 38 | @js.native 39 | trait FieldsNames extends js.Object { 40 | val names: Seq[String] 41 | } 42 | 43 | object FormTypes { 44 | type Value = String 45 | } 46 | 47 | @js.native 48 | trait FormOptions extends js.Object { 49 | val name: UndefOr[String] = js.native 50 | val validateMessages: js.Object = js.native 51 | } 52 | 53 | object FormOptions { 54 | def apply(name: UndefOr[String] = js.undefined, 55 | validateMessages: js.Object = js.Object()) = 56 | js.Dynamic.literal( 57 | name = name, 58 | validateMessages = validateMessages 59 | ).asInstanceOf[FormOptions] 60 | } 61 | 62 | @js.native 63 | trait FieldProperties extends js.Object { 64 | val value: String = js.native 65 | val errors: js.Array[js.Error] = js.native 66 | } 67 | 68 | object FieldProperties { 69 | def apply(value: String, errors: Seq[js.Error]) = 70 | js.Dynamic.literal(value = value, errors = errors.toJSArray).asInstanceOf[FieldProperties] 71 | } 72 | 73 | @js.native 74 | trait ValidationOptions extends js.Object { 75 | val first: Boolean = js.native 76 | val firstFields: js.Array[String] = js.native 77 | val force: Boolean = js.native 78 | val scroll: js.Object = js.native 79 | } 80 | 81 | object ValidationOptions { 82 | def apply(first: Boolean = true, 83 | firstFields: Seq[String] = Seq(), 84 | force: Boolean = false, 85 | scroll: js.Object = js.Object()) = 86 | js.Dynamic.literal( 87 | first = first, 88 | firstFields = firstFields.toJSArray, 89 | force = force, 90 | scroll = scroll 91 | ).asInstanceOf[ValidationOptions] 92 | 93 | def apply() = js.Object().asInstanceOf[ValidationOptions] 94 | } 95 | 96 | @js.native 97 | trait ValidationError extends js.Object { 98 | val message: String = js.native 99 | val field: String = js.native 100 | } 101 | 102 | @js.native 103 | trait ValidationErrorList extends js.Object { 104 | val errors: js.Array[ValidationError] 105 | } 106 | 107 | @js.native 108 | trait ValidationRules extends js.Object { 109 | val enum: UndefOr[String] = js.native 110 | val len: UndefOr[Int] = js.native 111 | val max: UndefOr[Int] = js.native 112 | val message: UndefOr[String | ReactElement] = js.native 113 | val min: UndefOr[Int] = js.native 114 | val pattern: UndefOr[RegExp] = js.native 115 | val required: Boolean = js.native 116 | val transform: UndefOr[Value => js.Any] = js.native 117 | val `type`: String = js.native 118 | //validator -> TODO = js.native 119 | val whitespace: Boolean = js.native 120 | } 121 | 122 | object ValidationRules { 123 | 124 | def apply(enum: UndefOr[String] = js.undefined, 125 | len: UndefOr[Int] = js.undefined, 126 | max: UndefOr[Int] = js.undefined, 127 | message: UndefOr[String | ReactElement] = js.undefined, 128 | min: UndefOr[Int] = js.undefined, 129 | pattern: UndefOr[RegExp] = js.undefined, 130 | required: Boolean = false, 131 | transform: UndefOr[Value => js.Any] = js.undefined, 132 | `type`: String = "string", 133 | whitespace: Boolean = false) = 134 | js.Dynamic.literal( 135 | "enum" -> enum, 136 | "len" -> len, 137 | "max" -> max, 138 | "message" -> message.asInstanceOf[js.Any], 139 | "min" -> min, 140 | "pattern" -> pattern, 141 | "required" -> required, 142 | "transform" -> transform, 143 | "type" -> `type`, 144 | "whitespace" -> whitespace 145 | ).asInstanceOf[ValidationRules] 146 | } 147 | 148 | @js.native 149 | trait FieldDecoratorOptions extends js.Object { 150 | val getValueFromEvent: UndefOr[Event => js.Object] = js.native 151 | val getValueProps: UndefOr[Value => js.Object] = js.native 152 | val initialValue: UndefOr[js.Object] = js.native 153 | val normalize: UndefOr[(Value, Value, Value) => js.Object] = js.native 154 | val preserve: UndefOr[Boolean] = js.native 155 | val rules: UndefOr[js.Array[ValidationRules]] = js.native 156 | val validateFirst: Boolean = js.native 157 | val trigger: String = js.native 158 | val validateTrigger: String | js.Array[String] = js.native 159 | val valuePropName: String = js.native 160 | } 161 | 162 | object FieldDecoratorOptions { 163 | def apply(getValueFromEvent: UndefOr[Event => js.Object] = js.undefined, 164 | getValueProps: UndefOr[Value => js.Object] = js.undefined, 165 | initialValue: UndefOr[js.Object] = js.undefined, 166 | normalize: UndefOr[(Value, Value, Value) => js.Object] = js.undefined, 167 | preserve: UndefOr[Boolean] = js.undefined, 168 | rules: UndefOr[Seq[ValidationRules]] = js.undefined, 169 | validateFirst: Boolean = false, 170 | trigger: String = "onChange", 171 | validateTrigger: String | Seq[String] = "onChange", 172 | valuePropName: String = "value") = 173 | js.Dynamic.literal( 174 | getValueFromEvent = getValueFromEvent, 175 | getValueProps = getValueProps, 176 | initialValue = initialValue, 177 | normalize = normalize, 178 | preserve = preserve, 179 | rules = rules.map(_.toJSArray), 180 | validateFirst = validateFirst, 181 | trigger = trigger, 182 | validateTrigger = validateTrigger.asInstanceOf[js.Any], 183 | valuePropName = valuePropName 184 | ).asInstanceOf[FieldDecoratorOptions] 185 | } 186 | 187 | @react object Form extends ExternalComponent { 188 | 189 | case class Props(hideRequiredMark: Boolean = false, 190 | layout: String = "horizontal", 191 | onSubmit: UndefOr[Event => Unit] = js.undefined) 192 | 193 | override val component = AntForm.Form 194 | 195 | } 196 | 197 | @js.native 198 | trait FormOps extends js.Object { 199 | 200 | def getFieldDecorator(id: String, options: FieldDecoratorOptions): js.Function1[ReactElement, ReactElement] = js.native 201 | 202 | def getFieldError(name: String): String = js.native 203 | 204 | def getFieldsError(names: js.Array[String]): js.Dictionary[js.Array[String]] = js.native 205 | 206 | def getFieldsError(): js.Dictionary[js.Array[String]] = js.native 207 | 208 | def getFieldsValue(names: js.Array[String]): js.Dictionary[Value] = js.native 209 | 210 | def getFieldsValue(): js.Dictionary[Value] = js.native 211 | 212 | def isFieldsTouched(names: js.Array[String]): Boolean = js.native 213 | 214 | def isFieldTouched(name: String): Boolean = js.native 215 | 216 | def isFieldValidating(name: String): Boolean = js.native 217 | 218 | def resetFields(names: js.Array[String]): Unit = js.native 219 | 220 | def resetFields(): Unit = js.native 221 | 222 | def setFields(fields: js.Dictionary[FieldProperties]): Unit = js.native 223 | 224 | def setFieldsValue(fields: js.Dictionary[String]): Unit = js.native 225 | 226 | def validateFields(fieldNames: js.Dictionary[String], 227 | options: ValidationOptions, 228 | callback: js.Function2[js.Dictionary[ValidationErrorList], js.Dictionary[Value], Unit]): Unit = js.native 229 | def validateFields(callback: js.Function2[js.Dictionary[ValidationErrorList], js.Dictionary[Value], Unit]): Unit = js.native 230 | 231 | def validateFieldsAndScroll(fieldNames: js.Dictionary[String], 232 | options: ValidationOptions, 233 | callback: js.Function2[js.Dictionary[ValidationErrorList], js.Dictionary[Value], Unit]): Unit = js.native 234 | def validateFieldsAndScroll(callback: js.Function2[js.Dictionary[ValidationErrorList], js.Dictionary[Value], Unit]): Unit = js.native 235 | 236 | } 237 | 238 | @js.native 239 | trait FormStaticProps extends js.Object { 240 | def form: FormOps = js.native 241 | } 242 | 243 | @js.native 244 | trait StaticProps extends js.Object { 245 | def props: FormStaticProps = js.native 246 | } 247 | 248 | @react object FormItem extends ExternalComponent { 249 | 250 | case class Props(colon: Boolean = true, 251 | extra: UndefOr[String | ReactElement] = js.undefined, 252 | hasFeedback: Boolean = false, 253 | help: UndefOr[String | ReactElement] = js.undefined, 254 | label: UndefOr[String | ReactElement] = js.undefined, 255 | labelCol: UndefOr[ColProps] = js.undefined, 256 | required: Boolean = false, 257 | validateStatus: UndefOr[String] = js.undefined, 258 | wrapperCol: UndefOr[ColProps] = js.undefined) 259 | 260 | override val component = AntForm.Form.Item 261 | } 262 | 263 | --------------------------------------------------------------------------------