From 7d1576b63d17ee9a95f7423f848bfd1389e7ba99 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Sat, 5 Jul 2025 21:42:10 +0200 Subject: [PATCH 01/13] Upgrade mill to 1.0.0 RC3 --- .mill-version | 1 - build.mill | 100 ++++++++++++++++++++++---------------------------- mill | 12 ++++-- mill.bat | 11 ++++-- 4 files changed, 60 insertions(+), 64 deletions(-) delete mode 100644 .mill-version diff --git a/.mill-version b/.mill-version deleted file mode 100644 index dfa546b..0000000 --- a/.mill-version +++ /dev/null @@ -1 +0,0 @@ -0.12.14 \ No newline at end of file diff --git a/build.mill b/build.mill index 4c5e246..62f0608 100644 --- a/build.mill +++ b/build.mill @@ -1,14 +1,10 @@ +//| mill-version: 1.0.0-RC3 package build - -import $ivy.`de.tototec::de.tobiasroeser.mill.vcs.version::0.4.1` -import $ivy.`ba.sake::mill-hepek::0.1.0` - import mill._ import mill.scalalib._, scalajslib._, scalanativelib._ import mill.scalalib.publish._ -import mill.scalalib.SonatypeCentralPublishModule -import de.tobiasroeser.mill.vcs.version.VcsVersion -import ba.sake.millhepek.MillHepekModule +import mill.javalib.SonatypeCentralPublishModule +import mill.vcs.VcsVersion object V { val tupson = "0.13.0" @@ -27,40 +23,40 @@ object `sharaf-core` extends Module { trait SharafCoreModule extends SharafPublishModule with PlatformScalaModule { def artifactName = "sharaf-core" // all deps should be cross jvm/native - def ivyDeps = super.ivyDeps() ++ Agg( - ivy"ba.sake::tupson::${V.tupson}", - ivy"com.lihaoyi::scalatags::${V.scalatags}", - ivy"com.lihaoyi::geny::1.1.1", - ivy"com.softwaremill.sttp.client4::core::4.0.5" + def mvnDeps = super.mvnDeps() ++ Seq( + mvn"ba.sake::tupson::${V.tupson}", + mvn"com.lihaoyi::scalatags::${V.scalatags}", + mvn"com.lihaoyi::geny::1.1.1", + mvn"com.softwaremill.sttp.client4::core::4.0.5" ) } } object `sharaf-undertow` extends SharafPublishModule { def artifactName = "sharaf-undertow" - def ivyDeps = super.ivyDeps() ++ Agg( - ivy"io.undertow:undertow-core:2.3.18.Final", - ivy"ba.sake::tupson-config:${V.tupson}", - ivy"ba.sake::hepek-components:${V.hepek}" + def mvnDeps = super.mvnDeps() ++ Seq( + mvn"io.undertow:undertow-core:2.3.18.Final", + mvn"ba.sake::tupson-config:${V.tupson}", + mvn"ba.sake::hepek-components:${V.hepek}" ) def moduleDeps = Seq(`sharaf-core`.jvm) object test extends ScalaTests with SharafTestModule { - def ivyDeps = super.ivyDeps() ++ Agg( - ivy"org.webjars:jquery:3.7.1" + def mvnDeps = super.mvnDeps() ++ Seq( + mvn"org.webjars:jquery:3.7.1" ) } } object `sharaf-helidon` extends SharafPublishModule { def artifactName = "sharaf-helidon" - def ivyDeps = super.ivyDeps() ++ Agg( - ivy"io.helidon.webserver:helidon-webserver:4.2.2", - ivy"io.helidon.config:helidon-config-yaml:4.2.2" + def mvnDeps = super.mvnDeps() ++ Seq( + mvn"io.helidon.webserver:helidon-webserver:4.2.2", + mvn"io.helidon.config:helidon-config-yaml:4.2.2" ) def moduleDeps = Seq(`sharaf-core`.jvm) object test extends ScalaTests with SharafTestModule { - def ivyDeps = super.ivyDeps() ++ Agg( - ivy"com.lihaoyi::requests:0.9.0" + def mvnDeps = super.mvnDeps() ++ Seq( + mvn"com.lihaoyi::requests:0.9.0" ) } } @@ -68,8 +64,8 @@ object `sharaf-helidon` extends SharafPublishModule { object `sharaf-snunit` extends ScalaNativeCommonModule with SharafPublishModule { def artifactName = "sharaf-snunit" - def ivyDeps = super.ivyDeps() ++ Agg( - ivy"com.github.lolgab::snunit::0.10.3" + def mvnDeps = super.mvnDeps() ++ Seq( + mvn"com.github.lolgab::snunit::0.10.3" ) def moduleDeps = Seq(`sharaf-core`.native) } @@ -83,8 +79,8 @@ object querson extends Module { trait QuersonModule extends SharafPublishModule with PlatformScalaModule { def artifactName = "querson" def pomSettings = super.pomSettings().copy(description = "Sharaf query params library") - def ivyDeps = super.ivyDeps() ++ Agg( - ivy"com.lihaoyi::fastparse::3.1.1" + def mvnDeps = super.mvnDeps() ++ Seq( + mvn"com.lihaoyi::fastparse::3.1.1" ) } } @@ -98,8 +94,8 @@ object formson extends Module { trait FormsonModule extends SharafPublishModule with PlatformScalaModule { def artifactName = "formson" def pomSettings = super.pomSettings().copy(description = "Sharaf form binding library") - def ivyDeps = super.ivyDeps() ++ Agg( - ivy"com.lihaoyi::fastparse::3.1.1" + def mvnDeps = super.mvnDeps() ++ Seq( + mvn"com.lihaoyi::fastparse::3.1.1" ) } } @@ -112,8 +108,8 @@ object validson extends Module { } trait ValidsonModule extends SharafPublishModule with PlatformScalaModule { def artifactName = "validson" - def ivyDeps = super.ivyDeps() ++ Agg( - ivy"com.lihaoyi::sourcecode::0.4.2" + def mvnDeps = super.mvnDeps() ++ Seq( + mvn"com.lihaoyi::sourcecode::0.4.2" ) def pomSettings = super.pomSettings().copy(description = "Sharaf validation library") } @@ -149,30 +145,30 @@ trait ScalaJvmCommonModule extends ScalaModule { trait ScalaJSCommonModule extends ScalaJSModule { def scalaJSVersion = "1.19.0" - def ivyDeps = super.ivyDeps() ++ Agg( - ivy"io.github.cquiroz::scala-java-time::2.6.0" + def mvnDeps = super.mvnDeps() ++ Seq( + mvn"io.github.cquiroz::scala-java-time::2.6.0" ) object test extends ScalaJSTests with SharafTestModule } trait ScalaNativeCommonModule extends ScalaNativeModule { def scalaNativeVersion = "0.5.7" - def ivyDeps = super.ivyDeps() ++ Agg( - ivy"io.github.cquiroz::scala-java-time::2.6.0" + def mvnDeps = super.mvnDeps() ++ Seq( + mvn"io.github.cquiroz::scala-java-time::2.6.0" ) } trait SharafTestModule extends TestModule.Munit { - def ivyDeps = Agg( - ivy"org.scalameta::munit::1.1.0" + def mvnDeps = Seq( + mvn"org.scalameta::munit::1.1.0" ) } //////////////////// examples trait SharafExampleModule extends SharafCommonModule { - def ivyDeps = Agg( - ivy"ch.qos.logback:logback-classic:1.4.6" + def mvnDeps = Seq( + mvn"ch.qos.logback:logback-classic:1.4.6" ) } @@ -187,22 +183,22 @@ object examples extends mill.Module { } object `user-pass-form` extends SharafExampleModule { def moduleDeps = Seq(`sharaf-undertow`) - def ivyDeps = super.ivyDeps() ++ Agg( - ivy"org.pac4j:undertow-pac4j:5.0.1", - ivy"org.pac4j:pac4j-http:5.7.0", - ivy"org.mindrot:jbcrypt:0.4" + def mvnDeps = super.mvnDeps() ++ Seq( + mvn"org.pac4j:undertow-pac4j:5.0.1", + mvn"org.pac4j:pac4j-http:5.7.0", + mvn"org.mindrot:jbcrypt:0.4" ) object test extends ScalaTests with SharafTestModule } object oauth2 extends SharafExampleModule { def moduleDeps = Seq(`sharaf-undertow`) - def ivyDeps = super.ivyDeps() ++ Agg( - ivy"org.pac4j:undertow-pac4j:5.0.1", - ivy"org.pac4j:pac4j-oauth:5.7.0" + def mvnDeps = super.mvnDeps() ++ Seq( + mvn"org.pac4j:undertow-pac4j:5.0.1", + mvn"org.pac4j:pac4j-oauth:5.7.0" ) object test extends ScalaTests with SharafTestModule { - def ivyDeps = super.ivyDeps() ++ Agg( - ivy"no.nav.security:mock-oauth2-server:0.5.10" + def mvnDeps = super.mvnDeps() ++ Seq( + mvn"no.nav.security:mock-oauth2-server:0.5.10" ) } } @@ -210,11 +206,3 @@ object examples extends mill.Module { def moduleDeps = Seq(`sharaf-snunit`) } } - -//////////////////// docs -object docs extends MillHepekModule with SharafCommonModule { - def ivyDeps = Agg( - ivy"ba.sake::hepek:${V.hepek}", - ivy"com.lihaoyi::os-lib:0.9.3" - ) -} diff --git a/mill b/mill index 555d7fc..15b007c 100755 --- a/mill +++ b/mill @@ -23,7 +23,7 @@ # into a cache location (~/.cache/mill/download). # # Mill Project URL: https://github.com/com-lihaoyi/mill -# Script Version: 1.0.0-M1-49-ac90e3 +# Script Version: 1.0.0-M1-21-7b6fae-DIRTY892b63e8 # # If you want to improve this script, please also contribute your changes back! # This script was generated from: dist/scripts/src/mill.sh @@ -32,8 +32,14 @@ set -e +if [ "$1" = "--setup-completions" ] ; then + # Need to preserve the first position of those listed options + MILL_FIRST_ARG=$1 + shift +fi + if [ -z "${DEFAULT_MILL_VERSION}" ] ; then - DEFAULT_MILL_VERSION=0.12.14 + DEFAULT_MILL_VERSION=1.0.0-RC3 fi @@ -309,7 +315,7 @@ if [ -z "$MILL_MAIN_CLI" ] ; then fi MILL_FIRST_ARG="" -if [ "$1" = "--bsp" ] || [ "$1" = "-i" ] || [ "$1" = "--interactive" ] || [ "$1" = "--no-server" ] || [ "$1" = "--repl" ] || [ "$1" = "--help" ] ; then +if [ "$1" = "--bsp" ] || [ "${1#"-i"}" != "$1" ] || [ "$1" = "--interactive" ] || [ "$1" = "--no-server" ] || [ "$1" = "--no-daemon" ] || [ "$1" = "--repl" ] || [ "$1" = "--help" ] ; then # Need to preserve the first position of those listed options MILL_FIRST_ARG=$1 shift diff --git a/mill.bat b/mill.bat index 85f9605..f36d812 100644 --- a/mill.bat +++ b/mill.bat @@ -23,7 +23,7 @@ rem this script downloads a binary file from Maven Central or Github Pages (this rem into a cache location (%USERPROFILE%\.mill\download). rem rem Mill Project URL: https://github.com/com-lihaoyi/mill -rem Script Version: 1.0.0-M1-49-ac90e3 +rem Script Version: 1.0.0-M1-21-7b6fae-DIRTY892b63e8 rem rem If you want to improve this script, please also contribute your changes back! rem This script was generated from: dist/scripts/src/mill.bat @@ -272,18 +272,21 @@ if [%~1%]==[--bsp] ( if [%~1%]==[--no-server] ( set MILL_FIRST_ARG=%1% ) else ( - if [%~1%]==[--repl] ( + if [%~1%]==[--no-daemon] ( set MILL_FIRST_ARG=%1% ) else ( - if [%~1%]==[--help] ( + if [%~1%]==[--repl] ( set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--help] ( + set MILL_FIRST_ARG=%1% + ) ) ) ) ) ) ) - set "MILL_PARAMS=%*%" if not [!MILL_FIRST_ARG!]==[] ( From e27c4f5f05525609a3b62af0c02bacf16e897598 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Sat, 5 Jul 2025 22:22:30 +0200 Subject: [PATCH 02/13] Play with scala 3 syntax in build.mill. Update scala to 3.7.1 --- build.mill | 110 +++++++----------- formson/src/ba/sake/formson/parse.scala | 2 +- querson/src/ba/sake/querson/parse.scala | 2 +- .../sharaf/snunit/SharafRequestHandler.scala | 2 +- 4 files changed, 44 insertions(+), 72 deletions(-) diff --git a/build.mill b/build.mill index 62f0608..84932d1 100644 --- a/build.mill +++ b/build.mill @@ -6,21 +6,20 @@ import mill.scalalib.publish._ import mill.javalib.SonatypeCentralPublishModule import mill.vcs.VcsVersion -object V { +object V: val tupson = "0.13.0" val scalatags = "0.13.1" val hepek = "0.33.0" -} -object `sharaf-core` extends Module { - object jvm extends SharafCoreModule with ScalaJvmCommonModule { +object `sharaf-core` extends Module: + object jvm extends SharafCoreModule with ScalaJvmCommonModule: def moduleDeps = Seq(querson.jvm, formson.jvm, validson.jvm) - } - object native extends SharafCoreModule with ScalaNativeCommonModule { + + object native extends SharafCoreModule with ScalaNativeCommonModule: def moduleDeps = Seq(querson.native, formson.native, validson.native) object test extends ScalaNativeTests with SharafTestModule - } - trait SharafCoreModule extends SharafPublishModule with PlatformScalaModule { + + trait SharafCoreModule extends SharafPublishModule with PlatformScalaModule: def artifactName = "sharaf-core" // all deps should be cross jvm/native def mvnDeps = super.mvnDeps() ++ Seq( @@ -29,10 +28,8 @@ object `sharaf-core` extends Module { mvn"com.lihaoyi::geny::1.1.1", mvn"com.softwaremill.sttp.client4::core::4.0.5" ) - } -} -object `sharaf-undertow` extends SharafPublishModule { +object `sharaf-undertow` extends SharafPublishModule: def artifactName = "sharaf-undertow" def mvnDeps = super.mvnDeps() ++ Seq( mvn"io.undertow:undertow-core:2.3.18.Final", @@ -40,82 +37,69 @@ object `sharaf-undertow` extends SharafPublishModule { mvn"ba.sake::hepek-components:${V.hepek}" ) def moduleDeps = Seq(`sharaf-core`.jvm) - object test extends ScalaTests with SharafTestModule { + object test extends ScalaTests with SharafTestModule : def mvnDeps = super.mvnDeps() ++ Seq( mvn"org.webjars:jquery:3.7.1" ) - } -} -object `sharaf-helidon` extends SharafPublishModule { +object `sharaf-helidon` extends SharafPublishModule: def artifactName = "sharaf-helidon" def mvnDeps = super.mvnDeps() ++ Seq( mvn"io.helidon.webserver:helidon-webserver:4.2.2", mvn"io.helidon.config:helidon-config-yaml:4.2.2" ) def moduleDeps = Seq(`sharaf-core`.jvm) - object test extends ScalaTests with SharafTestModule { + object test extends ScalaTests with SharafTestModule: def mvnDeps = super.mvnDeps() ++ Seq( mvn"com.lihaoyi::requests:0.9.0" ) - } -} -object `sharaf-snunit` extends ScalaNativeCommonModule with SharafPublishModule { +object `sharaf-snunit` extends ScalaNativeCommonModule with SharafPublishModule: def artifactName = "sharaf-snunit" def mvnDeps = super.mvnDeps() ++ Seq( mvn"com.github.lolgab::snunit::0.10.3" ) def moduleDeps = Seq(`sharaf-core`.native) -} -object querson extends Module { +object querson extends Module: object jvm extends QuersonModule with ScalaJvmCommonModule object js extends QuersonModule with ScalaJSCommonModule - object native extends QuersonModule with ScalaNativeCommonModule { + object native extends QuersonModule with ScalaNativeCommonModule: object test extends ScalaNativeTests with SharafTestModule - } - trait QuersonModule extends SharafPublishModule with PlatformScalaModule { + trait QuersonModule extends SharafPublishModule with PlatformScalaModule: def artifactName = "querson" def pomSettings = super.pomSettings().copy(description = "Sharaf query params library") def mvnDeps = super.mvnDeps() ++ Seq( mvn"com.lihaoyi::fastparse::3.1.1" ) - } -} -object formson extends Module { +object formson extends Module: object jvm extends FormsonModule with ScalaJvmCommonModule //object js extends FormsonModule with ScalaJSCommonModule // java.nio.Path not supported - object native extends FormsonModule with ScalaNativeCommonModule { + object native extends FormsonModule with ScalaNativeCommonModule: object test extends ScalaNativeTests with SharafTestModule - } - trait FormsonModule extends SharafPublishModule with PlatformScalaModule { + trait FormsonModule extends SharafPublishModule with PlatformScalaModule: def artifactName = "formson" def pomSettings = super.pomSettings().copy(description = "Sharaf form binding library") def mvnDeps = super.mvnDeps() ++ Seq( mvn"com.lihaoyi::fastparse::3.1.1" ) - } -} -object validson extends Module { + +object validson extends Module: object jvm extends ValidsonModule with ScalaJvmCommonModule object js extends ValidsonModule with ScalaJSCommonModule - object native extends ValidsonModule with ScalaNativeCommonModule { + object native extends ValidsonModule with ScalaNativeCommonModule: object test extends ScalaNativeTests with SharafTestModule - } - trait ValidsonModule extends SharafPublishModule with PlatformScalaModule { + trait ValidsonModule extends SharafPublishModule with PlatformScalaModule: def artifactName = "validson" + def pomSettings = super.pomSettings().copy(description = "Sharaf validation library") def mvnDeps = super.mvnDeps() ++ Seq( mvn"com.lihaoyi::sourcecode::0.4.2" ) - def pomSettings = super.pomSettings().copy(description = "Sharaf validation library") - } -} -trait SharafPublishModule extends SharafCommonModule with SonatypeCentralPublishModule { +trait SharafPublishModule extends SharafCommonModule with SonatypeCentralPublishModule: def publishVersion = VcsVersion.vcsState().format() def pomSettings = PomSettings( organization = "ba.sake", @@ -127,61 +111,53 @@ trait SharafPublishModule extends SharafCommonModule with SonatypeCentralPublish Developer("sake92", "Sakib Hadžiavdić", "https://sake.ba") ) ) -} -trait SharafCommonModule extends ScalaModule { - def scalaVersion = "3.4.2" + +trait SharafCommonModule extends ScalaModule: + def scalaVersion = "3.7.1" def scalacOptions = super.scalacOptions() ++ Seq( "-Yretain-trees", // needed for default parameters "-deprecation", "-Wunused:all", "-explain" ) -} -trait ScalaJvmCommonModule extends ScalaModule { +trait ScalaJvmCommonModule extends ScalaModule: object test extends ScalaTests with SharafTestModule -} -trait ScalaJSCommonModule extends ScalaJSModule { +trait ScalaJSCommonModule extends ScalaJSModule: def scalaJSVersion = "1.19.0" def mvnDeps = super.mvnDeps() ++ Seq( mvn"io.github.cquiroz::scala-java-time::2.6.0" ) object test extends ScalaJSTests with SharafTestModule -} -trait ScalaNativeCommonModule extends ScalaNativeModule { +trait ScalaNativeCommonModule extends ScalaNativeModule: def scalaNativeVersion = "0.5.7" def mvnDeps = super.mvnDeps() ++ Seq( mvn"io.github.cquiroz::scala-java-time::2.6.0" ) - -} -trait SharafTestModule extends TestModule.Munit { + +trait SharafTestModule extends TestModule.Munit: def mvnDeps = Seq( mvn"org.scalameta::munit::1.1.0" ) -} //////////////////// examples -trait SharafExampleModule extends SharafCommonModule { +trait SharafExampleModule extends SharafCommonModule: def mvnDeps = Seq( mvn"ch.qos.logback:logback-classic:1.4.6" ) -} -object examples extends mill.Module { - object api extends SharafExampleModule { +object examples extends mill.Module: + object api extends SharafExampleModule: def moduleDeps = Seq(`sharaf-undertow`) object test extends ScalaTests with SharafTestModule - } - object fullstack extends SharafExampleModule { + object fullstack extends SharafExampleModule: def moduleDeps = Seq(`sharaf-undertow`) object test extends ScalaTests with SharafTestModule - } - object `user-pass-form` extends SharafExampleModule { + object `user-pass-form` extends SharafExampleModule: def moduleDeps = Seq(`sharaf-undertow`) def mvnDeps = super.mvnDeps() ++ Seq( mvn"org.pac4j:undertow-pac4j:5.0.1", @@ -189,20 +165,16 @@ object examples extends mill.Module { mvn"org.mindrot:jbcrypt:0.4" ) object test extends ScalaTests with SharafTestModule - } - object oauth2 extends SharafExampleModule { + object oauth2 extends SharafExampleModule: def moduleDeps = Seq(`sharaf-undertow`) def mvnDeps = super.mvnDeps() ++ Seq( mvn"org.pac4j:undertow-pac4j:5.0.1", mvn"org.pac4j:pac4j-oauth:5.7.0" ) - object test extends ScalaTests with SharafTestModule { + object test extends ScalaTests with SharafTestModule: def mvnDeps = super.mvnDeps() ++ Seq( mvn"no.nav.security:mock-oauth2-server:0.5.10" ) - } - } - object snunit extends SharafExampleModule with ScalaNativeCommonModule { + object snunit extends SharafExampleModule with ScalaNativeCommonModule: def moduleDeps = Seq(`sharaf-snunit`) - } -} +end examples \ No newline at end of file diff --git a/formson/src/ba/sake/formson/parse.scala b/formson/src/ba/sake/formson/parse.scala index 4460679..cfad5a1 100644 --- a/formson/src/ba/sake/formson/parse.scala +++ b/formson/src/ba/sake/formson/parse.scala @@ -109,7 +109,7 @@ private[formson] class KeyParser(key: String) { private val ForbiddenKeyChars = Set('[', ']', '.') def parse(): Seq[String] = - val res = fastparse.parse(key, parseFinal(_)) + val res = fastparse.parse(key, parseFinal(using _)) res match case Success((firstKey, subKeys), index) => subKeys.prepended(firstKey) case f: Failure => throw FormsonException(f.msg) diff --git a/querson/src/ba/sake/querson/parse.scala b/querson/src/ba/sake/querson/parse.scala index b9a2c67..c937b99 100644 --- a/querson/src/ba/sake/querson/parse.scala +++ b/querson/src/ba/sake/querson/parse.scala @@ -115,7 +115,7 @@ private[querson] class KeyParser(key: String) { def parse(): Seq[String] = { - val res = fastparse.parse(key, parseFinal(_)) + val res = fastparse.parse(key, parseFinal(using _)) res match case Success((firstKey, subKeys), index) => subKeys.prepended(firstKey) case f: Failure => throw QuersonException(f.msg) diff --git a/sharaf-snunit/src/ba/sake/sharaf/snunit/SharafRequestHandler.scala b/sharaf-snunit/src/ba/sake/sharaf/snunit/SharafRequestHandler.scala index 412e538..af723f5 100644 --- a/sharaf-snunit/src/ba/sake/sharaf/snunit/SharafRequestHandler.scala +++ b/sharaf-snunit/src/ba/sake/sharaf/snunit/SharafRequestHandler.scala @@ -1,9 +1,9 @@ package ba.sake.sharaf.snunit +import java.io.ByteArrayOutputStream import snunit.{Request as SnunitRequest, *} import ba.sake.sharaf.* import ba.sake.sharaf.routing.* -import java.io.ByteArrayOutputStream class SharafRequestHandler(routes: Routes) extends RequestHandler { override def handleRequest(snunitRequest: SnunitRequest): Unit = { From 22290d47cbc9538dc0c2ea74e16e688606fdea82 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Sun, 6 Jul 2025 00:13:17 +0200 Subject: [PATCH 03/13] Migrate docs to flatmark --- .github/workflows/ghpages.yml | 10 +- .gitignore | 3 +- docs/_config.yaml | 17 ++ docs/_data/project.yaml | 13 ++ docs/_includes/footer.html | 18 ++ docs/content/howtos/cors.md | 19 +++ docs/content/howtos/exception-handler.md | 24 +++ docs/content/howtos/external-config.md | 34 ++++ docs/content/howtos/index.md | 12 ++ docs/content/howtos/not-found.md | 22 +++ docs/content/howtos/query-params.md | 96 +++++++++++ docs/content/howtos/redirect.md | 16 ++ docs/content/howtos/response-body.md | 28 +++ docs/content/howtos/routes.md | 122 +++++++++++++ docs/content/howtos/upload-file.md | 29 ++++ docs/content/index.md | 25 +++ .../philosophy/Authorization.scala | 0 docs/content/philosophy/alternatives.md | 29 ++++ docs/content/philosophy/authentication.md | 67 ++++++++ .../philosophy/dependency-injection.md | 65 +++++++ docs/content/philosophy/index.md | 33 ++++ .../philosophy/query-params-handling.md | 56 ++++++ docs/content/philosophy/routes-matching.md | 67 ++++++++ docs/content/reference/index.md | 11 ++ docs/content/tutorials/forms.md | 66 ++++++++ docs/content/tutorials/hello-world.md | 38 +++++ docs/content/tutorials/html.md | 101 +++++++++++ docs/content/tutorials/htmx.md | 65 +++++++ docs/content/tutorials/index.md | 37 ++++ docs/content/tutorials/json.md | 95 +++++++++++ docs/content/tutorials/path-params.md | 42 +++++ docs/content/tutorials/query-params.md | 60 +++++++ docs/content/tutorials/quickstart.md | 50 ++++++ docs/content/tutorials/sql.md | 105 ++++++++++++ docs/content/tutorials/static-files.md | 43 +++++ docs/content/tutorials/tests.md | 62 +++++++ docs/content/tutorials/validation.md | 137 +++++++++++++++ docs/src/files/Index.scala | 46 ----- docs/src/files/SearchIndex.scala | 12 -- docs/src/files/SearchResults.scala | 45 ----- docs/src/files/howtos/CORS.scala | 31 ---- docs/src/files/howtos/ExceptionHandler.scala | 35 ---- docs/src/files/howtos/ExternalConfig.scala | 45 ----- docs/src/files/howtos/HowToPage.scala | 24 --- docs/src/files/howtos/Index.scala | 20 --- docs/src/files/howtos/NotFound.scala | 32 ---- docs/src/files/howtos/QueryParams.scala | 120 ------------- docs/src/files/howtos/Redirect.scala | 28 --- docs/src/files/howtos/ResponseBody.scala | 39 ----- docs/src/files/howtos/Routes.scala | 160 ------------------ docs/src/files/howtos/UploadFile.scala | 43 ----- docs/src/files/philosophy/Alternatives.scala | 41 ----- .../src/files/philosophy/Authentication.scala | 81 --------- .../philosophy/DependencyInjection.scala | 68 -------- docs/src/files/philosophy/Index.scala | 43 ----- .../src/files/philosophy/PhilosophyPage.scala | 17 -- .../philosophy/QueryParamsHandling.scala | 85 ---------- .../src/files/philosophy/RoutesMatching.scala | 85 ---------- docs/src/files/reference/Index.scala | 23 --- docs/src/files/reference/ReferencePage.scala | 13 -- docs/src/files/tutorials/HTML.scala | 55 ------ docs/src/files/tutorials/HTMX.scala | 43 ----- docs/src/files/tutorials/HandlingForms.scala | 38 ----- docs/src/files/tutorials/HelloWorld.scala | 38 ----- docs/src/files/tutorials/Index.scala | 69 -------- docs/src/files/tutorials/JsonAPI.scala | 87 ---------- docs/src/files/tutorials/PathParams.scala | 39 ----- docs/src/files/tutorials/QueryParams.scala | 53 ------ docs/src/files/tutorials/SqlDb.scala | 94 ---------- docs/src/files/tutorials/StaticFiles.scala | 43 ----- docs/src/files/tutorials/Tests.scala | 39 ----- docs/src/files/tutorials/TutorialPage.scala | 37 ---- docs/src/files/tutorials/Validation.scala | 108 ------------ docs/src/utils/Consts.scala | 22 --- docs/src/utils/ScalaCliFiles.scala | 39 ----- docs/src/utils/package.scala | 36 ---- docs/src/utils/templates.scala | 131 -------------- docs/{resources/public => static}/.nojekyll | 0 .../public => static}/images/favicon.png | Bin .../public => static}/images/favicon.svg | 0 .../images/logo_original.png | Bin .../public => static}/scripts/main.js | 0 .../public => static}/styles/main.css | 0 83 files changed, 1714 insertions(+), 2110 deletions(-) create mode 100644 docs/_config.yaml create mode 100644 docs/_data/project.yaml create mode 100644 docs/_includes/footer.html create mode 100644 docs/content/howtos/cors.md create mode 100644 docs/content/howtos/exception-handler.md create mode 100644 docs/content/howtos/external-config.md create mode 100644 docs/content/howtos/index.md create mode 100644 docs/content/howtos/not-found.md create mode 100644 docs/content/howtos/query-params.md create mode 100644 docs/content/howtos/redirect.md create mode 100644 docs/content/howtos/response-body.md create mode 100644 docs/content/howtos/routes.md create mode 100644 docs/content/howtos/upload-file.md create mode 100644 docs/content/index.md rename docs/{src/files => content}/philosophy/Authorization.scala (100%) create mode 100644 docs/content/philosophy/alternatives.md create mode 100644 docs/content/philosophy/authentication.md create mode 100644 docs/content/philosophy/dependency-injection.md create mode 100644 docs/content/philosophy/index.md create mode 100644 docs/content/philosophy/query-params-handling.md create mode 100644 docs/content/philosophy/routes-matching.md create mode 100644 docs/content/reference/index.md create mode 100644 docs/content/tutorials/forms.md create mode 100644 docs/content/tutorials/hello-world.md create mode 100644 docs/content/tutorials/html.md create mode 100644 docs/content/tutorials/htmx.md create mode 100644 docs/content/tutorials/index.md create mode 100644 docs/content/tutorials/json.md create mode 100644 docs/content/tutorials/path-params.md create mode 100644 docs/content/tutorials/query-params.md create mode 100644 docs/content/tutorials/quickstart.md create mode 100644 docs/content/tutorials/sql.md create mode 100644 docs/content/tutorials/static-files.md create mode 100644 docs/content/tutorials/tests.md create mode 100644 docs/content/tutorials/validation.md delete mode 100644 docs/src/files/Index.scala delete mode 100644 docs/src/files/SearchIndex.scala delete mode 100644 docs/src/files/SearchResults.scala delete mode 100644 docs/src/files/howtos/CORS.scala delete mode 100644 docs/src/files/howtos/ExceptionHandler.scala delete mode 100644 docs/src/files/howtos/ExternalConfig.scala delete mode 100644 docs/src/files/howtos/HowToPage.scala delete mode 100644 docs/src/files/howtos/Index.scala delete mode 100644 docs/src/files/howtos/NotFound.scala delete mode 100644 docs/src/files/howtos/QueryParams.scala delete mode 100644 docs/src/files/howtos/Redirect.scala delete mode 100644 docs/src/files/howtos/ResponseBody.scala delete mode 100644 docs/src/files/howtos/Routes.scala delete mode 100644 docs/src/files/howtos/UploadFile.scala delete mode 100644 docs/src/files/philosophy/Alternatives.scala delete mode 100644 docs/src/files/philosophy/Authentication.scala delete mode 100644 docs/src/files/philosophy/DependencyInjection.scala delete mode 100644 docs/src/files/philosophy/Index.scala delete mode 100644 docs/src/files/philosophy/PhilosophyPage.scala delete mode 100644 docs/src/files/philosophy/QueryParamsHandling.scala delete mode 100644 docs/src/files/philosophy/RoutesMatching.scala delete mode 100644 docs/src/files/reference/Index.scala delete mode 100644 docs/src/files/reference/ReferencePage.scala delete mode 100644 docs/src/files/tutorials/HTML.scala delete mode 100644 docs/src/files/tutorials/HTMX.scala delete mode 100644 docs/src/files/tutorials/HandlingForms.scala delete mode 100644 docs/src/files/tutorials/HelloWorld.scala delete mode 100644 docs/src/files/tutorials/Index.scala delete mode 100644 docs/src/files/tutorials/JsonAPI.scala delete mode 100644 docs/src/files/tutorials/PathParams.scala delete mode 100644 docs/src/files/tutorials/QueryParams.scala delete mode 100644 docs/src/files/tutorials/SqlDb.scala delete mode 100644 docs/src/files/tutorials/StaticFiles.scala delete mode 100644 docs/src/files/tutorials/Tests.scala delete mode 100644 docs/src/files/tutorials/TutorialPage.scala delete mode 100644 docs/src/files/tutorials/Validation.scala delete mode 100644 docs/src/utils/Consts.scala delete mode 100644 docs/src/utils/ScalaCliFiles.scala delete mode 100644 docs/src/utils/package.scala delete mode 100644 docs/src/utils/templates.scala rename docs/{resources/public => static}/.nojekyll (100%) rename docs/{resources/public => static}/images/favicon.png (100%) rename docs/{resources/public => static}/images/favicon.svg (100%) rename docs/{resources/public => static}/images/logo_original.png (100%) rename docs/{resources/public => static}/scripts/main.js (100%) rename docs/{resources/public => static}/styles/main.css (100%) diff --git a/.github/workflows/ghpages.yml b/.github/workflows/ghpages.yml index 1c27ac4..b8da859 100644 --- a/.github/workflows/ghpages.yml +++ b/.github/workflows/ghpages.yml @@ -19,8 +19,14 @@ jobs: distribution: temurin java-version: 21 - name: Build - run: ./mill -i docs.hepek + env: + FLATMARK_BASE_URL: https://sake92.github.io/sharaf + run: | + FLATMARK_VERSION=0.0.24 + curl -L https://github.com/sake92/flatmark/releases/download/v${FLATMARK_VERSION}/flatmark_${FLATMARK_VERSION}_amd64.deb -o flatmark.deb + sudo apt install -y ./flatmark.deb + flatmark build - name: Deploy uses: JamesIves/github-pages-deploy-action@v4 with: - folder: docs/hepek_output + folder: _site diff --git a/.gitignore b/.gitignore index 4dca834..b8c8f59 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,6 @@ out/ .env -hepek_output/ +_site/ +.flatmark-cache diff --git a/docs/_config.yaml b/docs/_config.yaml new file mode 100644 index 0000000..ee888bb --- /dev/null +++ b/docs/_config.yaml @@ -0,0 +1,17 @@ + +name: Sharaf Documentation +description: Sharaf Framework Documentation + +categories: + tutorials: + label: Tutorials + description: Sharaf Tutorials + howtos: + label: How-Tos + description: Sharaf How-Tos + reference: + label: Reference + description: Sharaf Reference Documentation + philosophy: + label: Philosophy + description: Sharaf Philosophy diff --git a/docs/_data/project.yaml b/docs/_data/project.yaml new file mode 100644 index 0000000..a3ed06e --- /dev/null +++ b/docs/_data/project.yaml @@ -0,0 +1,13 @@ +name: Sharaf + +gh: + handle: "sake92" + projectName: "sharaf" + url: "https://github.com/sake92/sharaf" + sourcesUrl: "https://github.com/sake92/sharaf/tree/main" + +artifact: + org: "ba.sake" + name: "sharaf-undertow" + version: "0.11.1" + diff --git a/docs/_includes/footer.html b/docs/_includes/footer.html new file mode 100644 index 0000000..556c08c --- /dev/null +++ b/docs/_includes/footer.html @@ -0,0 +1,18 @@ + \ No newline at end of file diff --git a/docs/content/howtos/cors.md b/docs/content/howtos/cors.md new file mode 100644 index 0000000..5d5e423 --- /dev/null +++ b/docs/content/howtos/cors.md @@ -0,0 +1,19 @@ +--- +title: CORS +description: Sharaf How To CORS +--- + +# {{ page.title }} + +By default, Sharaf sets no permitted origins. +This means you can only use the API/website from the same domain. + +If you want to configure it to be available for other domains, +use the `withCorsSettings` method and set desired config: +```scala +import ba.sake.sharaf.handlers.cors.CorsSettings +import ba.sake.sharaf.* + +val corsSettings = CorsSettings.default.withAllowedOrigins(Set("https://example.com")) +UndertowSharafServer(routes).withCorsSettings(corsSettings)... +``` diff --git a/docs/content/howtos/exception-handler.md b/docs/content/howtos/exception-handler.md new file mode 100644 index 0000000..d72c101 --- /dev/null +++ b/docs/content/howtos/exception-handler.md @@ -0,0 +1,24 @@ +--- +title: Exception Handler +description: Sharaf How To Exception Handler +--- + +# {{ page.title }} + +How to customize the Exception handler? + +Use the `withExceptionMapper` on `UndertowSharafServer`: +```scala +val customExceptionMapper: ExceptionMapper = { + case e: MyException => + val errorPage = MyErrorPage(e.getMessage()) + Response.withBody(errorPage) + .withStatus(StatusCode.InternalServerError) +} +val finalExceptionMapper = customExceptionMapper.orElse(ExceptionMapper.default) +val server = UndertowSharafServer(routes).withExceptionMapper(finalExceptionMapper) +``` + +The `ExceptionMapper` is a partial function from an exception to `Response`. +Here we need to chain our custom exception mapper before the default one. + diff --git a/docs/content/howtos/external-config.md b/docs/content/howtos/external-config.md new file mode 100644 index 0000000..c9344f1 --- /dev/null +++ b/docs/content/howtos/external-config.md @@ -0,0 +1,34 @@ +--- +title: External Config +description: Sharaf How To External Config +--- + +# {{ page.title }} + +The [typesafe config](https://github.com/lightbend/config) library is already included in Sharaf. +Also included is the [tupson-config](https://sake92.github.io/tupson/tutorials/parsing-config.html#usage-1) which simplifies the process: + +```scala +import java.net.URL +import com.typesafe.config.ConfigFactory +import ba.sake.tupson.{given, *} +import ba.sake.tupson.config.* + +case class MyConf( + port: Int, + url: URL, + string: String, + seq: Seq[String] +) derives JsonRW + +val rawConfig = ConfigFactory.parseString(""" + port = 7777 + url = "http://example.com" + string = "str" + seq = [a, "b", c] +""") + +val myConf = rawConfig.parseConfig[MyConf] +// MyConf(7777,http://example.com,str,List(a, b, c)) +``` + diff --git a/docs/content/howtos/index.md b/docs/content/howtos/index.md new file mode 100644 index 0000000..221d217 --- /dev/null +++ b/docs/content/howtos/index.md @@ -0,0 +1,12 @@ +--- +title: How Tos +description: Sharaf How Tos +pagination: + sort_by: title +--- + +# {{ page.title }} + + +Here are some common questions and answers you might have when using Sharaf. + diff --git a/docs/content/howtos/not-found.md b/docs/content/howtos/not-found.md new file mode 100644 index 0000000..e68efba --- /dev/null +++ b/docs/content/howtos/not-found.md @@ -0,0 +1,22 @@ +--- +title: NotFound +description: Sharaf How To NotFound +--- + +# {{ page.title }} + +How to customize 404 NotFound handler? + + +Use the `withNotFoundHandler` on `UndertowSharafServer`: +```scala +UndertowSharafServer(routes).withNotFoundHandler { req => + Response.withBody(MyCustomNotFoundPage) + .withStatus(StatusCode.NotFound) +} +``` + +The `withNotFoundHandler` accepts a `Request => Response[?]` parameter. +You can use the request if you need to dynamically decide on what to return. +Or ignore it and return a static not found response. + diff --git a/docs/content/howtos/query-params.md b/docs/content/howtos/query-params.md new file mode 100644 index 0000000..3ef1a0e --- /dev/null +++ b/docs/content/howtos/query-params.md @@ -0,0 +1,96 @@ +--- +title: Query Parameters +description: Sharaf How To Query Parameters +--- + +# {{ page.title }} + + +## How to bind query parameter as an enum? + +Sharaf needs a `QueryStringRW[T]` instance for query params. +It can automatically derive an instance for singleton enums: + +```scala +enum Cloud derives QueryStringRW: + case aws, gcp, azure + +case class MyQueryParams( + cloud: Cloud +) derives QueryStringRW +``` + +## How to bind optional query parameter? + +The first option is to set the parameter to `Option[T]`: +```scala +case class MyQP(mandatory: String, opt: Option[Int]) derives QueryStringRW +``` +If you make a request with params `?mandatory=abc`, `opt` will have value of `None`. + +--- +The second option is to set the parameter to some default value: +```scala +case class MyQP2(mandatory: String, opt: Int = 42) derives QueryStringRW +``` +Here if you make a request with params `?mandatory=abc` the `opt` will have value of `42`. + +> Note that you need the `-Yretain-trees` scalac flag turned on, otherwise it won't work! + +## How to bind sequence query parameter? + +Set the parameter to `Seq[T]`: +```scala +case class MyQP(seq: Seq[Int]) derives QueryStringRW +``` + +Let's consider a few possible requests with these query params: +- `?` (empty) -> `seq` will be empty `Seq()` +- `?seq=123` -> `seq` will be `Seq(123)` +- `?seq[]=123&seq[]=456` -> `seq` will be `Seq(123, 456)` +- `?seq[1]=123&seq[0]=456` -> `seq` will be `Seq(456, 123)` (note it is sorted here) + +## How to bind composite query parameter? + +You can make a common query params class and use it in multiple top-level query params, or standalone: +```scala +case class PageQP(page: Int, size: Int) derives QueryStringRW +case class MyQP(q: String, p: PageQP) derives QueryStringRW +``` + +Sharaf is quite lenient when parsing the query parameters, so all these combinations will work: +- `?q=abc&p.page=0&p.size=10` -> object style +- `?q=abc&p[page]=0&p[size]=10` -> brackets style +- `?q=abc&p[page]=0&p.size=10` -> mixed style (dont) + + +## How to bind a custom query parameter? + +When you want to handle a custom *scalar* value in query params, +you need to implement a `QueryStringRW[T]` instance manually: +```scala +import ba.sake.querson.* + +given QueryStringRW[MyType] with { + override def write(path: String, value: MyType): QueryStringData = + QueryStringRW[String].write(path, value.toString) + + override def parse(path: String, qsData: QueryStringData): MyType = + val str = QueryStringRW[String].parse(path, qsData) + Try(MyType.fromString(str)).toOption.getOrElse(typeError(path, "MyType", str)) +} + +private def typeError(path: String, tpe: String, value: Any): Nothing = + throw ParsingException(ParseError(path, s"invalid \$tpe", Some(value))) +``` + +Then you can use it: +```scala +case class MyQueryParams( + myType: MyType +) derives QueryStringRW +``` + +--- +Note that Sharaf can automatically derive an instance for singleton enums. + diff --git a/docs/content/howtos/redirect.md b/docs/content/howtos/redirect.md new file mode 100644 index 0000000..0a069f0 --- /dev/null +++ b/docs/content/howtos/redirect.md @@ -0,0 +1,16 @@ +--- +title: Redirect +description: Sharaf How To Redirect +--- + +# {{ page.title }} + + +Use the `Response.redirect` function: +```scala +case GET -> Path("a-deprecated-route") => + Response.redirect("/this-other-place") +``` + +This will redirect the request to "/this-other-place", +with status `301 MOVED_PERMANENTLY` diff --git a/docs/content/howtos/response-body.md b/docs/content/howtos/response-body.md new file mode 100644 index 0000000..41297d2 --- /dev/null +++ b/docs/content/howtos/response-body.md @@ -0,0 +1,28 @@ +--- +title: Response Body +description: Sharaf How To Response Body +--- + +# {{ page.title }} + +How to use a custom response body? + +You need to define a custom `ResponseWritable[T]` for your type `T`. + +Let's say you have a `MyXML` class, and you want to use it as a response body. +You would write something like this: +```scala +given ResponseWritable[MyXML] with { + override def write(value: MyXML, exchange: HttpServerExchange): Unit = + exchange.getResponseSender.send(value.asString) + override def headers(value: String): Seq[(HttpString, Seq[String])] = Seq( + HttpString(HeaderNames.ContentType) -> Seq("text/xml") + ) +} +``` + +Now you can use `MyXML` as a response body: +```scala +val myXml = MyXML(...) +Response.withBody(myXml) +``` diff --git a/docs/content/howtos/routes.md b/docs/content/howtos/routes.md new file mode 100644 index 0000000..02a8479 --- /dev/null +++ b/docs/content/howtos/routes.md @@ -0,0 +1,122 @@ +--- +title: Routes +description: Sharaf How To Routes +--- + +# {{ page.title }} + +## How to match on multiple methods? + +You can use the `|` operator in a pattern match: +```scala +case (GET | POST) -> Path() => +... +``` +You can always check the [Scala docs](https://docs.scala-lang.org/scala3/book/control-structures.html#handling-multiple-possible-matches-on-one-line) +for more help. + +--- +If you want to handle all possible methods, just don't use any extractors: +```scala +case method -> Path() => +... +``` + + +## How to match on multiple paths? +You can use the `|` operator in a pattern match: +```scala +case GET -> (Path("hello") | Path("hello-world")) => + ... +``` +You can always check the [Scala docs](https://docs.scala-lang.org/scala3/book/control-structures.html#handling-multiple-possible-matches-on-one-line) +for more help. + +--- +If you want to handle all paths that start with "my-prefix/": +```scala +case GET -> Path("my-prefix", segments*) => + ... +``` + +--- +If you want to handle all possible paths: +```scala +case GET -> Path(segments*) => + ... +``` + +## How to bind path parameter as an enum? + +Sharaf needs a `FromPathParam[T]` instance for the `param[T]` extractor. +It can automatically derive an instance for singleton enums: + +```scala +enum Cloud derives FromPathParam: + case aws, gcp, azure + +val routes = Routes: + case GET -> Path("pricing", param[Cloud](cloud)) => + Response.withBody(s"cloud = ${cloud}") +``` + +## How to bind path parameter as a regex? + +```scala +val userIdRegex = "user_id_(\\d+)".r + +val routes = Routes: + case GET -> Path("pricing", userIdRegex(userId)) => + Response.withBody(s"userId = ${userId}") +``` + +Note that the `userId` is bound as a `String`. + +You could further match on it, for example: +```scala +val routes = Routes: + case GET -> Path("pricing", userIdRegex(param[Int](userId))) => +``` +would extract `userId` as an `Int`. + + +## How to bind a custom path parameter? + +Sharaf needs a `FromPathParam[T]` instance available: +```scala +import ba.sake.sharaf.routing.* + +given FromPathParam[MyType] with { + def parse(str: String): Option[MyType] = + parseMyType(str) // impl here +} + +val routes = Routes: + case GET -> Path("pricing", param[MyType](myType)) => + Response.withBody(s"myType = ${myType}") +``` + +## How to split Routes? + +When you have lots of routes, you will want to split them into multiple `Routes` handlers. +Combining them is done with `Routes.merge`. +The order of routes is preserved, of course: +```scala +val routes: Seq[Routes] = Seq(routes1, routes2, ... ) + +val allRoutes: Routes = Routes.merge(routes) +``` + +You can also `extend SharafController` instead of `Routes` directly. +```scala +class MyController1 extends SharafController: + override def routes: Routes = Routes: + case ... +class MyController2 extends SharafController: + override def routes: Routes = Routes: + case ... + +val server = UndertowSharafServer( + new MyController1, new MyController2 +) +``` diff --git a/docs/content/howtos/upload-file.md b/docs/content/howtos/upload-file.md new file mode 100644 index 0000000..f680c06 --- /dev/null +++ b/docs/content/howtos/upload-file.md @@ -0,0 +1,29 @@ +--- +title: Upload File +description: Sharaf How To Upload File +--- + +# {{ page.title }} + +Uploading a file is usually done via `multipart/form-data` form submission. + + +```scala +// 1. somewhere in a view, use enctype="multipart/form-data" +form(action := "/form-submit", method := "POST", enctype := "multipart/form-data")( + ... +) + +// 2. define form data class with a NIO Path file +import java.nio.file.Path +import ba.sake.formson.* + +case class MyData(file: Path) derives FormDataRW + +// 3. handle the file however you want +case POST -> Path("form-submit") => + val formData = Request.current.bodyForm[MyData] + val fileAsString = Files.readString(formData.file) +``` + +You can find a working example in the [repo]({{site.data.project.gh.sourcesUrl}}/examples/fullstack). diff --git a/docs/content/index.md b/docs/content/index.md new file mode 100644 index 0000000..957de49 --- /dev/null +++ b/docs/content/index.md @@ -0,0 +1,25 @@ +--- +title: Sharaf +description: Sharaf - a minimalistic Scala 3 web framework. +--- + +# {{ page.title }} + +Sharaf is a minimalistic Scala 3 web framework. + +Jump right into: +- [Tutorials](/tutorials) to get you started +- [How-Tos](/howtos) to get answers for some common questions +- [Reference](/reference) to see detailed information +- [Philosophy](/philosophy) to get insights into design decisions + +--- +Site map: + +TODO + + + + + + diff --git a/docs/src/files/philosophy/Authorization.scala b/docs/content/philosophy/Authorization.scala similarity index 100% rename from docs/src/files/philosophy/Authorization.scala rename to docs/content/philosophy/Authorization.scala diff --git a/docs/content/philosophy/alternatives.md b/docs/content/philosophy/alternatives.md new file mode 100644 index 0000000..a0fc506 --- /dev/null +++ b/docs/content/philosophy/alternatives.md @@ -0,0 +1,29 @@ +--- +title: Alternatives +description: Sharaf Alternatives +--- + +# {{ page.title }} + +What about other frameworks? + +## Async frameworks like Play, Akka HTTP etc +Synchronous programming is much, much easier to understand, debug, profile etc.. +Benefits (performance/throughput) of async handling are mostly void in Java 21, with introduction of Virtual threads. Yay! + +Only bummer for now is that Undertow doesn't still support them.. :/ +But undertow is very performant in the current shape too, so for most use cases it will be enough. +Java 24 is a game changer for Undertow, because it solves the problem of [Synchronize Virtual Threads without Pinning](https://openjdk.org/jeps/491). + +## Pure FP libs like http4s, zio-http etc + +Too much focus on purely functional programming and (mostly unnecessary) math concepts. +Easy to get lost in that and overcomplicate your code. + +## Enterprise frameworks like Spring Framework, Quarkus etc +Too much annotations, autoconfigurations, dependency injection, proxies and complexity. + +## Standalone JEE servers like Tomcat, Jetty etc +I was looking into these, but then sharaf would have to depend on Servlets API, +use `@Inject` and gazzilion of god-knows-what-they-do annotations just to configure OAuth2 for example... + diff --git a/docs/content/philosophy/authentication.md b/docs/content/philosophy/authentication.md new file mode 100644 index 0000000..121b143 --- /dev/null +++ b/docs/content/philosophy/authentication.md @@ -0,0 +1,67 @@ +--- +title: Authentication +description: Sharaf Authentication +--- + + +# {{ page.title }} + +Some important security principles from OWASP guidelines: +- use HTTPS +- use random user ids to prevent enumeration and other attacks +- use strong passwords, store them hashed, implement password recovery +- use MFA, CAPTCHA, rate limiting etc to prevent automated attacks +- etc. + +Read all of them in the [OWASP auth cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html). + + +## Pac4j + +Authentication in Sharaf is done usually by delegating it to [pac4j](https://www.pac4j.org/index.html). +Pac4j is a battle-tested and widely used library for authentication and authorization. +It supports many authentication mechanisms, including: +- form based authentication (username + password) +- OAuth2, with many providers (Google, Facebook, GitHub, etc) + +Pac4j has a concept of `Client`, which is a type of authentication mechanism. +The main split is between `IndirectClient` and `DirectClient`. + +### Indirect clients +Indirect clients are used for form based authentication, OAuth2, etc. +An important thing to mention here is the callback URL: +- for username + password authentication, the callback URL where the form is submitted to. Then a server-side session is created and user is signed in. +- for OAuth2 (and similar mechanisms), the callback URL where the user is redirected to *after authentication*. + The server will then exchange the code for an *access token* and create a server-side session. + +### Direct clients +Direct clients are used for API authentication *on every request* (e.g. Basic Auth, JWT, etc). +On every request, the client will extract the credentials from the request and authenticate the user. + + +## Deny by Default Principle + +One important principle in security is the "deny by default" principle. +You should use whitelisting, allow access only to what is needed. +This is because it is easy to forget to deny something, and it is hard to remember everything that should be denied. + +Concretely in pac4j, you can use `PathMatcher()`, to exclude certain paths from authentication: +```scala +val publicRoutesMatcher = PathMatcher() +publicRoutesMatcher.excludePaths("/", "/login-form") +pac4jConfig.addMatcher("publicRoutesMatcher", publicRoutesMatcher) +.. +SecurityHandler.build( + SharafHandler(..), + pac4jConfig, + "client1,client2...", + null, + "securityheaders,publicRoutesMatcher", // use publicRoutesMatcher here! + DefaultSecurityLogic() +) +``` + +There are also: +- `excludeBranch("/somepath")` to exclude all paths starting with "/somepath" +- `excludeRegex("^/somepath/.*\$")` to exclude all paths matching the regex (be careful with this one!) + diff --git a/docs/content/philosophy/dependency-injection.md b/docs/content/philosophy/dependency-injection.md new file mode 100644 index 0000000..2522604 --- /dev/null +++ b/docs/content/philosophy/dependency-injection.md @@ -0,0 +1,65 @@ +--- +title: Dependency Injection +description: Sharaf Dependency Injection +--- + +# {{ page.title }} + +Do you even Dependency Injection?? + +Not in a classical / "dependency container" / Spring / JEE style. + +Not in a purely-functional-monadic style. + +Yes in a direct style: +- for singletons: just *instantiate a class* and pass the object around. +- for request/session-scoped instances: use scala 3 *context functions* (implicit functions). + +If you ever used PlayFramework, Slick 2 and similar, you might have used this pattern: +```scala +someFunction { implicit ctx => + // some code that needs an implicit Ctx +} +``` + +In Scala 3 there is a new concept called "context function" which represents the pattern from above with a type: +```scala +type ContextualAction = Ctx ?=> Unit +``` +Now, instead of manually writing `implicit ctx` we can skip it: +```scala +someFunction { + // some code that needs an implicit Ctx +} +``` +and compiler will fill it in for us. +The `Ctx` parameter is implicitly/contextually available (only) in the function body. +You can get it with `summon[Ctx]` or `using ctx: Ctx` (which is a bit more readable). + + +--- +Sharaf has the `Routes` type that is defined as `Request ?=> PartialFunction[RequestParams, Response[?]]`. +This means that you can call `Request.current` only in a `Routes` definition body (because it requires a `given Request` to be present). + +If you need a request-scoped instance (a-la `@RequestScoped @Bean` in Spring), +you need to define a function that is `using Request`: +```scala +def currentUser(using req: Request): User = + // extract stuff from request +``` +Same as `Request.current`, you can only use the `currentUser` function in a context of a request! + +--- + +By using context functions, you avoid [banging your head against the wall](https://stackoverflow.com/questions/26305295/how-is-the-requestscoped-bean-instance-provided-to-sessionscoped-bean-in-runti) +while trying to figure out how-the-hell can you inject a request-scoped-thing into a singleton/session-scoped thing... +Proxy to proxy to proxy, something, something.. ok. + +You also avoid reading yet-another-lousy-monad-tutorial, losing your brain-battle agains `State`, `RWS`, `Kleisli`, higher-kinded-types, weird macros, compile times and type inference... + + + + + + + diff --git a/docs/content/philosophy/index.md b/docs/content/philosophy/index.md new file mode 100644 index 0000000..ce08dcd --- /dev/null +++ b/docs/content/philosophy/index.md @@ -0,0 +1,33 @@ +--- +title: Philosophy +description: Sharaf Philosophy +pagination: + sort_by: title +--- + +# {{ page.title }} + +## Why Sharaf? + +Simplicity and ease of use is the main focus of Sharaf. + +Sharaf is built on top of [Undertow](https://undertow.io/). +This means you can use awesome libraries built for Undertow, like [pac4j](https://github.com/pac4j/undertow-pac4j) for security and others. +You can leverage Undertow's lower level API, e.g. for WebSockets. + +Sharaf bundles a set of standalone libraries: +- [querson]({{site.data.project.gh.sourcesUrl}}/querson) for query parameters +- [tupson](https://github.com/sake92/tupson) for JSON +- [formson]({{site.data.project.gh.sourcesUrl}}/formson) for forms +- [validson]({{site.data.project.gh.sourcesUrl}}/validson) for validation +- [scalatags](https://github.com/com-lihaoyi/scalatags) for HTML +- [sttp](https://sttp.softwaremill.com/en/latest/) for firing HTTP requests +- [typesafe-config](https://github.com/lightbend/config) for configuration + +You can use any of above separately in your projects. + + +## Why name "Sharaf" + +Šaraf means "a screw" in Bosnian, which reminds me of scala spiral logo. +It's a germanism I think. diff --git a/docs/content/philosophy/query-params-handling.md b/docs/content/philosophy/query-params-handling.md new file mode 100644 index 0000000..04c711b --- /dev/null +++ b/docs/content/philosophy/query-params-handling.md @@ -0,0 +1,56 @@ +--- +title: Query Params Handling +description: Sharaf Query Params Handling +--- + +# {{ page.title }} + + +## Query params handling design + +Web frameworks do handle query params with various mechanisms: +- annotation + method param: [Spring](https://spring.io/guides/tutorials/rest/) and most other popular Java frameworks, [Cask](https://com-lihaoyi.github.io/cask/) etc +- special route file DSL: [PlayFramework](https://www.playframework.com/documentation/2.9.x/ScalaRouting#The-routes-file-syntax), Ruby on Rails +- in-language DSL: zio-http +- pattern matching: http4s +- parsing from request: Sharaf + + +## Why not annotations? + +This approach is mostly fine, as long as you know from where a parameter comes. + +In Spring you use the `@RequestParam` annotation when you have simple parameters. +But when you want to group them in a class [you don't use it](https://stackoverflow.com/questions/16942193/spring-mvc-complex-object-as-get-requestparam).. #wtf +Also, that same class can be bound from the form body too... convenient? eh. + +In [Cask](https://com-lihaoyi.github.io/cask/#variable-routes) there is no annotation, so it is ambiguous in my opinion. + + +## Why not special route file? +You need a special compiler for this, essentially a new language. +People have to learn how it works, there's probably no syntax highlighting, no autocomplete etc. + + +## Why not in-language DSL? + +Similar to special route file approach, people need to learn it. +Not a huge deal I guess. + + +## Why not pattern matching? +If you look at [http4s' approach](https://http4s.org/v0.23/docs/dsl.html#handling-query-parameters), +you can see that if the query param is not found, it falls through. +It is customizable, but more work for you. eh. +Essentially you'll get a 404.. which is not a good choice IMO. + +Rarely any framework does this, and you rarely want to handle *the same path* in 2 places. + + +## Sharaf's approach + +Sharaf parses query params from the `Request`. +Admittedly, you do have to make a new class if you want to parse them in a typesafe way. +But you usually do grouping of these parameters when passing them further, so why not do it immediatelly. + +[Composition](/howtos/query-params.html#how-to-bind-composite-query-parameter) adds even more benefits, which I rarely saw implemented in any framework. diff --git a/docs/content/philosophy/routes-matching.md b/docs/content/philosophy/routes-matching.md new file mode 100644 index 0000000..7047b2d --- /dev/null +++ b/docs/content/philosophy/routes-matching.md @@ -0,0 +1,67 @@ +--- +title: Routes Matching +description: Sharaf Routes Matching +--- + +# {{ page.title }} + + +## Routes matching design + + Web frameworks do their routes matching with various mechanisms: + - annotation + method param: [Spring](https://spring.io/guides/tutorials/rest/) and most other popular Java frameworks, [Cask](https://com-lihaoyi.github.io/cask/) etc + - special route file DSL: [PlayFramework](https://www.playframework.com/documentation/2.9.x/ScalaRouting#The-routes-file-syntax), Ruby on Rails + - in-language DSL: zio-http, akka-http + - pattern matching: Sharaf, http4s + + +## Why not annotations? + +Let's see an example: +```scala +@GetMapping(value = "/student/{studentId}") +public Student studentData1(@PathVariable Integer studentId) {} + +@GetMapping(value = "/student/{studentId}") +public Student studentData2(@PathVariable Integer studentId) {} + +@GetMapping(value = "/student/umm") +public Student studentData3(@PathVariable Integer studentId) {} +``` +Issues: +- the `studentId` appears in 2 places, you can make a typo and nothing will work. +- the `"/student/{studentId}"` route is duplicated, there is no compiler support and it will fail only in runtime.. +- you have to [wonder](https://stackoverflow.com/questions/2326912/ordered-requestmapping-in-spring-mvc) if `studentData1` will be picked up before `studentData3`..!? + + +## Why not special route file? +Well, you need a special compiler for this, essentially a new language. +People have to learn how it works, there's probably no syntax highlighting, no autocomplete etc. + + +## Why not in-language DSL? +Similar to special route file approach, people need to learn it. +And again, you don't leverage compiler's support like exhaustive pattern matching and extractors. + + +## Sharaf's approach + +Sharaf does its route matching in plain scala code. +Scala's pattern matching warns you when you have *duplicate routes*, or *impossible* routes. +For example, if you write this: +```scala +case GET -> Path("cars", brand) => ??? +case GET -> Path("cars", model) => ??? // Unreachable case + +case GET -> Path("files", segments*) => ??? +case GET -> Path("files", "abc.txt") => ??? // Unreachable case +``` +you will get nice warnings, thanks compiler! + +--- +You can extract path variables with pattern matching: +```scala +case GET -> Path("cars", param[Int](carId)) => ??? +``` +Here, the `carId` is parsed as `Int` and it *mentioned only once*, unlike with the annotation approach. + diff --git a/docs/content/reference/index.md b/docs/content/reference/index.md new file mode 100644 index 0000000..a0a9295 --- /dev/null +++ b/docs/content/reference/index.md @@ -0,0 +1,11 @@ +--- +title: Reference +description: Sharaf Reference +--- + +# {{ page.title }} + +Sharaf reference", + +Take a look at [Sharaf scaladoc](https://javadoc.io/doc/ba.sake/sharaf_3). + diff --git a/docs/content/tutorials/forms.md b/docs/content/tutorials/forms.md new file mode 100644 index 0000000..563fbdc --- /dev/null +++ b/docs/content/tutorials/forms.md @@ -0,0 +1,66 @@ +--- +title: Forms +description: Sharaf Tutorial Forms +--- + +# {{ page.title }} + + + +Form data can be extracted with `Request.current.bodyForm[MyData]`. +The `MyData` needs to have a `FormDataRW` given instance. + +Create a file `form_handling.sc` and paste this code into it: +```scala +//> using scala "3.7.0" +//> using dep ba.sake::sharaf-undertow:0.10.0 + +import scalatags.Text.all.* +import ba.sake.formson.FormDataRW +import ba.sake.sharaf.* +import ba.sake.sharaf.undertow.UndertowSharafServer + +val routes = Routes: + case GET -> Path() => + Response.withBody(ContactUsView) + case POST -> Path("handle-form") => + val formData = Request.current.bodyForm[ContactUsForm] + Response.withBody(s"Got form data: ${formData}") + +UndertowSharafServer("localhost", 8181, routes).start() + +println("Server started at http://localhost:8181") + + +def ContactUsView = doctype("html")( + html( + body( + form(action := "/handle-form", method := "POST")( + div( + label("Full Name: ", input(name := "fullName", autofocus)) + ), + div( + label("Email: ", input(name := "email", tpe := "email")) + ), + input(tpe := "Submit") + ) + ) + ) +) + +case class ContactUsForm(fullName: String, email: String) derives FormDataRW + +``` + +Then run it like this: +```sh +scala-cli form_handling.sc +``` + +Now go to [http://localhost:8181](http://localhost:8181) +and fill in the page with some data. + +When you click the "Submit" button you will see a response like this: +``` +Got form data: ContactUsForm(Bob,bob@example.com) +``` diff --git a/docs/content/tutorials/hello-world.md b/docs/content/tutorials/hello-world.md new file mode 100644 index 0000000..8158231 --- /dev/null +++ b/docs/content/tutorials/hello-world.md @@ -0,0 +1,38 @@ +--- +title: Hello World +description: Sharaf Tutorial Hello World +--- + +# {{ page.title }} + + +Let's make a Hello World example with scala-cli. +Create a file `hello.sc` and paste this code into it: +```scala +//> using scala "3.7.0" +//> using dep ba.sake::sharaf-undertow:0.10.0 + +import ba.sake.sharaf.* +import ba.sake.sharaf.undertow.UndertowSharafServer + +val routes = Routes: + case GET -> Path("hello", name) => + Response.withBody(s"Hello $name") + +UndertowSharafServer("localhost", 8181, routes).start() + +println(s"Server started at http://localhost:8181") +``` + +Then run it like this: +```sh +scala-cli hello.sc +``` +Go to http://localhost:8181/hello/Bob. +You will see a "Hello Bob" text response. + +--- +The most interesting part is the `Routes` definition. +Here we pattern match on `(HttpMethod, Path)`. +The `Path` contains a `Seq[String]`, which are the parts of the URL you can match on. + diff --git a/docs/content/tutorials/html.md b/docs/content/tutorials/html.md new file mode 100644 index 0000000..42ac955 --- /dev/null +++ b/docs/content/tutorials/html.md @@ -0,0 +1,101 @@ +--- +title: HTML +description: Sharaf Tutorial HTML +--- + +# {{ page.title }} + +You can return a scalatags `doctype` directly in the `Response.withBody()`. +Let's make a simple HTML page that greets the user. +Create a file `html.sc` and paste this code into it: +```scala +//> using scala "3.7.0" +//> using dep ba.sake::sharaf-undertow:0.10.0 + +import scalatags.Text.all.* +import ba.sake.sharaf.* +import ba.sake.sharaf.undertow.UndertowSharafServer + +val routes = Routes: + case GET -> Path() => + Response.withBody(IndexView) + case GET -> Path("hello", name) => + Response.withBody(HelloView(name)) + +UndertowSharafServer("localhost", 8181, routes).start() + +println(s"Server started at http://localhost:8181") + +def IndexView = doctype("html")( + html( + p("Welcome!"), + a(href := "/hello/Bob")("Go to /hello/Bob") + ) +) + +def HelloView(name: String) = doctype("html")( + html( + p("Welcome!"), + div("Hello ", b(name), "!") + ) +) +``` + +and run it like this: +```sh +scala-cli html.sc +``` + +Go to http://localhost:8181 +to see how it works. + + +### Hepek Components +Sharaf supports the [hepek-components](https://sake92.github.io/hepek/hepek/components/reference/bundle-reference.html) too. +Hepek wraps scalatags with helpful utilities like Bootstrap 5 templates, form helpers etc. so you can focus on the important stuff. +It is *plain scala code* as a "template engine", so there is no separate language you need to learn. + +--- + +Let's make a simple HTML page that greets the user. +Create a file `html.sc` and paste this code into it: +```scala +//> using scala "3.7.0" +//> using dep ba.sake::sharaf-undertow:0.10.0 + +import scalatags.Text.all.* +import ba.sake.hepek.html.HtmlPage +import ba.sake.sharaf.* +import ba.sake.sharaf.undertow.{*, given} + +val routes = Routes: + case GET -> Path() => + Response.withBody(IndexView) + case GET -> Path("hello", name) => + Response.withBody(HelloView(name)) + +UndertowSharafServer("localhost", 8181, routes).start() + +println(s"Server started at http://localhost:8181") + + +object IndexView extends HtmlPage: + override def pageContent = div( + p("Welcome!"), + a(href := "/hello/Bob")("Hello world") + ) + +class HelloView(name: String) extends HtmlPage: + override def pageContent = + div("Hello ", b(name), "!") + +``` + +and run it like this: +```sh +scala-cli html.sc +``` + +Go to [http://localhost:8181](http://localhost:8181) +to see how it works. + diff --git a/docs/content/tutorials/htmx.md b/docs/content/tutorials/htmx.md new file mode 100644 index 0000000..4a48ee5 --- /dev/null +++ b/docs/content/tutorials/htmx.md @@ -0,0 +1,65 @@ +--- +title: HTMX +description: Sharaf Tutorial HTMX +--- + +# {{ page.title }} + +[HTMX]("https://htmx.org/") is an incredibly simple, HTML-first library. +Instead of going through HTML->JS->JSON-API loop/mess, you can go directly HTML->HTML-API. +Basically you just return HTML snippets that get included where you want in your page. + +Sharaf is using the [hepek-components](https://sake92.github.io/hepek/hepek/components/reference/bundle-reference.html) +as its template engine, which has support for HTMX attributes. + +You can lots of examples in [examples/htmx]({{site.data.project.gh.sourcesUrl}}/examples/htmx) folder. + +--- + +Let's make a simple page that triggers a POST request to fetch a HTML snippet. +Create a file `htmx_load_snippet.sc` and paste this code into it: +```scala +//> using scala "3.7.0" +//> using dep ba.sake::sharaf-undertow:0.10.0 + +import scalatags.Text.all.* +import ba.sake.hepek.htmx.* +import ba.sake.sharaf.* +import ba.sake.sharaf.undertow.UndertowSharafServer + +val routes = Routes: + case GET -> Path() => + Response.withBody(IndexView) + case POST -> Path("html-snippet") => + Response.withBody( + div( + b("WOW, it works! 😲"), + div("Look ma, no JS! 😎") + ) + ) + +UndertowSharafServer("localhost", 8181, routes).start() + +println(s"Server started at http://localhost:8181") + +def IndexView = doctype("html")( + html( + head( + script(src := "https://unpkg.com/htmx.org@2.0.4") + ), + body( + button(hx.post := "/html-snippet", hx.swap := "outerHTML")("Click here!") + ) + ) +) +``` + +and run it like this: +```sh +scala-cli html.sc +``` + +Go to [http://localhost:8181](http://localhost:8181) +to see how it works. + + diff --git a/docs/content/tutorials/index.md b/docs/content/tutorials/index.md new file mode 100644 index 0000000..de29e70 --- /dev/null +++ b/docs/content/tutorials/index.md @@ -0,0 +1,37 @@ +--- +title: Tutorials +description: Sharaf Tutorials +pagination: + enabled: false +--- + +# {{ page.title }} + +{% + +set tutorials = [ + { label: "Quickstart", url: "/tutorials/quickstart.html" }, + { label: "Hello World", url: "/tutorials/hello-world.html" }, + { label: "Path Params", url: "/tutorials/path-params.html" }, + { label: "Query Params", url: "/tutorials/query-params.html" }, + { label: "Static Files", url: "/tutorials/static-files.html" }, + { label: "HTML", url: "/tutorials/html.html" }, + { label: "Forms", url: "/tutorials/forms.html" }, + { label: "JSON", url: "/tutorials/json.html" }, + { label: "Validation", url: "/tutorials/validation.html" }, + { label: "SQL", url: "/tutorials/sql.html" }, + { label: "Tests", url: "/tutorials/tests.html" }, + { label: "HTMX", url: "/tutorials/htmx.html" } +] + +%} + +{% for tut in tutorials %} +- [{{ tut.label }}]({{ tut.url}}) +{% endfor %} + + + + + + diff --git a/docs/content/tutorials/json.md b/docs/content/tutorials/json.md new file mode 100644 index 0000000..6bd422c --- /dev/null +++ b/docs/content/tutorials/json.md @@ -0,0 +1,95 @@ +--- +title: JSON +description: Sharaf Tutorial JSON +--- + +# {{ page.title }} + + +## Model definition + +Let's make a simple JSON API in scala-cli. +Create a file `json_api.sc` and paste this code into it: +```scala +//> using scala "3.7.0" +//> using dep ba.sake::sharaf-undertow:0.10.0 + +import ba.sake.tupson.JsonRW +import ba.sake.sharaf.* +import ba.sake.sharaf.undertow.UndertowSharafServer + +case class Car(brand: String, model: String, quantity: Int) derives JsonRW + +object CarsDb { + var db: Seq[Car] = Seq() + def findAll(): Seq[Car] = db + def findByBrand(brand: String): Seq[Car] = db.filter(_.brand == brand) + def add(car: Car): Unit = db = db.appended(car) +} +``` + +Here we defined a `Car` model, which `derives JsonRW`, so we can use the JSON support from Sharaf. + +We also use a `var db: Seq[Car]` to store our data. +(don't do this for real projects) + + +## Routes definition +Next step is to define a few routes for getting and adding cars: +```scala +val routes = Routes: + case GET -> Path("cars") => + Response.withBody(CarsDb.findAll()) + + case GET -> Path("cars", brand) => + val res = CarsDb.findByBrand(brand) + Response.withBody(res) + + case POST -> Path("cars") => + val reqBody = Request.current.bodyJson[Car] + CarsDb.add(reqBody) + Response.withBody(reqBody) +``` + +The first route returns all data in the database. + +The second route does some filtering on the database. + +The third route binds the JSON body from the HTTP request. +Then we add it to the database. + + +## Running the server + +Finally, start up the server: +```scala +UndertowSharafServer("localhost", 8181, routes) + .withExceptionMapper(ExceptionMapper.json) + .start() + +println("Server started at http://localhost:8181") +``` + +and run it like this: +```sh +scala-cli json_api.sc +``` + +Then try the following requests: +```sh +# get all cars +curl http://localhost:8181/cars + +# add a car +curl --request POST \ + --url http://localhost:8181/cars \ + --data '{ + "brand": "Mercedes", + "model": "ML350", + "quantity": 1 + }' + +# get cars by brand +curl http://localhost:8181/cars/Mercedes +``` + diff --git a/docs/content/tutorials/path-params.md b/docs/content/tutorials/path-params.md new file mode 100644 index 0000000..d0d7efe --- /dev/null +++ b/docs/content/tutorials/path-params.md @@ -0,0 +1,42 @@ +--- +title: Path Params +description: Sharaf Tutorial Path Params +--- + +# {{ page.title }} + +Path parameters can be extracted from the `Path(segments: Seq[String])` argument. + +Create a file `path_params.sc` and paste this code into it: +```scala +//> using scala "3.7.0" +//> using dep ba.sake::sharaf-undertow:0.10.0 + +import ba.sake.sharaf.* +import ba.sake.sharaf.undertow.UndertowSharafServer + +val routes = Routes: + case GET -> Path("string", x) => + Response.withBody(s"string = ${x}") + + case GET -> Path("int", param[Int](x)) => + Response.withBody(s"int = ${x}") + +UndertowSharafServer("localhost", 8181, routes).start() + +println(s"Server started at http://localhost:8181") +``` + +Then run it like this: +```sh +scala-cli path_params.sc +``` + +--- +Now go to [http://localhost:8181/string/abc](http://localhost:8181/string/abc) +and you will get the param returned: `string = abc`. + +When you go to [http://localhost:8181/int/123](http://localhost:8181/int/123), +Sharaf will *try to extract* an `Int` from the path parameter. +If it doesn't match, it will fall through, try the next route. + diff --git a/docs/content/tutorials/query-params.md b/docs/content/tutorials/query-params.md new file mode 100644 index 0000000..43bcf28 --- /dev/null +++ b/docs/content/tutorials/query-params.md @@ -0,0 +1,60 @@ +--- +title: Query Params +description: Sharaf Tutorial Query Params +--- + +# {{ page.title }} + +### Raw +Raw query parameters can be accessed through `Request.current.queryParamsRaw`. +This is a `Map[String, Seq[String]]` which you can use to extract query parameters. +The raw approach is useful for simple cases and dynamic query parameters. + +### Typed +For more type safety you can use the `QueryStringRW` typeclass. +Make a `case class MyParams(..) derives QueryStringRW` +and then use it like this: `Request.current.queryParams[MyParams]` + +--- + +Create a file `query_params.sc` and paste this code into it: +```scala +//> using scala "3.7.0" +//> using dep ba.sake::sharaf-undertow:0.10.0 + +import ba.sake.querson.QueryStringRW +import ba.sake.sharaf.* +import ba.sake.sharaf.undertow.UndertowSharafServer + +val routes = Routes: + case GET -> Path("raw") => + val qp = Request.current.queryParamsRaw + Response.withBody(s"params = ${qp}") + + case GET -> Path("typed") => + case class SearchParams(q: String, perPage: Int) derives QueryStringRW + val qp = Request.current.queryParams[SearchParams] + Response.withBody(s"params = ${qp}") + +UndertowSharafServer("localhost", 8181, routes).start() + +println(s"Server started at http://localhost:8181") +``` + +Then run it like this: +```sh +scala-cli query_params.sc +``` + +--- +Now go to http://localhost:8181/raw?q=what&perPage=10 +and you will get the raw query params map: +``` +params = Map(perPage -> List(10), q -> List(what)) +``` + +and if you go to http://localhost:8181/typed?q=what&perPage=10 +you will get a type-safe, parsed query params object: +``` +params = SearchParams(what,10) +``` \ No newline at end of file diff --git a/docs/content/tutorials/quickstart.md b/docs/content/tutorials/quickstart.md new file mode 100644 index 0000000..009d4df --- /dev/null +++ b/docs/content/tutorials/quickstart.md @@ -0,0 +1,50 @@ +--- +title: Quickstart +description: Sharaf Tutorial Quickstart +--- + +# {{ page.title }} + +Get started quickly with Sharaf framework. + +## Mill + +```scala +def ivyDeps = super.ivyDeps() ++ Agg( + ivy"{{site.data.project.artifact.org}}::{{site.data.project.artifact.name}}:{{site.data.project.artifact.version}}" +) +def scalacOptions = super.scalacOptions() ++ Seq("-Yretain-trees") +``` + +## Sbt + +```scala +libraryDependencies ++= Seq( + "{{site.data.project.artifact.org}}" %% "{{site.data.project.artifact.name}}" % "{{site.data.project.artifact.version}}" +), +scalacOptions ++= Seq("-Yretain-trees") +``` + + +## Scala CLI + +Create a file `my_script.sc` with the following content: +```scala +//> using dep {{site.data.project.artifact.org}}::{{site.data.project.artifact.name}}:{{site.data.project.artifact.version}} +``` +and then run it with: +```bash +scala-cli my_script.sc --scala-option -Yretain-trees +``` + + +## Examples + +- [scala-cli examples]({{site.data.project.gh.sourcesUrl}}/examples/scala-cli), standalone examples using scala-cli +- [scala-cli HTMX examples]({{site.data.project.gh.sourcesUrl}}/examples/htmx), standalone examples featuring HTMX +- [API example]({{site.data.project.gh.sourcesUrl}}/examples/api) featuring JSON and validation +- [full-stack example]({{site.data.project.gh.sourcesUrl}}/examples/fullstack) featuring HTML, static files and forms +- [sharaf-todo-backend](https://github.com/sake92/sharaf-todo-backend), implementation of the [todobackend.com](http://todobackend.com/) spec, featuring CORS handling +- [OAuth2 login]({{site.data.project.gh.sourcesUrl}}/examples/oauth2) with [Pac4J library](https://www.pac4j.org/) +- [PetClinic](https://github.com/sake92/sharaf-petclinic) implementation, featuring full-stack app with Postgres db, config, integration tests etc. +- [Giter8 template for fullstack app](https://github.com/sake92/sharaf-fullstack.g8) diff --git a/docs/content/tutorials/sql.md b/docs/content/tutorials/sql.md new file mode 100644 index 0000000..94ea114 --- /dev/null +++ b/docs/content/tutorials/sql.md @@ -0,0 +1,105 @@ +--- +title: SQL +description: Sharaf Tutorial SQL +--- + +# {{ page.title }} + + +## DB setup + +Create a new Postgres database with Docker: +```sh +docker run --name sharaf-postgres -e POSTGRES_PASSWORD=mysecretpassword -p 5432:5432 -d postgres +``` + +Then connect to it via `psql` (or your favorite SQL tool): +```sh +docker exec -it sharaf-postgres psql -U postgres postgres +``` +and create a table: +```sql +CREATE TABLE customers( + id SERIAL PRIMARY KEY, + name VARCHAR +); +``` + +## Squery setup +Sharaf recommends the [Squery](https://sake92.github.io/squery/) library for accessing databases with a JDBC driver. + +Create a file `sql_db.sc` and paste this code into it: +```scala +//> using scala "3.7.0" +//> using dep org.postgresql:postgresql:42.7.5 +//> using dep com.zaxxer:HikariCP:6.3.0 +//> using dep ba.sake::sharaf-undertow:0.10.0 +//> using dep ba.sake::squery:0.7.0 + +import ba.sake.tupson.JsonRW +import ba.sake.squery.{*, given} +import ba.sake.sharaf.* +import ba.sake.sharaf.undertow.UndertowSharafServer + +val ds = com.zaxxer.hikari.HikariDataSource() +ds.setJdbcUrl("jdbc:postgresql://localhost:5432/postgres") +ds.setUsername("postgres") +ds.setPassword("mysecretpassword") + +val ctx = new SqueryContext(ds) +``` + +Here we set up the `SqueryContext` which we can use for accessing the database. + + +## Querying +Now we can do some querying on the db: +```scala +case class Customer(name: String) derives JsonRW + +val routes = Routes: + case GET -> Path("customers") => + val customerNames = ctx.run { + sql"SELECT name FROM customers".readValues[String]() + } + Response.withBody(customerNames) + + case POST -> Path("customers") => + val customer = Request.current.bodyJson[Customer] + ctx.run { + sql""" + INSERT INTO customers(name) + VALUES (${customer.name}) + """.insert() + } + Response.withBody(customer) + +``` + + +## Running the server + +Finally, we need to start up the server: +```scala +UndertowSharafServer("localhost", 8181, routes).start() + +println(s"Server started at http://localhost:8181") +``` + +and run it like this: +```sh +scala-cli sql_db.sc +``` + +Then you can try the following requests: +```sh +# get all customers +curl http://localhost:8181/customers + +# add a customer +curl --request POST \ + --url http://localhost:8181/customers \ + --data '{ + "name": "Bob" + }' +``` diff --git a/docs/content/tutorials/static-files.md b/docs/content/tutorials/static-files.md new file mode 100644 index 0000000..eeb1c7a --- /dev/null +++ b/docs/content/tutorials/static-files.md @@ -0,0 +1,43 @@ +--- +title: Static Files +description: Sharaf Tutorial Static Files +--- + +# {{ page.title }} + +The static files are automatically served from the `resources/public` folder (on the classpath): +- in Mill those are under `my_project/resources/public` +- in sbt those are under `src/main/resources/public` +- in scala-cli you need to manually tell it where to look for with `--resource-dir resources` + +--- + +Let's serve an `example.js` file with Sharaf. +First create a file `resources/public/example.js`. +Put this text into it: `console.log('Hello Sharaf!');`. + +Now create a file `static_files.sc` and paste this code into it: +```scala +//> using scala "3.7.0" +//> using dep ba.sake::sharaf-undertow:0.10.0 + +import ba.sake.sharaf.* +import ba.sake.sharaf.undertow.UndertowSharafServer + +val routes = Routes: + case GET -> Path() => + Response.withBody("Try /example.js") + +UndertowSharafServer("localhost", 8181, routes).start() + +println(s"Server started at http://localhost:8181") +``` + +and run it like this: +```sh +scala-cli static_files.sc --resource-dir resources +``` + +Go to http://localhost:8181/example.js. +You will see the `example.js` contents served. + diff --git a/docs/content/tutorials/tests.md b/docs/content/tutorials/tests.md new file mode 100644 index 0000000..5c64956 --- /dev/null +++ b/docs/content/tutorials/tests.md @@ -0,0 +1,62 @@ +--- +title: Tests +description: Sharaf Tutorial Tests +--- + +# {{ page.title }} + +Tests are essential to any serious software component. +Writing integration tests with Munit and Requests is straightforward. + +Here we are testing the API from the [JSON API tutorial](/tutorials/json.html#routes-definition). +Create a file `json_api.test.scala` and paste this code into it: +```scala +//> using scala "3.7.0" +//> using dep ba.sake::tupson:0.13.0 +//> using dep com.lihaoyi::requests:0.9.0 +//> using test.dep org.scalameta::munit::1.1.1 + +import ba.sake.tupson.* + +case class Car(brand: String, model: String, quantity: Int) derives JsonRW + +class JsonApiSuite extends munit.FunSuite { + + val baseUrl = "http://localhost:8181" + + test("create and get cars") { + locally { + val res = requests.get(s"$baseUrl/cars") + val resBody = res.text.parseJson[Seq[Car]] + assertEquals(res.statusCode, 200) + assertEquals(res.headers("content-type"), Seq("application/json; charset=utf-8")) + assertEquals(res.text.parseJson[Seq[Car]], Seq.empty) + } + + locally { + val body = Car("Mercedes", "ML350", 1) + val res = requests.post(s"$baseUrl/cars", data = body.toJson) + assertEquals(res.statusCode, 200) + } + + locally { + val res = requests.get(s"$baseUrl/cars/Mercedes") + val resBody = res.text.parseJson[Seq[Car]] + assertEquals(res.statusCode, 200) + assertEquals(res.headers("content-type"), Seq("application/json; charset=utf-8")) + assertEquals(resBody, Seq(Car("Mercedes", "ML350", 1))) + } + } +} +``` + +First run the API server in one shell: +```sh +scala-cli test json_api.sc +``` + +and then run the tests in another shell: +```sh +scala-cli test json_api.test.scala +``` + diff --git a/docs/content/tutorials/validation.md b/docs/content/tutorials/validation.md new file mode 100644 index 0000000..f0ac4a9 --- /dev/null +++ b/docs/content/tutorials/validation.md @@ -0,0 +1,137 @@ +--- +title: Validation +description: Sharaf Tutorial Validation +--- + +# {{ page.title }} + + + +For validating data you need to use the `Validator` typeclass. +A small example: + +```scala +import ba.sake.validson.Validator + +case class ValidatedData(num: Int, str: String, seq: Seq[String]) + +object ValidatedData: + given Validator[ValidatedData] = Validator + .derived[ValidatedData] + .positive(_.num) + .notBlank(_.str) + .minItems(_.seq, 1) +``` + +The `ValidatedData` can be any `case class`: json data, form data, query params.. + +--- + +Create a file `validation.sc` and paste this code into it: + +```scala +//> using scala "3.7.0" +//> using dep ba.sake::sharaf-undertow:0.10.0 + +import ba.sake.querson.QueryStringRW +import ba.sake.tupson.JsonRW +import ba.sake.validson.Validator +import ba.sake.sharaf.* +import ba.sake.sharaf.undertow.UndertowSharafServer + +val routes = Routes: + case GET -> Path("cars") => + val qp = Request.current.queryParamsValidated[CarQuery] + Response.withBody(CarApiResult(s"Query OK: ${qp}")) + + case POST -> Path("cars") => + val json = Request.current.bodyJsonValidated[Car] + Response.withBody(CarApiResult(s"JSON body OK: ${json}")) + +UndertowSharafServer("localhost", 8181, routes) + .withExceptionMapper(ExceptionMapper.json) + .start() + +println(s"Server started at http://localhost:8181") + + +case class Car(brand: String, model: String, quantity: Int) derives JsonRW +object Car: + given Validator[Car] = Validator + .derived[Car] + .notBlank(_.brand) + .notBlank(_.model) + .nonNegative(_.quantity) + +case class CarQuery(brand: String) derives QueryStringRW +object CarQuery: + given Validator[CarQuery] = Validator + .derived[CarQuery] + .notBlank(_.brand) + +case class CarApiResult(message: String) derives JsonRW +``` + +Then run it like this: +```sh +scala-cli validation.sc +``` + +Notice above that we used `queryParamsValidated` and not plain `queryParams` (does not validate query params). +Also, for JSON body parsing+validation we use `bodyJsonValidated` and not plain `bodyJson` (does not validate JSON body). + +--- +When you do a GET [http://localhost:8181/cars?brand= ](http://localhost:8181/cars?brand= ) +you will get a nice JSON error message with HTTP Status of `400 Bad Request`: +```json +{ + "instance": null, + "invalidArguments": [ + { + "reason": "must not be blank", + "path": "$.brand", + "value": "" + } + ], + "detail": "", + "type": null, + "title": "Validation errors", + "status": 400 +} +``` + +The error message format follows the [RFC 7807 problem detail](https://datatracker.ietf.org/doc/html/rfc7807). + +--- + +When you do a POST [http://localhost:8181/cars](http://localhost:8181/cars) with a malformed body: +```json +{ + "brand": " ", + "model": "ML350", + "quantity": -5 +} +``` + +you will get these errors: +```json +{ + "instance": null, + "invalidArguments": [ + { + "reason": "must not be blank", + "path": "$.brand", + "value": " " + }, + { + "reason": "must not be negative", + "path": "$.quantity", + "value": "-5" + } + ], + "detail": "", + "type": null, + "title": "Validation errors", + "status": 400 +} +``` diff --git a/docs/src/files/Index.scala b/docs/src/files/Index.scala deleted file mode 100644 index 5d3f3f0..0000000 --- a/docs/src/files/Index.scala +++ /dev/null @@ -1,46 +0,0 @@ -package files - -import scalatags.Text.all.* -import ba.sake.hepek.html.statik.BlogPostPage -import utils.* - -object Index extends DocStaticPage { - - override def pageSettings = super.pageSettings - .withTitle(Consts.ProjectName) - - override def mainContent = - div( - h1(Consts.ProjectName), - s""" - ${Consts.ProjectName} is a minimalistic Scala 3 web framework. - - Jump right into: - - [Tutorials](${files.tutorials.Index.ref}) to get you started - - [How-Tos](${files.howtos.Index.ref}) to get answers for some common questions - - [Reference](${files.reference.Index.ref}) to see detailed information - - [Philosophy](${files.philosophy.Index.ref}) to get insights into design decisions - - --- - Site map: - """.md, - div(cls := "site-map")( - siteMap.md - ) - ) - - private def siteMap = - Index.staticSiteSettings.mainPages - .map { - case mp: BlogPostPage => - val subPages = mp.categoryPosts - .drop(1) // skip Index .. - .map { cp => - s" - [${cp.pageSettings.label}](${cp.ref})" - } - .mkString("\n") - s"- [${mp.pageSettings.label}](${mp.ref})\n" + subPages - case _ => "" - } - .mkString("\n") -} diff --git a/docs/src/files/SearchIndex.scala b/docs/src/files/SearchIndex.scala deleted file mode 100644 index 8d94120..0000000 --- a/docs/src/files/SearchIndex.scala +++ /dev/null @@ -1,12 +0,0 @@ -package files - -import ba.sake.hepek.fusejs.FusejsIndex -import ba.sake.hepek.html.statik.StaticPage -import utils.Consts - -object SearchIndex extends FusejsIndex { - - override def indexedPages: Seq[StaticPage] = - Consts.allSearchIndexedPages - -} diff --git a/docs/src/files/SearchResults.scala b/docs/src/files/SearchResults.scala deleted file mode 100644 index 9309c4a..0000000 --- a/docs/src/files/SearchResults.scala +++ /dev/null @@ -1,45 +0,0 @@ -package files - -import ba.sake.hepek.html.PageSettings -import ba.sake.tupson.toJson -import scalatags.Text.all.* - -object SearchResults extends utils.DocStaticPage { - - override def pageSettings: PageSettings = super.pageSettings - .withTitle("Search results") - .withLabel("Search results") - - override def mainContent = div( - h1("Search results"), - div(id := "search-results-content")( - p("Loading...") - ) - ) - - private lazy val fuseList = SearchIndex.fusejsIndexedPagesData.toJson - - override def scriptsInline: List[String] = super.scriptsInline ++ List(s""" - import Fuse from 'https://cdn.jsdelivr.net/npm/fuse.js@7.1.0/dist/fuse.mjs' - - const urlParams = new URLSearchParams(window.location.search); - const qParam = urlParams.get('q'); - - const fuseIndex = await fetch('${SearchIndex.ref}').then(r => r.json()) - const myIndex = Fuse.parseIndex(fuseIndex); - const fuse = new Fuse(${fuseList}, { - keys: [ "title", "text" ] - }, myIndex); - - const searchRes = fuse.search(qParam); - console.log(JSON.stringify(searchRes)); - const searchResultsContentElem = document.getElementById("search-results-content"); - searchResultsContentElem.innerHTML = searchRes.map(r => { - const page = r.item; - return `
-

