├── .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 | [](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 |
--------------------------------------------------------------------------------