├── .github └── FUNDING.yml ├── .gitignore ├── .scalafmt.conf ├── .travis.yml ├── LICENSE ├── README.md ├── build.sbt ├── common └── shared │ └── src │ └── main │ └── scala │ └── model │ ├── SampleModelObject.scala │ └── search.scala ├── project ├── build.properties └── plugins.sbt ├── server └── src │ ├── it │ └── scala │ │ ├── api │ │ └── ModelServiceIntegrationSpec.scala │ │ └── dao │ │ └── RepositoryIntegrationSpec.scala │ ├── main │ ├── resources │ │ ├── application.conf │ │ └── logback.xml │ ├── scala │ │ ├── Rest.scala │ │ ├── api │ │ │ ├── Api.scala │ │ │ ├── Config.scala │ │ │ ├── LiveEnvironment.scala │ │ │ └── ZIODirectives.scala │ │ ├── core │ │ │ └── Core.scala │ │ ├── dao │ │ │ ├── CRUDOperations.scala │ │ │ ├── LiveRepository.scala │ │ │ ├── ModelSlickInterop.scala │ │ │ ├── MySQLDatabaseProvider.scala │ │ │ ├── Repository.scala │ │ │ └── Tables.scala │ │ ├── mail │ │ │ └── Postman.scala │ │ ├── routes │ │ │ ├── CRUDRoute.scala │ │ │ ├── HTMLRoute.scala │ │ │ ├── ModelRoutes.scala │ │ │ └── SampleModelObjectRoute.scala │ │ ├── util │ │ │ ├── CodeGen.scala │ │ │ └── ModelPickler.scala │ │ ├── web │ │ │ └── Web.scala │ │ └── zioslick │ │ │ ├── DatabaseProvider.scala │ │ │ ├── RepositoryException.scala │ │ │ ├── ZioSlickSupport.scala │ │ │ └── package.scala │ └── sql │ │ └── 20191210.sql │ └── test │ ├── resources │ └── application-test.conf │ └── scala │ ├── api │ └── ModelServiceSpec.scala │ ├── dao │ └── MockRepository.scala │ └── util │ └── ZioTestSupport.scala └── webclient └── src └── main ├── scala ├── app │ ├── AppState.scala │ ├── Content.scala │ └── MainApp.scala ├── components │ └── AbstractComponent.scala ├── pages │ └── MainPage.scala ├── routes │ └── AppRouter.scala ├── service │ ├── RESTClient.scala │ └── package.scala └── util │ ├── Config.scala │ └── ModelPickler.scala └── web ├── css ├── app-sui-theme.css └── app.css └── index.html /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: rleibman 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # sbt 2 | lib_managed 3 | project/project 4 | target 5 | 6 | # Worksheets (Eclipse or IntelliJ) 7 | *.sc 8 | 9 | # Eclipse 10 | .cache* 11 | .classpath 12 | .project 13 | .scala_dependencies 14 | .settings 15 | .target 16 | .worksheet 17 | 18 | # IntelliJ 19 | .idea 20 | 21 | # ENSIME 22 | .ensime 23 | .ensime_lucene 24 | .ensime_cache 25 | 26 | # Mac 27 | .DS_Store 28 | 29 | # Akka 30 | ddata* 31 | journal 32 | snapshots 33 | 34 | npm 35 | *.lock 36 | 37 | # Log files 38 | *.log 39 | 40 | *.lock 41 | 42 | dist 43 | debugDist 44 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version=2.2.1 2 | style = defaultWithAlign 3 | maxColumn = 120 4 | align = most 5 | continuationIndent.defnSite = 2 6 | assumeStandardLibraryStripMargin = true 7 | docstrings = JavaDoc 8 | lineEndings = preserve 9 | includeCurlyBraceInSelectChains = false 10 | danglingParentheses = true 11 | indentOperator = spray 12 | project.excludeFilters = [".*\\.sbt"] 13 | rewrite.rules = [AsciiSortImports, RedundantBraces, RedundantParens] 14 | spaces.inImportCurlyBraces = true 15 | unindentTopLevelOperators = true 16 | optIn.annotationNewlines = true 17 | 18 | rewrite.rules = [SortImports, RedundantBraces] 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: scala 2 | 3 | scala: 4 | - 2.12.10 5 | 6 | jdk: 7 | - oraclejdk11 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.com/rleibman/full-scala-stack.svg?branch=master)](https://travis-ci.com/rleibman/full-scala-stack) 2 | 3 | # Full Scala Stack 4 | This is an example project that uses a full scala stack, server to client. 5 | These are some of the technologies I'm using, I briefly describe why and mention some options in case my choices are not yours. 6 | One of the most difficult thing in our field is to be able to choose a set of technologies for a project that fit well together 7 | (or can be easily made to fit), are well supported, are easy to find developers who know them (or easy to train in them), modern, etc. 8 | 9 | ## http-akka (https://akka.io/) 10 | I chose http-akka because it's stable, fast, well maintained and actively developed and full featured. I've also been using it since 11 | it was called spray, so there's the historical reason. If I was choosing from scratch today, I would likely not use play 12 | (it's too heavy, I think) and may use some lighter library instead, I'm curious as to where zio-http goes. 13 | My main 2 complaints about http-akka are related: it's not easy to reverse engineer documentation from the routes (as in swagger) and 14 | the routes are not typed (so if you accidentally change the type returned from a complete you can mess up your client) 15 | Alternatives: https://www.playframework.com/, https://http4s.org/, https://github.com/softwaremill/sttp, https://github.com/zio/zio-http 16 | 17 | ## slick (http://scala-slick.org/) 18 | I chose slick because I like it's almost ORM way to do tables, it's main disadvantage is that it's not very functional (in the academic sense), but 19 | it is fairly mature and I've been able to write complex CRUD code with it. 20 | Alternatives: https://github.com/tpolecat/doobie, https://getquill.io/, http://scalikejdbc.org/ 21 | 22 | ## zio (https://zio.dev/) 23 | Zio says: Type-safe, composable asynchronous and concurrent programming for Scala. For many reasons it's an excellent replacement 24 | for native scala futures, it's easy enough to understand. I have had to write a few bridges to be able to use zio with the 25 | rest of my stack, particularly akka-http and slick. 26 | Alternatives: Futures, scalaz, cats 27 | 28 | ## courier (https://github.com/dmurvihill/courier) 29 | Courier is not very well maintained, but there really isn't very much I require of a mail sender library. I did write a zio wrapper as 30 | part of this project to talk to courier. 31 | I would probably look at zio-email 32 | if I was choosing today, there might be other more full-featured mail libraries available. 33 | Alternatives: https://github.com/funcit/zio-email 34 | 35 | ## scala.js (https://www.scala-js.org/) 36 | I really wouldn't use anything else right now to create web pages. My biggest complaint about scala-js is that because all the 37 | source documents are scala it puts graphic web designers at a disadvantage. It really forces you to separate the graphic 38 | design domain (css) from the web content (traditionally html, now scala produced html), this is not necessarily a bad thing, but 39 | it may force your developers to do things that normally the graphic design team takes care of. 40 | Alternatives: https://www.playframework.com/ 41 | 42 | ## upickle (http://www.lihaoyi.com/upickle/) 43 | There's way too many json libraries for scala, I had used spray for a long time, but it's not heavily maintained anymore, so 44 | I started using upickle. I'd probably give circe a good look, particularly if you're more into pure functional programming 45 | Alternatives: https://circe.github.io/circe/, https://www.playframework.com/documentation/2.7.x/ScalaJson, https://github.com/spray/spray-json 46 | 47 | ## scalajs-react (https://github.com/japgolly/scalajs-react) 48 | I've been using scalajs-react for a long time, so historical reasons lead me to choose it. I particularly like it's use 49 | of it's zio-like Callback (it would be even better if it actually morphed to use zio). If I was choosing today, I'd probably 50 | give slinky a strong consideration, at least at first reading it seems a bit easier to use. 51 | Alternatives: https://github.com/shadaj/slinky 52 | 53 | ## scalablytyped (https://github.com/oyvindberg/ScalablyTyped) 54 | An amazing project (that I've participated in, so I'm biased) that takes every typescript project from http://definitelytyped.org/ and 55 | creates scala bindings for it. For React projects you can choose slinky or scalajs-react flavors. Also, coming soon, an sbt 56 | plugin that let's you chose exactly what javascript libraries you want to wrap. 57 | 58 | ## semantic ui (https://react.semantic-ui.com/) 59 | I really like how the set of react components from the semantic library look and feel, it's very themable as well. 60 | Alternatives: https://material-ui.com/, https://react-bootstrap.github.io/, http://nikgraf.github.io/belle/#/?_k=dyoot9 61 | 62 | ## MySQL 63 | I use mySQL mostly because I'm more familiar with it, switching the app to use something else should not be too difficult 64 | Alternatives: mariadb, postgress, oracle, sql server, etc... or if you want to go nosql: cassandra, mongoDB 65 | 66 | ## How the app is put together 67 | 68 | ### Configuring, compiling and running the app 69 | #### Database 70 | You'll need to have mysql running, if you want to try it first without a database, you can always replace the ```LiveModelDAO``` in ```api.LiveEnvironment``` with 71 | ```MockModelDAO``` 72 | To initialize the database, just run the scripts in the ```server/src/main/sql``` directory in order. 73 | Once you do that, you should configure the access to the database in ```server/src/main/resources/application.conf```, change the database url, username and password as needed 74 | 75 | ### Shared code 76 | The common subproject gets compiled in both jvm and js flavors, this is where I typically put my model, since I want to be able to share it between subprojects of both types 77 | 78 | #### Server 79 | In general a few parameters in ```server/src/main/resources/application.conf``` will control the application, tell it what port to run on, where to find the static web pages (the ```staticContentDir``` variable), etc. 80 | Once all is configured, in sbt, you should be able to run: 81 | ```sbt 82 | ~server/reStart 83 | ``` 84 | This will start the server, any changes you make will automatically be recompiled and the server restarted. 85 | 86 | #### Web client 87 | You need to compile all of the scala.js code into a nicely packaged js file. There's a ```dist``` command and a ```debugDist``` command, the first one 88 | does full optimization, but it's resulting file will be both harder to read and it will take longer to generate, use the second one for development. 89 | 90 | ## Here's how to do some common tasks 91 | Note that I've put a bunch of "//TODO"s throughout the code that in places where I think you can expand or put additional stuff. 92 | 93 | ### Adding a new web page 94 | For the most part, I follow the architecture laid out by [scalajs-react](https://github.com/japgolly/scalajs-react/blob/master/doc/ROUTER.md), the documentation there is pretty awesome. 95 | 96 | ### Adding a new model object 97 | - Add the model object itself in ```common/shared/src/main/scala/model``` typically these objects are scala case classes 98 | - Add the database creation code for your object (I like to put these as sql scripts in ```server/src/main/sql```), run it against your database 99 | - Use util.CodeGen to re-generate Tables.scala which will contain the stuff that maps your SQL database with our model. 100 | - Add the CRUD (and other) database operations to ```src/main/scala/dao/Repository```, you'll have to create "live" and "mock" versions of all the methods you create. 101 | For every object you'll probably want to declare a CRUDOperations entry. 102 | - Add a service that does basic REST crud operations about your object in ```server/src/main/scala/routes```, look at ```SampleModelObjectRoute``` as an example 103 | - In the web, you'll need a REST client that can talk to your server, declare one in ```webclient/src/main/scala/service/package.scala``` 104 | - Add the web pages you need to do stuff with your new object (see above). 105 | 106 | ### Adding a new javascript library 107 | Assuming you are using ScalablyTyped, you need to add to ```build.sbt``` the typings for the javascript library (read the [ScalablyTyped documentation](https://github.com/oyvindberg/ScalablyTyped) ), as well as adding the library itself to the bundler (the ```npmDependencies``` section). 108 | If the library is a react library, you should choose a flavor of react bindings (currently either japgolly or Slinky bindings). 109 | Once you do that you should be good to go! 110 | 111 | ## Testing 112 | Most of this project is boilerplate, so *by definition* there's not much to test. The question is always "what to test?". Business logic of course. In this architecture business logic resides in the following places: 113 | - The server's Service classes. I suggest you keep your routes simple and create either methods within those classes or separate business class logic. I'll write a couple of tests to show how to test the routes 114 | - The database specific Repository... because Slick is not a full ORM library, a lot of the mapping from Relational to OO happens in the DAO, it's a good idea to test these. 115 | these are considered integration tests and are in the server/src/it path 116 | - The web application itself, I personally find it very hard to write unit tests against user interface, you should read: 117 | - https://www.scala-js.org/libraries/testing.html 118 | - https://github.com/japgolly/scalajs-react/blob/master/doc/TESTING.md 119 | 120 | ## Production 121 | Creating production artifacts is a bit beyond the scope of this project (it's meant to get you started, not to get you finished). However I do use sbt-native-packager to create 122 | a debian package of the server portion. I'd like to integrate that to create a full package that also includes the web application. 123 | 124 | ## Acknowledgements 125 | - This [blog post](https://scalac.io/making-zio-akka-slick-play-together-nicely-part-1-zio-and-slick/) uses a stack that's very similar to the one described here, I borrowed from it extensively, mostly in it's use of zio. 126 | - [Oyvindberg](https://github.com/oyvindberg) has been super, super helpful with not only the ScalablyTyped project but with looking over my shoulder as I make mistakes. 127 | - All the people from all the projects above that work to make the scala culture so amazing! -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | ////////////////////////////////////////////////////////////////////////////////// 2 | // Common Stuff 3 | import java.nio.file.Files 4 | import java.nio.file.StandardCopyOption.REPLACE_EXISTING 5 | import sbtcrossproject.CrossPlugin.autoImport.crossProject 6 | 7 | import org.apache.commons.io.FileUtils 8 | 9 | lazy val root = project 10 | .in(file(".")) 11 | .aggregate(server, commonJS, commonJVM, webclient) 12 | .settings( 13 | publish := {}, 14 | publishLocal := {}, 15 | Global / onChangedBuildSource := ReloadOnSourceChanges, 16 | ) 17 | 18 | lazy val buildInfoSettings = 19 | Seq( 20 | buildInfoKeys := Seq[BuildInfoKey](name, version, scalaVersion, sbtVersion), 21 | buildInfoPackage := "x.web" 22 | ) 23 | 24 | lazy val gitSettings = 25 | Seq( 26 | git.useGitDescribe := true 27 | ) 28 | 29 | ThisBuild / name := "full-scala-stack" 30 | ThisBuild / organization := "net.leibman" 31 | ThisBuild / scalaVersion := "2.12.10" 32 | 33 | //////////////////////////////////////////////////////////////////////////////////// 34 | // common (i.e. model) 35 | lazy val common = 36 | crossProject(JSPlatform, JVMPlatform) 37 | .in(file("common")) 38 | .jvmSettings( 39 | libraryDependencies += "org.scala-js" %% "scalajs-stubs" % "1.0.0" % "provided" 40 | ) 41 | .jsSettings( 42 | libraryDependencies += "com.lihaoyi" %% "upickle" % "0.9.9" withSources () 43 | ) 44 | 45 | lazy val commonJVM = common.jvm 46 | lazy val commonJS = common.js 47 | 48 | //////////////////////////////////////////////////////////////////////////////////// 49 | // Server 50 | lazy val akkaVersion = "2.6.3" 51 | lazy val akkaHttpVersion = "10.1.11" 52 | lazy val slickVersion = "3.3.2" 53 | lazy val zioVersion = "1.0.0-RC17" 54 | 55 | lazy val start = TaskKey[Unit]("start") 56 | 57 | lazy val dist = TaskKey[File]("dist") 58 | 59 | lazy val debugDist = TaskKey[File]("debugDist") 60 | 61 | lazy val server = project 62 | .in(file("server")) 63 | .configs(IntegrationTest) 64 | .dependsOn(commonJVM) 65 | .settings( 66 | Defaults.itSettings, 67 | libraryDependencies ++= Seq( 68 | "dev.zio" %% "zio" % zioVersion withSources(), 69 | 70 | "com.typesafe.slick" %% "slick" % slickVersion withSources(), 71 | "com.typesafe.slick" %% "slick-codegen" % slickVersion withSources(), 72 | "mysql" % "mysql-connector-java" % "8.0.19", 73 | 74 | "com.github.daddykotex" %% "courier" % "2.0.0" withSources(), 75 | 76 | "com.github.pathikrit" %% "better-files" % "3.8.0" withSources(), 77 | 78 | "com.typesafe.akka" %% "akka-http" % akkaHttpVersion, 79 | "com.typesafe.akka" %% "akka-actor-typed" % akkaVersion, 80 | "com.typesafe.akka" %% "akka-stream" % akkaVersion, 81 | "ch.qos.logback" % "logback-classic" % "1.2.3", 82 | 83 | "com.lihaoyi" %% "upickle" % "0.9.9" withSources(), 84 | "de.heikoseeberger" %% "akka-http-upickle" % "1.31.0" withSources(), 85 | 86 | "com.typesafe.akka" %% "akka-http-testkit" % akkaHttpVersion % "it,test", 87 | "com.typesafe.akka" %% "akka-actor-testkit-typed" % akkaVersion % "it,test", 88 | "com.typesafe.akka" %% "akka-stream-testkit" % akkaVersion % "it,test", 89 | "org.scalatest" %% "scalatest" % "3.1.0" % "it,test", 90 | "dev.zio" %% "zio-test" % zioVersion % "it, test", 91 | "dev.zio" %% "zio-test-sbt" % zioVersion % "it, test" 92 | ), 93 | testFrameworks ++= Seq(new TestFramework("zio.test.sbt.ZTestFramework")) 94 | ) 95 | 96 | //////////////////////////////////////////////////////////////////////////////////// 97 | // Web client 98 | lazy val webclient = project 99 | .in(file("webclient")) 100 | .dependsOn(commonJS) 101 | .enablePlugins(ScalaJSPlugin, ScalajsReactTypedPlugin, AutomateHeaderPlugin, GitVersioning, BuildInfoPlugin) 102 | .configure(bundlerSettings) 103 | .settings(gitSettings, buildInfoSettings) 104 | .settings( 105 | debugDist := { 106 | 107 | val assets = (ThisBuild / baseDirectory).value / "webclient" / "src" / "main" / "web" 108 | 109 | val artifacts = (Compile / fastOptJS / webpack).value 110 | val artifactFolder = (Compile / fastOptJS / crossTarget).value 111 | val debugFolder = (ThisBuild / baseDirectory).value / "debugDist" 112 | 113 | debugFolder.mkdirs() 114 | FileUtils.copyDirectory(assets, debugFolder, true) 115 | artifacts.foreach { artifact => 116 | val target = artifact.data.relativeTo(artifactFolder) match { 117 | case None => debugFolder / artifact.data.name 118 | case Some(relFile) => debugFolder / relFile.toString 119 | } 120 | 121 | println(s"Trying to copy ${artifact.data.toPath} to ${target.toPath}") 122 | Files.copy(artifact.data.toPath, target.toPath, REPLACE_EXISTING) 123 | } 124 | 125 | debugFolder 126 | }, 127 | dist := { 128 | val assets = (ThisBuild / baseDirectory).value / "webclient" / "src" / "main" / "web" 129 | 130 | val artifacts = (Compile / fullOptJS / webpack).value 131 | val artifactFolder = (Compile / fullOptJS / crossTarget).value 132 | val distFolder = (ThisBuild / baseDirectory).value / "dist" 133 | 134 | distFolder.mkdirs() 135 | FileUtils.copyDirectory(assets, distFolder, true) 136 | artifacts.foreach { artifact => 137 | val target = artifact.data.relativeTo(artifactFolder) match { 138 | case None => distFolder / artifact.data.name 139 | case Some(relFile) => distFolder / relFile.toString 140 | } 141 | 142 | println(s"Trying to copy ${artifact.data.toPath} to ${target.toPath}") 143 | Files.copy(artifact.data.toPath, target.toPath, REPLACE_EXISTING) 144 | } 145 | 146 | distFolder 147 | }, 148 | resolvers += Resolver.bintrayRepo("oyvindberg", "ScalajsReactTyped"), 149 | libraryDependencies ++= Seq( 150 | ScalajsReactTyped.S.`semantic-ui-react`, 151 | ScalajsReactTyped.S.`stardust-ui__react-component-ref`, 152 | "commons-io" % "commons-io" % "2.6" withSources(), 153 | "ru.pavkin" %%% "scala-js-momentjs" % "0.10.1" withSources(), 154 | "io.github.cquiroz" %%% "scala-java-time" % "2.0.0-RC3" withSources(), 155 | "io.github.cquiroz" %%% "scala-java-time-tzdb" % "2.0.0-RC3_2019a" withSources(), 156 | "org.scala-js" %%% "scalajs-dom" % "0.9.8" withSources(), 157 | "com.olvind" %%% "scalablytyped-runtime" % "2.1.0", 158 | "com.github.japgolly.scalajs-react" %%% "core" % "1.6.0" withSources(), 159 | "com.github.japgolly.scalajs-react" %%% "extra" % "1.6.0" withSources(), 160 | "com.lihaoyi" %%% "upickle" % "0.9.9" withSources(), 161 | "com.lihaoyi" %%% "scalatags" % "0.8.5" withSources(), 162 | "com.github.japgolly.scalacss" %%% "core" % "0.6.0" withSources(), 163 | "com.github.japgolly.scalacss" %%% "ext-react" % "0.6.0" withSources(), 164 | "com.lihaoyi" %% "upickle" % "0.9.9" % "test" withSources(), 165 | "com.github.pathikrit" %% "better-files" % "3.8.0", 166 | "org.scalatest" %% "scalatest" % "3.1.0" % "test" withSources(), 167 | ), 168 | organization := "net.leibman", 169 | organizationName := "Roberto Leibman", 170 | startYear := Some(2020), 171 | licenses += ("Apache-2.0", url("http://www.apache.org/licenses/LICENSE-2.0")), 172 | scalacOptions ++= Seq( 173 | "-P:scalajs:sjsDefinedByDefault", 174 | "-deprecation", // Emit warning and location for usages of deprecated APIs. 175 | "-explaintypes", // Explain type errors in more detail. 176 | "-feature", // Emit warning and location for usages of features that should be imported explicitly. 177 | "-language:existentials", // Existential types (besides wildcard types) can be written and inferred 178 | "-language:experimental.macros", // Allow macro definition (besides implementation and application) 179 | "-language:higherKinds", // Allow higher-kinded types 180 | "-language:implicitConversions", // Allow definition of implicit functions called views 181 | "-unchecked", // Enable additional warnings where generated code depends on assumptions. 182 | "-Xcheckinit", // Wrap field accessors to throw an exception on uninitialized access. 183 | //"-Xfatal-warnings", // Fail the compilation if there are any warnings. 184 | "-Xlint:adapted-args", // Warn if an argument list is modified to match the receiver. 185 | "-Xlint:constant", // Evaluation of a constant arithmetic expression results in an error. 186 | "-Xlint:delayedinit-select", // Selecting member of DelayedInit. 187 | "-Xlint:doc-detached", // A Scaladoc comment appears to be detached from its element. 188 | "-Xlint:inaccessible", // Warn about inaccessible types in method signatures. 189 | "-Xlint:infer-any", // Warn when a type argument is inferred to be `Any`. 190 | "-Xlint:missing-interpolator", // A string literal appears to be missing an interpolator id. 191 | "-Xlint:nullary-override", // Warn when non-nullary `def f()' overrides nullary `def f'. 192 | "-Xlint:nullary-unit", // Warn when nullary methods return Unit. 193 | "-Xlint:option-implicit", // Option.apply used implicit view. 194 | "-Xlint:package-object-classes", // Class or object defined in package object. 195 | "-Xlint:poly-implicit-overload", // Parameterized overloaded implicit methods are not visible as view bounds. 196 | "-Xlint:private-shadow", // A private field (or class parameter) shadows a superclass field. 197 | "-Xlint:stars-align", // Pattern sequence wildcard must align with sequence component. 198 | "-Xlint:type-parameter-shadow", // A local type parameter shadows a type already in scope. 199 | "-Ywarn-dead-code", // Warn when dead code is identified. 200 | "-Ywarn-extra-implicit", // Warn when more than one implicit parameter section is defined. 201 | "-Ywarn-numeric-widen", // Warn when numerics are widened. 202 | "-Ywarn-unused:implicits", // Warn if an implicit parameter is unused. 203 | "-Ywarn-unused:imports", // Warn if an import selector is not referenced. 204 | "-Ywarn-unused:locals", // Warn if a local definition is unused. 205 | "-Ywarn-unused:params", // Warn if a value parameter is unused. 206 | "-Ywarn-unused:patvars", // Warn if a variable bound in a pattern is unused. 207 | "-Ywarn-unused:privates", // Warn if a private member is unused. 208 | "-Ywarn-value-discard", // Warn when non-Unit expression results are unused. 209 | "-Ybackend-parallelism", "8", // Enable paralellisation — change to desired number! 210 | "-Ycache-plugin-class-loader:last-modified", // Enables caching of classloaders for compiler plugins 211 | "-Ycache-macro-class-loader:last-modified", // and macro definitions. This can lead to performance improvements. 212 | ), 213 | Compile / unmanagedSourceDirectories := Seq((Compile / scalaSource).value), 214 | Test / unmanagedSourceDirectories := Seq((Test / scalaSource).value), 215 | webpackDevServerPort := 8009, 216 | ) 217 | 218 | lazy val bundlerSettings: Project => Project = 219 | _.enablePlugins(ScalaJSBundlerPlugin) 220 | .settings( 221 | scalaJSUseMainModuleInitializer := true, 222 | /* disabled because it somehow triggers many warnings */ 223 | emitSourceMaps := false, 224 | scalaJSModuleKind := ModuleKind.CommonJSModule, 225 | /* Specify current versions and modes */ 226 | startWebpackDevServer / version := "3.1.10", 227 | webpack / version := "4.28.3", 228 | Compile / fastOptJS / webpackExtraArgs += "--mode=development", 229 | Compile / fullOptJS / webpackExtraArgs += "--mode=production", 230 | Compile / fastOptJS / webpackDevServerExtraArgs += "--mode=development", 231 | Compile / fullOptJS / webpackDevServerExtraArgs += "--mode=production", 232 | useYarn := false, 233 | // jsEnv := new org.scalajs.jsenv.jsdomnodejs.JSDOMNodeJSEnv, 234 | fork in run := true, 235 | scalaJSStage in Global := FastOptStage, 236 | scalaJSUseMainModuleInitializer in Compile := true, 237 | scalaJSUseMainModuleInitializer in Test := false, 238 | skip in packageJSDependencies := false, 239 | artifactPath 240 | .in(Compile, fastOptJS) := ((crossTarget in(Compile, fastOptJS)).value / 241 | ((moduleName in fastOptJS).value + "-opt.js")), 242 | artifactPath 243 | .in(Compile, fullOptJS) := ((crossTarget in(Compile, fullOptJS)).value / 244 | ((moduleName in fullOptJS).value + "-opt.js")), 245 | webpackEmitSourceMaps := true, 246 | Compile / npmDependencies ++= Seq( 247 | // "jsdom"-> "^15.0.0", 248 | "react-dom" -> "16.9", 249 | "@types/react-dom" -> "16.9.1", 250 | "react" -> "16.9", 251 | "@types/react" -> "16.9.5", 252 | "semantic-ui-react" -> "0.88.1" 253 | ), 254 | npmDevDependencies.in(Compile) := Seq( 255 | // "jsdom"-> "^15.0.0", 256 | "style-loader" -> "0.23.1", 257 | "css-loader" -> "2.1.0", 258 | "sass-loader" -> "7.1.0", 259 | "compression-webpack-plugin" -> "2.0.0", 260 | "file-loader" -> "3.0.1", 261 | "gulp-decompress" -> "2.0.2", 262 | "image-webpack-loader" -> "4.6.0", 263 | "imagemin" -> "6.1.0", 264 | "less" -> "3.9.0", 265 | "less-loader" -> "4.1.0", 266 | "lodash" -> "4.17.11", 267 | "node-libs-browser" -> "2.1.0", 268 | "react-hot-loader" -> "4.6.3", 269 | "url-loader" -> "1.1.2", 270 | "expose-loader" -> "0.7.5", 271 | "webpack" -> "4.28.3", 272 | "webpack-merge" -> "4.2.2" 273 | ) 274 | ) 275 | 276 | //////////////////////////////////////////////////////////////////////////////////// 277 | // TODO: mobile client 278 | 279 | -------------------------------------------------------------------------------- /common/shared/src/main/scala/model/SampleModelObject.scala: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | case class SampleModelObject(id: Int, name: String) 4 | -------------------------------------------------------------------------------- /common/shared/src/main/scala/model/search.scala: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | object SortDirection extends Enumeration { 4 | type SortDirection = Value 5 | 6 | def reverse(direction: SortDirection): SortDirection = if (direction == asc) dsc else asc 7 | val asc, dsc = Value 8 | } 9 | 10 | import SortDirection._ 11 | 12 | trait Sort { 13 | val direction: SortDirection 14 | } 15 | 16 | case class DefaultSort(direction: SortDirection) extends Sort 17 | 18 | trait Search[SORT <: Sort] { 19 | val pageIndex: Int //NOTE!!!! PageIndex is zero based, if you're doing database work, you'll likely want the offset, which is 1 based 20 | val pageSize: Int 21 | val sort: Option[SORT] 22 | } 23 | 24 | case class SimpleSearch(pageIndex: Int = 0, pageSize: Int = 10, sort: Option[DefaultSort] = None) 25 | extends Search[DefaultSort] 26 | 27 | case class SimpleTextSearch( 28 | text: String = "", 29 | pageIndex: Int = 0, 30 | pageSize: Int = 10, 31 | sort: Option[DefaultSort] = None 32 | ) extends Search[DefaultSort] 33 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.3.10 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | //////////////////////////////////////////////////////////////////////////////////// 2 | // Common stuff 3 | addSbtPlugin("com.dwijnand" % "sbt-travisci" % "1.2.0") 4 | addSbtPlugin("com.typesafe.sbt" % "sbt-git" % "1.0.0") 5 | addSbtPlugin("de.heikoseeberger" % "sbt-header" % "5.2.0") 6 | addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.4.1") 7 | addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.9.0") 8 | addSbtPlugin("org.portable-scala" % "sbt-crossproject" % "0.6.0") // (1) 9 | addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "0.6.0") // (2) 10 | 11 | //////////////////////////////////////////////////////////////////////////////////// 12 | // Server 13 | addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1") 14 | addSbtPlugin("io.github.davidmweber" % "flyway-sbt" % "6.0.0") 15 | 16 | //////////////////////////////////////////////////////////////////////////////////// 17 | // Web client 18 | resolvers += Resolver.bintrayRepo("oyvindberg", "ScalajsReactTyped") 19 | 20 | addSbtPlugin("org.scalablytyped.japgolly" % "sbt-scalajsreacttyped" % "201912140138") 21 | addSbtPlugin("org.scala-js" % "sbt-scalajs" % "0.6.32") 22 | addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % "0.15.0-0.6") 23 | 24 | libraryDependencies += "org.slf4j" % "slf4j-nop" % "1.7.30" // Needed by sbt-git 25 | libraryDependencies += "org.vafer" % "jdeb" % "1.4" artifacts (Artifact("jdeb", "jar", "jar")) 26 | -------------------------------------------------------------------------------- /server/src/it/scala/api/ModelServiceIntegrationSpec.scala: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import akka.http.scaladsl.model.{ContentTypes, HttpEntity} 4 | import akka.http.scaladsl.testkit.ScalatestRouteTest 5 | import de.heikoseeberger.akkahttpupickle.UpickleSupport 6 | import model.{SampleModelObject, SimpleSearch} 7 | import org.scalatest.matchers.should.Matchers 8 | import org.scalatest.wordspec.{AnyWordSpec, AnyWordSpecLike} 9 | import routes.ModelRoutes 10 | import upickle.default._ 11 | import util.ModelPickler 12 | 13 | import scala.concurrent.ExecutionContext 14 | 15 | /** 16 | * A set of integration tests of the ModelService. 17 | * Note that these go against a live database, so be careful that you don't point it at production. 18 | */ 19 | class ModelServiceIntegrationSpec 20 | extends AnyWordSpec 21 | with Matchers 22 | with ScalatestRouteTest 23 | with ZIODirectives 24 | with UpickleSupport 25 | with ModelPickler 26 | { 27 | 28 | val service = new ModelRoutes with LiveEnvironment 29 | 30 | //TODO test your route here, we would probably not have a test like the one below in reality, since it's super simple. 31 | "The Service" should { 32 | "return one objects on a get" in { 33 | Get("/api/sampleModelObject/1") ~> service.apiRoute("") ~> check { 34 | val res = responseAs[Seq[SampleModelObject]].headOption 35 | 36 | println(res) 37 | assert(res.nonEmpty) 38 | } 39 | } 40 | "return some objects on a search" in { 41 | Post("/api/sampleModelObject/search", HttpEntity(ContentTypes.`application/json`, write(SimpleSearch()))) ~> service.apiRoute("") ~> check { 42 | val str = responseAs[ujson.Value] 43 | val res = responseAs[Seq[SampleModelObject]] 44 | 45 | println(res) 46 | assert(res.nonEmpty) 47 | } 48 | } 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /server/src/it/scala/dao/RepositoryIntegrationSpec.scala: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | import api.LiveEnvironment 4 | import zio.console._ 5 | import zio.test.{Assertion, _} 6 | 7 | object ModelDAOIntegrationSpec extends LiveEnvironment { 8 | 9 | val modelDAOSuite = suite("ModelDAO Suite")( 10 | testM("sampleModelObjects returns some objects") { 11 | assertM(repository.sampleModelObjectOps.search(None)(""), Assertion.isNonEmpty) 12 | }, 13 | testM("Another way of testing the same") { 14 | for { 15 | objects <- repository.sampleModelObjectOps.search(None)("") 16 | _ <- putStrLn("Hey, this happened") 17 | } yield { 18 | assert(objects, Assertion.isNonEmpty) 19 | } 20 | } 21 | ) 22 | } 23 | 24 | object AllSuites extends DefaultRunnableSpec(ModelDAOIntegrationSpec.modelDAOSuite) 25 | -------------------------------------------------------------------------------- /server/src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | 2 | akka { 3 | loglevel = "info" 4 | loggers = ["akka.event.Logging$DefaultLogger"] 5 | // event-handlers = ["akka.event.slf4j.Slf4jEventHandler"] 6 | // loggers = ["akka.event.slf4j.Slf4jLogger"] 7 | // logging-filter = "akka.event.slf4j.Slf4jLoggingFilter" 8 | 9 | http.host-connection-pool { 10 | max-connections = 64 11 | max-open-requests = 64 12 | } 13 | http.server.idle-timeout = 600 s 14 | http.server.parsing.max-method-length = 2048 15 | jvm-exit-on-fatal-error = false 16 | http.client.user-agent-header="w3m/0.5.5+git2015111" 17 | } 18 | slick { 19 | ansiDump = true 20 | sqlIndent = true 21 | } 22 | full-scala-stack { 23 | host = 0.0.0.0 24 | port = 8079 25 | staticContentDir = "../debugDist" 26 | //"slick.jdbc.MySQLProfile" 27 | driver = "com.mysql.cj.jdbc.Driver" 28 | url = "jdbc:mysql://localhost:3306/fullscalastack?current_schema=fullscalastack&nullNamePatternMatchesAll=true&useJDBCCompliantTimezoneShift=true&useLegacyDatetimeCode=false&serverTimezone=UTC" 29 | user = "root", //user 30 | password = "rleibman" 31 | connectionPool = disabled 32 | keepAliveConnection = true 33 | smtp { 34 | host = "mail.leibmanland.com" 35 | auth = false 36 | port = 25 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /server/src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | *** \(%logger{30}\)%green(%X{debugId}) %msg%n 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 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 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /server/src/main/scala/Rest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import api.{ Api, LiveEnvironment } 18 | import core.{ BootedCore, CoreActors } 19 | import web.Web 20 | 21 | import scala.concurrent.ExecutionContext 22 | 23 | /** 24 | * This is the actual application you run that contains everything it needs, the core, the actors, the api, the web, the environment. 25 | **/ 26 | object Rest 27 | extends App //To run it 28 | with BootedCore //For stop and start 29 | with CoreActors 30 | with Api // The api 31 | with Web // As a web service 32 | with LiveEnvironment 33 | -------------------------------------------------------------------------------- /server/src/main/scala/api/Api.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package api 18 | 19 | import akka.http.scaladsl.model.StatusCodes 20 | import akka.http.scaladsl.server.directives.DebuggingDirectives 21 | import akka.http.scaladsl.server.{ Directives, Route, RouteConcatenation } 22 | import core.{ Core, CoreActors } 23 | import routes.{ HTMLRoute, ModelRoutes } 24 | 25 | /** 26 | * This class puts all of the live services together with all of the routes 27 | * @author rleibman 28 | */ 29 | trait Api 30 | extends RouteConcatenation 31 | with Directives 32 | with LiveEnvironment 33 | with HTMLRoute 34 | with ModelRoutes 35 | with ZIODirectives { 36 | this: CoreActors with Core => 37 | 38 | private implicit val _ = actorSystem.dispatcher 39 | 40 | //TODO This particular example app doesn't use sessions, look up "com.softwaremill.akka-http-session" if you want sessions 41 | val sessionResult = Option("validsession") 42 | 43 | val routes: Route = DebuggingDirectives.logRequest("Request") { 44 | extractLog { log => 45 | unauthRoute ~ { 46 | extractRequestContext { requestContext => 47 | sessionResult match { 48 | case Some(session) => 49 | apiRoute(session) 50 | case None => 51 | log.info( 52 | s"Unauthorized request of ${requestContext.unmatchedPath}, redirecting to login" 53 | ) 54 | redirect("/loginForm", StatusCodes.Found) 55 | } 56 | } 57 | } ~ 58 | htmlRoute 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /server/src/main/scala/api/Config.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package api 18 | 19 | import better.files.File 20 | import com.typesafe.config.ConfigFactory 21 | 22 | /** 23 | * A trait to keep app configuration 24 | */ 25 | trait Config { 26 | val configKey = "full-scala-stack" 27 | val config: com.typesafe.config.Config = { 28 | val confFileName = 29 | System.getProperty("application.conf", "./src/main/resources/application.conf") 30 | val confFile = File(confFileName) 31 | val config = ConfigFactory 32 | .parseFile(confFile.toJava) 33 | .withFallback(ConfigFactory.load()) 34 | config 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /server/src/main/scala/api/LiveEnvironment.scala: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import dao.{ LiveRepository, MySQLDatabaseProvider } 4 | import mail.CourierPostman 5 | 6 | /** 7 | * This Creates a live environment, with actual running stuff (real email, real database, etc) 8 | */ 9 | trait LiveEnvironment extends MySQLDatabaseProvider with LiveRepository with CourierPostman with Config {} 10 | -------------------------------------------------------------------------------- /server/src/main/scala/api/ZIODirectives.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package api 18 | 19 | import akka.http.scaladsl.marshalling.{ Marshaller, Marshalling } 20 | import akka.http.scaladsl.model.HttpResponse 21 | import akka.http.scaladsl.server.{ Directive, Directive1, Route, RouteResult } 22 | import akka.http.scaladsl.marshalling.ToResponseMarshaller 23 | import akka.http.scaladsl.server.directives.RouteDirectives 24 | import akka.http.scaladsl.util.FastFuture._ 25 | import zio.{ DefaultRuntime, Task, ZIO } 26 | 27 | import scala.concurrent.{ Future, Promise } 28 | import scala.util.{ Failure, Success } 29 | 30 | /** 31 | * A special set of akka-http directives that take ZIOs, run them and marshalls them. 32 | */ 33 | trait ZIODirectives extends DefaultRuntime { 34 | import RouteDirectives._ 35 | 36 | implicit def zioMarshaller[A]( 37 | implicit m1: Marshaller[A, HttpResponse], 38 | m2: Marshaller[Throwable, HttpResponse] 39 | ): Marshaller[Task[A], HttpResponse] = 40 | Marshaller { implicit ec => a => 41 | val r = a.foldM( 42 | e => Task.fromFuture(implicit ec => m2(e)), 43 | a => Task.fromFuture(implicit ec => m1(a)) 44 | ) 45 | 46 | val p = Promise[List[Marshalling[HttpResponse]]]() 47 | 48 | unsafeRunAsync(r) { exit => 49 | exit.fold(e => p.failure(e.squash), s => p.success(s)) 50 | } 51 | 52 | p.future 53 | } 54 | 55 | private def fromFunction[A, B](f: A => Future[B]): ZIO[A, Throwable, B] = 56 | for { 57 | a <- ZIO.fromFunction(f) 58 | b <- ZIO.fromFuture(_ => a) 59 | } yield b 60 | 61 | implicit def zioRoute(z: ZIO[Any, Throwable, Route]): Route = ctx => { 62 | val p = Promise[RouteResult]() 63 | 64 | val f = z.flatMap(r => fromFunction(r)).provide(ctx) 65 | 66 | unsafeRunAsync(f) { exit => 67 | exit.fold(e => p.failure(e.squash), s => p.success(s)) 68 | } 69 | 70 | p.future 71 | } 72 | 73 | /** 74 | * "Unwraps" a `Task[T]` and runs the inner route when the task has failed 75 | * with the task's failure exception as an extraction of type `Throwable`. 76 | * If the task succeeds the request is completed using the values marshaller 77 | * (This directive therefore requires a marshaller for the task's type to be 78 | * implicitly available.) 79 | * 80 | * @group task 81 | */ 82 | def zioCompleteOrRecoverWith(magnet: ZIOCompleteOrRecoverWithMagnet): Directive1[Throwable] = 83 | magnet.directive 84 | 85 | } 86 | 87 | object ZIODirectives extends ZIODirectives 88 | 89 | trait ZIOCompleteOrRecoverWithMagnet { 90 | def directive: Directive1[Throwable] 91 | } 92 | 93 | object ZIOCompleteOrRecoverWithMagnet extends ZIODirectives { 94 | implicit def apply[T]( 95 | task: => Task[T] 96 | )(implicit m: ToResponseMarshaller[T]): ZIOCompleteOrRecoverWithMagnet = 97 | new ZIOCompleteOrRecoverWithMagnet { 98 | override val directive: Directive1[Throwable] = Directive[Tuple1[Throwable]] { inner => ctx => 99 | val future = unsafeRunToFuture(task) 100 | import ctx.executionContext 101 | future.fast.transformWith { 102 | case Success(res) => ctx.complete(res) 103 | case Failure(error) => inner(Tuple1(error))(ctx) 104 | } 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /server/src/main/scala/core/Core.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package core 18 | 19 | import akka.actor.ActorSystem 20 | 21 | /** 22 | * @author rleibman 23 | */ 24 | trait Core { 25 | 26 | implicit def actorSystem: ActorSystem 27 | } 28 | 29 | // $COVERAGE-OFF$ This is actual code that we can't test, so we shouldn't report on it 30 | /** 31 | * This trait implements ``Core`` by starting the required ``ActorSystem`` and registering the 32 | * termination handler to stop the system when the JVM exits. 33 | */ 34 | trait BootedCore extends Core { 35 | 36 | /** 37 | * Construct the ActorSystem we will use in our application 38 | */ 39 | implicit lazy val actorSystem: ActorSystem = ActorSystem("akka-spray") 40 | 41 | /** 42 | * Ensure that the constructed ActorSystem is shut down when the JVM shuts down 43 | */ 44 | sys.addShutdownHook({ 45 | actorSystem.terminate() 46 | () 47 | }) 48 | 49 | } 50 | // $COVERAGE-ON$ 51 | 52 | /** 53 | * This trait contains the actors that make up our application; it can be mixed in with 54 | * ``BootedCore`` for running code or ``TestKit`` for unit and integration tests. 55 | */ 56 | trait CoreActors { this: Core => 57 | } 58 | -------------------------------------------------------------------------------- /server/src/main/scala/dao/CRUDOperations.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dao 18 | 19 | import model.Search 20 | import zio.IO 21 | import zioslick.RepositoryException 22 | 23 | /** 24 | * Collects the basic CRUD operations of a single object (or object graph) against a data source. 25 | * @tparam E 26 | * @tparam PK 27 | * @tparam SEARCH 28 | * @tparam SESSION 29 | */ 30 | trait CRUDOperations[E, PK, SEARCH <: Search[_], SESSION] { 31 | def upsert(e: E)(implicit session: SESSION): IO[RepositoryException, E] 32 | def get(pk: PK)(implicit session: SESSION): IO[RepositoryException, Option[E]] 33 | def delete(pk: PK, softDelete: Boolean = false)(implicit session: SESSION): IO[RepositoryException, Boolean] 34 | def search(search: Option[SEARCH] = None)( 35 | implicit session: SESSION 36 | ): IO[RepositoryException, Seq[E]] 37 | def count(search: Option[SEARCH] = None)( 38 | implicit session: SESSION 39 | ): IO[RepositoryException, Long] 40 | } 41 | -------------------------------------------------------------------------------- /server/src/main/scala/dao/LiveRepository.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dao 18 | 19 | import api.Config 20 | import mail.Postman 21 | import model.{ SampleModelObject, SimpleSearch } 22 | import slick.dbio.DBIO 23 | import slick.jdbc.MySQLProfile.api._ 24 | import zio.IO 25 | import zioslick.{ DatabaseProvider, RepositoryException, ZioSlickSupport } 26 | 27 | /** 28 | * Implements the model's database methods using slick and a given database provider 29 | * Note that it isn't fully detached from MySQL, unfortunately, that would be nice 30 | */ 31 | trait LiveRepository 32 | extends Repository 33 | with ZioSlickSupport 34 | with Postman 35 | with DatabaseProvider 36 | with Config 37 | with ModelSlickInterop { 38 | 39 | private def provideDB[R](dbio: DBIO[R]): IO[RepositoryException, R] = 40 | fromDBIO(dbio).provide(this) 41 | 42 | override def repository: Repository.Service = new Repository.Service { 43 | 44 | import dao.Tables._ 45 | 46 | override val sampleModelObjectOps: CRUDOperations[SampleModelObject, Int, SimpleSearch, Any] = 47 | new CRUDOperations[SampleModelObject, Int, SimpleSearch, Any] { 48 | 49 | override def upsert(obj: SampleModelObject)(implicit session: Any): IO[RepositoryException, SampleModelObject] = 50 | provideDB( 51 | (SampleModelObjectQuery returning SampleModelObjectQuery.map(_.id) into ((_, id) => obj.copy(id = id))) 52 | .insertOrUpdate(SampleModelObject2SampleModelObjectRow(obj)) 53 | ) 54 | .map(_.getOrElse(obj)) 55 | 56 | override def get(pk: Int)(implicit session: Any): IO[RepositoryException, Option[SampleModelObject]] = 57 | provideDB( 58 | SampleModelObjectQuery 59 | .filter(_.id === pk) 60 | .result 61 | .headOption 62 | ) 63 | .map(_.map(SampleModelObjectRow2SampleModelObject)) 64 | 65 | override def delete(pk: Int, softDelete: Boolean)(implicit session: Any): IO[RepositoryException, Boolean] = 66 | provideDB( 67 | SampleModelObjectQuery 68 | .filter(_.id === pk) 69 | .delete 70 | ) 71 | .map(_ > 0) 72 | 73 | //TODO add any search and sort parameters here 74 | override def search( 75 | search: Option[SimpleSearch] 76 | )(implicit session: Any): IO[RepositoryException, Seq[SampleModelObject]] = 77 | provideDB(SampleModelObjectQuery.result) 78 | .map(_.map(SampleModelObjectRow2SampleModelObject)) 79 | 80 | //TODO add any search parameters here 81 | override def count(search: Option[SimpleSearch])(implicit session: Any): IO[RepositoryException, Long] = 82 | provideDB(SampleModelObjectQuery.length.result) 83 | .map(_.toLong) 84 | 85 | } 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /server/src/main/scala/dao/ModelSlickInterop.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dao 18 | 19 | import model.SampleModelObject 20 | 21 | /** 22 | * Converters between model objects and database objects. We could use one object for both, 23 | * but I find that I usually want the row objects as simple as possible 24 | */ 25 | trait ModelSlickInterop { 26 | 27 | import Tables._ 28 | 29 | implicit def SampleModelObjectRow2SampleModelObject(row: SampleModelObjectRow) = SampleModelObject(row.id, row.name) 30 | 31 | implicit def SampleModelObject2SampleModelObjectRow(value: SampleModelObject) = 32 | SampleModelObjectRow(value.id, value.name) 33 | } 34 | -------------------------------------------------------------------------------- /server/src/main/scala/dao/MySQLDatabaseProvider.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dao 18 | 19 | import slick.basic.BasicBackend 20 | import slick.jdbc.JdbcBackend._ 21 | import zio.{ UIO, ZIO } 22 | import zioslick.DatabaseProvider 23 | 24 | /** 25 | * Live database provider that provides a MySQL database, with a very specific configuration 26 | */ 27 | trait MySQLDatabaseProvider extends DatabaseProvider { 28 | val configKey: String 29 | 30 | override val databaseProvider: DatabaseProvider.Service = new DatabaseProvider.Service { 31 | //We use effect total because though starting up the db may indeed throw an exception, that's really catastrophic: it is not "normal" error behavior and should really break early. 32 | override val db: UIO[BasicBackend#DatabaseDef] = ZIO.effectTotal(Database.forConfig(configKey)) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /server/src/main/scala/dao/Repository.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dao 18 | 19 | import model.{ SampleModelObject, SimpleSearch } 20 | 21 | /** 22 | * This trait defines all of the Model's database methods. 23 | */ 24 | trait Repository { 25 | def repository: Repository.Service 26 | } 27 | 28 | object Repository { 29 | 30 | trait Service { 31 | val sampleModelObjectOps: CRUDOperations[SampleModelObject, Int, SimpleSearch, Any] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /server/src/main/scala/dao/Tables.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package dao 18 | // AUTO-GENERATED Slick data model 19 | /** Stand-alone Slick data model for immediate use */ 20 | object Tables extends { 21 | val profile = slick.jdbc.MySQLProfile 22 | } with Tables 23 | 24 | /** Slick data model trait for extension, choice of backend or usage in the cake pattern. (Make sure to initialize this late.) */ 25 | trait Tables { 26 | val profile: slick.jdbc.JdbcProfile 27 | import profile.api._ 28 | import slick.model.ForeignKeyAction 29 | // NOTE: GetResult mappers for plain SQL are only generated for tables where Slick knows how to map the types of all columns. 30 | import slick.jdbc.{ GetResult => GR } 31 | 32 | /** DDL for all tables. Call .create to execute. */ 33 | lazy val schema: profile.SchemaDescription = SampleModelObjectQuery.schema 34 | @deprecated("Use .schema instead of .ddl", "3.0") 35 | def ddl = schema 36 | 37 | /** Entity class storing rows of table SampleModelObject 38 | * @param id Database column id SqlType(INT), AutoInc, PrimaryKey 39 | * @param name Database column name SqlType(TEXT) */ 40 | case class SampleModelObjectRow(id: Int, name: String) 41 | 42 | /** GetResult implicit for fetching SampleModelObjectRow objects using plain SQL queries */ 43 | implicit def GetResultSampleModelObjectRow(implicit e0: GR[Int], e1: GR[String]): GR[SampleModelObjectRow] = GR { 44 | prs => 45 | import prs._ 46 | SampleModelObjectRow.tupled((<<[Int], <<[String])) 47 | } 48 | 49 | /** Table description of table SampleModelObject. Objects of this class serve as prototypes for rows in queries. */ 50 | class SampleModelObjectTable(_tableTag: Tag) 51 | extends profile.api.Table[SampleModelObjectRow](_tableTag, Some("fullscalastack"), "SampleModelObject") { 52 | def * = (id, name) <> (SampleModelObjectRow.tupled, SampleModelObjectRow.unapply) 53 | 54 | /** Maps whole row to an option. Useful for outer joins. */ 55 | def ? = 56 | ((Rep.Some(id), Rep.Some(name))).shaped.<>({ r => 57 | import r._; _1.map(_ => SampleModelObjectRow.tupled((_1.get, _2.get))) 58 | }, (_: Any) => throw new Exception("Inserting into ? projection not supported.")) 59 | 60 | /** Database column id SqlType(INT), AutoInc, PrimaryKey */ 61 | val id: Rep[Int] = column[Int]("id", O.AutoInc, O.PrimaryKey) 62 | 63 | /** Database column name SqlType(TEXT) */ 64 | val name: Rep[String] = column[String]("name") 65 | } 66 | 67 | /** Collection-like TableQuery object for table SampleModelObject */ 68 | lazy val SampleModelObjectQuery = new TableQuery(tag => new SampleModelObjectTable(tag)) 69 | } 70 | -------------------------------------------------------------------------------- /server/src/main/scala/mail/Postman.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package mail 18 | 19 | import com.typesafe.config.Config 20 | import courier.{ Envelope, Mailer } 21 | import zio.ZIO 22 | 23 | /** 24 | * A trait that knows how to deliver email, and uses ZIO 25 | * //TODO Might want to move to a separate project. 26 | */ 27 | trait Postman { 28 | val postman: Postman.Service[Any] 29 | } 30 | 31 | object Postman { 32 | trait Service[R] { 33 | def deliver(email: Envelope): ZIO[R, Throwable, Unit] 34 | } 35 | } 36 | 37 | /** 38 | * An instatiation of the Postman that user the courier mailer 39 | */ 40 | trait CourierPostman extends Postman { 41 | val configKey: String 42 | val config: Config 43 | 44 | override val postman: Postman.Service[Any] = new Postman.Service[Any] { 45 | lazy val mailer: Mailer = { 46 | val localhost = config.getString(s"$configKey.smtp.localhost") 47 | System.setProperty("mail.smtp.localhost", localhost) 48 | System.setProperty("mail.smtp.localaddress", localhost) 49 | val auth = config.getBoolean(s"$configKey.smtp.auth") 50 | if (auth) 51 | Mailer(config.getString(s"$configKey.smtp.host"), config.getInt(s"$configKey.smtp.port")) 52 | .auth(auth) 53 | .as( 54 | config.getString(s"$configKey.smtp.user"), 55 | config.getString(s"$configKey.smtp.password") 56 | ) 57 | .startTls(config.getBoolean(s"$configKey.smtp.startTTLS"))() 58 | else 59 | Mailer(config.getString(s"$configKey.smtp.host"), config.getInt(s"$configKey.smtp.port")) 60 | .auth(auth)() 61 | } 62 | 63 | override def deliver(email: Envelope): ZIO[Any, Throwable, Unit] = 64 | ZIO.fromFuture(implicit ec => mailer(email)) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /server/src/main/scala/routes/CRUDRoute.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package routes 17 | 18 | import akka.http.scaladsl.server.{ Directives, Route } 19 | import api.ZIODirectives 20 | import dao.CRUDOperations 21 | import de.heikoseeberger.akkahttpupickle.UpickleSupport 22 | import model.Search 23 | import ujson.Num 24 | import upickle.default.ReadWriter 25 | import zio.Task 26 | 27 | import scala.util.matching.Regex 28 | 29 | /** 30 | * A crud route avoids boilerplate by defining a simple route for crud operations of an object 31 | * 32 | * @tparam E The model object that is the base of the route 33 | * @tparam PK The type of the object's primary key (used for gets/deletes) 34 | * @tparam SEARCH A search object (extends Search) 35 | * @tparam SESSION Sessions are useful if you want to limit the capabalities that people have. 36 | */ 37 | trait CRUDRoute[E, PK, SEARCH <: Search[_], SESSION] { 38 | def crudRoute: CRUDRoute.Service[E, PK, SEARCH, SESSION] 39 | } 40 | 41 | object CRUDRoute { 42 | 43 | abstract class Service[E, PK, SEARCH <: Search[_], SESSION] 44 | extends Directives 45 | with ZIODirectives 46 | with UpickleSupport { 47 | 48 | import upickle.default.read 49 | 50 | val url: String 51 | 52 | val ops: CRUDOperations[E, PK, SEARCH, SESSION] 53 | 54 | val pkRegex: Regex = "^[0-9]*$".r 55 | 56 | val defaultSoftDelete: Boolean = false 57 | 58 | /** 59 | * Override this to add other authenticated (i.e. with session) routes 60 | * @param session 61 | * @return 62 | */ 63 | def other(session: SESSION): Route = reject 64 | 65 | /** 66 | * Override this to add routes that don't require a session 67 | * @return 68 | */ 69 | def unauthRoute: Route = reject 70 | 71 | /** 72 | * Override this to support children routes (e.g. /api/student/classroom) 73 | * @param obj A Task that will contain the "parent" object 74 | * @param session 75 | * @return 76 | */ 77 | def childrenRoutes(pk: PK, obj: Task[Option[E]], session: SESSION): Seq[Route] = Seq.empty 78 | 79 | /** 80 | * You need to override this method so that the architecture knows how to get a primary key from an object 81 | * @param obj 82 | * @return 83 | */ 84 | def getPK(obj: E): PK 85 | 86 | def getOperation(id: PK, session: SESSION): Task[Option[E]] = ops.get(id)(session) 87 | 88 | def deleteOperation(objTask: Task[Option[E]], session: SESSION): Task[Boolean] = 89 | for { 90 | objOpt <- objTask 91 | deleted <- objOpt.fold(Task.succeed(false): Task[Boolean])( 92 | obj => ops.delete(getPK(obj), defaultSoftDelete)(session) 93 | ) 94 | } yield deleted 95 | 96 | def upsertOperation(obj: E, session: SESSION): Task[E] = ops.upsert(obj)(session) 97 | 98 | def countOperation(search: Option[SEARCH], session: SESSION): Task[Long] = 99 | ops.count(search)(session) 100 | 101 | def searchOperation(search: Option[SEARCH], session: SESSION): Task[Seq[E]] = 102 | ops.search(search)(session) 103 | 104 | /** 105 | * The main route. Note that it takes a pair of upickle ReaderWriter implicits that we need to be able to 106 | * marshall the objects in-to json. 107 | * In scala3 we may move these to parameters of the trait instead. 108 | * @param session 109 | * @param objRW 110 | * @param searchRW 111 | * @param pkRW 112 | * @return 113 | */ 114 | def route( 115 | session: SESSION 116 | )(implicit objRW: ReadWriter[E], searchRW: ReadWriter[SEARCH], pkRW: ReadWriter[PK]): Route = 117 | pathPrefix(url) { 118 | other(session) ~ 119 | pathEndOrSingleSlash { 120 | (post | put) { 121 | entity(as[E]) { obj => 122 | complete(upsertOperation(obj, session)) 123 | } 124 | } 125 | } ~ 126 | path("search") { 127 | post { 128 | entity(as[Option[SEARCH]]) { search => 129 | complete(searchOperation(search, session)) 130 | } 131 | } 132 | } ~ 133 | path("count") { 134 | post { 135 | entity(as[Option[SEARCH]]) { search => 136 | complete(countOperation(search, session).map(a => Num(a.toDouble))) 137 | } 138 | } 139 | } ~ 140 | pathPrefix(pkRegex) { id => 141 | childrenRoutes(read[PK](id), getOperation(read[PK](id), session), session) 142 | .reduceOption(_ ~ _) 143 | .getOrElse(reject) ~ 144 | pathEndOrSingleSlash { 145 | get { 146 | complete( 147 | getOperation(read[PK](id), session) 148 | .map(_.toSeq) //The #!@#!@# akka optionMarshaller gets in our way and converts an option to null/object before it ships it, so we convert it to seq 149 | ) 150 | } ~ 151 | delete { 152 | complete(deleteOperation(getOperation(read[PK](id), session), session)) 153 | } 154 | } 155 | } 156 | } 157 | } 158 | 159 | } 160 | -------------------------------------------------------------------------------- /server/src/main/scala/routes/HTMLRoute.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package routes 18 | 19 | import akka.http.scaladsl.server.directives.ContentTypeResolver 20 | import akka.http.scaladsl.server.{ Directives, Route } 21 | import api.Config 22 | import util.ModelPickler 23 | 24 | /** 25 | * A route used to spit out static content 26 | */ 27 | trait HTMLRoute extends Directives with ModelPickler with Config { 28 | val staticContentDir: String = config.getString("full-scala-stack.staticContentDir") 29 | 30 | override def getFromDirectory( 31 | directoryName: String 32 | )(implicit resolver: ContentTypeResolver): Route = 33 | extractUnmatchedPath { unmatchedPath => 34 | getFromFile(s"$staticContentDir/$unmatchedPath") 35 | } 36 | 37 | def htmlRoute: Route = extractLog { log => 38 | pathEndOrSingleSlash { 39 | get { 40 | log.info("GET /") 41 | log.debug(s"GET $staticContentDir/index.html") 42 | getFromFile(s"$staticContentDir/index.html") 43 | } 44 | } ~ 45 | get { 46 | extractUnmatchedPath { path => 47 | log.debug(s"GET $path") 48 | encodeResponse { 49 | getFromDirectory(staticContentDir) 50 | } 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /server/src/main/scala/routes/ModelRoutes.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package routes 17 | 18 | import akka.http.scaladsl.server.{ Directives, Route } 19 | import api.LiveEnvironment 20 | import util.ModelPickler 21 | 22 | /** 23 | * For convenience, this trait aggregates all of the model routes. 24 | */ 25 | trait ModelRoutes extends Directives with ModelPickler { 26 | 27 | private val sampleModelObjectRouteRoute = new SampleModelObjectRoute with LiveEnvironment {} 28 | 29 | private val crudRoutes: List[CRUDRoute[_, _, _, Any]] = List( 30 | sampleModelObjectRouteRoute 31 | ) 32 | 33 | def unauthRoute: Route = 34 | crudRoutes.map(_.crudRoute.unauthRoute).reduceOption(_ ~ _).getOrElse(reject) 35 | 36 | //TODO: it would be nice to be able to do this, but it's hard to define the readers and writers for marshalling 37 | // def apiRoute(session: Any): Route = 38 | // pathPrefix("api") { 39 | // crudRoutes.map(_.crudRoute.route(session)).reduceOption(_ ~ _).getOrElse(reject) 40 | // } 41 | 42 | def apiRoute(session: Any): Route = pathPrefix("api") { 43 | sampleModelObjectRouteRoute.crudRoute.route(session) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /server/src/main/scala/routes/SampleModelObjectRoute.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package routes 17 | 18 | import akka.http.scaladsl.server.Directives 19 | import api.ZIODirectives 20 | import dao.Repository 21 | import mail.Postman 22 | import model.{ SampleModelObject, SimpleSearch } 23 | 24 | /** 25 | * You will likely have one of these for each of your model objects that require crud operations. Please look at 26 | * CRUDRoute, there are a few methods you can override if you want to support things other than just crud operations. 27 | * 28 | */ 29 | trait SampleModelObjectRoute extends CRUDRoute[SampleModelObject, Int, SimpleSearch, Any] with Repository with Postman { 30 | 31 | override def crudRoute: CRUDRoute.Service[SampleModelObject, Int, SimpleSearch, Any] = 32 | new CRUDRoute.Service[SampleModelObject, Int, SimpleSearch, Any]() with Directives with ZIODirectives { 33 | 34 | override val url: String = "sampleModelObject" 35 | 36 | override val ops = repository.sampleModelObjectOps 37 | 38 | override def getPK(obj: SampleModelObject): Int = obj.id 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /server/src/main/scala/util/CodeGen.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package util 17 | 18 | import api.LiveEnvironment 19 | import dao.Tables 20 | import model.SampleModelObject 21 | 22 | import scala.concurrent.ExecutionContext 23 | 24 | /** 25 | * Used to generate the database code automatically, or if you start there to generate the database schema. 26 | * Note that it generates it into the test directory, so that it doesn't override any changes you might have made. 27 | */ 28 | object CodeGen 29 | extends LiveEnvironment 30 | // with App 31 | { 32 | 33 | println(Tables.schema.createStatements.mkString(";\n")) 34 | println 35 | 36 | slick.codegen.SourceCodeGenerator.main( 37 | Array( 38 | "slick.jdbc.MySQLProfile", //profile 39 | "com.mysql.cj.jdbc.Driver", //jdbc driver 40 | "jdbc:mysql://localhost:3306/fullscalastack?current_schema=fullscalastack&nullNamePatternMatchesAll=true&useJDBCCompliantTimezoneShift=true&useLegacyDatetimeCode=false&serverTimezone=UTC", //url 41 | "/Volumes/Personal/projects/full-scala-stack/server/src/test/scala", //destination dir 42 | "dao", //package 43 | "root", //user 44 | "" 45 | ) //password 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /server/src/main/scala/util/ModelPickler.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package util 17 | 18 | import model._ 19 | import upickle.default.{ macroRW, ReadWriter => RW, _ } 20 | 21 | /** 22 | * Here's where we define all of the model object's picklers and unpicklers. 23 | * You may want to move this to the shared project, though I like to keep them separately in case 24 | * you want to use different methods for marshalling json in the client and in server 25 | */ 26 | trait ModelPickler { 27 | import SortDirection._ 28 | 29 | implicit val SortDirectionRW: RW[SortDirection] = upickle.default 30 | .readwriter[String] 31 | .bimap[SortDirection]( 32 | x => x.toString, 33 | str => SortDirection.withName(str) 34 | ) 35 | 36 | implicit val SampleModelObjectRW: RW[SampleModelObject] = macroRW 37 | implicit val SimpleSearchRW: RW[SimpleSearch] = macroRW 38 | implicit val DefaultSortRW: RW[DefaultSort] = macroRW 39 | implicit val SimpleTextSearchRW: RW[SimpleTextSearch] = macroRW 40 | } 41 | -------------------------------------------------------------------------------- /server/src/main/scala/web/Web.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package web 18 | 19 | import akka.event.{ Logging, LoggingAdapter } 20 | import akka.http.scaladsl.Http 21 | import akka.stream.scaladsl._ 22 | import api.{ Api, Config } 23 | import core.{ Core, CoreActors } 24 | 25 | import scala.concurrent.{ ExecutionContext, Future } 26 | import scala.util.control.NonFatal 27 | 28 | // $COVERAGE-OFF$ This is actual code that we can't test, so we shouldn't report on it 29 | /** 30 | * Provides the web server (spray-can) for the REST api in ``Api``, using the actor system 31 | * defined in ``Core``. 32 | * 33 | * You may sometimes wish to construct separate ``ActorSystem`` for the web server machinery. 34 | * However, for this simple application, we shall use the same ``ActorSystem`` for the 35 | * entire application. 36 | * 37 | * Benefits of separate ``ActorSystem`` include the ability to use completely different 38 | * configuration, especially when it comes to the threading model. 39 | */ 40 | trait Web extends Config { 41 | this: Api with CoreActors with Core => 42 | 43 | val log: LoggingAdapter = Logging.getLogger(actorSystem, this) 44 | 45 | val serverSource: Source[Http.IncomingConnection, Future[Http.ServerBinding]] = { 46 | implicit def executionContext: ExecutionContext = actorSystem.dispatcher 47 | 48 | val host = config.getString("full-scala-stack.host") 49 | val port = config.getInt("full-scala-stack.port") 50 | 51 | Http() 52 | .bind(interface = host, port = port) 53 | .mapMaterializedValue { bind => 54 | bind.foreach { server => 55 | log.info(server.localAddress.toString) 56 | } 57 | bind 58 | } 59 | } 60 | 61 | val bindingFuture: Future[Http.ServerBinding] = 62 | serverSource 63 | .to(Sink.foreach { connection => // foreach materializes the source 64 | log.debug("Accepted new connection from " + connection.remoteAddress) 65 | // ... and then actually handle the connection 66 | try { 67 | connection.flow.joinMat(routes)(Keep.both).run() 68 | () 69 | } catch { 70 | case NonFatal(e) => 71 | log.error(e, "Could not materialize handling flow for {}", connection) 72 | throw e 73 | } 74 | }) 75 | .run() 76 | } 77 | // $COVERAGE-ON$ 78 | -------------------------------------------------------------------------------- /server/src/main/scala/zioslick/DatabaseProvider.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package zioslick 18 | 19 | import slick.basic.BasicBackend 20 | import zio.UIO 21 | 22 | /** 23 | * A trait used to select with databaseProvider will be used (a database provider gives the application the Slick database it'll need) 24 | */ 25 | trait DatabaseProvider { 26 | def databaseProvider: DatabaseProvider.Service 27 | } 28 | 29 | object DatabaseProvider { 30 | trait Service { 31 | def db: UIO[BasicBackend#DatabaseDef] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /server/src/main/scala/zioslick/RepositoryException.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package zioslick 18 | 19 | case class RepositoryException(msg: String = "", cause: Option[Throwable] = None) extends Exception(msg, cause.orNull) 20 | -------------------------------------------------------------------------------- /server/src/main/scala/zioslick/ZioSlickSupport.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package zioslick 18 | 19 | import java.sql.SQLException 20 | 21 | import slick.SlickException 22 | import slick.dbio.DBIO 23 | import zio.ZIO 24 | 25 | trait ZioSlickSupport { 26 | def fromDBIO[R](dbio: DBIO[R]): SlickZIO[R] = 27 | for { 28 | db <- ZIO.accessM[DatabaseProvider](_.databaseProvider.db) 29 | r <- ZIO 30 | .fromFuture(_ => db.run(dbio)) 31 | .mapError { 32 | case e: SlickException => RepositoryException("Slick Repository Error", Some(e)) 33 | case e: SQLException => RepositoryException("SQL Repository Error", Some(e)) 34 | } 35 | } yield r 36 | 37 | } 38 | -------------------------------------------------------------------------------- /server/src/main/scala/zioslick/package.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import zio.ZIO 18 | 19 | package object zioslick { 20 | 21 | /** Defines a type of ZIO that has a database provider runtime **/ 22 | type SlickZIO[+A] = ZIO[DatabaseProvider, RepositoryException, A] 23 | 24 | } 25 | -------------------------------------------------------------------------------- /server/src/main/sql/20191210.sql: -------------------------------------------------------------------------------- 1 | create database fullscalastack; 2 | 3 | use fullscalastack; 4 | 5 | CREATE TABLE `SampleModelObject` ( 6 | `id` int(11) NOT NULL AUTO_INCREMENT, 7 | `name` text NOT NULL, 8 | PRIMARY KEY (`id`) 9 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 10 | 11 | insert into SampleModelObject (name) values ('One'); 12 | insert into SampleModelObject (name) values ('Two'); 13 | insert into SampleModelObject (name) values ('Three'); 14 | insert into SampleModelObject (name) values ('Four'); 15 | -------------------------------------------------------------------------------- /server/src/test/resources/application-test.conf: -------------------------------------------------------------------------------- 1 | include "application.conf" 2 | 3 | # default config for tests, we just import the regular conf -------------------------------------------------------------------------------- /server/src/test/scala/api/ModelServiceSpec.scala: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import akka.http.scaladsl.marshalling.Marshal 4 | import akka.http.scaladsl.model.{ContentType, ContentTypes, HttpEntity, HttpResponse, MessageEntity, RequestEntity} 5 | import akka.http.scaladsl.testkit.ScalatestRouteTest 6 | import dao.{CRUDOperations, MockRepository, Repository} 7 | import de.heikoseeberger.akkahttpupickle.UpickleSupport 8 | import mail.{CourierPostman, Postman} 9 | import model.{SampleModelObject, SimpleSearch} 10 | import org.scalatest.matchers.should.Matchers 11 | import org.scalatest.wordspec.AnyWordSpec 12 | import routes.{ModelRoutes, SampleModelObjectRoute} 13 | import upickle.default._ 14 | import util.ModelPickler 15 | import zio.{IO, ZIO} 16 | import zioslick.RepositoryException 17 | 18 | import scala.concurrent.ExecutionContext 19 | 20 | /** 21 | * A set of tests of the ModelService. 22 | * Note that we have a mock database behind it, so it's only testing business logic in the service 23 | * without testing the database, if you want to test both, you should have an integration test 24 | */ 25 | class ModelServiceSpec 26 | extends AnyWordSpec 27 | with Matchers 28 | with ScalatestRouteTest 29 | with ZIODirectives 30 | with UpickleSupport 31 | with ModelPickler 32 | { 33 | 34 | val objects = Seq( 35 | SampleModelObject(0, "Zero"), 36 | SampleModelObject(1, "One"), 37 | SampleModelObject(2, "Two"), 38 | SampleModelObject(3, "Three"), 39 | SampleModelObject(4, "Four"), 40 | ) 41 | 42 | val service = new SampleModelObjectRoute with MockRepository with CourierPostman with Config { 43 | override def repository: Repository.Service = new Repository.Service { 44 | override val sampleModelObjectOps: CRUDOperations[SampleModelObject, Int, SimpleSearch, Any] = new MockOps { 45 | override def search(search: Option[SimpleSearch])( 46 | implicit session: Any 47 | ): IO[RepositoryException, Seq[SampleModelObject]] = ZIO.succeed(objects) 48 | override def get(pk: Int)(implicit session: Any): IO[RepositoryException, Option[SampleModelObject]] = 49 | ZIO.succeed(objects.headOption) 50 | 51 | } 52 | } 53 | } 54 | 55 | //TODO test your route here, we would probably not have a test like the one below in reality, since it's super simple. 56 | "The Service" should { 57 | "return one objects on a get" in { 58 | Get("/sampleModelObject/1") ~> service.crudRoute.route("") ~> check { 59 | val res = responseAs[Seq[SampleModelObject]].headOption 60 | 61 | println(res) 62 | res shouldEqual objects.headOption 63 | } 64 | } 65 | "return some objects on a search" in { 66 | Post("/sampleModelObject/search", HttpEntity(ContentTypes.`application/json`, write(SimpleSearch()))) ~> service.crudRoute.route("") ~> check { 67 | val str = responseAs[ujson.Value] 68 | val res = responseAs[Seq[SampleModelObject]] 69 | 70 | println(res) 71 | res shouldEqual objects 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /server/src/test/scala/dao/MockRepository.scala: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | import model.{SampleModelObject, SimpleSearch} 4 | import zio.{IO, ZIO} 5 | import zioslick.RepositoryException 6 | 7 | /** 8 | * A mock repository used for testing. 9 | */ 10 | trait MockRepository extends Repository { 11 | abstract class MockOps extends CRUDOperations[SampleModelObject, Int, SimpleSearch, Any] { 12 | override def upsert(e: SampleModelObject)(implicit session: Any): IO[RepositoryException, SampleModelObject] = 13 | ZIO.succeed(e) 14 | 15 | override def get(pk: Int)(implicit session: Any): IO[RepositoryException, Option[SampleModelObject]] = 16 | ZIO.succeed(None) 17 | 18 | override def delete(pk: Int, softDelete: Boolean)(implicit session: Any): IO[RepositoryException, Boolean] = ZIO.succeed(true) 19 | 20 | override def search(search: Option[SimpleSearch])( 21 | implicit session: Any 22 | ): IO[RepositoryException, Seq[SampleModelObject]] = ZIO.succeed(Seq.empty) 23 | 24 | override def count(search: Option[SimpleSearch])(implicit session: Any): IO[RepositoryException, Long] = 25 | ZIO.succeed(0) 26 | } 27 | 28 | override def repository: Repository.Service = new Repository.Service { 29 | override val sampleModelObjectOps: CRUDOperations[SampleModelObject, Int, SimpleSearch, Any] = new MockOps {} 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /server/src/test/scala/util/ZioTestSupport.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019 Roberto Leibman -- All Rights Reserved 3 | * 4 | * Unauthorized copying of this file, via any medium is strictly prohibited 5 | * Proprietary and confidential 6 | * 7 | */ 8 | 9 | package util 10 | 11 | import org.scalatest.Assertion 12 | import org.scalatest.flatspec.AsyncFlatSpecLike 13 | import zio.{Cause, DefaultRuntime, ZIO} 14 | 15 | import scala.concurrent.Future 16 | 17 | trait ZioTestSupport extends AsyncFlatSpecLike { 18 | 19 | val runtime = new DefaultRuntime {} 20 | 21 | // used to allow pattern matching 22 | case class ZIOTestException[E](c: Cause[E]) extends RuntimeException(c.toString) 23 | 24 | implicit def toTestFuture2[E <: Throwable](zio: ZIO[Any, E, Assertion]): Future[Assertion] = 25 | runtime.unsafeRunToFuture(zio) 26 | 27 | } 28 | -------------------------------------------------------------------------------- /webclient/src/main/scala/app/AppState.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package app 18 | 19 | import japgolly.scalajs.react.React 20 | import japgolly.scalajs.react.React.Context 21 | 22 | /** 23 | * We put all global app state here, things like the current session, user name, theme, configuration, etc. 24 | */ 25 | case class AppState( 26 | //Add global app state here 27 | ) 28 | 29 | object AppState { 30 | val ctx: Context[AppState] = React.createContext(AppState()) 31 | } 32 | -------------------------------------------------------------------------------- /webclient/src/main/scala/app/Content.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package app 18 | 19 | import components.AbstractComponent 20 | import japgolly.scalajs.react._ 21 | import japgolly.scalajs.react.component.Scala.Unmounted 22 | import japgolly.scalajs.react.vdom.html_<^._ 23 | import routes.AppRouter 24 | 25 | /** 26 | * This is a helper class meant to load initial app state, scalajs-react normally 27 | * suggests (and rightfully so) that the router should be the main content of the app, 28 | * but having a middle piece that loads app state makes some sense, that way the router is in charge of routing and 29 | * presenting the app menu. 30 | */ 31 | object Content extends AbstractComponent { 32 | case class State(appState: AppState = AppState()) 33 | 34 | class Backend($ : BackendScope[_, State]) { 35 | def render(s: State): VdomElement = 36 | appContext.provide(s.appState) { 37 | AppRouter.router() 38 | } 39 | 40 | def refresh(s: State): Callback = Callback.empty //TODO: add ajax calls to initialize app state here 41 | } 42 | 43 | private val component = ScalaComponent 44 | .builder[Unit]("content") 45 | .initialState(State()) 46 | .renderBackend[Backend] 47 | .componentDidMount($ => $.backend.refresh($.state)) 48 | .build 49 | 50 | def apply(): Unmounted[Unit, State, Backend] = component() 51 | } 52 | -------------------------------------------------------------------------------- /webclient/src/main/scala/app/MainApp.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package app 18 | 19 | import org.scalajs.dom 20 | 21 | import scala.scalajs.js.annotation.JSExport 22 | 23 | /** 24 | * This is the main scalajs app, it loads the content into a dom item called content 25 | */ 26 | object MainApp { 27 | @JSExport 28 | def main(args: Array[String]): Unit = { 29 | 30 | Content().renderIntoDOM(dom.document.getElementById("content")) 31 | 32 | () 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /webclient/src/main/scala/components/AbstractComponent.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package components 18 | 19 | import app.AppState 20 | import japgolly.scalajs.react.React.Context 21 | 22 | /** 23 | * An abstract component trait from which all components in the app should derive. A good 24 | * place to put in global implicits, common code that should be in all pages, etc. 25 | */ 26 | trait AbstractComponent { 27 | val appContext: Context[AppState] = AppState.ctx 28 | } 29 | -------------------------------------------------------------------------------- /webclient/src/main/scala/pages/MainPage.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package pages 18 | 19 | import components.AbstractComponent 20 | import japgolly.scalajs.react._ 21 | import japgolly.scalajs.react.component.Scala.Unmounted 22 | import japgolly.scalajs.react.vdom.html_<^._ 23 | import model._ 24 | import org.scalajs.dom.raw.HTMLButtonElement 25 | import service.SampleModelObjectRESTClient 26 | import typingsJapgolly.semanticDashUiDashReact.components._ 27 | import typingsJapgolly.semanticDashUiDashReact.distCommonjsElementsButtonButtonMod.ButtonProps 28 | 29 | import scala.util.{ Failure, Success } 30 | 31 | /** 32 | * A "page" in the application, in this same directory you'd put all of the other application "pages". 33 | * These are not html pages per se, since we're dealing with a single page app. But it's useful to treat 34 | * each of these as pages internally. 35 | */ 36 | object MainPage extends AbstractComponent { 37 | case class State(objects: Seq[SampleModelObject] = Seq.empty) 38 | 39 | import util.ModelPickler._ 40 | 41 | class Backend($ : BackendScope[_, State]) { 42 | def init(state: State): Callback = Callback.empty 43 | def refresh(state: State): Callback = 44 | SampleModelObjectRESTClient.remoteSystem.search(None).completeWith { 45 | case Success(objects) => $.modState(_.copy(objects = objects)) 46 | case Failure(t) => 47 | t.printStackTrace() 48 | Callback.empty //TODO do something else with the failure here 49 | } 50 | 51 | def onAddNewObject(event: ReactMouseEventFrom[HTMLButtonElement], data: ButtonProps): Callback = 52 | Callback.alert( 53 | "Clicked on 'Add New object'... did you expect something else? hey, I can't write everything for you!" 54 | ) 55 | 56 | def render(state: State): VdomElement = 57 | appContext.consume { appState => 58 | <.div( 59 | Table()( 60 | TableHeader()( 61 | TableRow()( 62 | TableHeaderCell()("Id"), 63 | TableHeaderCell()("Name") 64 | ) 65 | ), 66 | TableBody()( 67 | state.objects.toVdomArray { obj => 68 | TableRow()( 69 | TableCell()(obj.id), 70 | TableCell()(obj.name) 71 | ) 72 | } 73 | ) 74 | ), 75 | Button(onClick = onAddNewObject _)("Add new object") 76 | ) 77 | } 78 | } 79 | private val component = ScalaComponent 80 | .builder[Unit]("MainPage") 81 | .initialState(State()) 82 | .renderBackend[Backend] 83 | .componentDidMount($ => $.backend.init($.state) >> $.backend.refresh($.state)) 84 | .build 85 | 86 | def apply(): Unmounted[Unit, State, Backend] = component() 87 | } 88 | -------------------------------------------------------------------------------- /webclient/src/main/scala/routes/AppRouter.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package routes 18 | 19 | import java.time.LocalDate 20 | 21 | import components.AbstractComponent 22 | import japgolly.scalajs.react.ReactMouseEventFrom 23 | import japgolly.scalajs.react.extra.router.StaticDsl.RouteB 24 | import japgolly.scalajs.react.extra.router._ 25 | import japgolly.scalajs.react.vdom.html_<^._ 26 | import org.scalajs.dom.raw.HTMLAnchorElement 27 | import pages.MainPage 28 | import typingsJapgolly.semanticDashUiDashReact.components._ 29 | import typingsJapgolly.semanticDashUiDashReact.distCommonjsCollectionsMenuMenuItemMod._ 30 | 31 | /** 32 | * The app's router. It has two main responsibilities: 33 | * - Present an app menu 34 | * - Choose which "page" to present depending on the route (i.e. the url) 35 | */ 36 | object AppRouter extends AbstractComponent { 37 | 38 | case class State() 39 | 40 | sealed trait AppPageData 41 | 42 | case object MainPageData extends AppPageData 43 | 44 | private def setEH(c: RouterCtl[AppPageData], target: AppPageData) = { 45 | (event: ReactMouseEventFrom[HTMLAnchorElement], data: MenuItemProps) => 46 | c.setEH(target)(event) 47 | } 48 | 49 | private def layout(page: RouterCtl[AppPageData], resolution: Resolution[AppPageData]) = { 50 | assert(page != null) 51 | <.div( 52 | ^.height := 100.pct, 53 | <.div( 54 | ^.height := 100.pct, 55 | ^.className := "full height", 56 | ^.display := "flex", 57 | ^.flexDirection := "row", 58 | <.div( 59 | ^.height := 100.pct, 60 | ^.className := "no-print", 61 | ^.flex := "0 0 auto", 62 | ^.position := "relative", 63 | Menu(vertical = true)( 64 | MenuItem( 65 | active = resolution.page == MainPageData, 66 | onClick = { (event: ReactMouseEventFrom[HTMLAnchorElement], data: MenuItemProps) => 67 | page.setEH(MainPageData)(event) 68 | } 69 | )("Main Page") 70 | ) 71 | ), 72 | <.div(^.flex := "1 1 auto", resolution.render()) 73 | ) 74 | ) 75 | } 76 | 77 | private val config: RouterConfig[AppPageData] = RouterConfigDsl[AppPageData].buildConfig { dsl => 78 | import dsl._ 79 | 80 | val seqInt = new RouteB[Seq[Int]]( 81 | regex = "(-?[\\d,]+)", 82 | matchGroups = 1, 83 | parse = { groups => 84 | Some(groups(0).split(",").map(_.toInt)) 85 | }, 86 | build = _.mkString(",") 87 | ) 88 | 89 | val dateRange = new RouteB[(LocalDate, LocalDate)]( 90 | regex = "\\((.*),(.*)\\)", 91 | matchGroups = 2, 92 | parse = { groups => 93 | Some((LocalDate.parse(groups(0)), LocalDate.parse(groups(1)))) 94 | }, 95 | build = { tuple => 96 | s"(${tuple._1.toString},${tuple._2.toString})" 97 | } 98 | ) 99 | 100 | (trimSlashes 101 | | staticRoute("#mainPage", MainPageData) ~> renderR(ctrl => MainPage())) 102 | .notFound(redirectToPage(MainPageData)(Redirect.Replace)) 103 | .renderWith(layout) 104 | } 105 | private val baseUrl: BaseUrl = BaseUrl.fromWindowOrigin_/ 106 | 107 | val router: Router[AppPageData] = Router.apply(baseUrl, config) 108 | } 109 | -------------------------------------------------------------------------------- /webclient/src/main/scala/service/RESTClient.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package service 18 | 19 | import japgolly.scalajs.react.{ AsyncCallback, Callback } 20 | import japgolly.scalajs.react.extra.Ajax 21 | import model.Search 22 | import org.scalajs.dom.XMLHttpRequest 23 | import org.scalajs.dom.ext.AjaxException 24 | import ujson.Value.InvalidData 25 | import upickle.default.{ read, _ } 26 | 27 | trait RESTOperations { 28 | def processErrors[A](fn: XMLHttpRequest => A)(xhr: XMLHttpRequest): A = 29 | try { 30 | if (xhr.status >= 400) { 31 | throw AjaxException(xhr) 32 | } else { 33 | fn(xhr) 34 | } 35 | } catch { 36 | case e: InvalidData => 37 | e.printStackTrace() 38 | throw e 39 | case e: AjaxException => 40 | e.printStackTrace() 41 | throw e 42 | } 43 | 44 | def RESTOperation[Request, Response]( 45 | method: String, 46 | fullUrl: String, 47 | request: Option[Request] = None, 48 | withTimeout: Option[(Double, XMLHttpRequest => Callback)] = None 49 | )( 50 | implicit requestW: Writer[Request], 51 | responseR: Reader[Response] 52 | ): AsyncCallback[Response] = { 53 | val step1 = Ajax(method, fullUrl) 54 | .and(_.withCredentials = true) 55 | 56 | val step2 = request 57 | .fold(step1.send)(s => step1.setRequestContentTypeJson.send(write(s))) 58 | 59 | withTimeout 60 | .fold(step2)(a => step2.withTimeout(a._1, a._2)) 61 | .asAsyncCallback 62 | .map { 63 | processErrors(xhr => read[Response](xhr.responseText)) 64 | } 65 | } 66 | } 67 | 68 | trait RESTClient[E, PK, SEARCH <: Search[_]] { 69 | def remoteSystem: RESTClient.Service[E, PK, SEARCH] 70 | } 71 | 72 | object RESTClient { 73 | 74 | trait Service[E, PK, SEARCH <: Search[_]] extends RESTOperations { 75 | def get(id: PK)(implicit typeRW: Reader[E]): AsyncCallback[Option[E]] 76 | 77 | def delete(id: PK): AsyncCallback[Boolean] 78 | 79 | def upsert(obj: E)(implicit typeRW: ReadWriter[E]): AsyncCallback[E] 80 | 81 | def search( 82 | search: Option[SEARCH] = None 83 | )(implicit typeRW: Reader[E], searchRW: Writer[SEARCH]): AsyncCallback[Seq[E]] 84 | 85 | def count( 86 | searchObj: Option[SEARCH] 87 | )(implicit typeRW: Reader[E], searchRW: Writer[SEARCH]): AsyncCallback[Int] 88 | 89 | } 90 | } 91 | 92 | trait LiveRESTClient[E, PK, SEARCH <: Search[_]] extends RESTClient[E, PK, SEARCH] { 93 | val baseUrl: String 94 | 95 | override def remoteSystem: RESTClient.Service[E, PK, SEARCH] = new LiveClientService 96 | 97 | class LiveClientService extends RESTClient.Service[E, PK, SEARCH] { 98 | 99 | override def get(id: PK)(implicit typeRW: Reader[E]): AsyncCallback[Option[E]] = 100 | RESTOperation[String, Option[E]]("GET", s"$baseUrl/${id.toString}", None) 101 | 102 | override def delete(id: PK): AsyncCallback[Boolean] = 103 | RESTOperation[String, Boolean]("DELETE", s"$baseUrl/${id.toString}", None) 104 | 105 | override def upsert(obj: E)(implicit typeRW: ReadWriter[E]): AsyncCallback[E] = 106 | RESTOperation[E, E]("POST", s"$baseUrl", Option(obj)) 107 | 108 | override def search( 109 | searchObj: Option[SEARCH] 110 | )(implicit typeRW: Reader[E], searchRW: Writer[SEARCH]): AsyncCallback[Seq[E]] = 111 | RESTOperation[SEARCH, Seq[E]]("POST", s"$baseUrl/search", searchObj) 112 | 113 | override def count( 114 | searchObj: Option[SEARCH] 115 | )(implicit typeRW: Reader[E], searchRW: Writer[SEARCH]): AsyncCallback[Int] = 116 | RESTOperation[SEARCH, Int]("POST", s"$baseUrl/count", searchObj) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /webclient/src/main/scala/service/package.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import model.{ SampleModelObject, SimpleSearch } 18 | import util.Config 19 | 20 | package object service extends Config { 21 | 22 | object SampleModelObjectRESTClient extends LiveRESTClient[SampleModelObject, Int, SimpleSearch] { 23 | override val baseUrl: String = s"$mealoramaHost/api/sampleModelObjects" 24 | //If you want anything more than CRUD out of this client, you override remoteSystem and add other methods you may want 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /webclient/src/main/scala/util/Config.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package util 18 | import org.scalajs.dom.window 19 | 20 | trait Config { 21 | val mealoramaHost = 22 | s"${window.location.hostname}${if (window.location.port != "" && window.location.port != "80") s":${window.location.port}" 23 | else ""}" 24 | } 25 | -------------------------------------------------------------------------------- /webclient/src/main/scala/util/ModelPickler.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Roberto Leibman 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package util 18 | 19 | import model.{ DefaultSort, SampleModelObject, SimpleSearch, SimpleTextSearch, SortDirection } 20 | import upickle.default.{ macroRW, ReadWriter => RW, _ } 21 | 22 | /** 23 | * Here's where we define all of the model object's picklers and unpicklers. 24 | * You may want to move this to the shared project, though I like to keep them separately in case 25 | * you want to use a different method for marshalling json between the client and server 26 | */ 27 | object ModelPickler { 28 | import SortDirection._ 29 | 30 | implicit val SortDirectionRW: RW[SortDirection] = upickle.default 31 | .readwriter[String] 32 | .bimap[SortDirection]( 33 | x => x.toString, 34 | str => SortDirection.withName(str) 35 | ) 36 | 37 | implicit val SampleModelObjectRW: RW[SampleModelObject] = macroRW 38 | implicit val SimpleSearchRW: RW[SimpleSearch] = macroRW 39 | implicit val DefaultSortRW: RW[DefaultSort] = macroRW 40 | implicit val SimpleTextSearchRW: RW[SimpleTextSearch] = macroRW 41 | } 42 | -------------------------------------------------------------------------------- /webclient/src/main/web/css/app.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 0; 3 | margin: 0; 4 | font-size: 10pt; 5 | } 6 | h1 { 7 | padding: 0; 8 | margin: 0; 9 | } 10 | h3 { 11 | font-size: 12pt; 12 | } 13 | 14 | table { 15 | border: 1px solid black; 16 | border-collapse: collapse; 17 | width: 100%; 18 | } 19 | 20 | tr { 21 | border: 1px solid black; 22 | } 23 | 24 | th { 25 | border: 1px solid black; 26 | padding: 3px; 27 | font-size: 11pt; 28 | } 29 | 30 | td { 31 | border: 1px solid black; 32 | padding: 3px; 33 | margin: 0; 34 | vertical-align: top; 35 | font-size: 10pt; 36 | } 37 | 38 | p { 39 | padding: 0; 40 | margin: 0; 41 | } 42 | blockquote { 43 | padding: 0; 44 | margin: 0 0 0 2em; 45 | } 46 | 47 | .ui.table td{ 48 | padding: 4px 4px 1px; 49 | } 50 | 51 | .ui.compact.table td{ 52 | padding: 3px 3px 1px; 53 | } 54 | 55 | .ui.input { 56 | display: flex; 57 | } 58 | 59 | .ui.form { 60 | padding: 10px; 61 | } 62 | 63 | .ui.page.modals.transition.visible { 64 | display: flex !important; 65 | } 66 | 67 | .ui.table.ingredientTable { 68 | background-color: #fff; 69 | } 70 | .ui.table.ingredientTable tbody{ 71 | background-color: #fff; 72 | } 73 | .ui.table.ingredientTable thead{ 74 | background-color: #fff; 75 | } 76 | .ui.table.ingredientTable tfoot{ 77 | background-color: #fff; 78 | } 79 | .ui.table.ingredientTable th{ 80 | background-color: #fff; 81 | } 82 | .ui.table.ingredientTable td{ 83 | background-color: #fff; 84 | } 85 | 86 | #login { 87 | background-color: white; 88 | color: black; 89 | width: 810px; 90 | margin: auto; 91 | height: 400px; 92 | } 93 | 94 | .ui.rawRecipe { 95 | height: 400px; 96 | overflow: scroll; 97 | } 98 | 99 | .ui.small.images { 100 | overflow: auto; 101 | white-space: nowrap; 102 | padding-top: 2px; 103 | border: 1px solid black; 104 | min-height: 100px; 105 | } 106 | 107 | .imageDropzone { 108 | display: inline; 109 | } 110 | 111 | .ui.dropdown.cuisineDropdown { 112 | min-width: 150px ; 113 | } 114 | 115 | .only-print, .only-print * 116 | { 117 | display: none !important; 118 | } 119 | 120 | @media print 121 | { 122 | .no-print, .no-print * 123 | { 124 | display: none !important; 125 | } 126 | 127 | .only-print, .only-print * 128 | { 129 | display: unset !important; 130 | } 131 | } 132 | 133 | /* Remove clear buttons from date and time input fields, because they're really stupid*/ 134 | input[type="time"]::-webkit-clear-button { 135 | display: none; 136 | } 137 | 138 | input[type="date"]::-webkit-clear-button { 139 | display: none; 140 | } 141 | 142 | .ui.dropdown.missing { 143 | background-color: lightyellow; 144 | } 145 | 146 | 147 | .mealPlannerSearchCell { 148 | display: flex; 149 | } 150 | 151 | .mealPlannerSearch { 152 | flex-basis: 100%; 153 | } 154 | 155 | /* Calendar stuff */ 156 | 157 | .cal-btn { 158 | color: inherit; 159 | font: inherit; 160 | margin: 0; 161 | } 162 | button.cal-btn { 163 | overflow: visible; 164 | text-transform: none; 165 | -webkit-appearance: button; 166 | cursor: pointer; 167 | } 168 | button[disabled].cal-btn { 169 | cursor: not-allowed; 170 | } 171 | button.cal-input::-moz-focus-inner { 172 | border: 0; 173 | padding: 0; 174 | } 175 | .cal-calendar { 176 | box-sizing: border-box; 177 | height: 100%; 178 | display: -webkit-flex; 179 | display: -ms-flexbox; 180 | display: flex; 181 | -webkit-flex-direction: column; 182 | -ms-flex-direction: column; 183 | flex-direction: column; 184 | -webkit-align-items: stretch; 185 | -ms-flex-align: stretch; 186 | align-items: stretch; 187 | } 188 | .cal-calendar *, 189 | .cal-calendar *:before, 190 | .cal-calendar *:after { 191 | box-sizing: inherit; 192 | } 193 | .cal-abs-full, 194 | .cal-row-bg { 195 | overflow: hidden; 196 | position: absolute; 197 | top: 0; 198 | left: 0; 199 | right: 0; 200 | bottom: 0; 201 | } 202 | .cal-ellipsis, 203 | .cal-event-label, 204 | .cal-row-segment .cal-event-content, 205 | .cal-show-more { 206 | display: block; 207 | overflow: hidden; 208 | text-overflow: ellipsis; 209 | white-space: nowrap; 210 | } 211 | .cal-rtl { 212 | direction: rtl; 213 | } 214 | .cal-off-range { 215 | color: #999999; 216 | } 217 | .cal-off-range-bg { 218 | background: #e5e5e5; 219 | } 220 | .cal-header { 221 | overflow: hidden; 222 | -webkit-flex: 1 0 0; 223 | -ms-flex: 1 0 0; 224 | flex: 1 0 0; 225 | text-overflow: ellipsis; 226 | white-space: nowrap; 227 | padding: 0 3px; 228 | text-align: center; 229 | vertical-align: middle; 230 | font-weight: bold; 231 | font-size: 90%; 232 | min-height: 0; 233 | border-bottom: 1px solid #DDD; 234 | } 235 | .cal-header + .cal-header { 236 | border-left: 1px solid #DDD; 237 | } 238 | .cal-rtl .cal-header + .cal-header { 239 | border-left-width: 0; 240 | border-right: 1px solid #DDD; 241 | } 242 | .cal-header > a, 243 | .cal-header > a:active, 244 | .cal-header > a:visited { 245 | color: inherit; 246 | text-decoration: none; 247 | } 248 | .cal-row-content { 249 | position: relative; 250 | -moz-user-select: none; 251 | -ms-user-select: none; 252 | user-select: none; 253 | -webkit-user-select: none; 254 | z-index: 4; 255 | } 256 | .cal-today { 257 | background-color: #eaf6ff; 258 | } 259 | .cal-toolbar { 260 | display: -webkit-flex; 261 | display: -ms-flexbox; 262 | display: flex; 263 | -webkit-flex-wrap: wrap; 264 | -ms-flex-wrap: wrap; 265 | flex-wrap: wrap; 266 | -webkit-justify-content: center; 267 | -ms-flex-pack: center; 268 | justify-content: center; 269 | -webkit-align-items: center; 270 | -ms-flex-align: center; 271 | align-items: center; 272 | margin-bottom: 10px; 273 | font-size: 16px; 274 | } 275 | .cal-toolbar .cal-toolbar-label { 276 | -webkit-flex-grow: 1; 277 | -ms-flex-positive: 1; 278 | flex-grow: 1; 279 | padding: 0 10px; 280 | text-align: center; 281 | } 282 | .cal-toolbar button { 283 | color: #373a3c; 284 | display: inline-block; 285 | margin: 0; 286 | text-align: center; 287 | vertical-align: middle; 288 | background: none; 289 | border: 1px solid #ccc; 290 | padding: .375rem 1rem; 291 | border-radius: 4px; 292 | line-height: normal; 293 | white-space: nowrap; 294 | } 295 | .cal-toolbar button:active, 296 | .cal-toolbar button.cal-active { 297 | background-image: none; 298 | box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); 299 | background-color: #e6e6e6; 300 | border-color: #adadad; 301 | } 302 | .cal-toolbar button:active:hover, 303 | .cal-toolbar button.cal-active:hover, 304 | .cal-toolbar button:active:focus, 305 | .cal-toolbar button.cal-active:focus { 306 | color: #373a3c; 307 | background-color: #d4d4d4; 308 | border-color: #8c8c8c; 309 | } 310 | .cal-toolbar button:focus { 311 | color: #373a3c; 312 | background-color: #e6e6e6; 313 | border-color: #adadad; 314 | } 315 | .cal-toolbar button:hover { 316 | color: #373a3c; 317 | background-color: #e6e6e6; 318 | border-color: #adadad; 319 | } 320 | .cal-btn-group { 321 | display: inline-block; 322 | white-space: nowrap; 323 | } 324 | .cal-btn-group > button:first-child:not(:last-child) { 325 | border-top-right-radius: 0; 326 | border-bottom-right-radius: 0; 327 | } 328 | .cal-btn-group > button:last-child:not(:first-child) { 329 | border-top-left-radius: 0; 330 | border-bottom-left-radius: 0; 331 | } 332 | .cal-rtl .cal-btn-group > button:first-child:not(:last-child) { 333 | border-radius: 0 4px 4px 0; 334 | } 335 | .cal-rtl .cal-btn-group > button:last-child:not(:first-child) { 336 | border-radius: 4px 0 0 4px; 337 | } 338 | .cal-btn-group > button:not(:first-child):not(:last-child) { 339 | border-radius: 0; 340 | } 341 | .cal-btn-group button + button { 342 | margin-left: -1px; 343 | } 344 | .cal-rtl .cal-btn-group button + button { 345 | margin-left: 0; 346 | margin-right: -1px; 347 | } 348 | .cal-btn-group + .cal-btn-group, 349 | .cal-btn-group + button { 350 | margin-left: 10px; 351 | } 352 | .cal-event { 353 | border: none; 354 | box-shadow: none; 355 | margin: 0; 356 | padding: 2px 5px; 357 | background-color: #3174ad; 358 | border-radius: 5px; 359 | color: #fff; 360 | cursor: pointer; 361 | width: 100%; 362 | text-align: left; 363 | } 364 | .cal-slot-selecting .cal-event { 365 | cursor: inherit; 366 | pointer-events: none; 367 | } 368 | .cal-event.cal-selected { 369 | background-color: #265985; 370 | } 371 | .cal-event:focus { 372 | outline: 5px auto #3b99fc; 373 | } 374 | .cal-event-label { 375 | font-size: 80%; 376 | } 377 | .cal-event-overlaps { 378 | box-shadow: -1px 1px 5px 0 rgba(51, 51, 51, 0.5); 379 | } 380 | .cal-event-continues-prior { 381 | border-top-left-radius: 0; 382 | border-bottom-left-radius: 0; 383 | } 384 | .cal-event-continues-after { 385 | border-top-right-radius: 0; 386 | border-bottom-right-radius: 0; 387 | } 388 | .cal-event-continues-earlier { 389 | border-top-left-radius: 0; 390 | border-top-right-radius: 0; 391 | } 392 | .cal-event-continues-later { 393 | border-bottom-left-radius: 0; 394 | border-bottom-right-radius: 0; 395 | } 396 | .cal-row { 397 | display: -webkit-flex; 398 | display: -ms-flexbox; 399 | display: flex; 400 | -webkit-flex-direction: row; 401 | -ms-flex-direction: row; 402 | flex-direction: row; 403 | } 404 | .cal-row-segment { 405 | padding: 0 1px 1px 1px; 406 | } 407 | .cal-selected-cell { 408 | background-color: rgba(0, 0, 0, 0.1); 409 | } 410 | .cal-show-more { 411 | background-color: rgba(255, 255, 255, 0.3); 412 | z-index: 4; 413 | font-weight: bold; 414 | font-size: 85%; 415 | height: auto; 416 | line-height: normal; 417 | white-space: nowrap; 418 | } 419 | .cal-month-view { 420 | position: relative; 421 | border: 1px solid #DDD; 422 | display: -webkit-flex; 423 | display: -ms-flexbox; 424 | display: flex; 425 | -webkit-flex-direction: column; 426 | -ms-flex-direction: column; 427 | flex-direction: column; 428 | -webkit-flex: 1 0 0; 429 | -ms-flex: 1 0 0; 430 | flex: 1 0 0; 431 | width: 100%; 432 | -moz-user-select: none; 433 | -ms-user-select: none; 434 | user-select: none; 435 | -webkit-user-select: none; 436 | height: 100%; 437 | } 438 | .cal-month-header { 439 | display: -webkit-flex; 440 | display: -ms-flexbox; 441 | display: flex; 442 | -webkit-flex-direction: row; 443 | -ms-flex-direction: row; 444 | flex-direction: row; 445 | } 446 | .cal-month-row { 447 | display: -webkit-flex; 448 | display: -ms-flexbox; 449 | display: flex; 450 | position: relative; 451 | -webkit-flex-direction: column; 452 | -ms-flex-direction: column; 453 | flex-direction: column; 454 | -webkit-flex: 1 0 0; 455 | -ms-flex: 1 0 0; 456 | flex: 1 0 0; 457 | -webkit-flex-basis: 0; 458 | -ms-flex-preferred-size: 0; 459 | flex-basis: 0; 460 | overflow: hidden; 461 | height: 100%; 462 | } 463 | .cal-month-row + .cal-month-row { 464 | border-top: 1px solid #DDD; 465 | } 466 | .cal-date-cell { 467 | -webkit-flex: 1 1 0; 468 | -ms-flex: 1 1 0; 469 | flex: 1 1 0; 470 | min-width: 0; 471 | padding-right: 5px; 472 | text-align: right; 473 | } 474 | .cal-date-cell.cal-now { 475 | font-weight: bold; 476 | } 477 | .cal-date-cell > a, 478 | .cal-date-cell > a:active, 479 | .cal-date-cell > a:visited { 480 | color: inherit; 481 | text-decoration: none; 482 | } 483 | .cal-row-bg { 484 | display: -webkit-flex; 485 | display: -ms-flexbox; 486 | display: flex; 487 | -webkit-flex-direction: row; 488 | -ms-flex-direction: row; 489 | flex-direction: row; 490 | -webkit-flex: 1 0 0; 491 | -ms-flex: 1 0 0; 492 | flex: 1 0 0; 493 | overflow: hidden; 494 | } 495 | .cal-day-bg { 496 | -webkit-flex: 1 0 0; 497 | -ms-flex: 1 0 0; 498 | flex: 1 0 0; 499 | } 500 | .cal-day-bg + .cal-day-bg { 501 | border-left: 1px solid #DDD; 502 | } 503 | .cal-rtl .cal-day-bg + .cal-day-bg { 504 | border-left-width: 0; 505 | border-right: 1px solid #DDD; 506 | } 507 | .cal-overlay { 508 | position: absolute; 509 | z-index: 5; 510 | border: 1px solid #e5e5e5; 511 | background-color: #fff; 512 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.25); 513 | padding: 10px; 514 | } 515 | .cal-overlay > * + * { 516 | margin-top: 1px; 517 | } 518 | .cal-overlay-header { 519 | border-bottom: 1px solid #e5e5e5; 520 | margin: -10px -10px 5px -10px; 521 | padding: 2px 10px; 522 | } 523 | .cal-agenda-view { 524 | display: -webkit-flex; 525 | display: -ms-flexbox; 526 | display: flex; 527 | -webkit-flex-direction: column; 528 | -ms-flex-direction: column; 529 | flex-direction: column; 530 | -webkit-flex: 1 0 0; 531 | -ms-flex: 1 0 0; 532 | flex: 1 0 0; 533 | overflow: auto; 534 | } 535 | .cal-agenda-view table.cal-agenda-table { 536 | width: 100%; 537 | border: 1px solid #DDD; 538 | border-spacing: 0; 539 | border-collapse: collapse; 540 | } 541 | .cal-agenda-view table.cal-agenda-table tbody > tr > td { 542 | padding: 5px 10px; 543 | vertical-align: top; 544 | } 545 | .cal-agenda-view table.cal-agenda-table .cal-agenda-time-cell { 546 | padding-left: 15px; 547 | padding-right: 15px; 548 | text-transform: lowercase; 549 | } 550 | .cal-agenda-view table.cal-agenda-table tbody > tr > td + td { 551 | border-left: 1px solid #DDD; 552 | } 553 | .cal-rtl .cal-agenda-view table.cal-agenda-table tbody > tr > td + td { 554 | border-left-width: 0; 555 | border-right: 1px solid #DDD; 556 | } 557 | .cal-agenda-view table.cal-agenda-table tbody > tr + tr { 558 | border-top: 1px solid #DDD; 559 | } 560 | .cal-agenda-view table.cal-agenda-table thead > tr > th { 561 | padding: 3px 5px; 562 | text-align: left; 563 | border-bottom: 1px solid #DDD; 564 | } 565 | .cal-rtl .cal-agenda-view table.cal-agenda-table thead > tr > th { 566 | text-align: right; 567 | } 568 | .cal-agenda-time-cell { 569 | text-transform: lowercase; 570 | } 571 | .cal-agenda-time-cell .cal-continues-after:after { 572 | content: ' »'; 573 | } 574 | .cal-agenda-time-cell .cal-continues-prior:before { 575 | content: '« '; 576 | } 577 | .cal-agenda-date-cell, 578 | .cal-agenda-time-cell { 579 | white-space: nowrap; 580 | } 581 | .cal-agenda-event-cell { 582 | width: 100%; 583 | } 584 | .cal-time-column { 585 | display: -webkit-flex; 586 | display: -ms-flexbox; 587 | display: flex; 588 | -webkit-flex-direction: column; 589 | -ms-flex-direction: column; 590 | flex-direction: column; 591 | min-height: 100%; 592 | } 593 | .cal-time-column .cal-timeslot-group { 594 | -webkit-flex: 1; 595 | -ms-flex: 1; 596 | flex: 1; 597 | } 598 | .cal-timeslot-group { 599 | border-bottom: 1px solid #DDD; 600 | min-height: 40px; 601 | display: -webkit-flex; 602 | display: -ms-flexbox; 603 | display: flex; 604 | -webkit-flex-flow: column nowrap; 605 | -ms-flex-flow: column nowrap; 606 | flex-flow: column nowrap; 607 | } 608 | .cal-time-gutter, 609 | .cal-header-gutter { 610 | -webkit-flex: none; 611 | -ms-flex: none; 612 | flex: none; 613 | } 614 | .cal-label { 615 | padding: 0 5px; 616 | } 617 | .cal-day-slot { 618 | position: relative; 619 | } 620 | .cal-day-slot .cal-events-container { 621 | bottom: 0; 622 | left: 0; 623 | position: absolute; 624 | right: 0; 625 | margin-right: 10px; 626 | top: 0; 627 | } 628 | .cal-day-slot .cal-events-container.cal-is-rtl { 629 | left: 10px; 630 | right: 0; 631 | } 632 | .cal-day-slot .cal-event { 633 | border: 1px solid #265985; 634 | display: -webkit-flex; 635 | display: -ms-flexbox; 636 | display: flex; 637 | max-height: 100%; 638 | min-height: 20px; 639 | -webkit-flex-flow: column wrap; 640 | -ms-flex-flow: column wrap; 641 | flex-flow: column wrap; 642 | -webkit-align-items: flex-start; 643 | -ms-flex-align: start; 644 | align-items: flex-start; 645 | overflow: hidden; 646 | position: absolute; 647 | } 648 | .cal-day-slot .cal-event-label { 649 | -webkit-flex: none; 650 | -ms-flex: none; 651 | flex: none; 652 | padding-right: 5px; 653 | width: auto; 654 | } 655 | .cal-day-slot .cal-event-content { 656 | width: 100%; 657 | -webkit-flex: 1 1 0; 658 | -ms-flex: 1 1 0; 659 | flex: 1 1 0; 660 | word-wrap: break-word; 661 | line-height: 1; 662 | height: 100%; 663 | min-height: 1em; 664 | } 665 | .cal-day-slot .cal-time-slot { 666 | border-top: 1px solid #f7f7f7; 667 | } 668 | .cal-time-view-resources .cal-time-gutter, 669 | .cal-time-view-resources .cal-time-header-gutter { 670 | position: -webkit-sticky; 671 | position: sticky; 672 | left: 0; 673 | background-color: white; 674 | border-right: 1px solid #DDD; 675 | z-index: 10; 676 | margin-right: -1px; 677 | } 678 | .cal-time-view-resources .cal-time-header { 679 | overflow: hidden; 680 | } 681 | .cal-time-view-resources .cal-time-header-content { 682 | min-width: auto; 683 | -webkit-flex: 1 0 0; 684 | -ms-flex: 1 0 0; 685 | flex: 1 0 0; 686 | -webkit-flex-basis: 0; 687 | -ms-flex-preferred-size: 0; 688 | flex-basis: 0; 689 | } 690 | .cal-time-view-resources .cal-time-header-cell-single-day { 691 | display: none; 692 | } 693 | .cal-time-view-resources .cal-day-slot { 694 | min-width: 140px; 695 | } 696 | .cal-time-view-resources .cal-header, 697 | .cal-time-view-resources .cal-day-bg { 698 | width: 140px; 699 | -webkit-flex: 1 1 0; 700 | -ms-flex: 1 1 0; 701 | flex: 1 1 0; 702 | -webkit-flex-basis: 0; 703 | -ms-flex-preferred-size: 0 px; 704 | flex-basis: 0; 705 | } 706 | .cal-time-header-content + .cal-time-header-content { 707 | margin-left: -1px; 708 | } 709 | .cal-time-slot { 710 | -webkit-flex: 1 0 0; 711 | -ms-flex: 1 0 0; 712 | flex: 1 0 0; 713 | } 714 | .cal-time-slot.cal-now { 715 | font-weight: bold; 716 | } 717 | .cal-day-header { 718 | text-align: center; 719 | } 720 | .cal-slot-selection { 721 | z-index: 10; 722 | position: absolute; 723 | background-color: rgba(0, 0, 0, 0.5); 724 | color: white; 725 | font-size: 75%; 726 | width: 100%; 727 | padding: 3px; 728 | } 729 | .cal-slot-selecting { 730 | cursor: move; 731 | } 732 | .cal-time-view { 733 | display: -webkit-flex; 734 | display: -ms-flexbox; 735 | display: flex; 736 | -webkit-flex-direction: column; 737 | -ms-flex-direction: column; 738 | flex-direction: column; 739 | -webkit-flex: 1; 740 | -ms-flex: 1; 741 | flex: 1; 742 | width: 100%; 743 | border: 1px solid #DDD; 744 | min-height: 0; 745 | } 746 | .cal-time-view .cal-time-gutter { 747 | white-space: nowrap; 748 | } 749 | .cal-time-view .cal-allday-cell { 750 | box-sizing: content-box; 751 | width: 100%; 752 | height: 100%; 753 | position: relative; 754 | } 755 | .cal-time-view .cal-allday-cell + .cal-allday-cell { 756 | border-left: 1px solid #DDD; 757 | } 758 | .cal-time-view .cal-allday-events { 759 | position: relative; 760 | z-index: 4; 761 | } 762 | .cal-time-view .cal-row { 763 | box-sizing: border-box; 764 | min-height: 20px; 765 | } 766 | .cal-time-header { 767 | display: -webkit-flex; 768 | display: -ms-flexbox; 769 | display: flex; 770 | -webkit-flex: 0 0 auto; 771 | -ms-flex: 0 0 auto; 772 | flex: 0 0 auto; 773 | -webkit-flex-direction: row; 774 | -ms-flex-direction: row; 775 | flex-direction: row; 776 | } 777 | .cal-time-header.cal-overflowing { 778 | border-right: 1px solid #DDD; 779 | } 780 | .cal-rtl .cal-time-header.cal-overflowing { 781 | border-right-width: 0; 782 | border-left: 1px solid #DDD; 783 | } 784 | .cal-time-header > .cal-row:first-child { 785 | border-bottom: 1px solid #DDD; 786 | } 787 | .cal-time-header > .cal-row.cal-row-resource { 788 | border-bottom: 1px solid #DDD; 789 | } 790 | .cal-time-header-cell-single-day { 791 | display: none; 792 | } 793 | .cal-time-header-content { 794 | -webkit-flex: 1; 795 | -ms-flex: 1; 796 | flex: 1; 797 | display: -webkit-flex; 798 | display: -ms-flexbox; 799 | display: flex; 800 | min-width: 0; 801 | -webkit-flex-direction: column; 802 | -ms-flex-direction: column; 803 | flex-direction: column; 804 | border-left: 1px solid #DDD; 805 | } 806 | .cal-rtl .cal-time-header-content { 807 | border-left-width: 0; 808 | border-right: 1px solid #DDD; 809 | } 810 | .cal-time-content { 811 | display: -webkit-flex; 812 | display: -ms-flexbox; 813 | display: flex; 814 | -webkit-flex: 1 0 0; 815 | -ms-flex: 1 0 0; 816 | flex: 1 0 0; 817 | -webkit-align-items: flex-start; 818 | -ms-flex-align: start; 819 | align-items: flex-start; 820 | width: 100%; 821 | border-top: 2px solid #DDD; 822 | overflow-y: auto; 823 | position: relative; 824 | } 825 | .cal-time-content > .cal-time-gutter { 826 | -webkit-flex: none; 827 | -ms-flex: none; 828 | flex: none; 829 | } 830 | .cal-time-content > * + * > * { 831 | border-left: 1px solid #DDD; 832 | } 833 | .cal-rtl .cal-time-content > * + * > * { 834 | border-left-width: 0; 835 | border-right: 1px solid #DDD; 836 | } 837 | .cal-time-content > .cal-day-slot { 838 | width: 100%; 839 | -moz-user-select: none; 840 | -ms-user-select: none; 841 | user-select: none; 842 | -webkit-user-select: none; 843 | } 844 | .cal-current-time-indicator { 845 | position: absolute; 846 | z-index: 3; 847 | left: 0; 848 | right: 0; 849 | height: 1px; 850 | background-color: #74ad31; 851 | pointer-events: none; 852 | } 853 | 854 | 855 | /* 856 | Toast stuff 857 | */ 858 | .toast { 859 | z-index: 100; 860 | } 861 | 862 | .toast .warning { 863 | background-color: rgb(255, 250, 230); 864 | box-shadow: rgba(0, 0, 0, 0.176) 0px 3px 8px; 865 | color: rgb(255, 139, 0); 866 | display: flex; 867 | margin-bottom: 8px; 868 | width: 360px; 869 | transform: translate3d(0px, 0px, 0px); 870 | border-radius: 4px; 871 | transition: transform 220ms cubic-bezier(0.2, 0, 0, 1) 0s; 872 | } 873 | 874 | .toast .warning .iconRegion { 875 | background-color: rgb(255, 171, 0); 876 | border-top-left-radius: 4px; 877 | border-bottom-left-radius: 4px; 878 | color: rgb(255, 250, 230); 879 | flex-shrink: 0; 880 | padding-bottom: 8px; 881 | padding-top: 8px; 882 | position: relative; 883 | text-align: center; 884 | width: 30px; 885 | overflow: hidden; 886 | } 887 | 888 | .toast .warning .countdown { 889 | background-color: rgba(0, 0, 0, 0.1); 890 | bottom: 0px; 891 | height: 0px; 892 | left: 0px; 893 | opacity: 0; 894 | position: absolute; 895 | width: 100%; 896 | animation: animation-hnule4-shrink 5000ms linear 0s 1 normal none running; 897 | } 898 | 899 | .toast .warning .icon { 900 | display: contents; 901 | vertical-align: text-top; 902 | fill: currentcolor; 903 | position: relative; 904 | } 905 | 906 | .toast .warning .textRegion { 907 | -webkit-box-flex: 1; 908 | flex-grow: 1; 909 | font-size: 14px; 910 | line-height: 1.4; 911 | min-height: 40px; 912 | padding: 8px 12px; 913 | } 914 | 915 | .toast .warning .closeButtonRegion { 916 | cursor: pointer; 917 | flex-shrink: 0; 918 | opacity: 0.5; 919 | padding: 8px 12px; 920 | transition: opacity 150ms ease 0s; 921 | } 922 | 923 | .toast .warning .closeSpan { 924 | clip: rect(1px, 1px, 1px, 1px); 925 | height: 1px; 926 | position: absolute; 927 | white-space: nowrap; 928 | width: 1px; 929 | border-width: 0px; 930 | border-style: initial; 931 | border-color: initial; 932 | border-image: initial; 933 | overflow: hidden; 934 | padding: 0px; 935 | } 936 | 937 | .toast .info { 938 | background-color: rgb(230, 230, 255); 939 | box-shadow: rgba(0, 0, 0, 0.176) 0px 3px 8px; 940 | color: rgb(0, 139, 255); 941 | display: flex; 942 | margin-bottom: 8px; 943 | width: 360px; 944 | transform: translate3d(0px, 0px, 0px); 945 | border-radius: 4px; 946 | transition: transform 220ms cubic-bezier(0.2, 0, 0, 1) 0s; 947 | } 948 | 949 | .toast .info .iconRegion { 950 | background-color: rgb(0, 171, 255); 951 | border-top-left-radius: 4px; 952 | border-bottom-left-radius: 4px; 953 | color: rgb(230, 230, 255); 954 | flex-shrink: 0; 955 | padding-bottom: 8px; 956 | padding-top: 8px; 957 | position: relative; 958 | text-align: center; 959 | width: 30px; 960 | overflow: hidden; 961 | } 962 | 963 | .toast .info .countdown { 964 | background-color: rgba(0, 0, 0, 0.1); 965 | bottom: 0px; 966 | height: 0px; 967 | left: 0px; 968 | opacity: 0; 969 | position: absolute; 970 | width: 100%; 971 | animation: animation-hnule4-shrink 5000ms linear 0s 1 normal none running; 972 | } 973 | 974 | .toast .info .icon { 975 | display: contents; 976 | vertical-align: text-top; 977 | fill: currentcolor; 978 | position: relative; 979 | } 980 | 981 | .toast .info .textRegion { 982 | -webkit-box-flex: 1; 983 | flex-grow: 1; 984 | font-size: 14px; 985 | line-height: 1.4; 986 | min-height: 40px; 987 | padding: 8px 12px; 988 | } 989 | 990 | .toast .info .closeButtonRegion { 991 | cursor: pointer; 992 | flex-shrink: 0; 993 | opacity: 0.5; 994 | padding: 8px 12px; 995 | transition: opacity 150ms ease 0s; 996 | } 997 | 998 | .toast .info .closeSpan { 999 | clip: rect(1px, 1px, 1px, 1px); 1000 | height: 1px; 1001 | position: absolute; 1002 | white-space: nowrap; 1003 | width: 1px; 1004 | border-width: 0px; 1005 | border-style: initial; 1006 | border-color: initial; 1007 | border-image: initial; 1008 | overflow: hidden; 1009 | padding: 0px; 1010 | } 1011 | 1012 | .toast .error { 1013 | background-color: rgb(255, 235, 230); 1014 | box-shadow: rgba(0, 0, 0, 0.176) 0px 3px 8px; 1015 | color: rgb(191, 38, 0); 1016 | display: flex; 1017 | margin-bottom: 8px; 1018 | width: 360px; 1019 | transform: translate3d(0px, 0px, 0px); 1020 | border-radius: 4px; 1021 | transition: transform 220ms cubic-bezier(0.2, 0, 0, 1) 0s; 1022 | } 1023 | 1024 | .toast .error .iconRegion { 1025 | background-color: rgb(255, 86, 48); 1026 | border-top-left-radius: 4px; 1027 | border-bottom-left-radius: 4px; 1028 | color: rgb(255, 235, 230); 1029 | flex-shrink: 0; 1030 | padding-bottom: 8px; 1031 | padding-top: 8px; 1032 | position: relative; 1033 | text-align: center; 1034 | width: 30px; 1035 | overflow: hidden; 1036 | } 1037 | 1038 | .toast .error .countdown { 1039 | background-color: rgba(0, 0, 0, 0.1); 1040 | bottom: 0px; 1041 | height: 0px; 1042 | left: 0px; 1043 | opacity: 0; 1044 | position: absolute; 1045 | width: 100%; 1046 | animation: animation-hnule4-shrink 5000ms linear 0s 1 normal none running; 1047 | } 1048 | 1049 | .toast .error .icon { 1050 | display: contents; 1051 | vertical-align: text-top; 1052 | fill: currentcolor; 1053 | position: relative; 1054 | } 1055 | 1056 | .toast .error .textRegion { 1057 | -webkit-box-flex: 1; 1058 | flex-grow: 1; 1059 | font-size: 14px; 1060 | line-height: 1.4; 1061 | min-height: 40px; 1062 | padding: 8px 12px; 1063 | } 1064 | 1065 | .toast .error .closeButtonRegion { 1066 | cursor: pointer; 1067 | flex-shrink: 0; 1068 | opacity: 0.5; 1069 | padding: 8px 12px; 1070 | transition: opacity 150ms ease 0s; 1071 | } 1072 | 1073 | .toast .error .closeSpan { 1074 | clip: rect(1px, 1px, 1px, 1px); 1075 | height: 1px; 1076 | position: absolute; 1077 | white-space: nowrap; 1078 | width: 1px; 1079 | border-width: 0px; 1080 | border-style: initial; 1081 | border-color: initial; 1082 | border-image: initial; 1083 | overflow: hidden; 1084 | padding: 0px; 1085 | } 1086 | 1087 | .toast .success { 1088 | background-color: rgb(227, 252, 239); 1089 | box-shadow: rgba(0, 0, 0, 0.176) 0px 3px 8px; 1090 | color: rgb(0, 102, 68); 1091 | display: flex; 1092 | margin-bottom: 8px; 1093 | width: 360px; 1094 | transform: translate3d(0px, 0px, 0px); 1095 | border-radius: 4px; 1096 | transition: transform 220ms cubic-bezier(0.2, 0, 0, 1) 0s; 1097 | } 1098 | 1099 | .toast .success .iconRegion { 1100 | background-color: rgb(54, 179, 126); 1101 | border-top-left-radius: 4px; 1102 | border-bottom-left-radius: 4px; 1103 | color: rgb(227, 252, 239); 1104 | flex-shrink: 0; 1105 | padding-bottom: 8px; 1106 | padding-top: 8px; 1107 | position: relative; 1108 | text-align: center; 1109 | width: 30px; 1110 | overflow: hidden; 1111 | } 1112 | 1113 | .toast .success .countdown { 1114 | background-color: rgba(0, 0, 0, 0.1); 1115 | bottom: 0px; 1116 | height: 0px; 1117 | left: 0px; 1118 | opacity: 0; 1119 | position: absolute; 1120 | width: 100%; 1121 | animation: animation-hnule4-shrink 5000ms linear 0s 1 normal none running; 1122 | } 1123 | 1124 | .toast .success .icon { 1125 | display: contents; 1126 | vertical-align: text-top; 1127 | fill: currentcolor; 1128 | position: relative; 1129 | } 1130 | 1131 | .toast .success .textRegion { 1132 | -webkit-box-flex: 1; 1133 | flex-grow: 1; 1134 | font-size: 14px; 1135 | line-height: 1.4; 1136 | min-height: 40px; 1137 | padding: 8px 12px; 1138 | } 1139 | 1140 | .toast .success .closeButtonRegion { 1141 | cursor: pointer; 1142 | flex-shrink: 0; 1143 | opacity: 0.5; 1144 | padding: 8px 12px; 1145 | transition: opacity 150ms ease 0s; 1146 | } 1147 | 1148 | .toast .success .closeSpan { 1149 | clip: rect(1px, 1px, 1px, 1px); 1150 | height: 1px; 1151 | position: absolute; 1152 | white-space: nowrap; 1153 | width: 1px; 1154 | border-width: 0px; 1155 | border-style: initial; 1156 | border-color: initial; 1157 | border-image: initial; 1158 | overflow: hidden; 1159 | padding: 0px; 1160 | } 1161 | 1162 | .mealoramaCalendar { 1163 | height: 500px; 1164 | max-width: 1500px; 1165 | } -------------------------------------------------------------------------------- /webclient/src/main/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Full Stack Scala App 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | --------------------------------------------------------------------------------