$${page.title}

-

$${page.text}

-
`; - }).join(""); - """) -} diff --git a/docs/src/files/howtos/CORS.scala b/docs/src/files/howtos/CORS.scala deleted file mode 100644 index 36eb7f1..0000000 --- a/docs/src/files/howtos/CORS.scala +++ /dev/null @@ -1,31 +0,0 @@ -package files.howtos - -import utils.* - -object CORS extends HowToPage { - - override def pageSettings = super.pageSettings - .withTitle("How To Set CORS") - .withLabel("CORS") - - override def blogSettings = - super.blogSettings.withSections(firstSection) - - val firstSection = Section( - "How to set up CORS?", - s""" - By default, Sharaf sets no permitted origins. - This means you can only use the API/website from the same domain. - - If you want to configure it to be available for other domains, - use the `withCorsSettings` method and set desired config: - ```scala - import ba.sake.sharaf.handlers.cors.CorsSettings - import ba.sake.sharaf.* - - val corsSettings = CorsSettings.default.withAllowedOrigins(Set("https://example.com")) - UndertowSharafServer(routes).withCorsSettings(corsSettings)... - ``` - """.md - ) -} diff --git a/docs/src/files/howtos/ExceptionHandler.scala b/docs/src/files/howtos/ExceptionHandler.scala deleted file mode 100644 index f0982da..0000000 --- a/docs/src/files/howtos/ExceptionHandler.scala +++ /dev/null @@ -1,35 +0,0 @@ -package files.howtos - -import utils.* - -object ExceptionHandler extends HowToPage { - - override def pageSettings = super.pageSettings - .withTitle("How To Customize Exception Handler") - .withLabel("Custom Exception Handler") - - override def blogSettings = - super.blogSettings.withSections(firstSection) - - val firstSection = Section( - "How to customize the Exception handler?", - s""" - - Use the `withExceptionMapper` on `UndertowSharafServer`: - ```scala - val customExceptionMapper: ExceptionMapper = { - case e: MyException => - val errorPage = MyErrorPage(e.getMessage()) - Response.withBody(errorPage) - .withStatus(StatusCode.InternalServerError) - } - val finalExceptionMapper = customExceptionMapper.orElse(ExceptionMapper.default) - val server = UndertowSharafServer(routes) - .withExceptionMapper(finalExceptionMapper) - ``` - - The `ExceptionMapper` is a partial function from an exception to `Response`. - Here we need to chain our custom exception mapper before the default one. - """.md - ) -} diff --git a/docs/src/files/howtos/ExternalConfig.scala b/docs/src/files/howtos/ExternalConfig.scala deleted file mode 100644 index 78b8ed8..0000000 --- a/docs/src/files/howtos/ExternalConfig.scala +++ /dev/null @@ -1,45 +0,0 @@ -package files.howtos - -import utils.* - -object ExternalConfig extends HowToPage { - - override def pageSettings = super.pageSettings - .withTitle("How To External Config") - .withLabel("External Config") - - override def blogSettings = - super.blogSettings.withSections(firstSection) - - val firstSection = Section( - "How to parse external config?", - s""" - - The [typesafe config](https://github.com/lightbend/config) library is already included in Sharaf. - Also included is the [tupson-config](https://sake92.github.io/tupson/tutorials/parsing-config.html#usage-1) which simplifies the process: - ```scala - import java.net.URL - import com.typesafe.config.ConfigFactory - import ba.sake.tupson.{given, *} - import ba.sake.tupson.config.* - - case class MyConf( - port: Int, - url: URL, - string: String, - seq: Seq[String] - ) derives JsonRW - - val rawConfig = ConfigFactory.parseString(${Consts.tq} - port = 7777 - url = "http://example.com" - string = "str" - seq = [a, "b", c] - ${Consts.tq}) - - val myConf = rawConfig.parseConfig[MyConf] - // MyConf(7777,http://example.com,str,List(a, b, c)) - ``` - """.md - ) -} diff --git a/docs/src/files/howtos/HowToPage.scala b/docs/src/files/howtos/HowToPage.scala deleted file mode 100644 index cdcd395..0000000 --- a/docs/src/files/howtos/HowToPage.scala +++ /dev/null @@ -1,24 +0,0 @@ -package files.howtos - -import utils.* - -trait HowToPage extends utils.DocPage { - - override def categoryPosts = - List( - Index, - Redirect, - Routes, - QueryParams, - ResponseBody, - UploadFile, - NotFound, - ExceptionHandler, - ExternalConfig, - CORS - ) - - override def pageCategory = Some("How-Tos") - - override def currentCategoryPage = Some(Index) -} diff --git a/docs/src/files/howtos/Index.scala b/docs/src/files/howtos/Index.scala deleted file mode 100644 index 27c8e5f..0000000 --- a/docs/src/files/howtos/Index.scala +++ /dev/null @@ -1,20 +0,0 @@ -package files.howtos - -import utils.* - -object Index extends HowToPage { - - override def pageSettings = - super.pageSettings.withTitle("How-Tos") - - override def blogSettings = - super.blogSettings.withSections(firstSection) - - val firstSection = Section( - "How-Tos", - s""" - - Here are some common questions and answers you might have when using ${Consts.ProjectName}. - """.md - ) -} diff --git a/docs/src/files/howtos/NotFound.scala b/docs/src/files/howtos/NotFound.scala deleted file mode 100644 index a8ffa30..0000000 --- a/docs/src/files/howtos/NotFound.scala +++ /dev/null @@ -1,32 +0,0 @@ -package files.howtos - -import utils.* - -object NotFound extends HowToPage { - - override def pageSettings = super.pageSettings - .withTitle("How To Customize NotFound Handler") - .withLabel("Custom NotFound Handler") - - override def blogSettings = - super.blogSettings.withSections(firstSection) - - val firstSection = Section( - "How to customize 404 NotFound handler?", - s""" - - Use the `withNotFoundHandler` on `UndertowSharafServer`: - ```scala - UndertowSharafServer(routes).withNotFoundHandler { req => - Response.withBody(MyCustomNotFoundPage) - .withStatus(StatusCode.NotFound) - } - ``` - - The `withNotFoundHandler` accepts a `Request => Response[?]` parameter. - You can use the request if you need to dynamically decide on what to return. - Or ignore it and return a static not found response. - - """.md - ) -} diff --git a/docs/src/files/howtos/QueryParams.scala b/docs/src/files/howtos/QueryParams.scala deleted file mode 100644 index 82a3e36..0000000 --- a/docs/src/files/howtos/QueryParams.scala +++ /dev/null @@ -1,120 +0,0 @@ -package files.howtos - -import utils.* - -object QueryParams extends HowToPage { - - override def pageSettings = super.pageSettings - .withTitle("How To Query Parameters in Sharaf") - .withLabel("Query Parameters") - - override def blogSettings = - super.blogSettings.withSections(enumSection, optionalSection, sequenceSection, compositeSection, customSection) - - val enumSection = Section( - "How to bind query parameter as an enum?", - s""" - - Sharaf needs a `QueryStringRW[T]` instance for query params. - It can automatically derive an instance for singleton enums: - - ```scala - enum Cloud derives QueryStringRW: - case aws, gcp, azure - - case class MyQueryParams( - cloud: Cloud - ) derives QueryStringRW - ``` - - """.md - ) - - val optionalSection = Section( - "How to bind optional query parameter?", - s""" - - The first option is to set the parameter to `Option[T]`: - ```scala - case class MyQP(mandatory: String, opt: Option[Int]) derives QueryStringRW - ``` - If you make a request with params `?mandatory=abc`, `opt` will have value of `None`. - - --- - The second option is to set the parameter to some default value: - ```scala - case class MyQP2(mandatory: String, opt: Int = 42) derives QueryStringRW - ``` - Here if you make a request with params `?mandatory=abc` the `opt` will have value of `42`. - - > Note that you need the `-Yretain-trees` scalac flag turned on, otherwise it won't work! - - """.md - ) - - val sequenceSection = Section( - "How to bind sequence query parameter?", - s""" - - Set the parameter to `Seq[T]`: - ```scala - case class MyQP(seq: Seq[Int]) derives QueryStringRW - ``` - - Let's consider a few possible requests with these query params: - - `?` (empty) -> `seq` will be empty `Seq()` - - `?seq=123` -> `seq` will be `Seq(123)` - - `?seq[]=123&seq[]=456` -> `seq` will be `Seq(123, 456)` - - `?seq[1]=123&seq[0]=456` -> `seq` will be `Seq(456, 123)` (note it is sorted here) - """.md - ) - - val compositeSection = Section( - "How to bind composite query parameter?", - s""" - You can make a common query params class and use it in multiple top-level query params, or standalone: - ```scala - case class PageQP(page: Int, size: Int) derives QueryStringRW - case class MyQP(q: String, p: PageQP) derives QueryStringRW - ``` - - Sharaf is quite lenient when parsing the query parameters, so all these combinations will work: - - `?q=abc&p.page=0&p.size=10` -> object style - - `?q=abc&p[page]=0&p[size]=10` -> brackets style - - `?q=abc&p[page]=0&p.size=10` -> mixed style (dont) - """.md - ) - - val customSection = Section( - "How to bind a custom query parameter?", - """ - When you want to handle a custom *scalar* value in query params, - you need to implement a `QueryStringRW[T]` instance manually: - ```scala - import ba.sake.querson.* - - given QueryStringRW[MyType] with { - override def write(path: String, value: MyType): QueryStringData = - QueryStringRW[String].write(path, value.toString) - - override def parse(path: String, qsData: QueryStringData): MyType = - val str = QueryStringRW[String].parse(path, qsData) - Try(MyType.fromString(str)).toOption.getOrElse(typeError(path, "MyType", str)) - } - - private def typeError(path: String, tpe: String, value: Any): Nothing = - throw ParsingException(ParseError(path, s"invalid \$tpe", Some(value))) - ``` - - Then you can use it: - ```scala - case class MyQueryParams( - myType: MyType - ) derives QueryStringRW - ``` - - --- - Note that Sharaf can automatically derive an instance for [singleton enums](${Routes.enumPathSection.ref}). - """.md - ) -} diff --git a/docs/src/files/howtos/Redirect.scala b/docs/src/files/howtos/Redirect.scala deleted file mode 100644 index 9964822..0000000 --- a/docs/src/files/howtos/Redirect.scala +++ /dev/null @@ -1,28 +0,0 @@ -package files.howtos - -import utils.* - -object Redirect extends HowToPage { - - override def pageSettings = super.pageSettings - .withTitle("How To Redirect") - .withLabel("Redirect") - - override def blogSettings = - super.blogSettings.withSections(firstSection) - - val firstSection = Section( - "How to redirect?", - s""" - Use the `Response.redirect` function: - ```scala - case GET -> Path("a-deprecated-route") => - Response.redirect("/this-other-place") - ``` - - This will redirect the request to "/this-other-place", - with status `301 MOVED_PERMANENTLY` - - """.md - ) -} diff --git a/docs/src/files/howtos/ResponseBody.scala b/docs/src/files/howtos/ResponseBody.scala deleted file mode 100644 index f98ca1e..0000000 --- a/docs/src/files/howtos/ResponseBody.scala +++ /dev/null @@ -1,39 +0,0 @@ -package files.howtos - -import utils.* - -object ResponseBody extends HowToPage { - - override def pageSettings = super.pageSettings - .withTitle("How To Custom Response Body") - .withLabel("Custom Response Body") - - override def blogSettings = - super.blogSettings.withSections(firstSection) - - val firstSection = Section( - "How to use a custom response body?", - s""" - You need to define a custom `ResponseWritable[T]` for your type `T`. - - Let's say you have a `MyXML` class, and you want to use it as a response body. - You would write something like this: - ```scala - given ResponseWritable[MyXML] with { - override def write(value: MyXML, exchange: HttpServerExchange): Unit = - exchange.getResponseSender.send(value.asString) - override def headers(value: String): Seq[(HttpString, Seq[String])] = Seq( - HttpString(HeaderNames.ContentType) -> Seq("text/xml") - ) - } - ``` - - Now you can use `MyXML` as a response body: - ```scala - val myXml = MyXML(...) - Response.withBody(myXml) - ``` - - """.md - ) -} diff --git a/docs/src/files/howtos/Routes.scala b/docs/src/files/howtos/Routes.scala deleted file mode 100644 index 85b4da0..0000000 --- a/docs/src/files/howtos/Routes.scala +++ /dev/null @@ -1,160 +0,0 @@ -package files.howtos - -import utils.* - -object Routes extends HowToPage { - - override def pageSettings = super.pageSettings - .withTitle("How To Routes in Sharaf") - .withLabel("Routes") - - override def blogSettings = - super.blogSettings.withSections( - multipleMethodsSection, - multiplePathsSection, - enumPathSection, - regexPathSection, - customPathSection, - splitRoutesSection - ) - - val multipleMethodsSection = Section( - "How to match on multiple methods?", - s""" - You can use the `|` operator in a pattern match: - ```scala - case (GET | POST) -> Path() => - ... - ``` - You can always check the [Scala docs](https://docs.scala-lang.org/scala3/book/control-structures.html#handling-multiple-possible-matches-on-one-line) - for more help. - - --- - If you want to handle all possible methods, just don't use any extractors: - ```scala - case method -> Path() => - ... - ``` - - """.md - ) - - val multiplePathsSection = Section( - "How to match on multiple paths?", - s""" - You can use the `|` operator in a pattern match: - ```scala - case GET -> (Path("hello") | Path("hello-world")) => - ... - ``` - You can always check the [Scala docs](https://docs.scala-lang.org/scala3/book/control-structures.html#handling-multiple-possible-matches-on-one-line) - for more help. - - --- - If you want to handle all paths that start with "my-prefix/": - ```scala - case GET -> Path("my-prefix", segments*) => - ... - ``` - - --- - If you want to handle all possible paths: - ```scala - case GET -> Path(segments*) => - ... - ``` - - """.md - ) - - val enumPathSection = Section( - "How to bind path parameter as an enum?", - """ - - Sharaf needs a `FromPathParam[T]` instance for the `param[T]` extractor. - It can automatically derive an instance for singleton enums: - - ```scala - enum Cloud derives FromPathParam: - case aws, gcp, azure - - val routes = Routes: - case GET -> Path("pricing", param[Cloud](cloud)) => - Response.withBody(s"cloud = \${cloud}") - ``` - - """.md - ) - - val regexPathSection = Section( - "How to bind path parameter as a regex?", - """ - - ```scala - val userIdRegex = "user_id_(\\d+)".r - - val routes = Routes: - case GET -> Path("pricing", userIdRegex(userId)) => - Response.withBody(s"userId = \${userId}") - ``` - - Note that the `userId` is bound as a `String`. - - You could further match on it, for example: - ```scala - val routes = Routes: - case GET -> Path("pricing", userIdRegex(param[Int](userId))) => - ``` - would extract `userId` as an `Int`. - """.md - ) - - val customPathSection = Section( - "How to bind a custom path parameter?", - """ - Sharaf needs a `FromPathParam[T]` instance available: - ```scala - import ba.sake.sharaf.routing.* - - given FromPathParam[MyType] with { - def parse(str: String): Option[MyType] = - parseMyType(str) // impl here - } - - val routes = Routes: - case GET -> Path("pricing", param[MyType](myType)) => - Response.withBody(s"myType = \${myType}") - ``` - """.md - ) - - val splitRoutesSection = Section( - "How to split Routes?", - s""" - - When you have lots of routes, you will want to split them into multiple `Routes` handlers. - Combining them is done with `Routes.merge`. - The order of routes is preserved, of course: - ```scala - val routes: Seq[Routes] = Seq(routes1, routes2, ... ) - - val allRoutes: Routes = Routes.merge(routes) - ``` - - You can also `extend SharafController` instead of `Routes` directly. - ```scala - class MyController1 extends SharafController: - override def routes: Routes = Routes: - case ... - class MyController2 extends SharafController: - override def routes: Routes = Routes: - case ... - - val server = UndertowSharafServer( - new MyController1, new MyController2 - ) - ``` - - """.md - ) -} diff --git a/docs/src/files/howtos/UploadFile.scala b/docs/src/files/howtos/UploadFile.scala deleted file mode 100644 index 3d09384..0000000 --- a/docs/src/files/howtos/UploadFile.scala +++ /dev/null @@ -1,43 +0,0 @@ -package files.howtos - -import utils.* - -object UploadFile extends HowToPage { - - override def pageSettings = super.pageSettings - .withTitle("How To Upload a File") - .withLabel("Upload a File") - - override def blogSettings = - super.blogSettings.withSections(firstSection) - - val firstSection = Section( - "How to upload a file?", - s""" - - Uploading a file is usually done via `multipart/form-data` form submission. - - - ```scala - // 1. somewhere in a view, use enctype="multipart/form-data" - form(action := "/form-submit", method := "POST", enctype := "multipart/form-data")( - ... - ) - - // 2. define form data class with a NIO Path file - import java.nio.file.Path - import ba.sake.formson.* - - case class MyData(file: Path) derives FormDataRW - - // 3. handle the file however you want - case POST -> Path("form-submit") => - val formData = Request.current.bodyForm[MyData] - val fileAsString = Files.readString(formData.file) - ``` - - You can find a working example in the [repo](${Consts.GhSourcesUrl}/examples/fullstack). - - """.md - ) -} diff --git a/docs/src/files/philosophy/Alternatives.scala b/docs/src/files/philosophy/Alternatives.scala deleted file mode 100644 index 5bfb0e8..0000000 --- a/docs/src/files/philosophy/Alternatives.scala +++ /dev/null @@ -1,41 +0,0 @@ -package files.philosophy - -import utils.* - -object Alternatives extends PhilosophyPage { - - override def pageSettings = - super.pageSettings.withTitle("Alternatives") - - override def blogSettings = - super.blogSettings.withSections(firstSection) - - val firstSection = Section( - "What about other frameworks?", - s""" - - ### Async frameworks like Play, Akka HTTP etc - Synchronous programming is much, much easier to understand, debug, profile etc.. - Benefits (performance/throughput) of async handling are mostly void in Java 21, with introduction of Virtual threads. Yay! - - Only bummer for now is that Undertow doesn't still support them.. :/ - But undertow is very performant in the current shape too, so for most use cases it will be enough. - Java 24 is a game changer for Undertow, because it solves the problem of [Synchronize Virtual Threads without Pinning](https://openjdk.org/jeps/491). - - ### Pure FP libs like http4s, zio-http etc - - Too much focus on purely functional programming and (mostly unnecessary) math concepts. - Easy to get lost in that and overcomplicate your code. - - ### Enterprise frameworks like Spring Framework, Quarkus etc - Too much annotations, autoconfigurations, dependency injection, proxies and complexity. - - ### Standalone JEE servers like Tomcat, Jetty etc - I was looking into these, but then sharaf would have to depend on Servlets API, - use `@Inject` and gazzilion of god-knows-what-they-do annotations just to configure OAuth2 for example... - - - """.md - ) - -} diff --git a/docs/src/files/philosophy/Authentication.scala b/docs/src/files/philosophy/Authentication.scala deleted file mode 100644 index 201c4cf..0000000 --- a/docs/src/files/philosophy/Authentication.scala +++ /dev/null @@ -1,81 +0,0 @@ -package files.philosophy - -import utils.* - -object Authentication extends PhilosophyPage { - - override def pageSettings = super.pageSettings - .withTitle("How To Authentication") - .withLabel("Authentication") - - override def blogSettings = - super.blogSettings.withSections(firstSection, pac4jSection, denyByDefaultSection) - - val firstSection = Section( - "Authentication", - s""" - Some important security principles from OWASP guidelines: - - use HTTPS - - use random user ids to prevent enumeration and other attacks - - use strong passwords, store them hashed, implement password recovery - - use MFA, CAPTCHA, rate limiting etc to prevent automated attacks - - etc. - - Read all of them in the [OWASP auth cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html). - """.md - ) - - val pac4jSection = Section( - "Pac4j", - s""" - Authentication in Sharaf is done usually by delegating it to [pac4j](https://www.pac4j.org/index.html). - Pac4j is a battle-tested and widely used library for authentication and authorization. - It supports many authentication mechanisms, including: - - form based authentication (username + password) - - OAuth2, with many providers (Google, Facebook, GitHub, etc) - - Pac4j has a concept of `Client`, which is a type of authentication mechanism. - The main split is between `IndirectClient` and `DirectClient`. - - ### Indirect clients - Indirect clients are used for form based authentication, OAuth2, etc. - An important thing to mention here is the callback URL: - - for username + password authentication, the callback URL where the form is submitted to. Then a server-side session is created and user is signed in. - - for OAuth2 (and similar mechanisms), the callback URL where the user is redirected to *after authentication*. - The server will then exchange the code for an *access token* and create a server-side session. - - ### Direct clients - Direct clients are used for API authentication *on every request* (e.g. Basic Auth, JWT, etc). - On every request, the client will extract the credentials from the request and authenticate the user. - """.md - ) - - val denyByDefaultSection = Section( - "Deny by Default Principle", - """ - One important principle in security is the "deny by default" principle. - You should use whitelisting, allow access only to what is needed. - This is because it is easy to forget to deny something, and it is hard to remember everything that should be denied. - - Concretely in pac4j, you can use `PathMatcher()`, to exclude certain paths from authentication: - ```scala - val publicRoutesMatcher = PathMatcher() - publicRoutesMatcher.excludePaths("/", "/login-form") - pac4jConfig.addMatcher("publicRoutesMatcher", publicRoutesMatcher) - .. - SecurityHandler.build( - SharafHandler(..), - pac4jConfig, - "client1,client2...", - null, - "securityheaders,publicRoutesMatcher", // use publicRoutesMatcher here! - DefaultSecurityLogic() - ) - ``` - - There are also: - - `excludeBranch("/somepath")` to exclude all paths starting with "/somepath" - - `excludeRegex("^/somepath/.*\$")` to exclude all paths matching the regex (be careful with this one!) - """.md - ) -} diff --git a/docs/src/files/philosophy/DependencyInjection.scala b/docs/src/files/philosophy/DependencyInjection.scala deleted file mode 100644 index b958ede..0000000 --- a/docs/src/files/philosophy/DependencyInjection.scala +++ /dev/null @@ -1,68 +0,0 @@ -package files.philosophy - -import utils.* - -object DependencyInjection extends PhilosophyPage { - - override def pageSettings = - super.pageSettings.withTitle("Dependency Injection") - - override def blogSettings = - super.blogSettings.withSections(firstSection) - - val firstSection = Section( - "Do you even Dependency Injection?", - s""" - Not in a classical / "dependency container" / Spring / JEE style. - - Not in a purely-functional-monadic style. - - Yes in a direct style: - - for singletons: just *instantiate a class* and pass the object around. - - for request/session-scoped instances: use scala 3 *context functions* (implicit functions). - - If you ever used PlayFramework, Slick 2 and similar, you might have used this pattern: - ```scala - someFunction { implicit ctx => - // some code that needs an implicit Ctx - } - ``` - - In Scala 3 there is a new concept called "context function" which represents the pattern from above with a type: - ```scala - type ContextualAction = Ctx ?=> Unit - ``` - Now, instead of manually writing `implicit ctx` we can skip it: - ```scala - someFunction { - // some code that needs an implicit Ctx - } - ``` - and compiler will fill it in for us. - The `Ctx` parameter is implicitly/contextually available (only) in the function body. - You can get it with `summon[Ctx]` or `using ctx: Ctx` (which is a bit more readable). - - - --- - Sharaf has the `Routes` type that is defined as `Request ?=> PartialFunction[RequestParams, Response[?]]`. - This means that you can call `Request.current` only in a `Routes` definition body (because it requires a `given Request` to be present). - - If you need a request-scoped instance (a-la `@RequestScoped @Bean` in Spring), - you need to define a function that is `using Request`: - ```scala - def currentUser(using req: Request): User = - // extract stuff from request - ``` - Same as `Request.current`, you can only use the `currentUser` function in a context of a request! - - --- - - By using context functions, you avoid [banging your head against the wall](https://stackoverflow.com/questions/26305295/how-is-the-requestscoped-bean-instance-provided-to-sessionscoped-bean-in-runti) - while trying to figure out how-the-hell can you inject a request-scoped-thing into a singleton/session-scoped thing... - Proxy to proxy to proxy, something, something.. ok. - - You also avoid reading yet-another-lousy-monad-tutorial, losing your brain-battle agains `State`, `RWS`, `Kleisli`, higher-kinded-types, weird macros, compile times and type inference... - """.md - ) - -} diff --git a/docs/src/files/philosophy/Index.scala b/docs/src/files/philosophy/Index.scala deleted file mode 100644 index 4aadfc9..0000000 --- a/docs/src/files/philosophy/Index.scala +++ /dev/null @@ -1,43 +0,0 @@ -package files.philosophy - -import utils.* - -object Index extends PhilosophyPage { - - override def pageSettings = - super.pageSettings.withTitle("Philosophy") - - override def blogSettings = - super.blogSettings.withSections(firstSection, nameSection) - - val firstSection = Section( - "Why Sharaf?", - s""" - Simplicity and ease of use is the main focus of Sharaf. - - Sharaf is built on top of [Undertow](https://undertow.io/). - This means you can use awesome libraries built for Undertow, like [pac4j](https://github.com/pac4j/undertow-pac4j) for security and others. - You can leverage Undertow's lower level API, e.g. for WebSockets. - - Sharaf bundles a set of standalone libraries: - - [querson](${Consts.GhSourcesUrl}/querson) for query parameters - - [tupson](https://github.com/sake92/tupson) for JSON - - [formson](${Consts.GhSourcesUrl}/formson) for forms - - [validson](${Consts.GhSourcesUrl}/validson) for validation - - [scalatags](https://github.com/com-lihaoyi/scalatags) for HTML - - [sttp](https://sttp.softwaremill.com/en/latest/) for firing HTTP requests - - [typesafe-config](https://github.com/lightbend/config) for configuration - - You can use any of above separately in your projects. - - """.md - ) - val nameSection = Section( - """Why name "Sharaf"?""", - s""" - Šaraf means a "screw" in Bosnian, which reminds me of scala spiral logo. - It's a germanism I think. - - """.md - ) -} diff --git a/docs/src/files/philosophy/PhilosophyPage.scala b/docs/src/files/philosophy/PhilosophyPage.scala deleted file mode 100644 index 2a20a6d..0000000 --- a/docs/src/files/philosophy/PhilosophyPage.scala +++ /dev/null @@ -1,17 +0,0 @@ -package files.philosophy - -trait PhilosophyPage extends utils.DocPage { - - override def categoryPosts = List( - Index, - Alternatives, - RoutesMatching, - QueryParamsHandling, - DependencyInjection, - Authentication - ) - - override def pageCategory = Some("Philosophy") - - override def currentCategoryPage = Some(Index) -} diff --git a/docs/src/files/philosophy/QueryParamsHandling.scala b/docs/src/files/philosophy/QueryParamsHandling.scala deleted file mode 100644 index cd2a630..0000000 --- a/docs/src/files/philosophy/QueryParamsHandling.scala +++ /dev/null @@ -1,85 +0,0 @@ -package files.philosophy - -import utils.* -import files.howtos.QueryParams - -object QueryParamsHandling extends PhilosophyPage { - - override def pageSettings = - super.pageSettings.withTitle("Query Params") - - override def blogSettings = - super.blogSettings.withSections( - firstSection, - annotationsSection, - specialRouteFile, - inLanguageDSL, - patternMatchingSection, - sharafSection - ) - - val firstSection = Section( - "Query params handling design", - s""" - Web frameworks do handle query params with various mechanisms: - - annotation + method param: [Spring](https://spring.io/guides/tutorials/rest/) and most other popular Java frameworks, [Cask](https://com-lihaoyi.github.io/cask/) etc - - special route file DSL: [PlayFramework](https://www.playframework.com/documentation/2.9.x/ScalaRouting#The-routes-file-syntax), Ruby on Rails - - in-language DSL: zio-http - - pattern matching: http4s - - parsing from request: Sharaf - """.md - ) - - val annotationsSection = Section( - "Why not annotations?", - s""" - This approach is mostly fine, as long as you know from where a parameter comes. - - In Spring you use the `@RequestParam` annotation when you have simple parameters. - But when you want to group them in a class [you don't use it](https://stackoverflow.com/questions/16942193/spring-mvc-complex-object-as-get-requestparam).. #wtf - Also, that same class can be bound from the form body too... convenient? eh. - - In [Cask](https://com-lihaoyi.github.io/cask/#variable-routes) there is no annotation, so it is ambiguous in my opinion. - """.md - ) - - val specialRouteFile = Section( - "Why not special route file?", - s""" - You need a special compiler for this, essentially a new language. - People have to learn how it works, there's probably no syntax highlighting, no autocomplete etc. - """.md - ) - - val inLanguageDSL = Section( - "Why not in-language DSL?", - s""" - Similar to special route file approach, people need to learn it. - Not a huge deal I guess. - """.md - ) - - val patternMatchingSection = Section( - "Why not pattern matching?", - s""" - If you look at [http4s' approach](https://http4s.org/v0.23/docs/dsl.html#handling-query-parameters), - you can see that if the query param is not found, it falls through. - It is customizable, but more work for you. eh. - Essentially you'll get a 404.. which is not a good choice IMO. - - Rarely any framework does this, and you rarely want to handle *the same path* in 2 places. - """.md - ) - - val sharafSection = Section( - "Sharaf's approach", - s""" - Sharaf parses query params from the `Request`. - Admittedly, you do have to make a new class if you want to parse them in a typesafe way. - But you usually do grouping of these parameters when passing them further, so why not do it immediatelly. - - [Composition](${QueryParams.compositeSection.ref}) adds even more benefits, which I rarely saw implemented in any framework. - """.md - ) - -} diff --git a/docs/src/files/philosophy/RoutesMatching.scala b/docs/src/files/philosophy/RoutesMatching.scala deleted file mode 100644 index 06d5987..0000000 --- a/docs/src/files/philosophy/RoutesMatching.scala +++ /dev/null @@ -1,85 +0,0 @@ -package files.philosophy - -import utils.* - -object RoutesMatching extends PhilosophyPage { - - override def pageSettings = - super.pageSettings.withTitle("Routes Matching") - - override def blogSettings = - super.blogSettings.withSections(firstSection, annotationsSection, specialRouteFile, inLanguageDSL, sharafSection) - - val firstSection = Section( - "Routes matching design", - s""" - Web frameworks do their routes matching with various mechanisms: - - annotation + method param: [Spring](https://spring.io/guides/tutorials/rest/) and most other popular Java frameworks, [Cask](https://com-lihaoyi.github.io/cask/) etc - - special route file DSL: [PlayFramework](https://www.playframework.com/documentation/2.9.x/ScalaRouting#The-routes-file-syntax), Ruby on Rails - - in-language DSL: zio-http, akka-http - - pattern matching: Sharaf, http4s - """.md - ) - - val annotationsSection = Section( - "Why not annotations?", - s""" - Let's see an example: - ```scala - @GetMapping(value = "/student/{studentId}") - public Student studentData1(@PathVariable Integer studentId) {} - - @GetMapping(value = "/student/{studentId}") - public Student studentData2(@PathVariable Integer studentId) {} - - @GetMapping(value = "/student/umm") - public Student studentData3(@PathVariable Integer studentId) {} - ``` - Issues: - - the `studentId` appears in 2 places, you can make a typo and nothing will work. - - the `"/student/{studentId}"` route is duplicated, there is no compiler support and it will fail only in runtime.. - - you have to [wonder](https://stackoverflow.com/questions/2326912/ordered-requestmapping-in-spring-mvc) if `studentData1` will be picked up before `studentData3`..!? - """.md - ) - - val specialRouteFile = Section( - "Why not special route file?", - s""" - Well, you need a special compiler for this, essentially a new language. - People have to learn how it works, there's probably no syntax highlighting, no autocomplete etc. - """.md - ) - - val inLanguageDSL = Section( - "Why not in-language DSL?", - s""" - Similar to special route file approach, people need to learn it. - And again, you don't leverage compiler's support like exhaustive pattern matching and extractors. - """.md - ) - - val sharafSection = Section( - "Sharaf's approach", - s""" - Sharaf does its route matching in plain scala code. - Scala's pattern matching warns you when you have *duplicate routes*, or *impossible* routes. - For example, if you write this: - ```scala - case GET -> Path("cars", brand) => ??? - case GET -> Path("cars", model) => ??? // Unreachable case - - case GET -> Path("files", segments*) => ??? - case GET -> Path("files", "abc.txt") => ??? // Unreachable case - ``` - you will get nice warnings, thanks compiler! - - --- - You can extract path variables with pattern matching: - ```scala - case GET -> Path("cars", param[Int](carId)) => ??? - ``` - Here, the `carId` is parsed as `Int` and it *mentioned only once*, unlike with the annotation approach. - """.md - ) - -} diff --git a/docs/src/files/reference/Index.scala b/docs/src/files/reference/Index.scala deleted file mode 100644 index f2e43c2..0000000 --- a/docs/src/files/reference/Index.scala +++ /dev/null @@ -1,23 +0,0 @@ -package files.reference - -import scalatags.Text.all.* -import utils.* - -object Index extends ReferencePage { - - override def pageSettings = - super.pageSettings.withTitle("Reference") - - override def blogSettings = - super.blogSettings.withSections(firstSection) - - val firstSection = Section( - s"${Consts.ProjectName} reference", - div( - s""" - - Take a look at [Sharaf scaladoc](https://javadoc.io/doc/ba.sake/sharaf_3). - """.md - ) - ) -} diff --git a/docs/src/files/reference/ReferencePage.scala b/docs/src/files/reference/ReferencePage.scala deleted file mode 100644 index 227039f..0000000 --- a/docs/src/files/reference/ReferencePage.scala +++ /dev/null @@ -1,13 +0,0 @@ -package files.reference - -import files.tutorials.Index -import utils.* - -trait ReferencePage extends DocPage { - - override def categoryPosts = List(Index) - - override def pageCategory = Some("Reference") - - override def currentCategoryPage = Some(Index) -} diff --git a/docs/src/files/tutorials/HTML.scala b/docs/src/files/tutorials/HTML.scala deleted file mode 100644 index 8326a51..0000000 --- a/docs/src/files/tutorials/HTML.scala +++ /dev/null @@ -1,55 +0,0 @@ -package files.tutorials - -import utils.* - -object HTML extends TutorialPage { - - override def pageSettings = super.pageSettings - .withTitle("HTML") - - override def blogSettings = - super.blogSettings.withSections(htmlSection) - - val htmlSection = Section( - "Serving HTML", - s""" - ### Scalatags - You can return a scalatags `doctype` directly in the `Response.withBody()`. - Let's make a simple HTML page that greets the user. - Create a file `html.sc` and paste this code into it: - ```scala - ${ScalaCliFiles.html_scalatags.indent(4)} - ``` - - and run it like this: - ```sh - scala-cli html.sc - ``` - - Go to [http://localhost:8181](http://localhost:8181) - to see how it works. - - ### Hepek Components - Sharaf supports the [hepek-components](https://sake92.github.io/hepek/hepek/components/reference/bundle-reference.html) too. - Hepek wraps scalatags with helpful utilities like Bootstrap 5 templates, form helpers etc. so you can focus on the important stuff. - It is *plain scala code* as a "template engine", so there is no separate language you need to learn. - - --- - - Let's make a simple HTML page that greets the user. - Create a file `html.sc` and paste this code into it: - ```scala - ${ScalaCliFiles.html_hepek.indent(4)} - ``` - - and run it like this: - ```sh - scala-cli html.sc - ``` - - Go to [http://localhost:8181](http://localhost:8181) - to see how it works. - - """.md - ) -} diff --git a/docs/src/files/tutorials/HTMX.scala b/docs/src/files/tutorials/HTMX.scala deleted file mode 100644 index 4afb768..0000000 --- a/docs/src/files/tutorials/HTMX.scala +++ /dev/null @@ -1,43 +0,0 @@ -package files.tutorials - -import utils.* - -object HTMX extends TutorialPage { - - override def pageSettings = super.pageSettings - .withTitle("HTMX") - - override def blogSettings = - super.blogSettings.withSections(htmxSection) - - val htmxSection = Section( - "Using HTMX", - s""" - [HTMX]("https://htmx.org/") is an incredibly simple, HTML-first library. - Instead of going through HTML->JS->JSON-API loop/mess, you can go directly HTML->HTML-API. - Basically you just return HTML snippets that get included where you want in your page. - - Sharaf is using the [hepek-components](https://sake92.github.io/hepek/hepek/components/reference/bundle-reference.html) - as its template engine, which has support for HTMX attributes. - - You can lots of examples in [examples/htmx](${Consts.GhSourcesUrl}/examples/htmx) folder. - - --- - - Let's make a simple page that triggers a POST request to fetch a HTML snippet. - Create a file `htmx_load_snippet.sc` and paste this code into it: - ```scala - ${ScalaCliFiles.htmx_load_snippet.indent(4)} - ``` - - and run it like this: - ```sh - scala-cli html.sc - ``` - - Go to [http://localhost:8181](http://localhost:8181) - to see how it works. - - """.md - ) -} diff --git a/docs/src/files/tutorials/HandlingForms.scala b/docs/src/files/tutorials/HandlingForms.scala deleted file mode 100644 index ad247d4..0000000 --- a/docs/src/files/tutorials/HandlingForms.scala +++ /dev/null @@ -1,38 +0,0 @@ -package files.tutorials - -import utils.* - -object HandlingForms extends TutorialPage { - - override def pageSettings = super.pageSettings - .withTitle("Handling Forms") - - override def blogSettings = - super.blogSettings.withSections(firstSection) - - val firstSection = Section( - "Handling Form data", - s""" - Form data can be extracted with `Request.current.bodyForm[MyData]`. - The `MyData` needs to have a `FormDataRW` given instance. - - Create a file `form_handling.sc` and paste this code into it: - ```scala - ${ScalaCliFiles.form_handling.indent(4)} - ``` - - Then run it like this: - ```sh - scala-cli form_handling.sc - ``` - - Now go to [http://localhost:8181](http://localhost:8181) - and fill in the page with some data. - - When you click the "Submit" button you will see a response like this: - ``` - Got form data: ContactUsForm(Bob,bob@example.com) - ``` - """.md - ) -} diff --git a/docs/src/files/tutorials/HelloWorld.scala b/docs/src/files/tutorials/HelloWorld.scala deleted file mode 100644 index 4ad1857..0000000 --- a/docs/src/files/tutorials/HelloWorld.scala +++ /dev/null @@ -1,38 +0,0 @@ -package files.tutorials - -import scalatags.Text.all.* -import utils.* - -object HelloWorld extends TutorialPage { - - override def pageSettings = super.pageSettings - .withTitle("Hello World") - - override def blogSettings = - super.blogSettings.withSections(firstSection) - - val firstSection = Section( - "Hello World", - div( - s""" - Let's make a Hello World example with scala-cli. - Create a file `hello_sharaf.sc` and paste this code into it: - ```scala - ${ScalaCliFiles.hello.indent(6)} - ``` - - Then run it like this: - ```sh - scala-cli hello_sharaf.sc - ``` - Go to [http://localhost:8181/hello/Bob](http://localhost:8181/hello/Bob). - You will see a "Hello Bob" text response. - - --- - The most interesting part is the `Routes` definition. - Here we pattern match on `(HttpMethod, Path)`. - The `Path` contains a `Seq[String]`, which are the parts of the URL you can match on. - """.md - ) - ) -} diff --git a/docs/src/files/tutorials/Index.scala b/docs/src/files/tutorials/Index.scala deleted file mode 100644 index e369b2b..0000000 --- a/docs/src/files/tutorials/Index.scala +++ /dev/null @@ -1,69 +0,0 @@ -package files.tutorials - -import utils.* - -object Index extends TutorialPage { - - override def pageSettings = super.pageSettings - .withTitle("Tutorials") - .withLabel("Tutorials") - - override def blogSettings = - super.blogSettings.withSections(firstSection) - - val firstSection = Section( - "Quickstart", - s"""Get started quickly with Sharaf framework.""".md, - List( - Section( - "Mill", - s""" - ```scala - def ivyDeps = super.ivyDeps() ++ Agg( - ivy"${Consts.ArtifactOrg}::${Consts.ArtifactName}:${Consts.ArtifactVersion}" - ) - def scalacOptions = super.scalacOptions() ++ Seq("-Yretain-trees") - ``` - """.md - ), - Section( - "Sbt", - s""" - ```scala - libraryDependencies ++= Seq( - "${Consts.ArtifactOrg}" %% "${Consts.ArtifactName}" % "${Consts.ArtifactVersion}" - ), - scalacOptions ++= Seq("-Yretain-trees") - ``` - """.md - ), - Section( - "Scala CLI", - s""" - Create a file `my_script.sc` with the following content: - ```scala - //> using dep ${Consts.ArtifactOrg}::${Consts.ArtifactName}:${Consts.ArtifactVersion} - ``` - and then run it with: - ```bash - scala-cli my_script.sc --scala-option -Yretain-trees - ``` - """.md - ), - Section( - "Examples", - s""" - - [scala-cli examples](${Consts.GhSourcesUrl}/examples/scala-cli), standalone examples using scala-cli - - [scala-cli HTMX examples](${Consts.GhSourcesUrl}/examples/htmx), standalone examples featuring HTMX - - [API example](${Consts.GhSourcesUrl}/examples/api) featuring JSON and validation - - [full-stack example](${Consts.GhSourcesUrl}/examples/fullstack) featuring HTML, static files and forms - - [sharaf-todo-backend](https://github.com/sake92/sharaf-todo-backend), implementation of the [todobackend.com](http://todobackend.com/) spec, featuring CORS handling - - [OAuth2 login](${Consts.GhSourcesUrl}/examples/oauth2) with [Pac4J library](https://www.pac4j.org/) - - [PetClinic](https://github.com/sake92/sharaf-petclinic) implementation, featuring full-stack app with Postgres db, config, integration tests etc. - - [Giter8 template for fullstack app](https://github.com/sake92/sharaf-fullstack.g8) - - """.md - ) - ) - ) -} diff --git a/docs/src/files/tutorials/JsonAPI.scala b/docs/src/files/tutorials/JsonAPI.scala deleted file mode 100644 index 1949efc..0000000 --- a/docs/src/files/tutorials/JsonAPI.scala +++ /dev/null @@ -1,87 +0,0 @@ -package files.tutorials - -import utils.* - -object JsonAPI extends TutorialPage { - - override def pageSettings = super.pageSettings - .withTitle("JSON API") - - override def blogSettings = - super.blogSettings.withSections(modelSection, routesSection, runSection) - - private val snip1 = ScalaCliFiles.json_api.snippet(until = "val routes").indent(4) - - private val snip2 = ScalaCliFiles.json_api - .snippet(from = "val routes", until = "UndertowSharafServer") - .indent(4) - .trim - - private val snip3 = ScalaCliFiles.json_api.snippet(from = "UndertowSharafServer").indent(4) - - val modelSection = Section( - "Model definition", - s""" - Let's make a simple JSON API in scala-cli. - Create a file `json_api.sc` and paste this code into it: - ```scala - ${snip1} - ``` - - Here we defined a `Car` model, which `derives JsonRW`, so we can use the JSON support from Sharaf. - - We also use a `var db: Seq[Car]` to store our data. - (don't do this for real projects) - """.md - ) - - val routesSection = Section( - "Routes definition", - s""" - Next step is to define a few routes for getting and adding cars: - ```scala - ${snip2} - ``` - - The first route returns all data in the database. - - The second route does some filtering on the database. - - The third route binds the JSON body from the HTTP request. - Then we add it to the database. - """.md - ) - - val runSection = Section( - "Running the server", - s""" - Finally, start up the server: - ```scala - ${snip3} - ``` - - and run it like this: - ```sh - scala-cli json_api.sc - ``` - - Then try the following requests: - ```sh - # get all cars - curl http://localhost:8181/cars - - # add a car - curl --request POST \\ - --url http://localhost:8181/cars \\ - --data '{ - "brand": "Mercedes", - "model": "ML350", - "quantity": 1 - }' - - # get cars by brand - curl http://localhost:8181/cars/Mercedes - ``` - """.md - ) -} diff --git a/docs/src/files/tutorials/PathParams.scala b/docs/src/files/tutorials/PathParams.scala deleted file mode 100644 index 2879142..0000000 --- a/docs/src/files/tutorials/PathParams.scala +++ /dev/null @@ -1,39 +0,0 @@ -package files.tutorials - -import utils.* - -object PathParams extends TutorialPage { - - override def pageSettings = super.pageSettings - .withTitle("Path Params") - - override def blogSettings = - super.blogSettings.withSections(firstSection) - - val firstSection = Section( - "Path Parameters", - s""" - Path parameters can be extracted from the `Path(segments: Seq[String])` argument. - - Create a file `path_params.sc` and paste this code into it: - ```scala - ${ScalaCliFiles.path_params.indent(4)} - ``` - - Then run it like this: - ```sh - scala-cli path_params.sc - ``` - - --- - Now go to [http://localhost:8181/string/abc](http://localhost:8181/string/abc) - and you will get the param returned: `string = abc`. - - When you go to [http://localhost:8181/int/123](http://localhost:8181/int/123), - Sharaf will *try to extract* an `Int` from the path parameter. - If it doesn't match, it will fall through, try the next route. - - """.md - ) - -} diff --git a/docs/src/files/tutorials/QueryParams.scala b/docs/src/files/tutorials/QueryParams.scala deleted file mode 100644 index ef52f22..0000000 --- a/docs/src/files/tutorials/QueryParams.scala +++ /dev/null @@ -1,53 +0,0 @@ -package files.tutorials - -import utils.* - -object QueryParams extends TutorialPage { - - override def pageSettings = super.pageSettings - .withTitle("Query Params") - - override def blogSettings = - super.blogSettings.withSections(firstSection) - - val firstSection = Section( - "Query Parameters", - s""" - ### Raw - Raw query parameters can be accessed through `Request.current.queryParamsRaw`. - This is a `Map[String, Seq[String]]` which you can use to extract query parameters. - The raw approach is useful for simple cases and dynamic query parameters. - - ### Typed - For more type safety you can use the `QueryStringRW` typeclass. - Make a `case class MyParams(..) derives QueryStringRW` - and then use it like this: `Request.current.queryParams[MyParams]` - - --- - - Create a file `query_params.sc` and paste this code into it: - ```scala - ${ScalaCliFiles.query_params.indent(4)} - ``` - - Then run it like this: - ```sh - scala-cli query_params.sc - ``` - - --- - Now go to [http://localhost:8181/raw?q=what&perPage=10](http://localhost:8181/raw?q=what&perPage=10) - and you will get the raw query params map: - ``` - params = Map(perPage -> List(10), q -> List(what)) - ``` - - and if you go to [http://localhost:8181/typed?q=what&perPage=10](http://localhost:8181/typed?q=what&perPage=10) - you will get a type-safe, parsed query params object: - ``` - params = SearchParams(what,10) - ``` - """.md - ) - -} diff --git a/docs/src/files/tutorials/SqlDb.scala b/docs/src/files/tutorials/SqlDb.scala deleted file mode 100644 index f837603..0000000 --- a/docs/src/files/tutorials/SqlDb.scala +++ /dev/null @@ -1,94 +0,0 @@ -package files.tutorials - -import utils.* - -object SqlDb extends TutorialPage { - - override def pageSettings = super.pageSettings - .withTitle("SQL DB") - - override def blogSettings = - super.blogSettings.withSections(dbSetup, squerySetup, routesSetup, runSection) - - val dbSetup = Section( - "DB setup", - s""" - Create a new Postgres database with Docker: - ```sh - docker run --name sharaf-postgres -e POSTGRES_PASSWORD=mysecretpassword -p 5432:5432 -d postgres - ``` - - Then connect to it via `psql` (or your favorite SQL tool): - ```sh - docker exec -it sharaf-postgres psql -U postgres postgres - ``` - and create a table: - ```sql - CREATE TABLE customers( - id SERIAL PRIMARY KEY, - name VARCHAR - ); - ``` - """.md - ) - - private val snip1 = ScalaCliFiles.sql_db.snippet(until = "case class Customer").indent(4) - private val snip2 = ScalaCliFiles.sql_db - .snippet(from = "case class Customer", until = "UndertowSharafServer") - .indent(4) - .trim - private val snip3 = ScalaCliFiles.sql_db.snippet(from = "UndertowSharafServer").indent(4) - - val squerySetup = Section( - "Squery setup", - s""" - Sharaf recommends the [Squery](https://sake92.github.io/squery/) library for accessing databases with a JDBC driver. - - Create a file `sql_db.sc` and paste this code into it: - ```scala - ${snip1} - ``` - - Here we set up the `SqueryContext` which we can use for accessing the database. - """.md - ) - - val routesSetup = Section( - "Querying", - s""" - Now we can do some querying on the db: - ```scala - ${snip2} - ``` - """.md - ) - - val runSection = Section( - "Running the server", - s""" - Finally, we need to start up the server: - ```scala - ${snip3} - ``` - - and run it like this: - ```sh - scala-cli sql_db.sc - ``` - - Then you can try the following requests: - ```sh - # get all customers - curl http://localhost:8181/customers - - # add a customer - curl --request POST \\ - --url http://localhost:8181/customers \\ - --data '{ - "name": "Bob" - }' - - ``` - """.md - ) -} diff --git a/docs/src/files/tutorials/StaticFiles.scala b/docs/src/files/tutorials/StaticFiles.scala deleted file mode 100644 index 2eaf9fd..0000000 --- a/docs/src/files/tutorials/StaticFiles.scala +++ /dev/null @@ -1,43 +0,0 @@ -package files.tutorials - -import utils.* - -object StaticFiles extends TutorialPage { - - override def pageSettings = super.pageSettings - .withTitle("Static Files") - - override def blogSettings = - super.blogSettings.withSections(htmlSection) - - val htmlSection = Section( - "Serving Static Files", - s""" - - The static files are automatically served from the `resources/public` folder (on the classpath): - - in Mill those are under `my_project/resources/public` - - in sbt those are under `src/main/resources/public` - - in scala-cli you need to manually tell it where to look for with `--resource-dir resources` - - --- - - Let's serve an `example.js` file with Sharaf. - First create a file `resources/public/example.js`. - Put this text into it: `console.log('Hello Sharaf!');`. - - Now create a file `static_files.sc` and paste this code into it: - ```scala - ${ScalaCliFiles.static_files.indent(4)} - ``` - - and run it like this: - ```sh - scala-cli static_files.sc --resource-dir resources - ``` - - Go to [http://localhost:8181/example.js](http://localhost:8181/example.js). - You will see the `example.js` contents served. - - """.md - ) -} diff --git a/docs/src/files/tutorials/Tests.scala b/docs/src/files/tutorials/Tests.scala deleted file mode 100644 index 8ca4f09..0000000 --- a/docs/src/files/tutorials/Tests.scala +++ /dev/null @@ -1,39 +0,0 @@ -package files.tutorials - -import scalatags.Text.all.* -import utils.* - -object Tests extends TutorialPage { - - override def pageSettings = super.pageSettings - .withTitle("Tests") - - override def blogSettings = - super.blogSettings.withSections(firstSection) - - val firstSection = Section( - "Tests", - div( - s""" - Tests are essential to any serious software component. - Writing integration tests with Munit and Requests is straightforward. - - Here we are testing the API from the [JSON API tutorial](${JsonAPI.routesSection.ref}). - Create a file `json_api.test.scala` and paste this code into it: - ```scala - ${ScalaCliFiles.json_api_test.indent(6)} - ``` - - First run the API server in one shell: - ```sh - scala-cli test json_api.sc - ``` - - and then run the tests in another shell: - ```sh - scala-cli test json_api.test.scala - ``` - """.md - ) - ) -} diff --git a/docs/src/files/tutorials/TutorialPage.scala b/docs/src/files/tutorials/TutorialPage.scala deleted file mode 100644 index 6016879..0000000 --- a/docs/src/files/tutorials/TutorialPage.scala +++ /dev/null @@ -1,37 +0,0 @@ -package files.tutorials - -import utils.* - -// TODO logging, logback + slf4j -// TODO docker - -// TODO JWT -// TODO basic auth? - -// TODO session? -// TODO cookie? - -// https://undertow.io/javadoc/1.3.x/io/undertow/Handlers.html -// TODO websockets -// TODO SSE -trait TutorialPage extends DocPage { - - override def categoryPosts = List( - Index, - HelloWorld, - PathParams, - QueryParams, - StaticFiles, - HTML, - HandlingForms, - JsonAPI, - Validation, - SqlDb, - Tests, - HTMX - ) - - override def pageCategory = Some("Tutorials") - - override def currentCategoryPage = Some(Index) -} diff --git a/docs/src/files/tutorials/Validation.scala b/docs/src/files/tutorials/Validation.scala deleted file mode 100644 index cee8979..0000000 --- a/docs/src/files/tutorials/Validation.scala +++ /dev/null @@ -1,108 +0,0 @@ -package files.tutorials - -import utils.* - -object Validation extends TutorialPage { - - override def pageSettings = super.pageSettings - .withTitle("Validation") - - override def blogSettings = - super.blogSettings.withSections(helloSection) - - val helloSection = Section( - "Validating data", - s""" - For validating data you need to use the `Validator` typeclass. - A small example: - - ```scala - import ba.sake.validson.Validator - - case class ValidatedData(num: Int, str: String, seq: Seq[String]) - - object ValidatedData: - given Validator[ValidatedData] = Validator - .derived[ValidatedData] - .positive(_.num) - .notBlank(_.str) - .minItems(_.seq, 1) - ``` - - The `ValidatedData` can be any `case class`: json data, form data, query params.. - - --- - - Create a file `validation.sc` and paste this code into it: - - ```scala - ${ScalaCliFiles.validation.indent(4)} - ``` - - Then run it like this: - ```sh - scala-cli validation.sc - ``` - - Notice above that we used `queryParamsValidated` and not plain `queryParams` (does not validate query params). - Also, for JSON body parsing+validation we use `bodyJsonValidated` and not plain `bodyJson` (does not validate JSON body). - - --- - When you do a GET [http://localhost:8181/cars?brand= ](http://localhost:8181/cars?brand= ) - you will get a nice JSON error message with HTTP Status of `400 Bad Request`: - ```json - { - "instance": null, - "invalidArguments": [ - { - "reason": "must not be blank", - "path": "\\$$.brand", - "value": "" - } - ], - "detail": "", - "type": null, - "title": "Validation errors", - "status": 400 - } - ``` - - The error message format follows the [RFC 7807 problem detail](https://datatracker.ietf.org/doc/html/rfc7807). - - --- - - When you do a POST [http://localhost:8181/cars](http://localhost:8181/cars) with a malformed body: - ```json - { - "brand": " ", - "model": "ML350", - "quantity": -5 - } - ``` - - you will get these errors: - ```json - { - "instance": null, - "invalidArguments": [ - { - "reason": "must not be blank", - "path": "\\$$.brand", - "value": " " - }, - { - "reason": "must not be negative", - "path": "\\$$.quantity", - "value": "-5" - } - ], - "detail": "", - "type": null, - "title": "Validation errors", - "status": 400 - } - ``` - """.md - ) - -} diff --git a/docs/src/utils/Consts.scala b/docs/src/utils/Consts.scala deleted file mode 100644 index 564af9b..0000000 --- a/docs/src/utils/Consts.scala +++ /dev/null @@ -1,22 +0,0 @@ -package utils - -object Consts: - - val ProjectName = "Sharaf" - - val ArtifactOrg = "ba.sake" - val ArtifactName = "sharaf" - val ArtifactVersion = "0.10.0" - - val GhHandle = "sake92" - val GhProjectName = "sharaf" - val GhUrl = s"https://github.com/${GhHandle}/${GhProjectName}" - val GhSourcesUrl = s"https://github.com/${GhHandle}/${GhProjectName}/tree/main" - - val tq = """"""""" - - def allSearchIndexedPages = Seq(files.Index) ++ - files.tutorials.Index.categoryPosts ++ - files.howtos.Index.categoryPosts ++ - files.reference.Index.categoryPosts ++ - files.philosophy.Index.categoryPosts diff --git a/docs/src/utils/ScalaCliFiles.scala b/docs/src/utils/ScalaCliFiles.scala deleted file mode 100644 index 14eb24f..0000000 --- a/docs/src/utils/ScalaCliFiles.scala +++ /dev/null @@ -1,39 +0,0 @@ -package utils - -// TODO extract to mill-hepek somehow -extension (str: String) { - - /** @param from - * Inclusive - * @param until - * Exclusive - * @return - */ - def snippet(from: String = "", until: String = ""): String = - str.linesWithSeparators - .dropWhile(line => from != "" && !line.trim.startsWith(from)) - .takeWhile(line => until == "" || !line.trim.startsWith(until)) - .mkString -} - -object ScalaCliFiles: - - val hello = get(os.RelPath("scala-cli/hello.sc")) - val path_params = get(os.RelPath("scala-cli/path_params.sc")) - val query_params = get(os.RelPath("scala-cli/query_params.sc")) - val static_files = get(os.RelPath("scala-cli/static_files.sc")) - val html_scalatags = get(os.RelPath("scala-cli/html_scalatags.sc")) - val html_hepek = get(os.RelPath("scala-cli/html_hepek.sc")) - val htmx_load_snippet = get(os.RelPath("htmx/htmx_load_snippet.sc")) - val form_handling = get(os.RelPath("scala-cli/form_handling.sc")) - val json_api = get(os.RelPath("scala-cli/json_api.sc")) - val json_api_test = get(os.RelPath("scala-cli/json_api.test.scala")) - - val sql_db = get(os.RelPath("scala-cli/sql_db.sc")) - - val validation = get(os.RelPath("scala-cli/validation.sc")) - - private def get(chunk: os.PathChunk) = - // os.pwd is sandboxed, this is called from plugin ! - val wd = os.Path(System.getenv("MILL_WORKSPACE_ROOT")) - os.read(wd / "examples" / chunk).replace("${", "\\${") // escaping for nodejs shiki diff --git a/docs/src/utils/package.scala b/docs/src/utils/package.scala deleted file mode 100644 index dc185c2..0000000 --- a/docs/src/utils/package.scala +++ /dev/null @@ -1,36 +0,0 @@ -package utils - -import scalatags.Text.all.* -import ba.sake.hepek.core.RelativePath -import ba.sake.hepek.html.statik - -type Section = statik.Section -val Section = statik.Section - -def pager(thisSp: statik.BlogPostPage)(using caller: RelativePath) = { - - def picoButtons(navLinks: Frag*) = tag("nav")( - div(role := "group")(navLinks) - ) - - val posts = thisSp.categoryPosts - val indexOfThis = posts.indexOf(thisSp) - if posts.length > 1 && indexOfThis >= 0 then { - if indexOfThis == 0 then - picoButtons( - a(href := "#", disabled, role := "button", cls := "outline")("Previous"), - a(href := posts(indexOfThis + 1).ref, role := "button", cls := "outline")("Next") - ) - else if indexOfThis == posts.length - 1 then - picoButtons( - a(href := posts(indexOfThis - 1).ref, role := "button", cls := "outline")("Previous"), - a(href := "#", disabled, role := "button", cls := "outline")("Next") - ) - else - picoButtons( - a(href := posts(indexOfThis - 1).ref, role := "button", cls := "outline")("Previous"), - a(href := posts(indexOfThis + 1).ref, role := "button", cls := "outline")("Next") - ) - } else frag() - -} diff --git a/docs/src/utils/templates.scala b/docs/src/utils/templates.scala deleted file mode 100644 index 4586dbf..0000000 --- a/docs/src/utils/templates.scala +++ /dev/null @@ -1,131 +0,0 @@ -package utils - -import scalatags.Text.all.* -import scalatags.Text.tags2.{aside, main, nav, section} -import ba.sake.hepek.anchorjs.AnchorjsDependencies -import ba.sake.hepek.html.statik.{BlogPostPage, ShikiSettings, StaticPage} -import files.SearchResults - -trait DocPage extends DocStaticPage with BlogPostPage { - - override def mainContent: Frag = div(cls := "blog-post")( - aside(id := "left-menu")( - nav( - ul( - for sameCatPost <- categoryPosts - yield li( - a( - href := sameCatPost.ref, - Option.when(this.relPath == sameCatPost.relPath)(attr("aria-current") := "page") - )( - sameCatPost.pageSettings.label - ) - ) - ) - ) - ), - div(cls := "main-content")( - pager(this), - renderSections(blogSettings.sections, 2) - ) - ) - - private def renderSections(secs: List[Section], depth: Int): List[Frag] = - secs.map { s => - // h2, h3... - val hTag = tag("h" + depth) - section(id := s.id)( - hTag(s.name), - s.content, - renderSections(s.children, depth + 1) - ) - } -} - -trait DocStaticPage extends StaticPage with AnchorjsDependencies { - - def currentCategoryPage: Option[StaticPage] = None - - override def staticSiteSettings = super.staticSiteSettings - .withIndexPage(files.Index) - .withMainPages( - files.tutorials.Index, - files.howtos.Index, - files.reference.Index, - files.philosophy.Index - ) - - override def siteSettings = super.siteSettings - .withName(Consts.ProjectName) - .withFaviconNormal(files.images.`favicon.png`.ref) - - override def shikiSettings = super.shikiSettings.withTheme("material-theme-ocean") - - override def styleURLs = - List("https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.cyan.min.css", files.styles.`main.css`.ref) - - override def pageContent = frag( - header(cls := "container")( - topNavbar - ), - main(cls := "container")( - mainContent - ), - footer(cls := "container flex-centered")( - a(href := "https://github.com/sake92/sharaf")( - raw(""" - - - - - """) - ), - a(href := "https://discord.gg/g9KVY3WkMG")( - raw(""" - - - - - - """) - ) - ) - ) - - private def topNavbar = nav( - ul( - siteSettings.faviconNormal.map { fav => - li(img(src := fav)) - }, - li(a(href := "https://sake92.github.io/sharaf/")("Sharaf Docs")) - ), - ul( - li( - form(action := SearchResults.ref, method := "GET")( - input( - name := "q", - tpe := "search", - placeholder := "Search" - ) - ) - ) - ), - ul( - for - mainPage <- staticSiteSettings.mainPages - labela = mainPage.pageCategory.getOrElse(mainPage.pageSettings.label) - yield li( - a( - href := mainPage.ref, - Option.when(currentCategoryPage.map(_.relPath).contains(mainPage.relPath))( - attr("aria-current") := "true" - ) - )(labela) - ) - ) - ) - - override def scriptURLs = super.scriptURLs - .appended(files.scripts.`main.js`.ref) - -} diff --git a/docs/resources/public/.nojekyll b/docs/static/.nojekyll similarity index 100% rename from docs/resources/public/.nojekyll rename to docs/static/.nojekyll diff --git a/docs/resources/public/images/favicon.png b/docs/static/images/favicon.png similarity index 100% rename from docs/resources/public/images/favicon.png rename to docs/static/images/favicon.png diff --git a/docs/resources/public/images/favicon.svg b/docs/static/images/favicon.svg similarity index 100% rename from docs/resources/public/images/favicon.svg rename to docs/static/images/favicon.svg diff --git a/docs/resources/public/images/logo_original.png b/docs/static/images/logo_original.png similarity index 100% rename from docs/resources/public/images/logo_original.png rename to docs/static/images/logo_original.png diff --git a/docs/resources/public/scripts/main.js b/docs/static/scripts/main.js similarity index 100% rename from docs/resources/public/scripts/main.js rename to docs/static/scripts/main.js diff --git a/docs/resources/public/styles/main.css b/docs/static/styles/main.css similarity index 100% rename from docs/resources/public/styles/main.css rename to docs/static/styles/main.css From a20ea56e02d7c23ae8489e68bd708c4cdf6f4dc0 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Sun, 6 Jul 2025 00:14:01 +0200 Subject: [PATCH 04/13] Fix docs --- .github/workflows/ghpages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ghpages.yml b/.github/workflows/ghpages.yml index b8da859..69ce099 100644 --- a/.github/workflows/ghpages.yml +++ b/.github/workflows/ghpages.yml @@ -25,7 +25,7 @@ jobs: FLATMARK_VERSION=0.0.24 curl -L https://github.com/sake92/flatmark/releases/download/v${FLATMARK_VERSION}/flatmark_${FLATMARK_VERSION}_amd64.deb -o flatmark.deb sudo apt install -y ./flatmark.deb - flatmark build + flatmark build -i docs - name: Deploy uses: JamesIves/github-pages-deploy-action@v4 with: From e80a491467408843d330c9e806c5af3ae86c7dc3 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Sun, 6 Jul 2025 00:15:22 +0200 Subject: [PATCH 05/13] Fix docs --- .github/workflows/ghpages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ghpages.yml b/.github/workflows/ghpages.yml index 69ce099..b9a6d6c 100644 --- a/.github/workflows/ghpages.yml +++ b/.github/workflows/ghpages.yml @@ -29,4 +29,4 @@ jobs: - name: Deploy uses: JamesIves/github-pages-deploy-action@v4 with: - folder: _site + folder: docs/_site From e5e209936e618fbfa1413413cbe9a51a0b6c9098 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Sun, 6 Jul 2025 00:17:43 +0200 Subject: [PATCH 06/13] Disable root pagination --- docs/content/index.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/content/index.md b/docs/content/index.md index 957de49..44d9f87 100644 --- a/docs/content/index.md +++ b/docs/content/index.md @@ -1,6 +1,8 @@ --- title: Sharaf description: Sharaf - a minimalistic Scala 3 web framework. +pagination: + enabled: false --- # {{ page.title }} From 038acf5468c7b1df38ee64257497f3531dffb7dc Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Sun, 6 Jul 2025 00:20:46 +0200 Subject: [PATCH 07/13] Fix docs search --- docs/content/reference/index.md | 3 +-- docs/content/search/results.html | 3 +++ 2 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 docs/content/search/results.html diff --git a/docs/content/reference/index.md b/docs/content/reference/index.md index a0a9295..4576751 100644 --- a/docs/content/reference/index.md +++ b/docs/content/reference/index.md @@ -5,7 +5,6 @@ description: Sharaf Reference # {{ page.title }} -Sharaf reference", - + Take a look at [Sharaf scaladoc](https://javadoc.io/doc/ba.sake/sharaf_3). diff --git a/docs/content/search/results.html b/docs/content/search/results.html new file mode 100644 index 0000000..fea2423 --- /dev/null +++ b/docs/content/search/results.html @@ -0,0 +1,3 @@ +--- +layout: search-results.html +--- \ No newline at end of file From e214af578fd9929cc2f18105cdec48d16c3154cf Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Sun, 6 Jul 2025 22:36:48 +0200 Subject: [PATCH 08/13] Pull out 'object test' from traits --- TODO.md | 2 -- build.mill | 23 ++++++++++++++--------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/TODO.md b/TODO.md index 8a7729d..ab80f82 100644 --- a/TODO.md +++ b/TODO.md @@ -2,8 +2,6 @@ - some kind of middleware mechanism -- migrate docs to Pico.css - - MiMa bin compat - add more validators https://javaee.github.io/javaee-spec/javadocs/javax/validation/constraints/package-summary.html diff --git a/build.mill b/build.mill index 84932d1..ce164d7 100644 --- a/build.mill +++ b/build.mill @@ -14,6 +14,7 @@ object V: object `sharaf-core` extends Module: object jvm extends SharafCoreModule with ScalaJvmCommonModule: def moduleDeps = Seq(querson.jvm, formson.jvm, validson.jvm) + object test extends ScalaTests with SharafTestModule object native extends SharafCoreModule with ScalaNativeCommonModule: def moduleDeps = Seq(querson.native, formson.native, validson.native) @@ -63,8 +64,10 @@ object `sharaf-snunit` extends ScalaNativeCommonModule with SharafPublishModule: def moduleDeps = Seq(`sharaf-core`.native) object querson extends Module: - object jvm extends QuersonModule with ScalaJvmCommonModule - object js extends QuersonModule with ScalaJSCommonModule + object jvm extends QuersonModule with ScalaJvmCommonModule: + object test extends ScalaTests with SharafTestModule + object js extends QuersonModule with ScalaJSCommonModule: + object test extends ScalaJSTests with SharafTestModule object native extends QuersonModule with ScalaNativeCommonModule: object test extends ScalaNativeTests with SharafTestModule trait QuersonModule extends SharafPublishModule with PlatformScalaModule: @@ -75,8 +78,10 @@ object querson extends Module: ) object formson extends Module: - object jvm extends FormsonModule with ScalaJvmCommonModule - //object js extends FormsonModule with ScalaJSCommonModule // java.nio.Path not supported + object jvm extends FormsonModule with ScalaJvmCommonModule: + object test extends ScalaTests with SharafTestModule + //object js extends FormsonModule with ScalaJSCommonModule: // java.nio.Path not supported + // object test extends ScalaJSTests with SharafTestModule object native extends FormsonModule with ScalaNativeCommonModule: object test extends ScalaNativeTests with SharafTestModule trait FormsonModule extends SharafPublishModule with PlatformScalaModule: @@ -88,8 +93,10 @@ object formson extends Module: object validson extends Module: - object jvm extends ValidsonModule with ScalaJvmCommonModule - object js extends ValidsonModule with ScalaJSCommonModule + object jvm extends ValidsonModule with ScalaJvmCommonModule: + object test extends ScalaTests with SharafTestModule + object js extends ValidsonModule with ScalaJSCommonModule: + object test extends ScalaJSTests with SharafTestModule object native extends ValidsonModule with ScalaNativeCommonModule: object test extends ScalaNativeTests with SharafTestModule trait ValidsonModule extends SharafPublishModule with PlatformScalaModule: @@ -122,15 +129,13 @@ trait SharafCommonModule extends ScalaModule: "-explain" ) -trait ScalaJvmCommonModule extends ScalaModule: - object test extends ScalaTests with SharafTestModule +trait ScalaJvmCommonModule extends ScalaModule trait ScalaJSCommonModule extends ScalaJSModule: def scalaJSVersion = "1.19.0" def mvnDeps = super.mvnDeps() ++ Seq( mvn"io.github.cquiroz::scala-java-time::2.6.0" ) - object test extends ScalaJSTests with SharafTestModule trait ScalaNativeCommonModule extends ScalaNativeModule: def scalaNativeVersion = "0.5.7" From 92f408d9ec7e41a7f164721dbf205b2144bcfbae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sakib=20Had=C5=BEiavdi=C4=87?= Date: Tue, 8 Jul 2025 12:08:47 +0200 Subject: [PATCH 09/13] Introduce SharafHandler to support middleware logic (#50) --- examples/api/src/api/Main.scala | 3 +- examples/oauth2/src/AppModule.scala | 4 +- examples/snunit/src/Main.scala | 12 ++- .../src/userpassform/Main.scala | 4 +- .../src/ba/sake/sharaf/HttpString.scala | 9 +- sharaf-core/src/ba/sake/sharaf/Response.scala | 3 + .../src/ba/sake/sharaf/SharafHandler.scala | 26 ++++++ .../ba/sake/sharaf/handlers/CorsHandler.scala | 42 ++++++++++ .../sharaf/handlers/ExceptionHandler.scala | 24 ++++++ .../sake/sharaf/handlers/RoutesHandler.scala | 12 +++ .../src/ba/sake/sharaf/routing/Routes.scala | 1 + .../sharaf/helidon/HelidonSharafServer.scala | 13 +-- .../sharaf/helidon/SharafHelidonHandler.scala | 14 ++-- .../helidon/HelidonSharafServerTest.scala | 2 +- .../ba/sake/sharaf/snunit/ResponseUtils.scala | 3 - .../sharaf/snunit/SharafRequestHandler.scala | 26 +++--- .../undertow/SharafUndertowHandler.scala | 39 +++++++++ ...r.scala => UndertowExceptionHandler.scala} | 9 +- .../undertow/UndertowSharafRequest.scala | 2 + .../undertow/UndertowSharafServer.scala | 70 ++++++++++++---- .../undertow/handlers/CorsHandler.scala | 50 ----------- .../undertow/handlers/RoutesHandler.scala | 45 ---------- .../undertow/handlers/SharafHandler.scala | 83 ------------------- .../ba/sake/sharaf/undertow/CorsTest.scala | 45 ++++++++++ .../ba/sake/sharaf/undertow/HeadersTest.scala | 5 +- .../sake/sharaf/undertow/SessionsTest.scala | 12 +-- .../ba/sake/sharaf/undertow/WebJarsTest.scala | 29 +++++++ .../undertow/handlers/ErrorHandlerTest.scala | 49 +++++------ 28 files changed, 349 insertions(+), 287 deletions(-) create mode 100644 sharaf-core/src/ba/sake/sharaf/SharafHandler.scala create mode 100644 sharaf-core/src/ba/sake/sharaf/handlers/CorsHandler.scala create mode 100644 sharaf-core/src/ba/sake/sharaf/handlers/ExceptionHandler.scala create mode 100644 sharaf-core/src/ba/sake/sharaf/handlers/RoutesHandler.scala delete mode 100644 sharaf-snunit/src/ba/sake/sharaf/snunit/ResponseUtils.scala create mode 100644 sharaf-undertow/src/ba/sake/sharaf/undertow/SharafUndertowHandler.scala rename sharaf-undertow/src/ba/sake/sharaf/undertow/{handlers/ExceptionHandler.scala => UndertowExceptionHandler.scala} (68%) delete mode 100644 sharaf-undertow/src/ba/sake/sharaf/undertow/handlers/CorsHandler.scala delete mode 100644 sharaf-undertow/src/ba/sake/sharaf/undertow/handlers/RoutesHandler.scala delete mode 100644 sharaf-undertow/src/ba/sake/sharaf/undertow/handlers/SharafHandler.scala create mode 100644 sharaf-undertow/test/src/ba/sake/sharaf/undertow/CorsTest.scala create mode 100644 sharaf-undertow/test/src/ba/sake/sharaf/undertow/WebJarsTest.scala diff --git a/examples/api/src/api/Main.scala b/examples/api/src/api/Main.scala index d7416bb..d907495 100644 --- a/examples/api/src/api/Main.scala +++ b/examples/api/src/api/Main.scala @@ -42,6 +42,5 @@ class JsonApiModule(port: Int) { Files.writeString(tmpFile, db.toJson) Response.withBody(tmpFile) - val server = UndertowSharafServer("localhost", port, routes) - .withExceptionMapper(ExceptionMapper.json) + val server = UndertowSharafServer("localhost", port, routes, exceptionMapper = ExceptionMapper.json) } diff --git a/examples/oauth2/src/AppModule.scala b/examples/oauth2/src/AppModule.scala index 6e6883a..2adca43 100644 --- a/examples/oauth2/src/AppModule.scala +++ b/examples/oauth2/src/AppModule.scala @@ -11,7 +11,7 @@ import org.pac4j.undertow.handler.CallbackHandler import org.pac4j.undertow.handler.LogoutHandler import org.pac4j.undertow.handler.SecurityHandler import ba.sake.sharaf.* -import ba.sake.sharaf.undertow.handlers.SharafHandler +import ba.sake.sharaf.undertow.SharafUndertowHandler class AppModule(port: Int, clients: Clients) { @@ -24,7 +24,7 @@ class AppModule(port: Int, clients: Clients) { private val httpHandler: HttpHandler = locally { val securityHandler = SecurityHandler.build( - SharafHandler(appRoutes.routes), + SharafUndertowHandler(SharafHandler.routes(appRoutes.routes)), securityConfig.pac4jConfig, securityConfig.clientNames.mkString(","), null, diff --git a/examples/snunit/src/Main.scala b/examples/snunit/src/Main.scala index 7087f01..e15fd0d 100644 --- a/examples/snunit/src/Main.scala +++ b/examples/snunit/src/Main.scala @@ -3,11 +3,15 @@ import ba.sake.sharaf.snunit.* @main def main: Unit = - val routes = Routes { case _ => - Response.withBody("Hello Snunit!") + val routes = Routes { + case GET -> Path("hello", name) => + Response.withBody(s"Hello ${name}!") + case _ => + Response.withBody("Hello Snunit!") } val server = _root_.snunit.SyncServerBuilder - .setRequestHandler(SharafRequestHandler(routes)) + .setRequestHandler( + SharafRequestHandler(SharafHandler.routes(routes)) + ) .build() - server.listen() diff --git a/examples/user-pass-form/src/userpassform/Main.scala b/examples/user-pass-form/src/userpassform/Main.scala index 0fc0493..4def9ff 100644 --- a/examples/user-pass-form/src/userpassform/Main.scala +++ b/examples/user-pass-form/src/userpassform/Main.scala @@ -2,7 +2,7 @@ package userpassform import scala.jdk.CollectionConverters.* import ba.sake.sharaf.* -import ba.sake.sharaf.undertow.handlers.SharafHandler +import ba.sake.sharaf.undertow.SharafUndertowHandler import io.undertow.server.session.{InMemorySessionManager, SessionAttachmentHandler, SessionCookieConfig} import io.undertow.{Handlers, Undertow} import org.pac4j.core.client.Clients @@ -53,7 +53,7 @@ class UserPassFormModule(port: Int) { val securityService = SecurityService(pac4jConfig) private val securityHandler = SecurityHandler.build( - SharafHandler(AppRoutes(callbackUrl, securityService).routes), + SharafUndertowHandler(SharafHandler.routes(AppRoutes(callbackUrl, securityService).routes)), pac4jConfig, clientNames.mkString(","), null, diff --git a/sharaf-core/src/ba/sake/sharaf/HttpString.scala b/sharaf-core/src/ba/sake/sharaf/HttpString.scala index 24dda6c..a7c90cd 100644 --- a/sharaf-core/src/ba/sake/sharaf/HttpString.scala +++ b/sharaf-core/src/ba/sake/sharaf/HttpString.scala @@ -1,15 +1,20 @@ package ba.sake.sharaf +import scala.util.hashing.MurmurHash3 + // TODO implicit conversion from String ?? /** Case-insensitive string for HTTP headers and such. */ final class HttpString private (val value: String) { override def equals(other: Any): Boolean = other match { - case that: HttpString => value.equalsIgnoreCase(that.value) - case _ => false + case h: AnyRef if this.eq(h) => true + case that: HttpString => value.equalsIgnoreCase(that.value) + case _ => false } + override def hashCode(): Int = MurmurHash3.stringHash(value.toLowerCase) + override def toString: String = value } diff --git a/sharaf-core/src/ba/sake/sharaf/Response.scala b/sharaf-core/src/ba/sake/sharaf/Response.scala index 5450e59..497edd9 100644 --- a/sharaf-core/src/ba/sake/sharaf/Response.scala +++ b/sharaf-core/src/ba/sake/sharaf/Response.scala @@ -39,6 +39,9 @@ final class Response[T] private ( cookieUpdates: CookieUpdates = cookieUpdates, body: Option[T2] = body )(using ResponseWritable[T2]) = new Response(status, headerUpdates, cookieUpdates, body) + + override def toString(): String = + s"Response(status=$status, headerUpdates=${headerUpdates}, cookieUpdates=${cookieUpdates}, body=...)" } object Response { diff --git a/sharaf-core/src/ba/sake/sharaf/SharafHandler.scala b/sharaf-core/src/ba/sake/sharaf/SharafHandler.scala new file mode 100644 index 0000000..fd962f1 --- /dev/null +++ b/sharaf-core/src/ba/sake/sharaf/SharafHandler.scala @@ -0,0 +1,26 @@ +package ba.sake.sharaf + +import ba.sake.sharaf.routing.{RequestParams, Routes} +import ba.sake.sharaf.handlers.* + +trait SharafHandler: + def handle(context: RequestContext): Response[?] + +object SharafHandler: + def routes(routes: Routes): SharafHandler = + RoutesHandler(routes) + + def exceptions(next: SharafHandler): SharafHandler = + exceptions(ExceptionMapper.default, next) + def exceptions(exceptionMapper: ExceptionMapper, next: SharafHandler): SharafHandler = + ExceptionHandler(exceptionMapper, next) + + def cors(next: SharafHandler): SharafHandler = + cors(CorsSettings.default, next) + def cors(corsSettings: CorsSettings, next: SharafHandler): SharafHandler = + CorsHandler(corsSettings, next) + +case class RequestContext( + params: RequestParams, + request: Request +) diff --git a/sharaf-core/src/ba/sake/sharaf/handlers/CorsHandler.scala b/sharaf-core/src/ba/sake/sharaf/handlers/CorsHandler.scala new file mode 100644 index 0000000..1c2dae3 --- /dev/null +++ b/sharaf-core/src/ba/sake/sharaf/handlers/CorsHandler.scala @@ -0,0 +1,42 @@ +package ba.sake.sharaf.handlers + +import scala.jdk.CollectionConverters.* +import ba.sake.sharaf.* +import sttp.model.HeaderNames + +// https://www.moesif.com/blog/technical/cors/Authoritative-Guide-to-CORS-Cross-Origin-Resource-Sharing-for-REST-APIs/ +final class CorsHandler(corsSettings: CorsSettings, next: SharafHandler) extends SharafHandler { + + override def handle(context: RequestContext): Response[?] = + if context.params._1 == HttpMethod.OPTIONS then { + setCorsHeaders(context.request, Response.default) + .settingHeader( + HeaderNames.AccessControlAllowMethods, + corsSettings.allowedHttpMethods.map(_.toString).mkString(", ") + ) + .settingHeader( + HeaderNames.AccessControlAllowHeaders, + corsSettings.allowedHttpHeaders.map(_.toString).mkString(", ") + ) + } else { + val res = next.handle(context) + setCorsHeaders(context.request, res) + } + + private def setCorsHeaders[T](req: Request, res: Response[T]): Response[T] = { + val allowOpt = + if corsSettings.allowedOrigins.contains("*") then Some("*") + else + req.headers.get(HttpString(HeaderNames.Origin)) match { + case Some(origins) => + Option.when(corsSettings.allowedOrigins(origins.head))(origins.head) + case _ => + None // noop + } + var tmpRes = res.settingHeader(HeaderNames.AccessControlAllowCredentials, corsSettings.allowCredentials.toString) + allowOpt.foreach { allow => + tmpRes = tmpRes.settingHeader(HeaderNames.AccessControlAllowOrigin, allow) + } + tmpRes + } +} diff --git a/sharaf-core/src/ba/sake/sharaf/handlers/ExceptionHandler.scala b/sharaf-core/src/ba/sake/sharaf/handlers/ExceptionHandler.scala new file mode 100644 index 0000000..7262c38 --- /dev/null +++ b/sharaf-core/src/ba/sake/sharaf/handlers/ExceptionHandler.scala @@ -0,0 +1,24 @@ +package ba.sake.sharaf.handlers + +import scala.util.control.NonFatal +import ba.sake.sharaf.* +import ba.sake.sharaf.exceptions.ExceptionMapper + +final class ExceptionHandler(exceptionMapper: ExceptionMapper, next: SharafHandler) extends SharafHandler { + + override def handle(context: RequestContext): Response[?] = + try next.handle(context) + catch { + case NonFatal(e) => + val errResponseOpt = exceptionMapper.lift(e) + errResponseOpt match { + case Some(response) => + response + case None => + // if no error response match, just propagate. + // will return 500 + throw e + } + } + +} diff --git a/sharaf-core/src/ba/sake/sharaf/handlers/RoutesHandler.scala b/sharaf-core/src/ba/sake/sharaf/handlers/RoutesHandler.scala new file mode 100644 index 0000000..4c0003c --- /dev/null +++ b/sharaf-core/src/ba/sake/sharaf/handlers/RoutesHandler.scala @@ -0,0 +1,12 @@ +package ba.sake.sharaf.handlers + +import ba.sake.sharaf.* +import ba.sake.sharaf.exceptions.NotFoundException + +class RoutesHandler(routes: Routes) extends SharafHandler: + override def handle(context: RequestContext): Response[?] = + given Request = context.request + val routesDefinition = routes.definition + routesDefinition + .lift(context.params) + .getOrElse(throw NotFoundException("route")) diff --git a/sharaf-core/src/ba/sake/sharaf/routing/Routes.scala b/sharaf-core/src/ba/sake/sharaf/routing/Routes.scala index be244fb..1f0913f 100644 --- a/sharaf-core/src/ba/sake/sharaf/routing/Routes.scala +++ b/sharaf-core/src/ba/sake/sharaf/routing/Routes.scala @@ -1,6 +1,7 @@ package ba.sake.sharaf.routing import ba.sake.sharaf.{HttpMethod, Request, Response} +import ba.sake.sharaf.exceptions.NotFoundException type RequestParams = (HttpMethod, Path) diff --git a/sharaf-helidon/src/ba/sake/sharaf/helidon/HelidonSharafServer.scala b/sharaf-helidon/src/ba/sake/sharaf/helidon/HelidonSharafServer.scala index 1efde24..8638b0a 100644 --- a/sharaf-helidon/src/ba/sake/sharaf/helidon/HelidonSharafServer.scala +++ b/sharaf-helidon/src/ba/sake/sharaf/helidon/HelidonSharafServer.scala @@ -3,9 +3,9 @@ package ba.sake.sharaf.helidon import io.helidon.config.Config import io.helidon.webserver.WebServer import io.helidon.webserver.http.HttpRouting -import ba.sake.sharaf.Routes +import ba.sake.sharaf.SharafHandler -class HelidonSharafServer(host: String, port: Int, sharafHandler: SharafHelidonHandler) { +class HelidonSharafServer(host: String, port: Int, sharafHelidonHandler: SharafHelidonHandler) { System.setProperty("server.host", host) System.setProperty("server.port", port.toString) @@ -14,7 +14,7 @@ class HelidonSharafServer(host: String, port: Int, sharafHandler: SharafHelidonH .builder() .config(Config.create().get("server")) .routing { (builder: HttpRouting.Builder) => - builder.any(sharafHandler) + builder.any(sharafHelidonHandler) () } .build() @@ -26,10 +26,11 @@ class HelidonSharafServer(host: String, port: Int, sharafHandler: SharafHelidonH object HelidonSharafServer { + def apply(host: String, port: Int, sharafHandler: SharafHandler): HelidonSharafServer = + new HelidonSharafServer(host, port, SharafHelidonHandler(sharafHandler)) + + // if need tweaking def apply(host: String, port: Int, sharafHelidonHandler: SharafHelidonHandler): HelidonSharafServer = new HelidonSharafServer(host, port, sharafHelidonHandler) - def apply(host: String, port: Int, routes: Routes): HelidonSharafServer = - apply(host, port, SharafHelidonHandler(routes)) - } diff --git a/sharaf-helidon/src/ba/sake/sharaf/helidon/SharafHelidonHandler.scala b/sharaf-helidon/src/ba/sake/sharaf/helidon/SharafHelidonHandler.scala index 8890c0b..5f4ffe9 100644 --- a/sharaf-helidon/src/ba/sake/sharaf/helidon/SharafHelidonHandler.scala +++ b/sharaf-helidon/src/ba/sake/sharaf/helidon/SharafHelidonHandler.scala @@ -4,18 +4,14 @@ import io.helidon.webserver.http.{Handler, ServerRequest, ServerResponse} import ba.sake.sharaf.* import ba.sake.sharaf.routing.* -class SharafHelidonHandler(routes: Routes) extends Handler { +class SharafHelidonHandler(sharafHandler: SharafHandler) extends Handler { override def handle(helidonReq: ServerRequest, helidonRes: ServerResponse): Unit = { - given Request = HelidonSharafRequest.create(helidonReq) val reqParams = fillReqParams(helidonReq) - routes.definition.lift(reqParams) match { - case Some(res) => - ResponseUtils.writeResponse(res, helidonRes) - case None => - // will be catched by ExceptionHandler - throw exceptions.NotFoundException("route") - } + val req = HelidonSharafRequest.create(helidonReq) + val requestContext = RequestContext(reqParams, req) + val res = sharafHandler.handle(requestContext) + ResponseUtils.writeResponse(res, helidonRes) } private def fillReqParams(req: ServerRequest): RequestParams = { diff --git a/sharaf-helidon/test/src/ba/sake/sharaf/helidon/HelidonSharafServerTest.scala b/sharaf-helidon/test/src/ba/sake/sharaf/helidon/HelidonSharafServerTest.scala index 88f87dd..178bb47 100644 --- a/sharaf-helidon/test/src/ba/sake/sharaf/helidon/HelidonSharafServerTest.scala +++ b/sharaf-helidon/test/src/ba/sake/sharaf/helidon/HelidonSharafServerTest.scala @@ -10,7 +10,7 @@ class HelidonSharafServerTest extends munit.FunSuite { Response.withBody("Hello World!") } val port = NetworkUtils.getFreePort() - val server = HelidonSharafServer("localhost", port, routes) + val server = HelidonSharafServer("localhost", port, SharafHandler.routes(routes)) override def beforeAll(): Unit = server.start() diff --git a/sharaf-snunit/src/ba/sake/sharaf/snunit/ResponseUtils.scala b/sharaf-snunit/src/ba/sake/sharaf/snunit/ResponseUtils.scala deleted file mode 100644 index 3c40e78..0000000 --- a/sharaf-snunit/src/ba/sake/sharaf/snunit/ResponseUtils.scala +++ /dev/null @@ -1,3 +0,0 @@ -package ba.sake.sharaf.snunit - - diff --git a/sharaf-snunit/src/ba/sake/sharaf/snunit/SharafRequestHandler.scala b/sharaf-snunit/src/ba/sake/sharaf/snunit/SharafRequestHandler.scala index af723f5..c0d9da6 100644 --- a/sharaf-snunit/src/ba/sake/sharaf/snunit/SharafRequestHandler.scala +++ b/sharaf-snunit/src/ba/sake/sharaf/snunit/SharafRequestHandler.scala @@ -5,24 +5,20 @@ import snunit.{Request as SnunitRequest, *} import ba.sake.sharaf.* import ba.sake.sharaf.routing.* -class SharafRequestHandler(routes: Routes) extends RequestHandler { +class SharafRequestHandler(sharafHandler: SharafHandler) extends RequestHandler { override def handleRequest(snunitRequest: SnunitRequest): Unit = { - given Request = SnunitSharafRequest.create(snunitRequest) val reqParams = fillReqParams(snunitRequest) - routes.definition.lift(reqParams) match { - case Some(res) => - val headers = buildHeaders(res.headerUpdates) - res.body match { - case Some(body) => - val aos = new ByteArrayOutputStream - res.rw.write(body, aos) - send(snunitRequest)(StatusCode(res.status.code), aos.toByteArray(), headers) - case None => - send(snunitRequest)(StatusCode(res.status.code), "", headers) - } + val req = SnunitSharafRequest.create(snunitRequest) + val requestContext = RequestContext(reqParams, req) + val res = sharafHandler.handle(requestContext) + val headers = buildHeaders(res.headerUpdates) + res.body match { + case Some(body) => + val aos = new ByteArrayOutputStream + res.rw.write(body, aos) + send(snunitRequest)(StatusCode(res.status.code), aos.toByteArray(), headers) case None => - // will be catched by ExceptionHandler - throw exceptions.NotFoundException("route") + send(snunitRequest)(StatusCode(res.status.code), "", headers) } } diff --git a/sharaf-undertow/src/ba/sake/sharaf/undertow/SharafUndertowHandler.scala b/sharaf-undertow/src/ba/sake/sharaf/undertow/SharafUndertowHandler.scala new file mode 100644 index 0000000..1519d20 --- /dev/null +++ b/sharaf-undertow/src/ba/sake/sharaf/undertow/SharafUndertowHandler.scala @@ -0,0 +1,39 @@ +package ba.sake.sharaf.undertow + +import io.undertow.server.HttpHandler +import io.undertow.server.HttpServerExchange +import ba.sake.sharaf.* +import ba.sake.sharaf.routing.* +import ba.sake.sharaf.undertow.* +import ba.sake.sharaf.exceptions.NotFoundException + +final class SharafUndertowHandler(sharafHandler: SharafHandler, next: Option[HttpHandler] = None) extends HttpHandler { + + override def handleRequest(exchange: HttpServerExchange): Unit = + val reqParams = fillReqParams(exchange) + val req = UndertowSharafRequest.create(exchange) + val requestContext = RequestContext(reqParams, req) + try { + val res = sharafHandler.handle(requestContext) + ResponseUtils.writeResponse(res, exchange) + } catch { + case e: NotFoundException => + next match { + case Some(handler) => handler.handleRequest(exchange) + case None => throw e + } + } + + private def fillReqParams(exchange: HttpServerExchange): RequestParams = { + val method = HttpMethod.valueOf(exchange.getRequestMethod.toString) + val relPath = + if exchange.getRelativePath.startsWith("/") then exchange.getRelativePath.drop(1) + else exchange.getRelativePath + val pathSegments = relPath.split("/") + val path = + if pathSegments.size == 1 && pathSegments.head == "" + then Path() + else Path(pathSegments*) + (method, path) + } +} diff --git a/sharaf-undertow/src/ba/sake/sharaf/undertow/handlers/ExceptionHandler.scala b/sharaf-undertow/src/ba/sake/sharaf/undertow/UndertowExceptionHandler.scala similarity index 68% rename from sharaf-undertow/src/ba/sake/sharaf/undertow/handlers/ExceptionHandler.scala rename to sharaf-undertow/src/ba/sake/sharaf/undertow/UndertowExceptionHandler.scala index d29424b..6c3fcc2 100644 --- a/sharaf-undertow/src/ba/sake/sharaf/undertow/handlers/ExceptionHandler.scala +++ b/sharaf-undertow/src/ba/sake/sharaf/undertow/UndertowExceptionHandler.scala @@ -1,4 +1,4 @@ -package ba.sake.sharaf.undertow.handlers +package ba.sake.sharaf.undertow import scala.util.control.NonFatal import io.undertow.server.HttpHandler @@ -7,7 +7,7 @@ import ba.sake.sharaf.* import ba.sake.sharaf.undertow.* import ba.sake.sharaf.exceptions.ExceptionMapper -final class ExceptionHandler private (next: HttpHandler, exceptionMapper: ExceptionMapper) extends HttpHandler { +final class UndertowExceptionHandler(exceptionMapper: ExceptionMapper, next: HttpHandler) extends HttpHandler { override def handleRequest(exchange: HttpServerExchange): Unit = try next.handleRequest(exchange) @@ -25,8 +25,3 @@ final class ExceptionHandler private (next: HttpHandler, exceptionMapper: Except } } - -object ExceptionHandler { - def apply(next: HttpHandler, exceptionMapper: ExceptionMapper = ExceptionMapper.default): ExceptionHandler = - new ExceptionHandler(next, exceptionMapper) -} diff --git a/sharaf-undertow/src/ba/sake/sharaf/undertow/UndertowSharafRequest.scala b/sharaf-undertow/src/ba/sake/sharaf/undertow/UndertowSharafRequest.scala index 8e437ef..8c6c833 100644 --- a/sharaf-undertow/src/ba/sake/sharaf/undertow/UndertowSharafRequest.scala +++ b/sharaf-undertow/src/ba/sake/sharaf/undertow/UndertowSharafRequest.scala @@ -49,6 +49,8 @@ final class UndertowSharafRequest(val underlyingHttpServerExchange: HttpServerEx val uFormData = parser.parseBlocking() UndertowSharafRequest.undertowFormData2FormsonMap(uFormData) + override def toString(): String = + s"UndertowSharafRequest(headers=${headers}, cookies=${cookies}, queryParamsRaw=${queryParamsRaw}, bodyString=...)" } object UndertowSharafRequest { diff --git a/sharaf-undertow/src/ba/sake/sharaf/undertow/UndertowSharafServer.scala b/sharaf-undertow/src/ba/sake/sharaf/undertow/UndertowSharafServer.scala index a308720..7650eaa 100644 --- a/sharaf-undertow/src/ba/sake/sharaf/undertow/UndertowSharafServer.scala +++ b/sharaf-undertow/src/ba/sake/sharaf/undertow/UndertowSharafServer.scala @@ -2,39 +2,73 @@ package ba.sake.sharaf.undertow import io.undertow.Undertow import ba.sake.sharaf.* -import ba.sake.sharaf.undertow.handlers.SharafHandler +import io.undertow.server.handlers.BlockingHandler +import io.undertow.server.handlers.resource.ResourceHandler +import io.undertow.server.handlers.resource.ClassPathResourceManager +import io.undertow.server.HttpHandler +import sttp.model.StatusCode -class UndertowSharafServer private (host: String, port: Int, sharafHandler: SharafHandler) { +class UndertowSharafServer(host: String, port: Int, handler: HttpHandler) { private val server = Undertow .builder() .addHttpListener(port, host) - .setHandler(sharafHandler) + .setHandler(handler) .build() def start(): Unit = server.start() def stop(): Unit = server.stop() +} - def withCorsSettings(corsSettings: CorsSettings): UndertowSharafServer = - val newHandler = sharafHandler.withCorsSettings(corsSettings) - copy(sharafHandler = newHandler) +object UndertowSharafServer { - def withExceptionMapper(exceptionMapper: ExceptionMapper): UndertowSharafServer = - val newHandler = sharafHandler.withExceptionMapper(exceptionMapper) - copy(sharafHandler = newHandler) + private val defaultNotFoundResponse = Response.withBody("Not Found").withStatus(StatusCode.NotFound) - def withNotFoundHandler(notFoundHandler: Request => Response[?]): UndertowSharafServer = - val newHandler = sharafHandler.withNotFoundHandler(notFoundHandler) - copy(sharafHandler = newHandler) + def apply( + host: String, + port: Int, + routes: Routes, + corsSettings: CorsSettings = CorsSettings.default, + exceptionMapper: ExceptionMapper = ExceptionMapper.default, + notFoundHandler: Request => Response[?] = _ => defaultNotFoundResponse + ): UndertowSharafServer = { + val notFoundRoutes = Routes { _ => + notFoundHandler(Request.current) + } + val resourceHandler = ResourceHandler( // load from classpath in public/ folder + ClassPathResourceManager(getClass.getClassLoader, "public"), { + // or load from classpath in WebJars + val webJarHandler = new ResourceHandler( + ClassPathResourceManager(getClass.getClassLoader, "META-INF/resources/webjars"), + SharafUndertowHandler(SharafHandler.routes(notFoundRoutes)) // handle 404s at the end + ) + // dont serve index.html etc from random webjars... + webJarHandler.setWelcomeFiles() + webJarHandler + } + ) + val finalHandler = + BlockingHandler( // synchronous/blocking handler + UndertowExceptionHandler( + exceptionMapper, + next = SharafUndertowHandler( + SharafHandler.cors( + corsSettings, + SharafHandler.routes(routes) + ), + next = Some(resourceHandler) + ) + ) + ) - private def copy(sharafHandler: SharafHandler = sharafHandler) = new UndertowSharafServer(host, port, sharafHandler) -} + new UndertowSharafServer(host, port, finalHandler) + } -object UndertowSharafServer { def apply(host: String, port: Int, sharafHandler: SharafHandler): UndertowSharafServer = - new UndertowSharafServer(host, port, sharafHandler) + new UndertowSharafServer(host, port, SharafUndertowHandler(sharafHandler)) - def apply(host: String, port: Int, routes: Routes): UndertowSharafServer = - apply(host, port, SharafHandler(routes)) + // if need tweaking + def apply(host: String, port: Int, sharafUndertowHandler: SharafUndertowHandler): UndertowSharafServer = + new UndertowSharafServer(host, port, sharafUndertowHandler) } diff --git a/sharaf-undertow/src/ba/sake/sharaf/undertow/handlers/CorsHandler.scala b/sharaf-undertow/src/ba/sake/sharaf/undertow/handlers/CorsHandler.scala deleted file mode 100644 index 1f5e51c..0000000 --- a/sharaf-undertow/src/ba/sake/sharaf/undertow/handlers/CorsHandler.scala +++ /dev/null @@ -1,50 +0,0 @@ -package ba.sake.sharaf.undertow.handlers - -import ba.sake.sharaf.* -import io.undertow.server.{HttpHandler, HttpServerExchange} -import io.undertow.util.{Headers, HttpString, Methods} - -import scala.jdk.CollectionConverters.* - -// https://www.moesif.com/blog/technical/cors/Authoritative-Guide-to-CORS-Cross-Origin-Resource-Sharing-for-REST-APIs/ -final class CorsHandler private (next: HttpHandler, corsSettings: CorsSettings) extends HttpHandler { - - private val accessControlAllowOrigin = HttpString("Access-Control-Allow-Origin") - private val accessControlAllowCredentials = HttpString("Access-Control-Allow-Credentials") - - // only for OPTIONS / preflight - private val accessControlAllowMethods = HttpString("Access-Control-Allow-Methods") - private val accessControlAllowHeaders = HttpString("Access-Control-Allow-Headers") - - override def handleRequest(exchange: HttpServerExchange): Unit = - if exchange.getRequestMethod == Methods.OPTIONS then - setCorsHeaders(exchange) - setPreflightHeaders(exchange) - else - setCorsHeaders(exchange) - next.handleRequest(exchange) - - private def setPreflightHeaders(exchange: HttpServerExchange): Unit = { - exchange.getResponseHeaders - .putAll(accessControlAllowMethods, corsSettings.allowedHttpMethods.map(_.toString).asJava) - exchange.getResponseHeaders - .putAll(accessControlAllowHeaders, corsSettings.allowedHttpHeaders.map(_.toString).asJava) - } - - private def setCorsHeaders(exchange: HttpServerExchange): Unit = { - exchange.getResponseHeaders - .put(accessControlAllowCredentials, corsSettings.allowCredentials.toString) - if corsSettings.allowedOrigins.contains("*") then exchange.getResponseHeaders.put(accessControlAllowOrigin, "*") - else - Option(exchange.getRequestHeaders.getFirst(Headers.ORIGIN)) match { - case None => // noop - case Some(origin) => - if corsSettings.allowedOrigins.contains(origin) then - exchange.getResponseHeaders.put(accessControlAllowOrigin, origin) - } - } -} - -object CorsHandler: - def apply(next: HttpHandler, corsSettings: CorsSettings): CorsHandler = - new CorsHandler(next, corsSettings) diff --git a/sharaf-undertow/src/ba/sake/sharaf/undertow/handlers/RoutesHandler.scala b/sharaf-undertow/src/ba/sake/sharaf/undertow/handlers/RoutesHandler.scala deleted file mode 100644 index d3f6796..0000000 --- a/sharaf-undertow/src/ba/sake/sharaf/undertow/handlers/RoutesHandler.scala +++ /dev/null @@ -1,45 +0,0 @@ -package ba.sake.sharaf.undertow.handlers - -import io.undertow.server.HttpHandler -import io.undertow.server.HttpServerExchange -import ba.sake.sharaf.* -import ba.sake.sharaf.routing.* -import ba.sake.sharaf.undertow.* - -final class RoutesHandler private (routes: Routes, nextHandler: Option[HttpHandler]) extends HttpHandler { - - override def handleRequest(exchange: HttpServerExchange): Unit = { - given Request = UndertowSharafRequest.create(exchange) - val reqParams = fillReqParams(exchange) - routes.definition.lift(reqParams) match { - case Some(res) => ResponseUtils.writeResponse(res, exchange) - case None => - nextHandler match - case Some(next) => next.handleRequest(exchange) - case None => - // will be catched by ExceptionHandler - throw exceptions.NotFoundException("route") - } - } - - private def fillReqParams(exchange: HttpServerExchange): RequestParams = { - val method = HttpMethod.valueOf(exchange.getRequestMethod.toString) - val relPath = - if exchange.getRelativePath.startsWith("/") then exchange.getRelativePath.drop(1) - else exchange.getRelativePath - val pathSegments = relPath.split("/") - val path = - if pathSegments.size == 1 && pathSegments.head == "" - then Path() - else Path(pathSegments*) - (method, path) - } - -} - -object RoutesHandler: - def apply(routes: Routes): RoutesHandler = - new RoutesHandler(routes, None) - - def apply(routes: Routes, nextHandler: HttpHandler): RoutesHandler = - new RoutesHandler(routes, Some(nextHandler)) diff --git a/sharaf-undertow/src/ba/sake/sharaf/undertow/handlers/SharafHandler.scala b/sharaf-undertow/src/ba/sake/sharaf/undertow/handlers/SharafHandler.scala deleted file mode 100644 index 474da0a..0000000 --- a/sharaf-undertow/src/ba/sake/sharaf/undertow/handlers/SharafHandler.scala +++ /dev/null @@ -1,83 +0,0 @@ -package ba.sake.sharaf.undertow.handlers - -import io.undertow.server.HttpHandler -import io.undertow.server.HttpServerExchange -import io.undertow.server.handlers.BlockingHandler -import io.undertow.server.handlers.resource.ResourceHandler -import io.undertow.server.handlers.resource.ClassPathResourceManager -import sttp.model.StatusCode -import ba.sake.sharaf.* -import ba.sake.sharaf.exceptions.ExceptionMapper -import ba.sake.sharaf.routing.* -import ba.sake.sharaf.undertow.* - -final class SharafHandler private ( - routes: Routes, - corsSettings: CorsSettings, - exceptionMapper: ExceptionMapper, - notFoundHandler: Request => Response[?] -) extends HttpHandler { - - private val notFoundRoutes = Routes { _ => - notFoundHandler(Request.current) - } - - // everything is wrapped in a synchronous/blocking handler - private val finalHandler = - BlockingHandler( // synchronous/blocking handler - ExceptionHandler( // handle exceptions gracefully - CorsHandler( // handle CORS preflight requests - RoutesHandler( // main Sharaf routes handler - routes, - ResourceHandler( // or else load from classpath in public/ folder - ClassPathResourceManager(getClass.getClassLoader, "public"), { - // or else load from classpath in WebJars - val webJarHandler = new ResourceHandler( - ClassPathResourceManager(getClass.getClassLoader, "META-INF/resources/webjars"), - RoutesHandler(notFoundRoutes) // handle 404s at the end - ) - // dont serve index.html etc from random webjars... - webJarHandler.setWelcomeFiles() - webJarHandler - } - ) - ), - corsSettings - ), - exceptionMapper - ) - ) - - override def handleRequest(exchange: HttpServerExchange): Unit = - finalHandler.handleRequest(exchange) - - def withRoutes(routes: Routes): SharafHandler = - copy(routes) - - def withCorsSettings(corsSettings: CorsSettings): SharafHandler = - copy(corsSettings = corsSettings) - - def withExceptionMapper(exceptionMapper: ExceptionMapper): SharafHandler = - copy(exceptionMapper = exceptionMapper) - - def withNotFoundHandler(notFoundHandler: Request => Response[?]): SharafHandler = - copy(notFoundHandler = notFoundHandler) - - private def copy( - routes: Routes = routes, - corsSettings: CorsSettings = corsSettings, - exceptionMapper: ExceptionMapper = exceptionMapper, - notFoundHandler: Request => Response[?] = notFoundHandler - ) = new SharafHandler(routes, corsSettings, exceptionMapper, notFoundHandler) -} - -object SharafHandler: - - private val defaultNotFoundResponse = Response.withBody("Not Found").withStatus(StatusCode.NotFound) - - def apply(routes: Routes): SharafHandler = - new SharafHandler(routes, CorsSettings.default, ExceptionMapper.default, _ => SharafHandler.defaultNotFoundResponse) - - def apply(controllers: SharafController*): SharafHandler = - val routes = Routes.merge(controllers.map(_.routes)) - apply(routes) diff --git a/sharaf-undertow/test/src/ba/sake/sharaf/undertow/CorsTest.scala b/sharaf-undertow/test/src/ba/sake/sharaf/undertow/CorsTest.scala new file mode 100644 index 0000000..86a09ad --- /dev/null +++ b/sharaf-undertow/test/src/ba/sake/sharaf/undertow/CorsTest.scala @@ -0,0 +1,45 @@ +package ba.sake.sharaf.undertow + +import sttp.model.* +import sttp.client4.quick.* +import ba.sake.sharaf.* +import ba.sake.sharaf.routing.* +import ba.sake.sharaf.utils.* + +class CorsTest extends munit.FunSuite { + + val port = NetworkUtils.getFreePort() + val baseUrl = s"http://localhost:$port" + val routes = Routes { case GET -> Path("cors") => + Response.withBody("CORS") + } + + val server = UndertowSharafServer( + "localhost", + port, + routes, + corsSettings = CorsSettings.default.withAllowedOrigins(Set("http://example.com")) + ) + + override def beforeAll(): Unit = server.start() + + override def afterAll(): Unit = server.stop() + + test("CORS should work") { + locally { + // localhost always works + val res = quickRequest.get(uri"${baseUrl}/cors").send() + assertEquals(res.code, StatusCode.Ok) + } + locally { + // allowed origin is allowed + val res = quickRequest.get(uri"${baseUrl}/cors").headers(Map(HeaderNames.Origin -> "http://example.com")).send() + assertEquals(res.headers(HeaderNames.AccessControlAllowOrigin), Seq("http://example.com")) + } + locally { + // forbidden origin is not allowed (to browser) + val res = quickRequest.get(uri"${baseUrl}/cors").headers(Map(HeaderNames.Origin -> "http://example2.com")).send() + assertEquals(res.headers(HeaderNames.AccessControlAllowOrigin), Seq.empty) + } + } +} diff --git a/sharaf-undertow/test/src/ba/sake/sharaf/undertow/HeadersTest.scala b/sharaf-undertow/test/src/ba/sake/sharaf/undertow/HeadersTest.scala index c00d2ce..0814e2b 100644 --- a/sharaf-undertow/test/src/ba/sake/sharaf/undertow/HeadersTest.scala +++ b/sharaf-undertow/test/src/ba/sake/sharaf/undertow/HeadersTest.scala @@ -14,8 +14,7 @@ class HeadersTest extends munit.FunSuite { case GET -> Path("settingHeader") => Response.settingHeader("header1", "header1Value") case GET -> Path("removingHeader") => - // this one is set by default in the CorsHandler - Response.removingHeader("access-control-allow-credentials") + Response.settingHeader("bla", "bla1").removingHeader("bla") case GET -> Path("setAndRemove") => Response.settingHeader("header1", "header1Value").removingHeader("header1") } @@ -33,7 +32,7 @@ class HeadersTest extends munit.FunSuite { test("removingHeader removes a header") { val res = quickRequest.get(uri"${baseUrl}/removingHeader").send() - assertEquals(res.headers(HeaderNames.AccessControlAllowCredentials), Seq.empty) + assertEquals(res.headers("bla"), Seq.empty) } test("settingHeader and then removingHeader removes a header") { diff --git a/sharaf-undertow/test/src/ba/sake/sharaf/undertow/SessionsTest.scala b/sharaf-undertow/test/src/ba/sake/sharaf/undertow/SessionsTest.scala index 2a47232..5007636 100644 --- a/sharaf-undertow/test/src/ba/sake/sharaf/undertow/SessionsTest.scala +++ b/sharaf-undertow/test/src/ba/sake/sharaf/undertow/SessionsTest.scala @@ -4,8 +4,8 @@ import io.undertow.Undertow import io.undertow.server.session.{InMemorySessionManager, SessionAttachmentHandler, SessionCookieConfig} import sttp.client4.quick.* import ba.sake.sharaf.* -import ba.sake.sharaf.undertow.handlers.SharafHandler import ba.sake.sharaf.utils.NetworkUtils +import io.undertow.server.handlers.BlockingHandler class SessionsTest extends munit.FunSuite { val port = NetworkUtils.getFreePort() @@ -27,10 +27,12 @@ class SessionsTest extends munit.FunSuite { .builder() .addHttpListener(port, "localhost") .setHandler( - new SessionAttachmentHandler( - SharafHandler(routes), - new InMemorySessionManager("in-memory-session-manager"), - new SessionCookieConfig() + BlockingHandler( + new SessionAttachmentHandler( + SharafUndertowHandler(SharafHandler.routes(routes)), + new InMemorySessionManager("in-memory-session-manager"), + new SessionCookieConfig() + ) ) ) .build() diff --git a/sharaf-undertow/test/src/ba/sake/sharaf/undertow/WebJarsTest.scala b/sharaf-undertow/test/src/ba/sake/sharaf/undertow/WebJarsTest.scala new file mode 100644 index 0000000..8564d2c --- /dev/null +++ b/sharaf-undertow/test/src/ba/sake/sharaf/undertow/WebJarsTest.scala @@ -0,0 +1,29 @@ +package ba.sake.sharaf.undertow + +import sttp.model.* +import sttp.client4.quick.* +import ba.sake.sharaf.* +import ba.sake.sharaf.routing.* +import ba.sake.sharaf.utils.* + +class WebJarsTest extends munit.FunSuite { + + val port = NetworkUtils.getFreePort() + val baseUrl = s"http://localhost:$port" + val routes = Routes { case GET -> Path() => + Response.withBody("WebJars!") + } + + val server = UndertowSharafServer("localhost", port, routes) + + override def beforeAll(): Unit = server.start() + + override def afterAll(): Unit = server.stop() + + // WebJars + test("WebJars should work") { + val res = quickRequest.get(uri"${baseUrl}/jquery/3.7.1/jquery.js").send() + assertEquals(res.headers(HeaderNames.ContentType), Seq("application/javascript")) + assert(res.body.length > 100) + } +} diff --git a/sharaf-undertow/test/src/ba/sake/sharaf/undertow/handlers/ErrorHandlerTest.scala b/sharaf-undertow/test/src/ba/sake/sharaf/undertow/handlers/ErrorHandlerTest.scala index 00800a0..6d3017b 100644 --- a/sharaf-undertow/test/src/ba/sake/sharaf/undertow/handlers/ErrorHandlerTest.scala +++ b/sharaf-undertow/test/src/ba/sake/sharaf/undertow/handlers/ErrorHandlerTest.scala @@ -10,6 +10,8 @@ import ba.sake.validson.Validator import ba.sake.sharaf.* import ba.sake.sharaf.routing.* import ba.sake.sharaf.utils.* +import ba.sake.sharaf.undertow.SharafUndertowHandler +import io.undertow.server.handlers.BlockingHandler class ErrorHandlerTest extends munit.FunSuite { @@ -36,11 +38,24 @@ class ErrorHandlerTest extends munit.FunSuite { .setHandler( Handlers .path() - .addPrefixPath("default", SharafHandler(routes)) - .addPrefixPath("json", SharafHandler(routes).withExceptionMapper(ExceptionMapper.json)) .addPrefixPath( - "cors", - SharafHandler(routes).withCorsSettings(CorsSettings.default.withAllowedOrigins(Set("http://example.com"))) + "default", + BlockingHandler( + SharafUndertowHandler( + SharafHandler.exceptions(SharafHandler.routes(routes)) + ) + ) + ) + .addPrefixPath( + "json", + BlockingHandler( + SharafUndertowHandler( + SharafHandler.exceptions( + ExceptionMapper.json, + SharafHandler.routes(routes) + ) + ) + ) ) ) .build() @@ -147,32 +162,6 @@ class ErrorHandlerTest extends munit.FunSuite { assertEquals(res.headers(HeaderNames.ContentType), Seq("application/json; charset=utf-8")) } - // WebJars - test("WebJars should work") { - val res = quickRequest.get(uri"${baseUrl}/default/jquery/3.7.1/jquery.js").send() - assertEquals(res.headers(HeaderNames.ContentType), Seq("application/javascript")) - assert(res.body.length > 100) - } - - // CORS - test("CORS should work") { - locally { - // localhost always works - val res = quickRequest.get(uri"${baseUrl}/cors").send() - assertEquals(res.code, StatusCode.Ok) - } - locally { - // allowed origin is allowed - val res = quickRequest.get(uri"${baseUrl}/cors").headers(Map(HeaderNames.Origin -> "http://example.com")).send() - assertEquals(res.headers(HeaderNames.AccessControlAllowOrigin), Seq("http://example.com")) - } - locally { - // forbidden origin is not allowed (to browser) - val res = quickRequest.get(uri"${baseUrl}/cors").headers(Map(HeaderNames.Origin -> "http://example2.com")).send() - assertEquals(res.headers(HeaderNames.AccessControlAllowOrigin), Seq.empty) - } - } - case class TestQuery(name: String) derives QueryStringRW object TestQuery { given Validator[TestQuery] = Validator.derived[TestQuery].minLength(_.name, 3) From c425bb6f73a8d8beffd022b18bdfc0e029c6bbd1 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Tue, 8 Jul 2025 12:11:55 +0200 Subject: [PATCH 10/13] Release 0.12.0 From 766c8491e15e6d14f7b7bcdf2e266cc5e3f6a127 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sakib=20Had=C5=BEiavdi=C4=87?= Date: Tue, 8 Jul 2025 14:03:28 +0200 Subject: [PATCH 11/13] Add twirl support with html and xml interpolators (#51) --- DEV.md | 4 +- build.mill | 4 ++ .../fullstack/test/src/FullstackSuite.scala | 2 +- examples/oauth2/src/AppRoutes.scala | 2 +- formson/src/ba/sake/formson/FormDataRW.scala | 4 +- .../src/ba/sake/querson/QueryStringRW.scala | 2 +- .../src-jvm/ba/sake/sharaf/instances.scala | 24 +++++++++ .../sharaf/exceptions/ExceptionMapper.scala | 2 - sharaf-core/src/ba/sake/sharaf/package.scala | 8 +-- .../sharaf/snunit/SnunitSharafRequest.scala | 3 -- .../ba/sake/sharaf/undertow/HeadersTest.scala | 1 - .../undertow/ResponseWritableTest.scala | 53 +++++++++++++++++++ validson/src/ba/sake/validson/Validator.scala | 2 +- 13 files changed, 93 insertions(+), 18 deletions(-) create mode 100644 sharaf-core/src-jvm/ba/sake/sharaf/instances.scala diff --git a/DEV.md b/DEV.md index 9fa0163..904e25e 100644 --- a/DEV.md +++ b/DEV.md @@ -18,10 +18,10 @@ scala-cli compile examples\scala-cli ```sh # RELEASE -$VERSION="0.11.0" +$VERSION="0.12.0" git commit --allow-empty -m "Release $VERSION" git tag -a $VERSION -m "Release $VERSION" -git push --atomic origin main --tags +git push --atomic origin main $VERSION ``` diff --git a/build.mill b/build.mill index ce164d7..70e65fb 100644 --- a/build.mill +++ b/build.mill @@ -14,6 +14,10 @@ object V: object `sharaf-core` extends Module: object jvm extends SharafCoreModule with ScalaJvmCommonModule: def moduleDeps = Seq(querson.jvm, formson.jvm, validson.jvm) + def mvnDeps = super.mvnDeps() ++ Seq( + // TODO move to common when published for native, and remove scalatags + mvn"org.playframework.twirl::twirl-api:2.1.0-M4" + ) object test extends ScalaTests with SharafTestModule object native extends SharafCoreModule with ScalaNativeCommonModule: diff --git a/examples/fullstack/test/src/FullstackSuite.scala b/examples/fullstack/test/src/FullstackSuite.scala index d03f7ee..e15e42d 100644 --- a/examples/fullstack/test/src/FullstackSuite.scala +++ b/examples/fullstack/test/src/FullstackSuite.scala @@ -5,7 +5,7 @@ import scala.compiletime.uninitialized import sttp.model.* import sttp.client4.quick.* import ba.sake.formson.* -import ba.sake.sharaf.{*, given} +import ba.sake.sharaf.* import ba.sake.sharaf.utils.* class FullstackSuite extends munit.FunSuite { diff --git a/examples/oauth2/src/AppRoutes.scala b/examples/oauth2/src/AppRoutes.scala index 3eb0ea2..51ad185 100644 --- a/examples/oauth2/src/AppRoutes.scala +++ b/examples/oauth2/src/AppRoutes.scala @@ -4,7 +4,7 @@ import scalatags.Text.all.* import ba.sake.sharaf.* import ba.sake.sharaf.routing.* import ba.sake.hepek.html.HtmlPage -import ba.sake.sharaf.undertow.{*, given} +import ba.sake.sharaf.undertow.given class AppRoutes(securityService: SecurityService) { diff --git a/formson/src/ba/sake/formson/FormDataRW.scala b/formson/src/ba/sake/formson/FormDataRW.scala index 9b3fa9d..eeff954 100644 --- a/formson/src/ba/sake/formson/FormDataRW.scala +++ b/formson/src/ba/sake/formson/FormDataRW.scala @@ -359,7 +359,7 @@ object FormDataRW { try { $tryBlock } catch { - case e: IllegalArgumentException => + case _: IllegalArgumentException => throw ParsingException( ParseError( path, @@ -423,7 +423,7 @@ object FormDataRW { } } - case hmm => report.errorAndAbort(s"Sum types are not supported ") + case _ => report.errorAndAbort(s"Sum types are not supported ") } /* macro utils */ diff --git a/querson/src/ba/sake/querson/QueryStringRW.scala b/querson/src/ba/sake/querson/QueryStringRW.scala index d02d45f..8563b7b 100644 --- a/querson/src/ba/sake/querson/QueryStringRW.scala +++ b/querson/src/ba/sake/querson/QueryStringRW.scala @@ -338,7 +338,7 @@ object QueryStringRW { } } - case hmm => report.errorAndAbort("Sum types are not supported") + case _ => report.errorAndAbort("Sum types are not supported") } /* macro utils */ diff --git a/sharaf-core/src-jvm/ba/sake/sharaf/instances.scala b/sharaf-core/src-jvm/ba/sake/sharaf/instances.scala new file mode 100644 index 0000000..e1e2fbb --- /dev/null +++ b/sharaf-core/src-jvm/ba/sake/sharaf/instances.scala @@ -0,0 +1,24 @@ +package ba.sake.sharaf + +import java.io.OutputStream +import play.twirl.api.{Html, Xml} + +object ResponseWritableInstances { +// twirl HTML and XML + given ResponseWritable[Html] with { + override def write(value: Html, outputStream: OutputStream): Unit = + ResponseWritable[String].write(value.toString, outputStream) + override def headers(value: Html): Seq[(HttpString, Seq[String])] = Seq( + ContentTypeHttpString -> Seq("text/html; charset=utf-8") + ) + } + + given ResponseWritable[Xml] with { + override def write(value: Xml, outputStream: OutputStream): Unit = + ResponseWritable[String].write(value.toString, outputStream) + override def headers(value: Xml): Seq[(HttpString, Seq[String])] = Seq( + ContentTypeHttpString -> Seq("application/xml; charset=utf-8") + ) + } + +} diff --git a/sharaf-core/src/ba/sake/sharaf/exceptions/ExceptionMapper.scala b/sharaf-core/src/ba/sake/sharaf/exceptions/ExceptionMapper.scala index 5e079b5..9fca466 100644 --- a/sharaf-core/src/ba/sake/sharaf/exceptions/ExceptionMapper.scala +++ b/sharaf-core/src/ba/sake/sharaf/exceptions/ExceptionMapper.scala @@ -1,7 +1,5 @@ package ba.sake.sharaf.exceptions -import java.net.URI -import scala.jdk.CollectionConverters.* import sttp.model.StatusCode import ba.sake.tupson import ba.sake.formson diff --git a/sharaf-core/src/ba/sake/sharaf/package.scala b/sharaf-core/src/ba/sake/sharaf/package.scala index 2eae00c..1ba6454 100644 --- a/sharaf-core/src/ba/sake/sharaf/package.scala +++ b/sharaf-core/src/ba/sake/sharaf/package.scala @@ -7,6 +7,8 @@ import ba.sake.{formson, querson} import formson.* import querson.* +export HttpMethod.* + type ExceptionMapper = exceptions.ExceptionMapper val ExceptionMapper = exceptions.ExceptionMapper @@ -19,12 +21,10 @@ object param: def unapply[T](str: String)(using fp: FromPathParam[T]): Option[T] = fp.parse(str) -export HttpMethod.* - // conversions to STTP extension [T](value: T)(using rw: formson.FormDataRW[T]) def toSttpMultipart(config: formson.Config = formson.DefaultFormsonConfig): Seq[Part[BasicBodyPart]] = - val multiParts = value.toFormDataMap().flatMap { case (key, values) => + val multiParts = value.toFormDataMap(config).flatMap { case (key, values) => values.map { case formson.FormValue.Str(value) => multipart(key, value) case formson.FormValue.File(value) => multipartFile(key, value.toFile) @@ -35,5 +35,5 @@ extension [T](value: T)(using rw: formson.FormDataRW[T]) extension [T](value: T)(using rw: querson.QueryStringRW[T]) def toSttpQuery(config: querson.Config = querson.DefaultQuersonConfig): QueryParams = - val params = value.toQueryStringMap().map { (k, vs) => k -> vs } + val params = value.toQueryStringMap(config).map { (k, vs) => k -> vs } QueryParams.fromMultiMap(params) diff --git a/sharaf-snunit/src/ba/sake/sharaf/snunit/SnunitSharafRequest.scala b/sharaf-snunit/src/ba/sake/sharaf/snunit/SnunitSharafRequest.scala index e2ae99b..414fbe0 100644 --- a/sharaf-snunit/src/ba/sake/sharaf/snunit/SnunitSharafRequest.scala +++ b/sharaf-snunit/src/ba/sake/sharaf/snunit/SnunitSharafRequest.scala @@ -1,13 +1,10 @@ package ba.sake.sharaf.snunit import java.nio.charset.StandardCharsets -import scala.jdk.CollectionConverters.* -import scala.jdk.StreamConverters.* import snunit.{Request as SnunitRequest, *} import ba.sake.formson.* import ba.sake.querson.* import ba.sake.sharaf.* -import ba.sake.sharaf.exceptions.* class SnunitSharafRequest(underlyingRequest: SnunitRequest) extends Request { diff --git a/sharaf-undertow/test/src/ba/sake/sharaf/undertow/HeadersTest.scala b/sharaf-undertow/test/src/ba/sake/sharaf/undertow/HeadersTest.scala index 0814e2b..4ccb3a0 100644 --- a/sharaf-undertow/test/src/ba/sake/sharaf/undertow/HeadersTest.scala +++ b/sharaf-undertow/test/src/ba/sake/sharaf/undertow/HeadersTest.scala @@ -1,6 +1,5 @@ package ba.sake.sharaf.undertow -import sttp.model.* import sttp.client4.quick.* import ba.sake.sharaf.* import ba.sake.sharaf.utils.NetworkUtils diff --git a/sharaf-undertow/test/src/ba/sake/sharaf/undertow/ResponseWritableTest.scala b/sharaf-undertow/test/src/ba/sake/sharaf/undertow/ResponseWritableTest.scala index eadd427..0c2e045 100644 --- a/sharaf-undertow/test/src/ba/sake/sharaf/undertow/ResponseWritableTest.scala +++ b/sharaf-undertow/test/src/ba/sake/sharaf/undertow/ResponseWritableTest.scala @@ -43,6 +43,31 @@ class ResponseWritableTest extends munit.FunSuite { import scalatags.Text.all.* val res = div("this is a div") Response.withBody(res) + case GET -> Path("twirl", "html") => + import play.twirl.api.* + import ResponseWritableInstances.given + Response.withBody(html""" + + + Codestin Search App + + +

