├── .gitignore ├── .scalafmt.conf ├── README.md ├── build.sbt ├── project ├── build.properties └── plugins.sbt ├── src └── main │ ├── resources │ ├── application.conf │ ├── css │ │ └── main.css │ └── db │ │ └── migration │ │ └── V1_1__init.sql │ └── scala │ └── com │ └── rockthejvm │ ├── Main.scala │ ├── controllers │ ├── ContactsController.scala │ ├── Redirects.scala │ └── requestSyntax.scala │ ├── db │ ├── Db.scala │ ├── DbConfig.scala │ └── DbMigrator.scala │ ├── domain │ ├── config │ │ └── Configuration.scala │ ├── data │ │ └── Contact.scala │ └── errors │ │ ├── ErrorInfo.scala │ │ ├── ErrorMapper.scala │ │ └── ServerExceptions.scala │ ├── repositories │ └── ContactsRepository.scala │ ├── services │ └── ContactService.scala │ └── views │ ├── ContactsView.scala │ ├── HomePage.scala │ └── htmx │ └── HtmxAttributes.scala └── static └── neon pages.png /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /dist 6 | 7 | # IDEs and editors 8 | /.idea 9 | .project 10 | .classpath 11 | .c9/ 12 | *.launch 13 | .settings/ 14 | *.sublime-workspace 15 | .metals/ 16 | 17 | 18 | # IDE - VSCode 19 | .vscode/* 20 | !.vscode/settings.json 21 | !.vscode/tasks.json 22 | !.vscode/launch.json 23 | !.vscode/extensions.json 24 | .history/* 25 | 26 | 27 | #System Files 28 | .DS_Store 29 | Thumbs.db 30 | 31 | 32 | #log files 33 | npm-debug.log* 34 | yarn-debug.log* 35 | yarn-error.log* 36 | /sbt_run.log 37 | 38 | # build tool specific entries 39 | .bloop 40 | target 41 | metals.sbt 42 | project/project 43 | /.bsp/* 44 | 45 | *.sqlite 46 | /http_requests/http-client.private.env.json 47 | /postman_collections/Conduit.postman_collection.json 48 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 3.7.17 2 | maxColumn = 140 3 | runner.dialect = scala3 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ScalaTags + htmx + ZIO HTTP Demo 2 | 3 | This is a small demo project showcasing an integration between 4 | - ZIO HTTP 5 | - ScalaTags 6 | - htmx 7 | 8 | Welcome to Neon Pages: an extremely minimal CRM where you can keep a database of people with name, email, phone number or other data. You can add, remove, edit and search for people in the web app, and bulk-edit/bulk-delete. 9 | 10 | This is a single-page application (SPA) built with ZIO, ScalaTags and htmx. 11 | 12 | ![neon pages](./static/neon%20pages.png) 13 | 14 | ## How to run 15 | 16 | This demo project has a main class in `Main.scala`, so you can run the project by 17 | 18 | ``` 19 | sbt run 20 | ``` 21 | 22 | ## How to develop 23 | 24 | Fork or clone this repo and in an SBT console do 25 | 26 | ``` 27 | ~compile 28 | ``` 29 | 30 | and SBT will pick up your changes as you develop. The server will have to be restarted. 31 | 32 | ## Structure 33 | 34 | This application is built with 35 | - a SQLite store, which can be replaced by Postgres or some other database 36 | - [Quill](https://zio.dev/zio-quill/) for type-safe queries 37 | - [ZIO](https://zio.dev/) for managing effects 38 | - [ZIO HTTP](https://zio.dev/zio-http/) for server endpoints 39 | - [ScalaTags](https://com-lihaoyi.github.io/scalatags/) for server-side rendering (SSR) 40 | - [htmx](https://htmx.org/), a JavaScript library which manages classic single-page application (SPA) flows and backend calls via custom HTML attributes 41 | 42 | The "architecture" of the application is layered using ZLayers: 43 | - in `db` we have layers for the Quill data sources 44 | - `repositories` is layer for type-safe CRUD operations we're interested in, created with Quill, based on the `db` layer 45 | - `services` is a layer dedicated to business logic, usually with one or more actions from `repositories` 46 | - `controllers` is a layer for HTTP endpoints and request/response handling logic, usually delegating to one or more `services` 47 | - on top of that, `views` is the Scala representation of the web pages (with ScalaTags and htmx attributes), which we serve directly 48 | 49 | Besides the classical layers, we have a `domain` for the data types and errors we're using in the app, and a `config` layer which can fetch configuration from `application.conf`. The application also contains a `Flyway` service for migrations. 50 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | val currentScalaVersion = "3.3.1" 2 | val emailValidatorVersion = "1.7" 3 | val flywayVersion = "10.1.0" 4 | val hikariVersion = "5.1.0" 5 | val jwtVersion = "4.4.0" 6 | val logbackVersion = "1.4.14" 7 | val password4jVersion = "1.7.3" 8 | val quillVersion = "4.8.0" 9 | val sqliteVersion = "3.42.0.1" 10 | val zioConfigVersion = "4.0.0-RC16" 11 | val sttpZioJsonVersion = "3.9.1" 12 | val zioLoggingVersion = "2.1.16" 13 | val zioTestVersion = "2.0.19" 14 | 15 | val config = Seq( 16 | "dev.zio" %% "zio-config-typesafe" % zioConfigVersion, 17 | "dev.zio" %% "zio-config-magnolia" % zioConfigVersion 18 | ) 19 | 20 | val db = Seq( 21 | "org.xerial" % "sqlite-jdbc" % sqliteVersion, 22 | "org.flywaydb" % "flyway-core" % flywayVersion, 23 | "com.zaxxer" % "HikariCP" % hikariVersion, 24 | "io.getquill" %% "quill-jdbc-zio" % quillVersion 25 | ) 26 | 27 | val html = Seq( 28 | "com.lihaoyi" %% "scalatags" % "0.12.0" 29 | ) 30 | 31 | val http = Seq( 32 | "dev.zio" %% "zio-http" % "3.0.0-RC4" 33 | ) 34 | val tests = Seq( 35 | "dev.zio" %% "zio-logging" % zioLoggingVersion, 36 | "dev.zio" %% "zio-logging-slf4j" % zioLoggingVersion, 37 | "ch.qos.logback" % "logback-classic" % logbackVersion, 38 | "dev.zio" %% "zio-test" % zioTestVersion % Test, 39 | "dev.zio" %% "zio-test-sbt" % zioTestVersion % Test, 40 | "com.softwaremill.sttp.client3" %% "zio-json" % sttpZioJsonVersion % Test 41 | ) 42 | 43 | lazy val rootProject = (project in file(".")).settings( 44 | Seq( 45 | name := "zio-http-htmx", 46 | version := "0.1.0-SNAPSHOT", 47 | organization := "com.rockthejvm", 48 | scalaVersion := currentScalaVersion, 49 | Test / fork := true, 50 | scalacOptions ++= Seq( 51 | "-Xmax-inlines", 52 | "64" 53 | ), 54 | libraryDependencies ++= config ++ db ++ tests ++ html ++ http, 55 | testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")) 56 | ) 57 | ) 58 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.9.7 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | val sbtSoftwareMillVersion = "2.0.12" 2 | val scalaFmtVersion = "2.5.1" 3 | val sbtRevolverVersion = "0.10.0" 4 | 5 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % scalaFmtVersion) 6 | addSbtPlugin("com.softwaremill.sbt-softwaremill" % "sbt-softwaremill-common" % sbtSoftwareMillVersion) 7 | addSbtPlugin("io.spray" % "sbt-revolver" % sbtRevolverVersion) 8 | -------------------------------------------------------------------------------- /src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | db { 2 | url = "jdbc:sqlite:contacts.sqlite" 3 | } 4 | -------------------------------------------------------------------------------- /src/main/resources/css/main.css: -------------------------------------------------------------------------------- 1 | tr.htmx-swapping { 2 | opacity: 0; 3 | transition: opacity 1s ease-out; 4 | } 5 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V1_1__init.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE contacts 2 | ( 3 | id INTEGER PRIMARY KEY, 4 | name TEXT NOT NULL, 5 | phone TEXT NOT NULL, 6 | email TEXT NOT NULL UNIQUE 7 | ); -------------------------------------------------------------------------------- /src/main/scala/com/rockthejvm/Main.scala: -------------------------------------------------------------------------------- 1 | package com.rockthejvm 2 | 3 | import com.rockthejvm.controllers.{ContactsController, Redirects} 4 | import com.rockthejvm.db.{Db, DbConfig, DbMigrator} 5 | import com.rockthejvm.domain.config.Configuration 6 | import com.rockthejvm.repositories.ContactsRepository 7 | import com.rockthejvm.services.ContactService 8 | import zio.* 9 | import zio.http.* 10 | import zio.logging.LogFormat 11 | import zio.logging.backend.SLF4J 12 | 13 | object Main extends ZIOAppDefault { 14 | 15 | override val bootstrap: ZLayer[ZIOAppArgs, Any, Any] = 16 | SLF4J.slf4j(LogFormat.colored) 17 | 18 | override def run: ZIO[Any & ZIOAppArgs & Scope, Any, Any] = { 19 | val basicRoutes = Routes( 20 | Method.GET / "" -> handler(Response.redirect(Redirects.contacts)), 21 | Method.GET / "static" / "css" / "main.css" -> Handler.fromResource("css/main.css").orDie 22 | ) 23 | 24 | val htmxApp = ZIO.service[ContactsController].map { contacts => 25 | basicRoutes.toHttpApp ++ contacts.routes.toHttpApp 26 | } 27 | 28 | val program = 29 | for { 30 | migrator <- ZIO.service[DbMigrator] 31 | _ <- Console.printLine(s"Running db migrations") 32 | _ <- migrator.migrate() 33 | _ <- ZIO.logInfo("Successfully ran migrations") 34 | app <- htmxApp 35 | _ <- ZIO.logInfo("Starting server....") 36 | _ <- Server.serve(app @@ Middleware.debug @@ Middleware.flashScopeHandling) 37 | _ <- ZIO.logInfo("Server started - Rock the JVM!") 38 | } yield () 39 | 40 | program.provide( 41 | Configuration.live, 42 | DbConfig.live, 43 | Db.dataSourceLive, 44 | Db.quillLive, 45 | DbMigrator.live, 46 | ContactsRepository.live, 47 | ContactsController.live, 48 | ContactService.live, 49 | Server.default 50 | ) 51 | .exitCode 52 | } 53 | } -------------------------------------------------------------------------------- /src/main/scala/com/rockthejvm/controllers/ContactsController.scala: -------------------------------------------------------------------------------- 1 | package com.rockthejvm.controllers 2 | 3 | import com.rockthejvm.domain.errors.ErrorMapper.* 4 | import com.rockthejvm.domain.errors.ServerExceptions 5 | import com.rockthejvm.services.ContactService 6 | import com.rockthejvm.views.{ContactsView, HomePage} 7 | import scalatags.Text 8 | import zio.* 9 | import zio.http.* 10 | 11 | import scala.util.chaining.scalaUtilChainingOps 12 | 13 | case class ContactsController private (service: ContactService) { 14 | 15 | def routes: Routes[Any, Response] = Routes( 16 | Method.GET / "contacts" -> handler { (req: Request) => 17 | val page = req.url.queryParams.get("page").flatMap(_.toIntOption).getOrElse(0) 18 | val searchTerm = req.url.queryParams.getOrElse("q", "") 19 | val isActiveSearch = req.headers.exists(header => header.headerName == "HX-Trigger" && header.renderedValue == "search-input") 20 | 21 | service 22 | .searchContacts(searchTerm, page) 23 | .zipPar(service.countContacts) 24 | .map((contacts, count) => 25 | if (isActiveSearch) { 26 | ContactsView.listView(contacts, page + 1, searchTerm, count) 27 | } else { 28 | HomePage.generate(ContactsView.fullBody(contacts, page + 1, searchTerm, count)) 29 | } 30 | ) 31 | .map(scalatagsToResponse) 32 | .defaultErrorsMappings 33 | }, 34 | Method.GET / "contacts" / "new" -> handler { (req: Request) => 35 | newContactForm 36 | }, 37 | Method.GET / "contacts" / long("id") -> handler { (id: Long, req: Request) => 38 | service 39 | .findById(id) 40 | .map(contact => { 41 | toHomePageResponse( 42 | ContactsView 43 | .viewContact(contact) 44 | ) 45 | }) 46 | .defaultErrorsMappings 47 | }, 48 | Method.GET / "contacts" / long("id") / "email" -> handler { (id: Long, req: Request) => 49 | ZIO 50 | .getOrFailWith(ServerExceptions.BadRequest("No email query parameter found"))(req.url.queryParams.get("email")) 51 | .flatMap(service.validateEmail) 52 | .map(validationResult => Response(body = Body.fromString(validationResult))) 53 | .logError 54 | .defaultErrorsMappings 55 | }, 56 | Method.GET / "contacts" / long("id") / "edit" -> handler { (id: Long, req: Request) => 57 | service 58 | .findById(id) 59 | .map(contact => 60 | toHomePageResponse( 61 | ContactsView 62 | .editContact(contact) 63 | ) 64 | ) 65 | .defaultErrorsMappings 66 | }, 67 | Method.POST / "contacts" / long("id") / "edit" -> handler { (id: Long, req: Request) => 68 | updateContact(id, req) 69 | .as( 70 | Response 71 | .seeOther(Redirects.editContact(id)) 72 | .addFlashMessage("Updated contact") 73 | ) 74 | .catchSome { case e: ServerExceptions.ValidationError => 75 | service 76 | .findById(id) 77 | .map(contact => 78 | toHomePageResponse( 79 | ContactsView 80 | .editContact(contact, e.errors) 81 | ) 82 | ) 83 | } 84 | .defaultErrorsMappings 85 | }, 86 | Method.DELETE / "contacts" -> handler { (req: Request) => 87 | val page = req.url.queryParams.get("page").flatMap(_.toIntOption).getOrElse(0) 88 | val deleteContacts = for { 89 | form <- req.body.asURLEncodedForm 90 | // Converts incoming contact ids 91 | selectedContacts <- ZIO.attempt( 92 | form 93 | .map("selected_contact_ids") 94 | .stringValue 95 | .map(_.split(",").map(_.toLong).toList) 96 | .getOrElse(List.empty) 97 | ) 98 | _ <- ZIO.foreach(selectedContacts)(service.delete) 99 | contacts <- service.listContacts(0) 100 | count <- service.count() 101 | } yield ContactsView.fullBody(contacts, page + 1, "", count) 102 | 103 | deleteContacts 104 | .map(scalatagsToResponse) 105 | .defaultErrorsMappings 106 | }, 107 | Method.DELETE / "contacts" / long("id") -> handler { (id: Long, req: Request) => 108 | // checks that request was triggered by the delete-btn 109 | val isDeleteButton = req.headers 110 | .exists(header => header.renderedValue == "delete-btn" && header.headerName == "HX-Trigger") 111 | 112 | val response = if (isDeleteButton) { 113 | Response 114 | .seeOther(Redirects.contacts) 115 | .addFlashMessage("Deleted contact") 116 | } else { 117 | Response(body = Body.fromString("")) 118 | } 119 | 120 | service 121 | .delete(id) 122 | .as(response) 123 | .defaultErrorsMappings 124 | }, 125 | Method.POST / "contacts" / "new" -> handler { (req: Request) => 126 | createContact(req) 127 | .as( 128 | Response 129 | .seeOther(Redirects.contacts) 130 | .addFlashMessage("Created new contact") 131 | ) 132 | .catchSome { case e: ServerExceptions.ValidationError => 133 | req.body.asURLEncodedForm 134 | .map(form => form.map.view.map(kv => kv._1 -> kv._2.stringValue.getOrElse("")).toMap) 135 | .map(formMap => toHomePageResponse(ContactsView.newContactForm(formMap, e.errors))) 136 | } 137 | .defaultErrorsMappings 138 | } 139 | ) 140 | 141 | private def toHomePageResponse(html: Text.TypedTag[String]) = HomePage 142 | .generate(html) 143 | .pipe(scalatagsToResponse) 144 | 145 | private def createContact(req: Request): ZIO[Any, RuntimeException, Long] = { 146 | req.body.asURLEncodedForm 147 | .mapError(e => ServerExceptions.BadRequest("Malformed form data")) 148 | .map(_.map) 149 | .flatMap(service.create) 150 | } 151 | 152 | private def updateContact(id: Long, req: Request) = { 153 | req.body.asURLEncodedForm 154 | .mapError(e => ServerExceptions.BadRequest("Malformed form data")) 155 | .flatMap(form => service.update(id, form.map)) 156 | } 157 | 158 | private def newContactForm: Response = { 159 | ContactsView 160 | .newContactForm() 161 | .pipe(HomePage.generate) 162 | .pipe(scalatagsToResponse) 163 | } 164 | 165 | private def scalatagsToResponse(view: Text.TypedTag[String]): Response = Response( 166 | Status.Ok, 167 | Headers(Header.ContentType(MediaType.text.html).untyped), 168 | Body.fromString(view.render) 169 | ) 170 | } 171 | 172 | object ContactsController { 173 | val live = ZLayer.derive[ContactsController] 174 | } 175 | -------------------------------------------------------------------------------- /src/main/scala/com/rockthejvm/controllers/Redirects.scala: -------------------------------------------------------------------------------- 1 | package com.rockthejvm.controllers 2 | 3 | import zio.http.URL 4 | import java.net.URI 5 | 6 | object Redirects { 7 | 8 | val contacts = URL.fromURI(URI.create("/contacts")).get 9 | 10 | def editContact(id: Long) = contacts.addPath(s"${id}/edit") 11 | } 12 | -------------------------------------------------------------------------------- /src/main/scala/com/rockthejvm/controllers/requestSyntax.scala: -------------------------------------------------------------------------------- 1 | package com.rockthejvm.controllers 2 | 3 | import com.rockthejvm.domain.errors.ServerExceptions 4 | import zio.ZIO 5 | import zio.http.Request 6 | import zio.json.* 7 | 8 | object requestSyntax { 9 | extension (request: Request) { 10 | def to[T: JsonDecoder]: ZIO[Any, ServerExceptions.BadRequest, T] = 11 | for 12 | body <- request.body.asString 13 | .mapError(err => ServerExceptions.BadRequest(s"Failure getting request body: ${err.getMessage}")) 14 | result <- ZIO 15 | .fromEither(body.fromJson[T]) 16 | .mapError(err => ServerExceptions.BadRequest(s"Failure parsing json: ${err}")) 17 | yield result 18 | 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/scala/com/rockthejvm/db/Db.scala: -------------------------------------------------------------------------------- 1 | package com.rockthejvm.db 2 | 3 | import com.zaxxer.hikari.{HikariConfig, HikariDataSource} 4 | import io.getquill.* 5 | import io.getquill.jdbczio.* 6 | import zio.{ZIO, ZLayer} 7 | 8 | import javax.sql.DataSource 9 | 10 | object Db { 11 | private def create(dbConfig: DbConfig): HikariDataSource = { 12 | val poolConfig = new HikariConfig() 13 | poolConfig.setJdbcUrl(dbConfig.jdbcUrl) 14 | poolConfig.setConnectionInitSql(dbConfig.connectionInitSql) 15 | new HikariDataSource(poolConfig) 16 | } 17 | 18 | val dataSourceLive: ZLayer[DbConfig, Nothing, DataSource] = 19 | ZLayer.scoped { 20 | ZIO.fromAutoCloseable { 21 | for { 22 | dbConfig <- ZIO.service[DbConfig] 23 | dataSource <- ZIO.succeed(create(dbConfig)) 24 | } yield dataSource 25 | } 26 | } 27 | 28 | val quillLive: ZLayer[DataSource, Nothing, Quill.Sqlite[SnakeCase]] = 29 | Quill.Sqlite.fromNamingStrategy(SnakeCase) 30 | } -------------------------------------------------------------------------------- /src/main/scala/com/rockthejvm/db/DbConfig.scala: -------------------------------------------------------------------------------- 1 | package com.rockthejvm.db 2 | 3 | import com.rockthejvm.domain.config.AppConfig 4 | import zio.{ZIO, ZLayer} 5 | 6 | case class DbConfig(jdbcUrl: String) { 7 | val connectionInitSql = "PRAGMA foreign_keys = ON" 8 | } 9 | 10 | object DbConfig { 11 | val live: ZLayer[AppConfig, Nothing, DbConfig] = 12 | ZLayer.fromFunction { (appConfig: AppConfig) => 13 | DbConfig(appConfig.db.url) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/scala/com/rockthejvm/db/DbMigrator.scala: -------------------------------------------------------------------------------- 1 | package com.rockthejvm.db 2 | 3 | import org.flywaydb.core.Flyway 4 | import org.flywaydb.core.api.output.MigrateErrorResult 5 | import zio.{Task, ZIO, ZLayer} 6 | 7 | import javax.sql.DataSource 8 | 9 | class DbMigrator(ds: DataSource) { 10 | import DbMigrator.* 11 | 12 | def migrate(): Task[Unit] = { 13 | ZIO 14 | .attempt( 15 | Flyway 16 | .configure() 17 | .dataSource(ds) 18 | .load() 19 | .migrate() 20 | ) 21 | .flatMap { 22 | case r: MigrateErrorResult => ZIO.fail(DbMigrationFailed(r.error.message, r.error.stackTrace)) 23 | case e => ZIO.unit 24 | } 25 | .onError(cause => ZIO.logErrorCause("Database migration has failed", cause)) 26 | } 27 | } 28 | 29 | object DbMigrator { 30 | case class DbMigrationFailed(msg: String, stackTrace: String) extends RuntimeException(s"$msg\n$stackTrace") 31 | 32 | val live: ZLayer[DataSource, Nothing, DbMigrator] = 33 | ZLayer.fromFunction(DbMigrator(_)) 34 | } 35 | -------------------------------------------------------------------------------- /src/main/scala/com/rockthejvm/domain/config/Configuration.scala: -------------------------------------------------------------------------------- 1 | package com.rockthejvm.domain.config 2 | 3 | import zio.* 4 | import zio.config.magnolia.* 5 | import zio.config.typesafe.* 6 | 7 | final case class AppConfig(db: DbConfig) 8 | final case class DbConfig(url: String) 9 | 10 | object Configuration { 11 | val live: ZLayer[Any, Config.Error, AppConfig] = 12 | ZLayer.fromZIO(TypesafeConfigProvider.fromResourcePath().load(deriveConfig[AppConfig])) 13 | } 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/main/scala/com/rockthejvm/domain/data/Contact.scala: -------------------------------------------------------------------------------- 1 | package com.rockthejvm.domain.data 2 | 3 | import zio.json.{DeriveJsonDecoder, DeriveJsonEncoder, JsonCodec, JsonDecoder, JsonEncoder} 4 | 5 | case class Contact( 6 | id: Long, 7 | name: String, 8 | phone: String, 9 | email: String 10 | ) derives JsonCodec 11 | -------------------------------------------------------------------------------- /src/main/scala/com/rockthejvm/domain/errors/ErrorInfo.scala: -------------------------------------------------------------------------------- 1 | package com.rockthejvm.domain.errors 2 | 3 | import zio.json.JsonCodec 4 | 5 | sealed trait ErrorInfo 6 | case class BadRequest(error: String = "Bad request.") extends ErrorInfo derives JsonCodec 7 | case class Unauthorized(error: String = "Unauthorized.") extends ErrorInfo derives JsonCodec 8 | case class Forbidden(error: String = "Forbidden.") extends ErrorInfo derives JsonCodec 9 | case class NotFound(error: String = "Not found.") extends ErrorInfo derives JsonCodec 10 | case class Conflict(error: String = "Conflict.") extends ErrorInfo derives JsonCodec 11 | case class ValidationFailed(errors: Map[String, List[String]]) extends ErrorInfo derives JsonCodec 12 | case class InternalServerError(error: String = "Internal server error.") extends ErrorInfo derives JsonCodec 13 | -------------------------------------------------------------------------------- /src/main/scala/com/rockthejvm/domain/errors/ErrorMapper.scala: -------------------------------------------------------------------------------- 1 | package com.rockthejvm.domain.errors 2 | 3 | import zio.* 4 | import zio.http.* 5 | 6 | object ErrorMapper { 7 | extension [E <: Throwable, A](task: ZIO[Any, E, A]) 8 | def defaultErrorsMappings: ZIO[Any, Response, A] = task.mapError { 9 | case e: ServerExceptions.AlreadyInUse => 10 | Response( 11 | status = Status.Conflict, 12 | body = Body.fromString(e.message) 13 | ) 14 | case e: ServerExceptions.NotFound => 15 | Response( 16 | status = Status.NotFound, 17 | body = Body.fromString(e.message) 18 | ) 19 | case e: ServerExceptions.BadRequest => 20 | Response( 21 | status = Status.BadRequest, 22 | body = Body.fromString(e.message) 23 | ) 24 | case e: ServerExceptions.Unauthorized => 25 | Response( 26 | status = Status.Unauthorized, 27 | body = Body.fromString(e.message) 28 | ) 29 | case e: ServerExceptions.DatabaseException => 30 | Response( 31 | status = Status.InternalServerError, 32 | body = Body.fromString(e.message) 33 | ) 34 | case _ => 35 | Response( 36 | status = Status.InternalServerError 37 | ) 38 | } 39 | } -------------------------------------------------------------------------------- /src/main/scala/com/rockthejvm/domain/errors/ServerExceptions.scala: -------------------------------------------------------------------------------- 1 | package com.rockthejvm.domain.errors 2 | 3 | object ServerExceptions { 4 | private val InvalidCredentialsMsg = "Invalid email or password!" 5 | 6 | case class BadRequest(message: String) extends RuntimeException(message) 7 | case class Unauthorized(message: String = InvalidCredentialsMsg) extends RuntimeException(message) 8 | case class NotFound(message: String) extends RuntimeException(message) 9 | case class AlreadyInUse(message: String) extends RuntimeException(message) 10 | case class DatabaseException(message: String) extends RuntimeException(message) 11 | case class ValidationError(errors: Map[String, String]) extends RuntimeException(s"Validation errors: ${errors.mkString(",")}") 12 | } -------------------------------------------------------------------------------- /src/main/scala/com/rockthejvm/repositories/ContactsRepository.scala: -------------------------------------------------------------------------------- 1 | package com.rockthejvm.repositories 2 | 3 | import com.rockthejvm.domain.data.Contact 4 | import com.rockthejvm.domain.errors.ServerExceptions 5 | import io.getquill.* 6 | import io.getquill.jdbczio.* 7 | import org.sqlite.SQLiteErrorCode.SQLITE_CONSTRAINT_UNIQUE 8 | import org.sqlite.{SQLiteErrorCode, SQLiteException} 9 | import zio.{IO, RIO, Task, ZIO, ZLayer} 10 | 11 | import java.sql.SQLException 12 | import java.time.Instant 13 | import scala.collection.immutable 14 | import scala.util.chaining.* 15 | 16 | class ContactsRepository(quill: Quill.Sqlite[SnakeCase]) { 17 | import quill.* 18 | 19 | private inline def queryContact = quote(querySchema[Contact](entity = "contacts")) 20 | 21 | def count() = 22 | run(queryContact.size) 23 | 24 | def insert(contact: Contact): ZIO[Any, RuntimeException, Long] = 25 | run(queryContact.insert(_.phone -> lift(contact.phone), _.name -> lift(contact.name), _.email -> lift(contact.email))) 26 | .mapDatabaseException("Contact already exists") 27 | 28 | def update(id: Long, contact: Contact): ZIO[Any, RuntimeException, Long] = 29 | run( 30 | queryContact 31 | .filter(_.id == lift(id)) 32 | .update(_.phone -> lift(contact.phone), _.name -> lift(contact.name), _.email -> lift(contact.email)) 33 | ).mapDatabaseException() 34 | 35 | def delete(ids: List[Long]): ZIO[Any, RuntimeException, Long] = 36 | run( 37 | queryContact 38 | .filter(c => liftQuery(ids).contains(c.id)) 39 | .delete 40 | ).mapDatabaseException() 41 | 42 | def delete(id: Long): ZIO[Any, RuntimeException, Long] = 43 | run( 44 | queryContact 45 | .filter(_.id == lift(id)) 46 | .delete 47 | ).mapDatabaseException() 48 | 49 | def listContacts(page: Int): ZIO[Any, SQLException, List[Contact]] = 50 | run(queryContact.drop(10 * lift(page)).take(10)) 51 | 52 | def findById(id: Long): ZIO[Any, RuntimeException, Contact] = 53 | run(queryContact.filter(c => c.id == lift(id))) 54 | .map(_.headOption) 55 | .mapDatabaseException() 56 | .flatMap(maybeContact => ZIO.getOrFailWith(ServerExceptions.NotFound(s"No contact with id ${id} found"))(maybeContact)) 57 | 58 | def findByEmail(email: String): ZIO[Any, RuntimeException, Option[Contact]] = 59 | run(queryContact.filter(c => c.email == lift(email))) 60 | .map(_.headOption) 61 | .mapDatabaseException() 62 | 63 | def filter(term: String, page: Int) = run( 64 | queryContact 65 | .sortBy(_.id) 66 | .filter(c => c.email.like(lift(s"%$term%")) || c.name.like(lift(s"%$term%")) || c.phone.like(lift(s"%$term%"))) 67 | .drop(10 * lift(page)) 68 | .take(10) 69 | ) 70 | 71 | extension [R, A](task: RIO[R, A]) 72 | // [SQLITE_CONSTRAINT_UNIQUE] A UNIQUE constraint failed (UNIQUE constraint failed: contacts.email) 73 | def mapDatabaseException(message: String = "Unknown error when connecting to database"): ZIO[R, RuntimeException, A] = task.mapError { 74 | case e: SQLiteException if e.getResultCode == SQLITE_CONSTRAINT_UNIQUE => 75 | ServerExceptions.AlreadyInUse(e.getMessage) 76 | case e => ServerExceptions.DatabaseException(s"${message}: e.getMessage") 77 | } 78 | } 79 | 80 | object ContactsRepository { 81 | val live: ZLayer[Quill.Sqlite[SnakeCase], Nothing, ContactsRepository] = 82 | ZLayer.fromFunction(new ContactsRepository(_)) 83 | } 84 | -------------------------------------------------------------------------------- /src/main/scala/com/rockthejvm/services/ContactService.scala: -------------------------------------------------------------------------------- 1 | package com.rockthejvm.services 2 | 3 | import com.rockthejvm.domain.data.Contact 4 | import com.rockthejvm.domain.errors.ServerExceptions 5 | import com.rockthejvm.repositories.ContactsRepository 6 | import zio.http.FormField 7 | import zio.prelude.{Validation, ZValidation} 8 | import zio.* 9 | 10 | class ContactService(contactsRepository: ContactsRepository) { 11 | type FormMap = Map[String, FormField] 12 | 13 | def searchContacts(searchTerm: String, page: Int) = 14 | if (searchTerm.isBlank) contactsRepository.listContacts(page) 15 | else contactsRepository.filter(searchTerm, page) 16 | 17 | def countContacts = 18 | contactsRepository.count() 19 | 20 | def findById(id: Long) = 21 | contactsRepository.findById(id) 22 | 23 | def delete(id: Long) = 24 | contactsRepository.delete(id) 25 | 26 | def update(id: Long, formMap: FormMap) = 27 | extractContactFromQueryParams(formMap) 28 | .flatMap(contactsRepository.update(id, _)) 29 | 30 | def create(formMap: FormMap) = 31 | extractContactFromQueryParams(formMap) 32 | .flatMap(createContact) 33 | 34 | def listContacts(page: Int) = 35 | contactsRepository.listContacts(page) 36 | 37 | def count() = 38 | contactsRepository.count() 39 | 40 | def validateEmail(email: String) = 41 | contactsRepository.findByEmail(email) 42 | .map(maybeEmail => maybeEmail.map(_ => "Email already in use").getOrElse("")) 43 | 44 | private def createContact(contact: Contact) = 45 | contactsRepository.insert(contact) 46 | 47 | private def extractContactFromQueryParams(formData: FormMap): IO[RuntimeException, Contact] = { 48 | val validations: ZValidation[Nothing, Map[String, String], Contact] = Validation.validateWith( 49 | Validation.succeed(-1L), 50 | extractNonEmptyString("name", formData.get("name")), 51 | extractNonEmptyString("phone", formData.get("phone")), 52 | extractNonEmptyString("email", formData.get("email")) 53 | )(Contact.apply) 54 | 55 | validations.toZIOParallelErrors 56 | .mapError(err => err.foldLeft(Map.empty[String, String])(_ ++ _)) 57 | .mapError(err => ServerExceptions.ValidationError(err)) 58 | } 59 | 60 | private def extractNonEmptyString(name: String, maybe: Option[FormField]): ZValidation[Nothing, Map[String, String], String] = 61 | for { 62 | value <- Validation 63 | .fromOptionWith(s"${name.capitalize} can't be empty")(maybe.flatMap(_.stringValue)) 64 | .mapError(err => Map(name -> err)) 65 | _ <- Validation.fromPredicateWith(s"${name.capitalize} can't be blank")(value)(!_.isBlank).mapError(err => Map(name -> err)) 66 | } yield value 67 | } 68 | 69 | object ContactService { 70 | val live: ZLayer[ContactsRepository, Nothing, ContactService] = 71 | ZLayer.derive[ContactService] 72 | } 73 | -------------------------------------------------------------------------------- /src/main/scala/com/rockthejvm/views/ContactsView.scala: -------------------------------------------------------------------------------- 1 | package com.rockthejvm.views 2 | 3 | import com.rockthejvm.domain.data.Contact 4 | import com.rockthejvm.views.htmx.HtmxAttributes 5 | import scalatags.Text 6 | import scalatags.Text.all.* 7 | 8 | object ContactsView { 9 | def fullBody(contacts: List[Contact] = List.empty, page: Int = 0, search: String = "", count: Long = 0): Text.TypedTag[String] = { 10 | div(searchForm(search), listView(contacts, page, search, count)) 11 | } 12 | 13 | def newContactForm( 14 | previousFormValues: Map[String, String] = Map.empty, 15 | errors: Map[String, String] = Map.empty 16 | ): Text.TypedTag[String] = { 17 | div( 18 | `class` := "container", 19 | form( 20 | action := "/contacts/new", 21 | method := "post", 22 | fieldset( 23 | legend("Contact Values"), 24 | p( 25 | label(`for` := "email", "Email"), 26 | input( 27 | name := "email", 28 | id := "email", 29 | `type` := "email", 30 | placeholder := "Email", 31 | value := previousFormValues.getOrElse("email", "") 32 | ), 33 | errors.get("email").map(email => span(cls := "error", email)) 34 | ), 35 | p( 36 | label(`for` := "name", "Name"), 37 | input(name := "name", id := "name", `type` := "text", placeholder := "Name", value := previousFormValues.getOrElse("name", "")), 38 | errors.get("name").map(name => span(cls := "error", name)) 39 | ), 40 | p( 41 | label(`for` := "phone", "Phone"), 42 | input( 43 | name := "phone", 44 | id := "phone", 45 | `type` := "text", 46 | placeholder := "Phone", 47 | value := previousFormValues.getOrElse("phone", "") 48 | ), 49 | errors.get("phone").map(phone => span(cls := "error", phone)) 50 | ), 51 | button("Save") 52 | ) 53 | ), 54 | p( 55 | a(href := "/contacts", "Back") 56 | ) 57 | ) 58 | } 59 | 60 | private def searchForm(formInputValue: String): Text.TypedTag[String] = form( 61 | action := "/contacts", 62 | method := "get", 63 | `class` := "tool-bar", 64 | h1( 65 | `for` := "search", 66 | "Neon Pages - a Scala + HTMX demo" 67 | ), 68 | div( 69 | style := "display: flex", 70 | input( 71 | id := "search-input", 72 | `type` := "search", 73 | style := "flex: 1; margin-right: 20px", 74 | name := "q", 75 | value := formInputValue, 76 | HtmxAttributes.get("/contacts"), 77 | HtmxAttributes.trigger("search, keyup delay:200ms changed"), 78 | HtmxAttributes.target("tbody"), 79 | HtmxAttributes.pushUrl(), 80 | HtmxAttributes.indicator(), 81 | HtmxAttributes.select("tbody tr") 82 | ), 83 | input( 84 | `type` := "submit", 85 | id := "search-submit", 86 | style := "flex: 0 0 100px", 87 | value := "Search" 88 | ) 89 | ) 90 | ) 91 | 92 | def listView(contacts: List[Contact], page: Int, searchTerm: String, count: Long) = div( 93 | `class` := "container", 94 | form( 95 | table( 96 | `class` := "table", 97 | thead( 98 | tr( 99 | th(), 100 | th("Name"), 101 | th("Email"), 102 | th("Phone") 103 | ) 104 | ), 105 | tbody( 106 | contacts.map(c => 107 | tr( 108 | td(input(`type` := "checkbox", name := "selected_contact_ids", value := c.id)), 109 | td(c.name), 110 | td(c.email), 111 | td(c.phone), 112 | td(a(href := s"/contacts/${c.id}/edit", "Edit")), 113 | td(a(href := s"/contacts/${c.id}", "View")), 114 | td( 115 | a( 116 | href := "#", 117 | HtmxAttributes.delete(s"/contacts/${c.id}"), 118 | HtmxAttributes.swap("outerHTML swap:1s"), 119 | HtmxAttributes.confirm("Are you sure you want to delete this contact?"), 120 | HtmxAttributes.target("closest tr"), 121 | "Delete" 122 | ) 123 | ) 124 | ), 125 | ) 126 | ) 127 | ), 128 | div( 129 | style := "display: flex; justify-content: space-between", 130 | button( 131 | style := "width: 160px", 132 | HtmxAttributes.get(s"/contacts?page=${page}&q=${searchTerm}"), 133 | HtmxAttributes.target("closest tr"), 134 | HtmxAttributes.swap("outerHTML"), 135 | HtmxAttributes.select("tbody > tr"), 136 | "Load More" 137 | ), 138 | button( 139 | style := "width: 280px", 140 | HtmxAttributes.delete("/contacts"), 141 | HtmxAttributes.confirm("Are you sure you want to delete these contacts?"), 142 | HtmxAttributes.target("body"), 143 | "Delete Selected Contacts" 144 | ) 145 | ) 146 | ), 147 | p( 148 | a(href := "/contacts/new", "Add Contact"), 149 | span(s" (${count} total Contacts)") 150 | ) 151 | ) 152 | 153 | def editContact(contact: Contact, errMap: Map[String, String] = Map.empty) = div( 154 | `class` := "container", 155 | form( 156 | action := s"/contacts/${contact.id}/edit", 157 | method := "post", 158 | fieldset( 159 | legend("Contact Values"), 160 | p( 161 | label(`for` := "email", "Email"), 162 | input( 163 | name := "email", 164 | id := "email", 165 | `type` := "text", 166 | placeholder := "Email", 167 | HtmxAttributes.get(s"/contacts/${contact.id}/email"), 168 | HtmxAttributes.trigger("change, keyup delay:200ms changed"), 169 | HtmxAttributes.target("next .error"), 170 | value := contact.email 171 | ), 172 | span(cls := "error", errMap.getOrElse("email", "")) 173 | ), 174 | p( 175 | label(`for` := "name", "Name"), 176 | input(name := "name", id := "name", `type` := "text", placeholder := "Name", value := contact.name), 177 | errMap.get("name").map(name => span(cls := "error", name)) 178 | ), 179 | p( 180 | label(`for` := "phone", "Phone"), 181 | input(name := "phone", id := "phone", `type` := "text", placeholder := "Phone", value := contact.phone), 182 | errMap.get("phone").map(phone => span(cls := "error", phone)), 183 | button("Save") 184 | ) 185 | ) 186 | ), 187 | button( 188 | id := "delete-btn", 189 | HtmxAttributes.delete(s"/contacts/${contact.id}"), 190 | HtmxAttributes.pushUrl(), 191 | HtmxAttributes.confirm(s"Are you sure you want to delete ${contact.name}"), 192 | HtmxAttributes.target("body"), 193 | "Delete Contact" 194 | ), 195 | p( 196 | a(href := "/contacts/", "Back") 197 | ) 198 | ) 199 | 200 | def viewContact(contact: Contact) = div( 201 | h1(contact.name), 202 | div( 203 | div(s"Phone: ${contact.phone}"), 204 | div(s"Email: ${contact.email}") 205 | ), 206 | p( 207 | a(href := s"/contacts/${contact.id}/edit", "Edit"), 208 | a(href := "/contacts", "Back") 209 | ) 210 | ) 211 | } 212 | -------------------------------------------------------------------------------- /src/main/scala/com/rockthejvm/views/HomePage.scala: -------------------------------------------------------------------------------- 1 | package com.rockthejvm.views 2 | 3 | import com.rockthejvm.views.htmx.HtmxAttributes 4 | import scalatags.Text.TypedTag 5 | import scalatags.Text.all.* 6 | 7 | object HomePage { 8 | def generate(bodyContents: TypedTag[String]): TypedTag[String] = generate(List(bodyContents)) 9 | def generate(bodyContents: List[TypedTag[String]] = List.empty): TypedTag[String] = { 10 | html( 11 | head( 12 | link(rel := "stylesheet", href := "https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css"), 13 | script(src := "https://unpkg.com/htmx.org@1.9.10"), 14 | link(rel := "stylesheet", href := "/static/css/main.css") 15 | ), 16 | body( 17 | `class` := "container", 18 | div( 19 | HtmxAttributes.boost(), 20 | bodyContents 21 | ) 22 | ) 23 | ) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/scala/com/rockthejvm/views/htmx/HtmxAttributes.scala: -------------------------------------------------------------------------------- 1 | package com.rockthejvm.views.htmx 2 | 3 | import scalatags.Text.all.* 4 | 5 | object HtmxAttributes { 6 | def boost(value: Boolean = true) = attr("hx-boost") := value 7 | def pushUrl(value: Boolean = true) = attr("hx-push-url") := value 8 | def confirm(message: String) = attr("hx-confirm") := message 9 | 10 | def delete(endpoint: String) = attr("hx-delete") := endpoint 11 | def get(endpoint: String) = attr("hx-get") := endpoint 12 | def indicator(indicatorType: String = "#spinner") = attr("hx-indicator") := indicatorType 13 | def select(value: String) = attr("hx-select") := value 14 | def swap(value: String) = attr("hx-swap") := value 15 | 16 | def target(element: String) = attr("hx-target") := element 17 | def trigger(value: String) = attr("hx-trigger") := value 18 | } 19 | -------------------------------------------------------------------------------- /static/neon pages.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rockthejvm/scalatags-htmx-demo/5bef6fe197138d6b4d5b7d9d759718db7f2c049d/static/neon pages.png --------------------------------------------------------------------------------