This is a Twirl HTML response

+ + + """) + case GET -> Path("twirl", "xml") => + import play.twirl.api.* + import ResponseWritableInstances.given + Response.withBody(xml""" + + + Tove + Jani + Reminder + Don't forget me this weekend! + + """) case GET -> Path("scalatags", "doctype") => import scalatags.Text.all.{title => _, *} import scalatags.Text.tags2.title @@ -111,6 +136,34 @@ class ResponseWritableTest extends munit.FunSuite { assertEquals(res.headers(HeaderNames.ContentType), Seq("application/json; charset=utf-8")) } + test("Write response twirl HTML") { + val res = quickRequest.get(uri"${baseUrl}/twirl/html").send() + assertEquals(res.body.trim, """ + + + Codestin Search App + + +

This is a Twirl HTML response

+ + """.trim) + assertEquals(res.headers(HeaderNames.ContentType), Seq("text/html; charset=utf-8")) + } + + test("Write response twirl XML") { + val res = quickRequest.get(uri"${baseUrl}/twirl/xml").send() + assertEquals(res.body.trim, """ + + + Tove + Jani + Reminder + Don't forget me this weekend! + + """.trim) + assertEquals(res.headers(HeaderNames.ContentType), Seq("application/xml; charset=utf-8")) + } + test("Write response scalatags Frag") { val res = quickRequest.get(uri"${baseUrl}/scalatags/frag").send() assertEquals(res.body, """
this is a div
""".trim) diff --git a/validson/src/ba/sake/validson/Validator.scala b/validson/src/ba/sake/validson/Validator.scala index 052086d..06c8cc1 100644 --- a/validson/src/ba/sake/validson/Validator.scala +++ b/validson/src/ba/sake/validson/Validator.scala @@ -118,7 +118,7 @@ object Validator extends LowPriValidators { } } - case hmm => report.errorAndAbort("Sum types are not supported") + case _ => report.errorAndAbort("Sum types are not supported") } /* macro utils */ From 595a23ea3980d8ea8b5f0915ed50af3f233e52db Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Tue, 8 Jul 2025 14:18:36 +0200 Subject: [PATCH 12/13] Export html,xml interpolators --- .../src-jvm/ba/sake/sharaf/instances.scala | 32 +++++++++---------- .../undertow/ResponseWritableTest.scala | 20 ++++++------ 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/sharaf-core/src-jvm/ba/sake/sharaf/instances.scala b/sharaf-core/src-jvm/ba/sake/sharaf/instances.scala index e1e2fbb..13e519b 100644 --- a/sharaf-core/src-jvm/ba/sake/sharaf/instances.scala +++ b/sharaf-core/src-jvm/ba/sake/sharaf/instances.scala @@ -3,22 +3,22 @@ package ba.sake.sharaf import java.io.OutputStream import play.twirl.api.{Html, Xml} -object ResponseWritableInstances { -// twirl HTML and XML - given ResponseWritable[Html] with { - override def write(value: Html, outputStream: OutputStream): Unit = - ResponseWritable[String].write(value.toString, outputStream) - override def headers(value: Html): Seq[(HttpString, Seq[String])] = Seq( - ContentTypeHttpString -> Seq("text/html; charset=utf-8") - ) - } +// TODO move to common when published for native +export play.twirl.api.StringInterpolation - given ResponseWritable[Xml] with { - override def write(value: Xml, outputStream: OutputStream): Unit = - ResponseWritable[String].write(value.toString, outputStream) - override def headers(value: Xml): Seq[(HttpString, Seq[String])] = Seq( - ContentTypeHttpString -> Seq("application/xml; charset=utf-8") - ) - } +// twirl HTML and XML +given ResponseWritable[Html] with { + override def write(value: Html, outputStream: OutputStream): Unit = + ResponseWritable[String].write(value.toString, outputStream) + override def headers(value: Html): Seq[(HttpString, Seq[String])] = Seq( + ContentTypeHttpString -> Seq("text/html; charset=utf-8") + ) +} +given ResponseWritable[Xml] with { + override def write(value: Xml, outputStream: OutputStream): Unit = + ResponseWritable[String].write(value.toString, outputStream) + override def headers(value: Xml): Seq[(HttpString, Seq[String])] = Seq( + ContentTypeHttpString -> Seq("application/xml; charset=utf-8") + ) } diff --git a/sharaf-undertow/test/src/ba/sake/sharaf/undertow/ResponseWritableTest.scala b/sharaf-undertow/test/src/ba/sake/sharaf/undertow/ResponseWritableTest.scala index 0c2e045..301a5df 100644 --- a/sharaf-undertow/test/src/ba/sake/sharaf/undertow/ResponseWritableTest.scala +++ b/sharaf-undertow/test/src/ba/sake/sharaf/undertow/ResponseWritableTest.scala @@ -4,7 +4,7 @@ import java.nio.charset.StandardCharsets import java.nio.file.Paths import sttp.model.* import sttp.client4.quick.* -import ba.sake.sharaf.* +import ba.sake.sharaf.{*, given} import ba.sake.sharaf.undertow.{*, given} import ba.sake.sharaf.utils.NetworkUtils import ba.sake.tupson.JsonRW @@ -44,8 +44,6 @@ class ResponseWritableTest extends munit.FunSuite { val res = div("this is a div") Response.withBody(res) case GET -> Path("twirl", "html") => - import play.twirl.api.* - import ResponseWritableInstances.given Response.withBody(html""" @@ -57,8 +55,6 @@ class ResponseWritableTest extends munit.FunSuite { """) case GET -> Path("twirl", "xml") => - import play.twirl.api.* - import ResponseWritableInstances.given Response.withBody(xml""" @@ -138,7 +134,9 @@ class ResponseWritableTest extends munit.FunSuite { test("Write response twirl HTML") { val res = quickRequest.get(uri"${baseUrl}/twirl/html").send() - assertEquals(res.body.trim, """ + assertEquals( + res.body.trim, + """ Codestin Search App @@ -146,13 +144,16 @@ class ResponseWritableTest extends munit.FunSuite {

This is a Twirl HTML response

- """.trim) + """.trim + ) assertEquals(res.headers(HeaderNames.ContentType), Seq("text/html; charset=utf-8")) } test("Write response twirl XML") { val res = quickRequest.get(uri"${baseUrl}/twirl/xml").send() - assertEquals(res.body.trim, """ + assertEquals( + res.body.trim, + """ Tove @@ -160,7 +161,8 @@ class ResponseWritableTest extends munit.FunSuite { Reminder Don't forget me this weekend! - """.trim) + """.trim + ) assertEquals(res.headers(HeaderNames.ContentType), Seq("application/xml; charset=utf-8")) } From e88d27dadb6edfd394453be70fed51382b0ca038 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Tue, 8 Jul 2025 14:21:46 +0200 Subject: [PATCH 13/13] Release 0.12.1