diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fedb95b..7548ef7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,4 +19,7 @@ jobs: with: distribution: 'temurin' java-version: 21 - - run: ./mill -i __.test + - name: Compile + run: ./mill -i __.compile + - name: Test + run: ./mill -i __.test diff --git a/.github/workflows/ghpages.yml b/.github/workflows/ghpages.yml index b9a6d6c..6dcb057 100644 --- a/.github/workflows/ghpages.yml +++ b/.github/workflows/ghpages.yml @@ -11,20 +11,16 @@ permissions: jobs: build-and-deploy: - runs-on: ubuntu-latest + runs-on: macos-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-java@v3 - with: - distribution: temurin - java-version: 21 - name: Build 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_VERSION=0.0.25 + curl -L https://github.com/sake92/flatmark/releases/download/v${FLATMARK_VERSION}/flatmark-1.0.0.pkg -o flatmark.pkg + sudo installer -verbose -pkg flatmark.pkg -target / flatmark build -i docs - name: Deploy uses: JamesIves/github-pages-deploy-action@v4 diff --git a/DEV.md b/DEV.md index d53958c..6ba008f 100644 --- a/DEV.md +++ b/DEV.md @@ -18,10 +18,7 @@ scala-cli compile examples\scala-cli ```sh # RELEASE -$VERSION="0.13.0" -git commit --allow-empty -m "Release $VERSION" -git tag -a $VERSION -m "Release $VERSION" -git push --atomic origin main $VERSION +./scripts/release.sh 0.14.0 ``` diff --git a/build.mill b/build.mill index a37f4c6..b198d10 100644 --- a/build.mill +++ b/build.mill @@ -1,14 +1,14 @@ -//| mill-version: 1.0.0-RC3 +//| mill-version: 1.0.6 package build -import mill._ -import mill.scalalib._, scalajslib._, scalanativelib._ -import mill.scalalib.publish._ + +import mill.* +import mill.scalalib.*, scalajslib.*, scalanativelib.* +import mill.scalalib.publish.* import mill.javalib.SonatypeCentralPublishModule -import mill.vcs.VcsVersion +import mill.util.VcsVersion object V: - val tupson = "0.13.0" - val hepek = "0.33.0" + val tupson = "0.18.0" object `sharaf-core` extends Module: object jvm extends SharafCoreModule with ScalaJvmCommonModule: @@ -18,11 +18,11 @@ object `sharaf-core` extends Module: mvn"org.playframework.twirl::twirl-api:2.1.0-M4" ) object test extends ScalaTests with SharafTestModule - + 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: def artifactName = "sharaf-core" // all deps should be cross jvm/native @@ -39,11 +39,28 @@ object `sharaf-undertow` extends SharafPublishModule: mvn"ba.sake::tupson-config:${V.tupson}" ) 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-http4s` extends Module: + object jvm extends SharafHttp4sModule with PlatformScalaModule: + def moduleDeps = Seq(`sharaf-core`.jvm) + object test extends TestModule + // object native extends SharafHttp4sModule with ScalaNativeCommonModule with PlatformScalaModule: + // def moduleDeps = Seq(`sharaf-core`.native) + // object test extends ScalaNativeTests with TestModule + trait SharafHttp4sModule extends SharafPublishModule: + def artifactName = "sharaf-https" + def mvnDeps = super.mvnDeps() ++ Seq( + mvn"org.http4s::http4s-server::0.23.32" + ) + trait TestModule extends ScalaTests with SharafTestModule: + def mvnDeps = super.mvnDeps() ++ Seq( + mvn"org.http4s::http4s-client::0.23.32" + ) + object `sharaf-helidon` extends SharafPublishModule: def artifactName = "sharaf-helidon" def mvnDeps = super.mvnDeps() ++ Seq( @@ -64,61 +81,6 @@ object `sharaf-snunit` extends ScalaNativeCommonModule with SharafPublishModule: ) def moduleDeps = Seq(`sharaf-core`.native) -object `sharaf-hepek-components` extends Module: - object jvm extends SharafHepekComponentsCoreModule with ScalaJvmCommonModule: - def moduleDeps = Seq(`sharaf-core`.jvm) - //object native extends SharafHepekComponentsCoreModule with ScalaNativeCommonModule: - // def moduleDeps = Seq(`sharaf-core`.native) - - trait SharafHepekComponentsCoreModule extends SharafPublishModule with PlatformScalaModule: - def artifactName = "sharaf-hepek-components" - def mvnDeps = super.mvnDeps() ++ Seq( - mvn"ba.sake::hepek-components:${V.hepek}" - ) - -object querson extends Module: - 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: - 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 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: - 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 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: - def artifactName = "validson" - def pomSettings = super.pomSettings().copy(description = "Sharaf validation library") - def mvnDeps = super.mvnDeps() ++ Seq( - mvn"com.lihaoyi::sourcecode::0.4.2" - ) - trait SharafPublishModule extends SharafCommonModule with SonatypeCentralPublishModule: def publishVersion = VcsVersion.vcsState().format() def pomSettings = PomSettings( @@ -134,13 +96,19 @@ trait SharafPublishModule extends SharafCommonModule with SonatypeCentralPublish trait SharafCommonModule extends ScalaModule: - def scalaVersion = "3.7.1" + def scalaVersion = "3.7.3" def scalacOptions = super.scalacOptions() ++ Seq( "-Yretain-trees", // needed for default parameters "-deprecation", "-Wunused:all", "-explain" ) + override def runClasspath: T[Seq[PathRef]] = Task { + localClasspath() ++ + transitiveLocalClasspath() ++ + resolvedRunMvnDeps().toSeq ++ + super.runClasspath() + } trait ScalaJvmCommonModule extends ScalaModule @@ -151,7 +119,7 @@ trait ScalaJSCommonModule extends ScalaJSModule: ) trait ScalaNativeCommonModule extends ScalaNativeModule: - def scalaNativeVersion = "0.5.7" + def scalaNativeVersion = "0.5.9" def mvnDeps = super.mvnDeps() ++ Seq( mvn"io.github.cquiroz::scala-java-time::2.6.0" ) @@ -161,38 +129,3 @@ trait SharafTestModule extends TestModule.Munit: def mvnDeps = Seq( mvn"org.scalameta::munit::1.1.0" ) - -//////////////////// examples -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: - def moduleDeps = Seq(`sharaf-undertow`) - object test extends ScalaTests with SharafTestModule - object fullstack extends SharafExampleModule: - def moduleDeps = Seq(`sharaf-undertow`) - object test extends ScalaTests with SharafTestModule - object `user-pass-form` extends SharafExampleModule: - def moduleDeps = Seq(`sharaf-undertow`) - 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 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 mvnDeps = super.mvnDeps() ++ Seq( - mvn"no.nav.security:mock-oauth2-server:0.5.10" - ) - object snunit extends SharafExampleModule with ScalaNativeCommonModule: - def moduleDeps = Seq(`sharaf-snunit`) -end examples \ No newline at end of file diff --git a/docs/_data/project.yaml b/docs/_data/project.yaml index 548efc5..5a3b347 100644 --- a/docs/_data/project.yaml +++ b/docs/_data/project.yaml @@ -9,5 +9,5 @@ gh: artifact: org: "ba.sake" name: "sharaf-undertow" - version: "0.12.1" + version: "0.13.0" diff --git a/docs/_includes/form_handling.sc b/docs/_includes/form_handling.sc new file mode 100644 index 0000000..c1244ab --- /dev/null +++ b/docs/_includes/form_handling.sc @@ -0,0 +1,36 @@ +//> using scala "3.7.0" +//> using dep ba.sake::sharaf-undertow:0.13.0 + +import ba.sake.formson.FormDataRW +import ba.sake.sharaf.{*, given} +import ba.sake.sharaf.undertow.UndertowSharafServer + +val routes = Routes: + case GET -> Path() => + Response.withBody(ContactUsView) + case POST -> Path("handle-form") => + case class ContactUsForm(fullName: String, email: String) derives FormDataRW + 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 = + html""" + + + +
+
+ +
+
+ +
+ +
+ + + """ diff --git a/docs/_includes/hello.sc b/docs/_includes/hello.sc index 3e265ac..ecc3c6b 100644 --- a/docs/_includes/hello.sc +++ b/docs/_includes/hello.sc @@ -1,5 +1,5 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 import ba.sake.sharaf.* import ba.sake.sharaf.undertow.UndertowSharafServer diff --git a/docs/_includes/html.sc b/docs/_includes/html.sc new file mode 100644 index 0000000..2843398 --- /dev/null +++ b/docs/_includes/html.sc @@ -0,0 +1,40 @@ +//> using scala "3.7.0" +//> using dep ba.sake::sharaf-undertow:0.13.0 + +import ba.sake.sharaf.{*, given} +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 = + html""" + + + +
+

Welcome!

+ Hello world +
+ + + """ + +def HelloView(name: String) = + html""" + + + +
+ Hello ${name}! +
+ + + """ diff --git a/docs/_includes/htmx_load_snippet.sc b/docs/_includes/htmx_load_snippet.sc new file mode 100644 index 0000000..15bf1ba --- /dev/null +++ b/docs/_includes/htmx_load_snippet.sc @@ -0,0 +1,34 @@ +//> using scala "3.7.0" +//> using dep ba.sake::sharaf-undertow:0.13.0 + +import ba.sake.sharaf.{*, given} +import ba.sake.sharaf.undertow.UndertowSharafServer + +val routes = Routes: + case GET -> Path() => + Response.withBody(IndexView) + case POST -> Path("html-snippet") => + Response.withBody: + html""" +
+ WOW, it works! 😲 +
Look ma, no JS! 😎
+
+ """ + +UndertowSharafServer("localhost", 8181, routes).start() + +println(s"Server started at http://localhost:8181") + +def IndexView = + html""" + + + + + + + + + + """ diff --git a/docs/_includes/path_params.sc b/docs/_includes/path_params.sc index 8635334..038acbf 100644 --- a/docs/_includes/path_params.sc +++ b/docs/_includes/path_params.sc @@ -1,5 +1,5 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 import ba.sake.sharaf.* import ba.sake.sharaf.undertow.UndertowSharafServer diff --git a/docs/_includes/query_params.sc b/docs/_includes/query_params.sc index 65f226c..eb1620b 100644 --- a/docs/_includes/query_params.sc +++ b/docs/_includes/query_params.sc @@ -1,5 +1,5 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 import ba.sake.querson.QueryStringRW import ba.sake.sharaf.* diff --git a/docs/_includes/static_files.sc b/docs/_includes/static_files.sc index 5194d6e..0bd3df8 100644 --- a/docs/_includes/static_files.sc +++ b/docs/_includes/static_files.sc @@ -1,5 +1,5 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 import ba.sake.sharaf.* import ba.sake.sharaf.undertow.UndertowSharafServer diff --git a/docs/_includes/validation.sc b/docs/_includes/validation.sc index 4a16a1d..b5b521d 100644 --- a/docs/_includes/validation.sc +++ b/docs/_includes/validation.sc @@ -1,5 +1,5 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 import ba.sake.querson.QueryStringRW import ba.sake.tupson.JsonRW diff --git a/docs/_layouts/search-results.html b/docs/_layouts/search-results.html new file mode 100644 index 0000000..254511b --- /dev/null +++ b/docs/_layouts/search-results.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} + +{% block title %}Search Results{% endblock %} + +{% block content %} +
+{% endblock %} + + +{% block scripts %} + + + +{% endblock %} \ No newline at end of file diff --git a/docs/content/howtos/upload-file.md b/docs/content/howtos/upload-file.md index 5d66858..693c5cd 100644 --- a/docs/content/howtos/upload-file.md +++ b/docs/content/howtos/upload-file.md @@ -7,15 +7,13 @@ description: Sharaf How To Upload File Uploading a file is usually done via `multipart/form-data` form submission. -{% -set form_snippet = '
-... -
' -%} - ```scala // 1. somewhere in a view, use enctype="multipart/form-data" -{{ form_snippet | e }} +html""" +
+ ... +
+""" // 2. define form data class with a NIO Path file import java.nio.file.Path diff --git a/docs/content/tutorials/forms.md b/docs/content/tutorials/forms.md index 80f7b1b..c46dafe 100644 --- a/docs/content/tutorials/forms.md +++ b/docs/content/tutorials/forms.md @@ -5,54 +5,13 @@ 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: -{# need to HTML encode these snippets, so that Markdown doesnt process them! #} -{% set contact_us_view = 'html""" - - - -
-
- -
-
- -
- -
- - - """' -%} - ```scala -//> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 - -import ba.sake.formson.FormDataRW -import ba.sake.sharaf.{*, given} -import ba.sake.sharaf.undertow.UndertowSharafServer - -val routes = Routes: - case GET -> Path() => - Response.withBody(ContactUsView) - case POST -> Path("handle-form") => - case class ContactUsForm(fullName: String, email: String) derives FormDataRW - 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 = - {{ contact_us_view | e }} +{% include 'form_handling.sc' %} ``` Then run it like this: diff --git a/docs/content/tutorials/html.md b/docs/content/tutorials/html.md index 7e9dc01..37d65de 100644 --- a/docs/content/tutorials/html.md +++ b/docs/content/tutorials/html.md @@ -11,53 +11,8 @@ Then you return it 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: -{# need to HTML encode these snippets, so that Markdown doesnt process them! #} -{% set index_view = 'html""" - - - -
-

Welcome!

- Hello world -
- - - """' -%} -{% set hello_view = 'html""" - - - -
- Hello ${name}! -
- - - """' -%} - ```scala -//> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 - -import ba.sake.sharaf.{*, given} -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 = - {{ index_view | e }} - -def HelloView(name: String) = - {{ hello_view | e }} +{% include 'html.sc' %} ``` and run it like this: diff --git a/docs/content/tutorials/htmx.md b/docs/content/tutorials/htmx.md index 5154fe5..02d74b6 100644 --- a/docs/content/tutorials/htmx.md +++ b/docs/content/tutorials/htmx.md @@ -16,54 +16,13 @@ You can lots of examples in [examples/htmx]({{site.data.project.gh.sourcesUrl}}/ 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: -{# need to HTML encode these snippets, so that Markdown doesnt process them! #} -{% set div_snippet = 'html""" -
- WOW, it works! 😲 -
Look ma, no JS! 😎
-
- """' -%} -{% set index_view = 'html""" - - - - - - - - - - """' -%} - - ```scala -//> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 - -import ba.sake.sharaf.{*, given} -import ba.sake.sharaf.undertow.UndertowSharafServer - -val routes = Routes: - case GET -> Path() => - Response.withBody(IndexView) - case POST -> Path("html-snippet") => - Response.withBody: - {{ div_snippet | e }} - -UndertowSharafServer("localhost", 8181, routes).start() - -println(s"Server started at http://localhost:8181") - -def IndexView = - {{ index_view | e }} - +{% include 'htmx_load_snippet.sc' %} ``` and run it like this: ```sh -scala html.sc +scala htmx_load_snippet.sc ``` Go to [http://localhost:8181](http://localhost:8181) diff --git a/docs/content/tutorials/index.md b/docs/content/tutorials/index.md index de29e70..66a3160 100644 --- a/docs/content/tutorials/index.md +++ b/docs/content/tutorials/index.md @@ -26,8 +26,7 @@ set tutorials = [ %} -{% for tut in tutorials %} -- [{{ tut.label }}]({{ tut.url}}) +{% for tut in tutorials %}- [{{ tut.label }}]({{ tut.url}}) {% endfor %} diff --git a/docs/content/tutorials/json.md b/docs/content/tutorials/json.md index 82dd49c..87cfb94 100644 --- a/docs/content/tutorials/json.md +++ b/docs/content/tutorials/json.md @@ -12,7 +12,7 @@ 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.12.1 +//> using dep {{site.data.project.artifact.org}}::{{site.data.project.artifact.name}}:{{site.data.project.artifact.version}} import ba.sake.tupson.JsonRW import ba.sake.sharaf.* diff --git a/docs/content/tutorials/quickstart.md b/docs/content/tutorials/quickstart.md index c8bad9f..97627b9 100644 --- a/docs/content/tutorials/quickstart.md +++ b/docs/content/tutorials/quickstart.md @@ -40,11 +40,15 @@ scala my_script.sc --scala-option -Yretain-trees ## Examples -- [scala examples]({{site.data.project.gh.sourcesUrl}}/examples/scala-cli), standalone examples using scala-cli -- [scala HTMX examples]({{site.data.project.gh.sourcesUrl}}/examples/htmx), standalone examples featuring HTMX +- [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/) +- [Username+Password form login]({{site.data.project.gh.sourcesUrl}}/examples/user-pass-form) with [Pac4J](https://www.pac4j.org/) +- [JWT auth]({{site.data.project.gh.sourcesUrl}}/examples/jwt) with [Pac4J](https://www.pac4j.org/) +- [OAuth2 login]({{site.data.project.gh.sourcesUrl}}/examples/oauth2) with [Pac4J](https://www.pac4j.org/) +- [Snunit]({{site.data.project.gh.sourcesUrl}}/examples/snunit) demo app +- [Http4s]({{site.data.project.gh.sourcesUrl}}/examples/http4s) demo app - [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 index db9f44f..02c494b 100644 --- a/docs/content/tutorials/sql.md +++ b/docs/content/tutorials/sql.md @@ -33,7 +33,7 @@ Create a file `sql_db.sc` and paste this code into it: //> 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.12.1 +//> using dep {{site.data.project.artifact.org}}::{{site.data.project.artifact.name}}:{{site.data.project.artifact.version}} //> using dep ba.sake::squery:0.7.0 import ba.sake.tupson.JsonRW diff --git a/docs/copy-examples.ps1 b/docs/copy-examples.ps1 index 6ef5a8e..82d821a 100644 --- a/docs/copy-examples.ps1 +++ b/docs/copy-examples.ps1 @@ -6,7 +6,10 @@ $examplesList = @( "examples/scala-cli/query_params.sc", "examples/scala-cli/static_files.sc", "examples/scala-cli/json_api.test.scala", - "examples/scala-cli/validation.sc" + "examples/scala-cli/validation.sc", + "examples/scala-cli/html.sc", + "examples/scala-cli/form_handling.sc", + "examples/htmx/htmx_load_snippet.sc" ) $targetFolder = "docs/_includes" diff --git a/examples/fullstack/src/views/ShowFormPage.scala b/examples/fullstack/src/views/ShowFormPage.scala index 7a347bb..0e6290a 100644 --- a/examples/fullstack/src/views/ShowFormPage.scala +++ b/examples/fullstack/src/views/ShowFormPage.scala @@ -3,7 +3,6 @@ package fullstack.views import ba.sake.validson.ValidationError import ba.sake.sharaf.* import fullstack.CreateCustomerForm -import play.twirl.api.Html def ShowFormPage(formData: CreateCustomerForm, errors: Seq[ValidationError] = Seq.empty) = { // errors are returned as JSON Path, hence the $. prefix below! @@ -30,7 +29,7 @@ def ShowFormPage(formData: CreateCustomerForm, errors: Seq[ValidationError] = Se """ } - val hobbiesInputs = formData.hobbies.zipWithIndex.map { case (hobby, idx) => + val hobbiesInputs = formData.hobbies.zipWithIndex.map { case (_, idx) => withInputErrors(s"hobbies[${idx}]", _.hobbies.applyOrElse(idx, _ => "")) { case (fieldName, fieldValue, fieldErrors) => html""" diff --git a/examples/htmx/htmx_active_search.sc b/examples/htmx/htmx_active_search.sc index ec2984a..d5f84c4 100644 --- a/examples/htmx/htmx_active_search.sc +++ b/examples/htmx/htmx_active_search.sc @@ -1,5 +1,5 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 // https://htmx.org/examples/active-search/ diff --git a/examples/htmx/htmx_animations.sc b/examples/htmx/htmx_animations.sc index 3f9951a..ef198a7 100644 --- a/examples/htmx/htmx_animations.sc +++ b/examples/htmx/htmx_animations.sc @@ -1,9 +1,8 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 // https://htmx.org/examples/animations/ -import play.twirl.api.Html import ba.sake.sharaf.{*, given} import ba.sake.sharaf.undertow.UndertowSharafServer diff --git a/examples/htmx/htmx_bulk_update.sc b/examples/htmx/htmx_bulk_update.sc index 910e3a6..935dcc6 100644 --- a/examples/htmx/htmx_bulk_update.sc +++ b/examples/htmx/htmx_bulk_update.sc @@ -1,9 +1,9 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 // https://htmx.org/examples/bulk-update/ -import play.twirl.api.Html + import ba.sake.sharaf.{*, given} import ba.sake.sharaf.undertow.UndertowSharafServer import ba.sake.formson.FormDataRW diff --git a/examples/htmx/htmx_cascading_selects.sc b/examples/htmx/htmx_cascading_selects.sc index 3a9a48e..089641f 100644 --- a/examples/htmx/htmx_cascading_selects.sc +++ b/examples/htmx/htmx_cascading_selects.sc @@ -1,9 +1,9 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 // https://htmx.org/examples/value-select/ -import play.twirl.api.Html + import ba.sake.querson.QueryStringRW import ba.sake.sharaf.{*, given} import ba.sake.sharaf.undertow.UndertowSharafServer diff --git a/examples/htmx/htmx_click_edit.sc b/examples/htmx/htmx_click_edit.sc index 4e40a0a..b20f2c7 100644 --- a/examples/htmx/htmx_click_edit.sc +++ b/examples/htmx/htmx_click_edit.sc @@ -1,9 +1,9 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 // https://htmx.org/examples/click-to-edit/ -import play.twirl.api.Html + import ba.sake.sharaf.{*, given} import ba.sake.sharaf.undertow.UndertowSharafServer import ba.sake.formson.FormDataRW diff --git a/examples/htmx/htmx_click_to_load.sc b/examples/htmx/htmx_click_to_load.sc index 546c9dc..0d6f393 100644 --- a/examples/htmx/htmx_click_to_load.sc +++ b/examples/htmx/htmx_click_to_load.sc @@ -1,10 +1,10 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 // https://htmx.org/examples/click-to-load/ import java.util.UUID -import play.twirl.api.Html + import ba.sake.querson.QueryStringRW import ba.sake.sharaf.{*, given} import ba.sake.sharaf.undertow.UndertowSharafServer diff --git a/examples/htmx/htmx_delete_row.sc b/examples/htmx/htmx_delete_row.sc index 889c80a..32571a9 100644 --- a/examples/htmx/htmx_delete_row.sc +++ b/examples/htmx/htmx_delete_row.sc @@ -1,5 +1,5 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 // https://htmx.org/examples/delete-row/ diff --git a/examples/htmx/htmx_dialogs_bootstrap_form.sc b/examples/htmx/htmx_dialogs_bootstrap_form.sc index 3d1ee13..4815c01 100644 --- a/examples/htmx/htmx_dialogs_bootstrap_form.sc +++ b/examples/htmx/htmx_dialogs_bootstrap_form.sc @@ -1,5 +1,5 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 // example of BS5 modal with a form // https://htmx.org/examples/modal-bootstrap/ diff --git a/examples/htmx/htmx_dialogs_browser.sc b/examples/htmx/htmx_dialogs_browser.sc index eb5e59e..4ac80af 100644 --- a/examples/htmx/htmx_dialogs_browser.sc +++ b/examples/htmx/htmx_dialogs_browser.sc @@ -1,5 +1,5 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 // https://htmx.org/examples/dialogs/ diff --git a/examples/htmx/htmx_edit_row.sc b/examples/htmx/htmx_edit_row.sc index f8c8fd1..442536a 100644 --- a/examples/htmx/htmx_edit_row.sc +++ b/examples/htmx/htmx_edit_row.sc @@ -1,9 +1,9 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 // https://htmx.org/examples/edit-row/ -import play.twirl.api.Html + import ba.sake.formson.FormDataRW import ba.sake.sharaf.{*, given} import ba.sake.sharaf.undertow.UndertowSharafServer diff --git a/examples/htmx/htmx_file_upload_js.sc b/examples/htmx/htmx_file_upload_js.sc index 5215098..1d0f5df 100644 --- a/examples/htmx/htmx_file_upload_js.sc +++ b/examples/htmx/htmx_file_upload_js.sc @@ -1,5 +1,5 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 import play.twirl.api.{Html, HtmlFormat} import ba.sake.formson.FormDataRW diff --git a/examples/htmx/htmx_infinite_scroll.sc b/examples/htmx/htmx_infinite_scroll.sc index fcf2435..d77f842 100644 --- a/examples/htmx/htmx_infinite_scroll.sc +++ b/examples/htmx/htmx_infinite_scroll.sc @@ -1,10 +1,10 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 // https://htmx.org/examples/click-to-load/ import java.util.UUID -import play.twirl.api.Html + import ba.sake.querson.QueryStringRW import ba.sake.sharaf.{*, given} import ba.sake.sharaf.undertow.UndertowSharafServer diff --git a/examples/htmx/htmx_inline_validation.sc b/examples/htmx/htmx_inline_validation.sc index f8acf66..ba54f1c 100644 --- a/examples/htmx/htmx_inline_validation.sc +++ b/examples/htmx/htmx_inline_validation.sc @@ -1,9 +1,8 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 // https://htmx.org/examples/inline-validation/ -import play.twirl.api.Html import ba.sake.sharaf.{*, given} import ba.sake.sharaf.undertow.UndertowSharafServer import ba.sake.formson.FormDataRW diff --git a/examples/htmx/htmx_lazy_load.sc b/examples/htmx/htmx_lazy_load.sc index 0b37ff0..e148858 100644 --- a/examples/htmx/htmx_lazy_load.sc +++ b/examples/htmx/htmx_lazy_load.sc @@ -1,5 +1,5 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 // https://htmx.org/examples/lazy-load/ diff --git a/examples/htmx/htmx_load_snippet.sc b/examples/htmx/htmx_load_snippet.sc index c7065fc..15bf1ba 100644 --- a/examples/htmx/htmx_load_snippet.sc +++ b/examples/htmx/htmx_load_snippet.sc @@ -1,5 +1,5 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 import ba.sake.sharaf.{*, given} import ba.sake.sharaf.undertow.UndertowSharafServer diff --git a/examples/htmx/htmx_progress_bar.sc b/examples/htmx/htmx_progress_bar.sc index 627470c..66f9c62 100644 --- a/examples/htmx/htmx_progress_bar.sc +++ b/examples/htmx/htmx_progress_bar.sc @@ -1,5 +1,5 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 import java.util.concurrent.TimeUnit // https://htmx.org/examples/progress-bar/ diff --git a/examples/htmx/htmx_sse.sc b/examples/htmx/htmx_sse.sc new file mode 100644 index 0000000..0c0b40f --- /dev/null +++ b/examples/htmx/htmx_sse.sc @@ -0,0 +1,51 @@ +//> using scala 3.7.3 +//> using dep ba.sake::sharaf-undertow:0.13.3 + +import ba.sake.sharaf.{*, given} +import ba.sake.sharaf.undertow.UndertowSharafServer + +val routes = Routes { + case GET -> Path() => + Response.withBody( + html""" + + + + + + + + +
+

Hello HTMX + SSE!

+
+
+ + + """ + ) + case GET -> Path("sse-events") => + val sseSender = SseSender() + new Thread(() => { + for i <- 1 to 5 do + sseSender.send( + ServerSentEvent.Message( + data = html"""
event${i}
""".toString + ) + ) + Thread.sleep(1_000) + sseSender.send(ServerSentEvent.Done()) + }).start() + Response.withBody(sseSender) +} + +UndertowSharafServer("localhost", 8181, routes).start() +println(s"Server started at http://localhost:8181") diff --git a/examples/htmx/htmx_tabs_hateoas.sc b/examples/htmx/htmx_tabs_hateoas.sc index a2fe5f6..c5beaa2 100644 --- a/examples/htmx/htmx_tabs_hateoas.sc +++ b/examples/htmx/htmx_tabs_hateoas.sc @@ -1,5 +1,5 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 import ba.sake.sharaf.{*, given} import ba.sake.sharaf.undertow.UndertowSharafServer diff --git a/examples/http4s/README.md b/examples/http4s/README.md new file mode 100644 index 0000000..fb98cf7 --- /dev/null +++ b/examples/http4s/README.md @@ -0,0 +1,17 @@ +The following steps are done from root of this git repo. + +---- +Run from repo root: + +```scala + +./mill examples.http4s.jvm.run + +``` + +Then in another shell: + +```shell +curl localhost:8080 +# should return "Hello Http4s!" +``` diff --git a/examples/http4s/src/Main.scala b/examples/http4s/src/Main.scala new file mode 100644 index 0000000..1740450 --- /dev/null +++ b/examples/http4s/src/Main.scala @@ -0,0 +1,23 @@ +import ba.sake.sharaf.* +import ba.sake.sharaf.http4s.* + +import cats.effect.* +import com.comcast.ip4s.* +import org.http4s.ember.server.* + +val routes = Routes { + case GET -> Path("hello", name) => + Response.withBody(s"Hello ${name}!") + case _ => + Response.withBody("Hello Http4s!") +} + +object Main extends IOApp.Simple: + def run = + EmberServerBuilder + .default[cats.effect.IO] + .withHost(ipv4"0.0.0.0") + .withPort(port"8080") + .withHttpApp(SharafHttpApp(SharafHandler.routes(routes))) + .build + .useForever diff --git a/examples/jwt/README.md b/examples/jwt/README.md new file mode 100644 index 0000000..e14472b --- /dev/null +++ b/examples/jwt/README.md @@ -0,0 +1,17 @@ + + +```shell +# public route +curl localhost:8181 + +# should return 401 Unauthorized when JWT is not provided or is invalid +curl localhost:8181/protected + +# should return 200 OK +curl localhost:8181/protected -H "Authorization: eyJhbGc....." +``` + + + + + diff --git a/examples/jwt/src/Main.scala b/examples/jwt/src/Main.scala new file mode 100644 index 0000000..48a5c0e --- /dev/null +++ b/examples/jwt/src/Main.scala @@ -0,0 +1,92 @@ +package jwt + +import java.util.Optional +import scala.jdk.CollectionConverters.* +import io.undertow.Undertow +import org.pac4j.core.client.Clients +import org.pac4j.core.config.Config +import org.pac4j.http.client.direct.HeaderClient +import org.pac4j.undertow.handler.SecurityHandler +import org.pac4j.core.context.WebContext +import org.pac4j.core.context.session.SessionStore +import org.pac4j.core.matching.matcher.{DefaultMatchers, PathMatcher} +import org.pac4j.core.profile.BasicUserProfile +import org.pac4j.jwt.config.signature.SecretSignatureConfiguration +import org.pac4j.jwt.credentials.authenticator.JwtAuthenticator +import org.pac4j.jwt.profile.JwtGenerator +import ba.sake.sharaf.* +import ba.sake.sharaf.undertow.* + +// TODO add a test + +@main def main(): Unit = + val module = JwtModule(8181) + module.server.start() + println(s"Started HTTP server at ${module.baseUrl}") + +class JwtModule(port: Int) { + + val baseUrl = s"http://localhost:${port}" + + private val signatureConfiguration = new SecretSignatureConfiguration("your_jwt_secret_key_that_is_at_least_32_chars") + private val jwtAuthenticator = JwtAuthenticator(signatureConfiguration) + private val headerClient = new HeaderClient("Authorization", jwtAuthenticator) + + // generate a JWT claims set for testing purposes + locally { + val up = BasicUserProfile() + up.setId("12345") + val jwt = JwtGenerator(signatureConfiguration).generate(up) + println(s"Use this JWT: ${jwt}") + } + + private val clients = Clients("/callback", headerClient) + private val pac4jConfig = Config(clients) + // use noop session store, JWTs are stateless + pac4jConfig.setSessionStoreFactory(_ => NoopSessionStore()) + private val clientNames = clients.getClients.asScala.map(_.getName()).toSeq + private val publicRoutesMatcher = PathMatcher() + private val publicRoutesMatcherName = "publicRoutesMatcher" + publicRoutesMatcher.excludePaths("/") + pac4jConfig.addMatcher(publicRoutesMatcherName, publicRoutesMatcher) + private val securityHandler = + SecurityHandler.build( + UndertowExceptionHandler( + ExceptionMapper.default, + SharafUndertowHandler(SharafHandler.routes(Routes { + case GET -> Path() => + Response.withBody("Hello there! This is a public endpoint. Try accessing localhost:8181/protected.") + case GET -> Path("protected") => + Response.withBody("This is a protected resource. You are authenticated.") + })) + ), + pac4jConfig, + clientNames.mkString(","), + null, + s"${DefaultMatchers.SECURITYHEADERS},${publicRoutesMatcherName}" + ) + + val server = Undertow + .builder() + .addHttpListener(port, "localhost") + .setHandler(securityHandler) + .build() +} + +// A no-op session store implementation that does not store any session data +class NoopSessionStore extends SessionStore { + override def getTrackableSession(context: WebContext): Optional[AnyRef] = Optional.empty() + + override def buildFromTrackableSession(context: WebContext, trackableSession: Any): Optional[SessionStore] = + Optional.empty() + + override def getSessionId(context: WebContext, createSession: Boolean): Optional[String] = Optional.empty() + + override def get(context: WebContext, key: String): Optional[AnyRef] = Optional.empty() + + override def set(context: WebContext, key: String, value: Any): Unit = () + + override def destroySession(context: WebContext): Boolean = false + + override def renewSession(context: WebContext): Boolean = false +} diff --git a/examples/oauth2/src/AppModule.scala b/examples/oauth2/src/AppModule.scala index 2adca43..2dd3b02 100644 --- a/examples/oauth2/src/AppModule.scala +++ b/examples/oauth2/src/AppModule.scala @@ -6,6 +6,7 @@ import io.undertow.server.HttpHandler import io.undertow.server.session.InMemorySessionManager import io.undertow.server.session.SessionAttachmentHandler import io.undertow.server.session.SessionCookieConfig +import io.undertow.server.handlers.BlockingHandler import org.pac4j.core.client.Clients import org.pac4j.undertow.handler.CallbackHandler import org.pac4j.undertow.handler.LogoutHandler @@ -24,25 +25,30 @@ class AppModule(port: Int, clients: Clients) { private val httpHandler: HttpHandler = locally { val securityHandler = SecurityHandler.build( - SharafUndertowHandler(SharafHandler.routes(appRoutes.routes)), + SharafUndertowHandler( + SharafHandler.exceptions( + SharafHandler.routes(appRoutes.routes) + ) + ), securityConfig.pac4jConfig, securityConfig.clientNames.mkString(","), null, - securityConfig.matchers, - CustomSecurityLogic() + securityConfig.matchers ) val pathHandler = Handlers .path() - .addExactPath("/callback", CallbackHandler.build(securityConfig.pac4jConfig, null, CustomCallbackLogic())) + .addExactPath("/callback", CallbackHandler.build(securityConfig.pac4jConfig)) .addExactPath("/logout", LogoutHandler(securityConfig.pac4jConfig, "/")) .addPrefixPath("/", securityHandler) - SessionAttachmentHandler(pathHandler, InMemorySessionManager("SessionManager"), SessionCookieConfig()) + BlockingHandler( + SessionAttachmentHandler(pathHandler, InMemorySessionManager("SessionManager"), SessionCookieConfig()) + ) } val server = Undertow .builder() - .addHttpListener(port, "0.0.0.0", httpHandler) + .addHttpListener(port, "localhost", httpHandler) .build() } diff --git a/examples/oauth2/src/AppRoutes.scala b/examples/oauth2/src/AppRoutes.scala index 724a78b..6c1aea3 100644 --- a/examples/oauth2/src/AppRoutes.scala +++ b/examples/oauth2/src/AppRoutes.scala @@ -25,7 +25,7 @@ def IndexPage(userOpt: Option[CustomUserProfile]) =
Hello there!
- Login with GitHub + Login with GitHub
diff --git a/examples/oauth2/src/CustomCallbackLogic.scala b/examples/oauth2/src/CustomCallbackLogic.scala index 86e5614..bbb0bb2 100644 --- a/examples/oauth2/src/CustomCallbackLogic.scala +++ b/examples/oauth2/src/CustomCallbackLogic.scala @@ -1,25 +1,23 @@ package demo import org.pac4j.core.config.Config -import org.pac4j.core.context.WebContext -import org.pac4j.core.context.session.SessionStore import org.pac4j.core.engine.DefaultCallbackLogic import org.pac4j.core.profile.UserProfile import org.pac4j.oauth.profile.github.GitHubProfile import org.pac4j.oauth.profile.OAuth20Profile +import org.pac4j.core.context.CallContext class CustomCallbackLogic() extends DefaultCallbackLogic { - override def saveUserProfile( - context: WebContext, - sessionStore: SessionStore, + override protected def saveUserProfile( + context: CallContext, config: Config, userProfile: UserProfile, saveProfileInSession: Boolean, multiProfile: Boolean, renewSession: Boolean ): Unit = { - super.saveUserProfile(context, sessionStore, config, userProfile, saveProfileInSession, multiProfile, renewSession) + super.saveUserProfile(context, config, userProfile, saveProfileInSession, multiProfile, renewSession) userProfile match case profile: GitHubProfile => @@ -30,6 +28,5 @@ class CustomCallbackLogic() extends DefaultCallbackLogic { println(s"Saving TEST profile to database: $profile") case other => throw RuntimeException(s"Cant handle Pac4jUserProfile: $other") - } } diff --git a/examples/oauth2/src/CustomSecurityLogic.scala b/examples/oauth2/src/CustomSecurityLogic.scala deleted file mode 100644 index d9192f2..0000000 --- a/examples/oauth2/src/CustomSecurityLogic.scala +++ /dev/null @@ -1,38 +0,0 @@ -package demo - -import java.{util => ju} -import scala.jdk.CollectionConverters.* -import scala.jdk.OptionConverters.* -import org.pac4j.core.client.Client -import org.pac4j.core.context.WebContext -import org.pac4j.core.context.session.SessionStore -import org.pac4j.core.engine.DefaultSecurityLogic -import org.pac4j.core.exception.http.HttpAction -import org.pac4j.core.exception.http.UnauthorizedAction - -class CustomSecurityLogic extends DefaultSecurityLogic { - - override protected def redirectToIdentityProvider( - context: WebContext, - sessionStore: SessionStore, - currentClients: ju.List[Client] - ): HttpAction = { - // Pac4J redirects to the FIRST CLIENT by default - // here we take the desired login method from the *query parameter* - // https://stackoverflow.com/questions/68428308/in-which-order-are-pac4j-client-used - val providerOpt = context.getRequestParameter("provider").toScala - providerOpt match - case None => - // we return 401 if not authenticated - // you *could* set a default client to be redirected to - return UnauthorizedAction() - case Some(clientName) => - currentClients.asScala.find(_.getName() == clientName) match - case None => - val action = UnauthorizedAction() - action.setContent("Unsupported provider") - action - case Some(client) => client.getRedirectionAction(context, sessionStore).get() - - } -} diff --git a/examples/oauth2/src/Main.scala b/examples/oauth2/src/Main.scala index c5b62de..e72247a 100644 --- a/examples/oauth2/src/Main.scala +++ b/examples/oauth2/src/Main.scala @@ -2,18 +2,16 @@ package demo import org.pac4j.core.client.Clients import org.pac4j.oauth.client.* +import ba.sake.sharaf.utils.NetworkUtils -@main def main: Unit = - +@main def main(): Unit = + System.setProperty("org.jboss.logging.provider", "slf4j") // configure your OAuth2 clients with your values // from pac4j's huge list https://www.pac4j.org/docs/clients/oauth.html val githubClient = GitHubClient("KEY", "SECRET") githubClient.setScope("read:user, user:email") - // val facebookClient = FacebookClient(...) - - val clients = Clients(s"http://localhost:8181/callback", githubClient) - - val module = AppModule(8181, clients) + val port = NetworkUtils.getFreePort() + val clients = Clients(s"http://localhost:${port}/callback", githubClient) + val module = AppModule(port, clients) module.server.start() - println(s"Started HTTP server at ${module.baseUrl}") diff --git a/examples/oauth2/src/SecurityConfig.scala b/examples/oauth2/src/SecurityConfig.scala index a3ec2f5..be125b5 100644 --- a/examples/oauth2/src/SecurityConfig.scala +++ b/examples/oauth2/src/SecurityConfig.scala @@ -23,6 +23,7 @@ class SecurityConfig(clients: Clients) { val config = Config(clients) config.addMatcher(publicRoutesMatcherName, publicRoutesMatcher) + config.setCallbackLogic(CustomCallbackLogic()) config } diff --git a/examples/oauth2/src/SecurityService.scala b/examples/oauth2/src/SecurityService.scala index 798e849..5219a6c 100644 --- a/examples/oauth2/src/SecurityService.scala +++ b/examples/oauth2/src/SecurityService.scala @@ -2,8 +2,7 @@ package demo import scala.jdk.OptionConverters.* import org.pac4j.core.config.Config -import org.pac4j.core.util.FindBest -import org.pac4j.undertow.context.{UndertowSessionStore, UndertowWebContext} +import org.pac4j.undertow.context.{UndertowParameters, UndertowWebContext} import ba.sake.sharaf.* import ba.sake.sharaf.undertow.UndertowSharafRequest @@ -11,8 +10,7 @@ class SecurityService(config: Config) { def currentUser(using req: Request): Option[CustomUserProfile] = { val exchange = req.asInstanceOf[UndertowSharafRequest].underlyingHttpServerExchange - @annotation.nowarn - val sessionStore = FindBest.sessionStore(null, config, UndertowSessionStore(exchange)) + val sessionStore = config.getSessionStoreFactory.newSessionStore(UndertowParameters(exchange)) val profileManager = config.getProfileManagerFactory.apply(UndertowWebContext(exchange), sessionStore) profileManager.getProfile().toScala.map { profile => // val identityProvider = profile match .. diff --git a/examples/oauth2/test/src/AppTests.scala b/examples/oauth2/test/src/AppTests.scala index 717ca31..138d13f 100644 --- a/examples/oauth2/test/src/AppTests.scala +++ b/examples/oauth2/test/src/AppTests.scala @@ -5,19 +5,18 @@ import sttp.client4.quick.* class AppTests extends IntegrationTest { - test("/protected should return 401 when not logged in") { + test("/protected should return 302 Found when not logged in") { val module = moduleFixture() val baseUrl = module.baseUrl - - val res = quickRequest.get(uri"$baseUrl/protected").send() - - assertEquals(res.code, StatusCode.Unauthorized) + val res = quickRequest.get(uri"$baseUrl/protected").followRedirects(false).send() + assertEquals(res.code, StatusCode.Found) } - test("/protected should return 200 when logged in") { + test("/protected should return 200 Ok when logged in") { val module = moduleFixture() val baseUrl = module.baseUrl + // we use a stateful backend to keep the session cookie! val cookieHandler = new java.net.CookieManager() val javaClient = java.net.http.HttpClient.newBuilder().cookieHandler(cookieHandler).build() val statefulBackend = sttp.client4.httpclient.HttpClientSyncBackend.usingClient(javaClient) @@ -25,7 +24,7 @@ class AppTests extends IntegrationTest { // and we get a JSESSSIONID cookie quickRequest.get(uri"$baseUrl/login?provider=GenericOAuth20Client").send(statefulBackend) - val res = quickRequest.get(uri"$baseUrl/protected").send(statefulBackend) + val res = quickRequest.get(uri"$baseUrl/protected").followRedirects(false).send(statefulBackend) assertEquals(res.code, StatusCode.Ok) } } diff --git a/examples/oauth2/test/src/IntegrationTest.scala b/examples/oauth2/test/src/IntegrationTest.scala index b51df3a..56f4c13 100644 --- a/examples/oauth2/test/src/IntegrationTest.scala +++ b/examples/oauth2/test/src/IntegrationTest.scala @@ -16,28 +16,24 @@ object TestData { trait IntegrationTest extends munit.FunSuite { - protected val moduleFixture = new Fixture[AppModule]("AppModule") { + protected val moduleFixture: Fixture[AppModule] = new Fixture[AppModule]("AppModule") { private var mockOauth2server: MockOAuth2Server = uninitialized - private var module: AppModule = uninitialized - def apply() = module + def apply(): AppModule = module override def beforeEach(context: BeforeEach): Unit = - // mock OAuth2 server mockOauth2server = MockOAuth2Server() mockOauth2server.start() - val issuerId = "fakeOAuthIssuer" - // set user that gets logged in mockOauth2server.enqueueCallback( DefaultOAuth2TokenCallback( issuerId, TestData.username, - JOSEObjectType.JWT.getType(), + JOSEObjectType.JWT.getType, null, Map( "id" -> "123", @@ -47,19 +43,16 @@ trait IntegrationTest extends munit.FunSuite { ) ) - // start real server val client = GenericOAuth20Client() client.setKey("fakeKey") client.setSecret("fakeSecret") - client.setAuthUrl(mockOauth2server.authorizationEndpointUrl(issuerId).toString()) + client.setAuthUrl(mockOauth2server.authorizationEndpointUrl(issuerId).toString) client.setScope("openid whatever") - client.setTokenUrl(mockOauth2server.tokenEndpointUrl(issuerId).toString()) - client.setProfileUrl(mockOauth2server.userInfoUrl(issuerId).toString()) + client.setTokenUrl(mockOauth2server.tokenEndpointUrl(issuerId).toString) + client.setProfileUrl(mockOauth2server.userInfoUrl(issuerId).toString) val port = NetworkUtils.getFreePort() val clients = Clients(s"http://localhost:${port}/callback", client) - - // assign fixture module = AppModule(port, clients) module.server.start() @@ -68,5 +61,5 @@ trait IntegrationTest extends munit.FunSuite { mockOauth2server.shutdown() } - override def munitFixtures = List(moduleFixture) + override def munitFixtures: Seq[Fixture[AppModule]] = List(moduleFixture) } diff --git a/examples/package.mill b/examples/package.mill new file mode 100644 index 0000000..413a4cf --- /dev/null +++ b/examples/package.mill @@ -0,0 +1,65 @@ +package build.examples + +import mill.* +import mill.scalalib.* +import build.{SharafCommonModule, ScalaNativeCommonModule, SharafTestModule} +import build.`sharaf-undertow` +import build.`sharaf-snunit` + +trait SharafExampleModule extends SharafCommonModule: + def mvnDeps = Seq( + mvn"ch.qos.logback:logback-classic:1.4.6" + ) + +object `package` extends Module: + object api extends SharafExampleModule: + def moduleDeps = Seq(`sharaf-undertow`) + object test extends ScalaTests with SharafTestModule + + object fullstack extends SharafExampleModule: + def moduleDeps = Seq(`sharaf-undertow`) + object test extends ScalaTests with SharafTestModule + + object `user-pass-form` extends SharafExampleModule: + def moduleDeps = Seq(`sharaf-undertow`) + def mvnDeps = super.mvnDeps() ++ Seq( + mvn"org.pac4j:undertow-pac4j:6.0.0", + mvn"org.pac4j:pac4j-http:6.1.2", + mvn"org.mindrot:jbcrypt:0.4" + ) + object test extends ScalaTests with SharafTestModule + + object jwt extends SharafExampleModule: + def moduleDeps = Seq(`sharaf-undertow`) + def mvnDeps = super.mvnDeps() ++ Seq( + mvn"org.pac4j:undertow-pac4j:6.0.0", + mvn"org.pac4j:pac4j-http:6.1.2", + mvn"org.pac4j:pac4j-jwt:6.1.2" + ) + object test extends ScalaTests with SharafTestModule + + object oauth2 extends SharafExampleModule: + def moduleDeps = Seq(`sharaf-undertow`) + def mvnDeps = super.mvnDeps() ++ Seq( + mvn"org.pac4j:undertow-pac4j:6.0.0", + mvn"org.pac4j:pac4j-oauth:6.1.2", + mvn"com.google.guava:guava:33.4.6-jre" + ) + 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: + def moduleDeps = Seq(`sharaf-snunit`) + object http4s extends Module: + trait HttpsModule extends SharafExampleModule: + def mvnDeps = super.mvnDeps() ++ Seq( + mvn"org.http4s::http4s-ember-server::0.23.32" + ) + + object jvm extends HttpsModule with PlatformScalaModule: + def moduleDeps = Seq(build.`sharaf-http4s`.jvm) + // object native extends HttpsModule with ScalaNativeCommonModule with PlatformScalaModule: + // def moduleDeps = Seq(build.`sharaf-http4s`.native) +end `package` diff --git a/examples/scala-cli/demo.sc b/examples/scala-cli/demo.sc index 07e81dc..d6e3947 100644 --- a/examples/scala-cli/demo.sc +++ b/examples/scala-cli/demo.sc @@ -1,5 +1,5 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 import ba.sake.querson.QueryStringRW import ba.sake.tupson.JsonRW diff --git a/examples/scala-cli/form_handling.sc b/examples/scala-cli/form_handling.sc index 61d6cf3..c1244ab 100644 --- a/examples/scala-cli/form_handling.sc +++ b/examples/scala-cli/form_handling.sc @@ -1,5 +1,5 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 import ba.sake.formson.FormDataRW import ba.sake.sharaf.{*, given} diff --git a/examples/scala-cli/hello.sc b/examples/scala-cli/hello.sc index 3e265ac..ecc3c6b 100644 --- a/examples/scala-cli/hello.sc +++ b/examples/scala-cli/hello.sc @@ -1,5 +1,5 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 import ba.sake.sharaf.* import ba.sake.sharaf.undertow.UndertowSharafServer diff --git a/examples/scala-cli/html.sc b/examples/scala-cli/html.sc index ce109d0..2843398 100644 --- a/examples/scala-cli/html.sc +++ b/examples/scala-cli/html.sc @@ -1,5 +1,5 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 import ba.sake.sharaf.{*, given} import ba.sake.sharaf.undertow.UndertowSharafServer diff --git a/examples/scala-cli/html_hepek.sc b/examples/scala-cli/html_hepek.sc index 44c85e9..decc30c 100644 --- a/examples/scala-cli/html_hepek.sc +++ b/examples/scala-cli/html_hepek.sc @@ -1,5 +1,5 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 import scalatags.Text.all.* import ba.sake.hepek.html.HtmlPage diff --git a/examples/scala-cli/html_scalatags.sc b/examples/scala-cli/html_scalatags.sc index 36db66a..bdeb16b 100644 --- a/examples/scala-cli/html_scalatags.sc +++ b/examples/scala-cli/html_scalatags.sc @@ -1,5 +1,5 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 import scalatags.Text.all.* import ba.sake.sharaf.* diff --git a/examples/scala-cli/json_api.sc b/examples/scala-cli/json_api.sc index 3af69a9..84946cc 100644 --- a/examples/scala-cli/json_api.sc +++ b/examples/scala-cli/json_api.sc @@ -1,5 +1,5 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 import ba.sake.tupson.JsonRW import ba.sake.sharaf.* diff --git a/examples/scala-cli/path_params.sc b/examples/scala-cli/path_params.sc index 8635334..038acbf 100644 --- a/examples/scala-cli/path_params.sc +++ b/examples/scala-cli/path_params.sc @@ -1,5 +1,5 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 import ba.sake.sharaf.* import ba.sake.sharaf.undertow.UndertowSharafServer diff --git a/examples/scala-cli/query_params.sc b/examples/scala-cli/query_params.sc index 65f226c..eb1620b 100644 --- a/examples/scala-cli/query_params.sc +++ b/examples/scala-cli/query_params.sc @@ -1,5 +1,5 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 import ba.sake.querson.QueryStringRW import ba.sake.sharaf.* diff --git a/examples/scala-cli/sql_db.sc b/examples/scala-cli/sql_db.sc index 5094d08..bf050df 100644 --- a/examples/scala-cli/sql_db.sc +++ b/examples/scala-cli/sql_db.sc @@ -1,7 +1,7 @@ //> 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.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 //> using dep ba.sake::squery:0.7.0 import ba.sake.tupson.JsonRW diff --git a/examples/scala-cli/static_files.sc b/examples/scala-cli/static_files.sc index 5194d6e..0bd3df8 100644 --- a/examples/scala-cli/static_files.sc +++ b/examples/scala-cli/static_files.sc @@ -1,5 +1,5 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 import ba.sake.sharaf.* import ba.sake.sharaf.undertow.UndertowSharafServer diff --git a/examples/scala-cli/validation.sc b/examples/scala-cli/validation.sc index 4a16a1d..b5b521d 100644 --- a/examples/scala-cli/validation.sc +++ b/examples/scala-cli/validation.sc @@ -1,5 +1,5 @@ //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep ba.sake::sharaf-undertow:0.13.0 import ba.sake.querson.QueryStringRW import ba.sake.tupson.JsonRW diff --git a/examples/user-pass-form/src/userpassform/AppRoutes.scala b/examples/user-pass-form/src/userpassform/AppRoutes.scala index d054d02..696bee7 100644 --- a/examples/user-pass-form/src/userpassform/AppRoutes.scala +++ b/examples/user-pass-form/src/userpassform/AppRoutes.scala @@ -1,11 +1,14 @@ package userpassform import ba.sake.sharaf.{*, given} +import ba.sake.querson.QueryStringRW class AppRoutes(callbackUrl: String, securityService: SecurityService) { val routes = Routes { case GET -> Path("login-form") => - Response.withBody(views.showForm(callbackUrl)) + case class QP(username: String = "", error: Option[String]) derives QueryStringRW + val qp = Request.current.queryParams[QP] + Response.withBody(views.showForm(callbackUrl, qp.error.nonEmpty, qp.username)) case GET -> Path("protected-resource") => securityService.withCurrentUser { Response.withBody(views.protectedResource) @@ -58,7 +61,7 @@ object views { """ - def showForm(callbackUrl: String) = + def showForm(callbackUrl: String, isError: Boolean, username: String) = html""" @@ -66,15 +69,16 @@ object views {
+ ${if isError then html"
Login failed, please try again.
" else ""}
- Use johndoe/johndoe as username/password to login. + Use john_doe/john_doe as username/password to login.
diff --git a/examples/user-pass-form/src/userpassform/SecurityService.scala b/examples/user-pass-form/src/userpassform/SecurityService.scala index a2c37f0..206f225 100644 --- a/examples/user-pass-form/src/userpassform/SecurityService.scala +++ b/examples/user-pass-form/src/userpassform/SecurityService.scala @@ -2,8 +2,7 @@ package userpassform import scala.jdk.OptionConverters.* import org.pac4j.core.config.Config -import org.pac4j.core.util.FindBest -import org.pac4j.undertow.context.{UndertowSessionStore, UndertowWebContext} +import org.pac4j.undertow.context.{UndertowParameters, UndertowWebContext} import ba.sake.sharaf.* import ba.sake.sharaf.undertow.UndertowSharafRequest @@ -11,8 +10,7 @@ class SecurityService(config: Config) { def currentUser(using req: Request): Option[CustomUserProfile] = { val exchange = req.asInstanceOf[UndertowSharafRequest].underlyingHttpServerExchange - @annotation.nowarn - val sessionStore = FindBest.sessionStore(null, config, UndertowSessionStore(exchange)) + val sessionStore = config.getSessionStoreFactory.newSessionStore(UndertowParameters(exchange)) val profileManager = config.getProfileManagerFactory.apply(UndertowWebContext(exchange), sessionStore) profileManager.getProfile().toScala.map { profile => CustomUserProfile(profile.getUsername) diff --git a/formson/package.mill b/formson/package.mill new file mode 100644 index 0000000..3067499 --- /dev/null +++ b/formson/package.mill @@ -0,0 +1,23 @@ +package build.formson + +import mill.* +import mill.scalalib.* +import build.{SharafPublishModule, ScalaJvmCommonModule, ScalaJSCommonModule, ScalaNativeCommonModule, SharafTestModule} + +object `package` extends Module: + 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: + 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" + ) +end `package` diff --git a/formson/src/ba/sake/formson/FormDataRW.scala b/formson/src/ba/sake/formson/FormDataRW.scala index eeff954..73f3d92 100644 --- a/formson/src/ba/sake/formson/FormDataRW.scala +++ b/formson/src/ba/sake/formson/FormDataRW.scala @@ -5,6 +5,11 @@ import java.nio.file.Path import java.time.* import java.util.UUID import scala.deriving.* +import scala.compiletime.summonInline +import NamedTuple.AnyNamedTuple +import NamedTuple.Names +import NamedTuple.DropNames +import NamedTuple.withNames import scala.compiletime.* import scala.quoted.* import scala.reflect.ClassTag @@ -37,7 +42,7 @@ trait FormDataRW[T] { } } -object FormDataRW { +object FormDataRW extends LowPriorityFormDataRWInstances { def apply[T](using instance: FormDataRW[T]): FormDataRW[T] = instance @@ -79,6 +84,15 @@ object FormDataRW { str.toDoubleOption.getOrElse(typeError(path, "Double", str)) } + given FormDataRW[Boolean] with { + override def write(path: String, value: Boolean): FormData = + FormDataRW[String].write(path, if value then "true" else "false") + + override def parse(path: String, formData: FormData): Boolean = + val str = FormDataRW[String].parse(path, formData) + str.toBooleanOption.getOrElse(typeError(path, "Boolean", str)) + } + given FormDataRW[UUID] with { override def write(path: String, value: UUID): FormData = FormDataRW[String].write(path, value.toString) @@ -426,6 +440,37 @@ object FormDataRW { case _ => report.errorAndAbort(s"Sum types are not supported ") } + inline given autoderiveUnion[T: IsUnion]: FormDataRW[T] = ${ deriveUnionTC[T] } + + private def deriveUnionTC[T: Type](using Quotes): Expr[FormDataRW[T]] = { + import quotes.reflect.* + TypeRepr.of[T] match { + case OrType(left, right) => + left.asType match { + case '[l] => + right.asType match { + case '[r] => + '{ + new FormDataRW[T] { + override def write(path: String, value: T): FormData = value match { + case a: l => summonInline[FormDataRW[l]].write(path, a) + case b: r => summonInline[FormDataRW[r]].write(path, b) + } + override def parse(path: String, formData: FormData): T = try { + summonInline[FormDataRW[l]].parse(path, formData).asInstanceOf[T] + } catch { + case _: FormsonException => + summonInline[FormDataRW[r]].parse(path, formData).asInstanceOf[T] + } + } + } + } + } + case _ => + report.errorAndAbort(s"Cannot automatically derive FormDataRW for non-union type ${Type.show[T]}") + } + } + /* macro utils */ private def summonInstances[T: Type, Elems: Type](using Quotes): List[Expr[FormDataRW[?]]] = Type.of[Elems] match @@ -487,3 +532,45 @@ object FormDataRW { private def parseError(path: String, msg: String): Nothing = throw ParsingException(ParseError(path, msg)) } + +private[formson] trait LowPriorityFormDataRWInstances { + + inline given autoderiveNamedTuple[T <: AnyNamedTuple]: FormDataRW[T] = { + val fieldNames = compiletime.constValueTuple[Names[T]].productIterator.asInstanceOf[Iterator[String]].toSeq + val tcInstances = + compiletime + .summonAll[Tuple.Map[DropNames[T], FormDataRW]] + .productIterator + .asInstanceOf[Iterator[FormDataRW[Any]]] + .toSeq + deriveNamedTupleTC[T](fieldNames, tcInstances) + } + + private def deriveNamedTupleTC[T](fieldNames: Seq[String], tcInstances: Seq[FormDataRW[Any]]) = + new FormDataRW[T] { + override def write(path: String, value: T): FormData = + val fieldValues = value.asInstanceOf[Tuple].productIterator.asInstanceOf[Iterator[Any]] + val fdFields = fieldNames.zip(fieldValues).zip(tcInstances).map { case ((name, v), rw) => + name -> rw.write(s"$path.$name", v) + } + FormData.Obj(SeqMap.from(fdFields)) + + override def parse(path: String, formData: FormData): T = formData match { + case FormData.Obj(fields) => + val fieldMap = fields.toMap + val parsedValues = fieldNames.zip(tcInstances).map { case (name, rw) => + fieldMap.get(name) match { + case Some(jv) => rw.parse(s"$path.$name", jv) + case None => throw ParsingException(ParseError(s"$path.$name", "is missing")) + } + } + val tupleValue = Tuple.fromArray(parsedValues.toArray) + withNames(tupleValue).asInstanceOf[T] + case _ => + throw ParsingException( + ParseError(path, s"should be Object but it is ${formData.tpe}") + ) + } + } + +} diff --git a/formson/src/ba/sake/formson/IsUnion.scala b/formson/src/ba/sake/formson/IsUnion.scala new file mode 100644 index 0000000..02761a0 --- /dev/null +++ b/formson/src/ba/sake/formson/IsUnion.scala @@ -0,0 +1,26 @@ +package ba.sake.formson + +import scala.quoted.* + +// stolen from https://github.com/iRevive/union-derivation/blob/main/modules/core/src/main/scala/io/github/irevive/union/derivation/IsUnion.scala + +@annotation.implicitNotFound("${A} is not a union type") +trait IsUnion[A] + +object IsUnion { + + // the only instance for IsUnion used to avoid overhead + val singleton: IsUnion[Any] = new IsUnion[Any] {} + + transparent inline given derived[A]: IsUnion[A] = ${ deriveImpl[A] } + + private def deriveImpl[A](using quotes: Quotes, t: Type[A]): Expr[IsUnion[A]] = { + import quotes.reflect.* + val tpe: TypeRepr = TypeRepr.of[A] + tpe.dealias match { + case o: OrType => '{ IsUnion.singleton.asInstanceOf[IsUnion[A]] }.asExprOf[IsUnion[A]] + case other => report.errorAndAbort(s"${tpe.show} is not a Union") + } + } + +} diff --git a/formson/test/src/ba/sake/formson/FormDataParseSuite.scala b/formson/test/src/ba/sake/formson/FormDataParseSuite.scala index 335055d..9386fda 100644 --- a/formson/test/src/ba/sake/formson/FormDataParseSuite.scala +++ b/formson/test/src/ba/sake/formson/FormDataParseSuite.scala @@ -20,9 +20,10 @@ class FormDataParseSuite extends munit.FunSuite { "int" -> Seq("42").map(FormValue.Str.apply), "uuid" -> Seq(uuid.toString).map(FormValue.Str.apply), "file" -> Seq(FormValue.File(file)), - "bytes" -> Seq(FormValue.ByteArray(byteArray)) + "bytes" -> Seq(FormValue.ByteArray(byteArray)), + "bool" -> Seq(FormValue.Str("true")) ), - FormSimple("text", None, 42, uuid, file, byteArray) + FormSimple("text", None, 42, uuid, file, byteArray, true) ) ).foreach { case (fdMap, expected) => val res = fdMap.parseFormDataMap[FormSimple] @@ -171,7 +172,8 @@ class FormDataParseSuite extends munit.FunSuite { ParseError("int", "is missing", None), ParseError("uuid", "is missing", None), ParseError("file", "is missing", None), - ParseError("bytes", "is missing", None) + ParseError("bytes", "is missing", None), + ParseError("bool", "is missing", None) ) ) } @@ -183,7 +185,8 @@ class FormDataParseSuite extends munit.FunSuite { "int" -> Seq("not_an_int").map(FormValue.Str.apply), "uuid" -> Seq("uuidddd_NOT").map(FormValue.Str.apply), "file" -> Seq(), - "bytes" -> Seq() + "bytes" -> Seq(), + "bool" -> Seq() ) .parseFormDataMap[FormSimple] } @@ -194,7 +197,8 @@ class FormDataParseSuite extends munit.FunSuite { ParseError("int", "invalid Int", Some("not_an_int")), ParseError("uuid", "invalid UUID", Some("uuidddd_NOT")), ParseError("file", "is missing", None), - ParseError("bytes", "is missing", None) + ParseError("bytes", "is missing", None), + ParseError("bool", "is missing", None) ) ) } @@ -238,4 +242,33 @@ class FormDataParseSuite extends munit.FunSuite { ) } } + + test("parseFormDataMap named tuple") { + val res = SeqMap("q" -> Seq("searchme").map(FormValue.Str.apply), "page" -> Seq("42").map(FormValue.Str.apply)) + .parseFormDataMap[(q: String, page: Int)] + assertEquals(res, (q = "searchme", page = 42)) + } + + test("parse union type") { + locally { + val res = SeqMap("id" -> Seq("myid1").map(FormValue.Str.apply)).parseFormDataMap[(id: String | Int)] + assertEquals(res, (id = "myid1")) + } + locally { + val res = SeqMap("stuff" -> Seq("true").map(FormValue.Str.apply)).parseFormDataMap[(stuff: Int | Boolean)] + assertEquals(res, (stuff = true)) + } + locally { + val res1 = SeqMap("color" -> Seq("Red").map(FormValue.Str.apply)).parseFormDataMap[FormEnum | FormSeq] + assertEquals(res1, FormEnum(Color.Red)) + val res2 = SeqMap("a" -> Seq("Red").map(FormValue.Str.apply)).parseFormDataMap[FormEnum | FormSeq] + assertEquals(res2, FormSeq(Seq("Red"))) + } + locally { // combining named tuples with a union + val res1 = SeqMap("firstname" -> Seq("Mujo").map(FormValue.Str.apply)).parseFormDataMap[(firstname: String) | (lastname: String)] + assertEquals(res1, (firstname = "Mujo")) + val res2 = SeqMap("lastname" -> Seq("Hrnjica").map(FormValue.Str.apply)).parseFormDataMap[(firstname: String) | (lastname: String)] + assertEquals(res2, (lastname = "Hrnjica")) + } + } } diff --git a/formson/test/src/ba/sake/formson/FormDataWriteSuite.scala b/formson/test/src/ba/sake/formson/FormDataWriteSuite.scala index 942d529..704a6cf 100644 --- a/formson/test/src/ba/sake/formson/FormDataWriteSuite.scala +++ b/formson/test/src/ba/sake/formson/FormDataWriteSuite.scala @@ -21,24 +21,26 @@ class FormDataWriteSuite extends munit.FunSuite { test("toFormDataMap should write simple case class") { Seq[(FormSimple, FormDataMap)]( ( - FormSimple("text", None, 42, uuid, file, byteArray), + FormSimple("text", None, 42, uuid, file, byteArray, true), SeqMap( "str" -> Seq("text").map(FormValue.Str.apply), "int" -> Seq("42").map(FormValue.Str.apply), "uuid" -> Seq(uuid.toString).map(FormValue.Str.apply), "file" -> Seq(FormValue.File(file)), - "bytes" -> Seq(FormValue.ByteArray(byteArray)) + "bytes" -> Seq(FormValue.ByteArray(byteArray)), + "bool" -> Seq(FormValue.Str("true")) ) ), ( - FormSimple("text", Some("strOptVal"), 42, uuid, file, byteArray), + FormSimple("text", Some("strOptVal"), 42, uuid, file, byteArray, true), SeqMap( "str" -> Seq("text").map(FormValue.Str.apply), "strOpt" -> Seq("strOptVal").map(FormValue.Str.apply), "int" -> Seq("42").map(FormValue.Str.apply), "uuid" -> Seq(uuid.toString).map(FormValue.Str.apply), "file" -> Seq(FormValue.File(file)), - "bytes" -> Seq(FormValue.ByteArray(byteArray)) + "bytes" -> Seq(FormValue.ByteArray(byteArray)), + "bool" -> Seq(FormValue.Str("true")) ) ) ).foreach { case (data, expected) => @@ -110,4 +112,9 @@ class FormDataWriteSuite extends munit.FunSuite { ) } + test("toFormDataMap should write named tuple to string") { + val res1 = (q = "searchme", page = 42).toFormDataMap() + assertEquals(res1, SeqMap("q" -> Seq(FormValue.Str("searchme")), "page" -> Seq(FormValue.Str("42")))) + } + } diff --git a/formson/test/src/ba/sake/formson/types.scala b/formson/test/src/ba/sake/formson/types.scala index 5131903..f205091 100644 --- a/formson/test/src/ba/sake/formson/types.scala +++ b/formson/test/src/ba/sake/formson/types.scala @@ -20,8 +20,15 @@ enum Annot1 derives FormDataRW: case A case B(x: String) -case class FormSimple(str: String, strOpt: Option[String], int: Int, uuid: UUID, file: Path, bytes: Array[Byte]) - derives FormDataRW +case class FormSimple( + str: String, + strOpt: Option[String], + int: Int, + uuid: UUID, + file: Path, + bytes: Array[Byte], + bool: Boolean +) derives FormDataRW case class FormSimpleReservedChars(`what%the&stu$f?@[]`: String) derives FormDataRW case class FormEnum(color: Color) derives FormDataRW diff --git a/mill b/mill index 15b007c..17ceb7f 100755 --- a/mill +++ b/mill @@ -39,7 +39,7 @@ if [ "$1" = "--setup-completions" ] ; then fi if [ -z "${DEFAULT_MILL_VERSION}" ] ; then - DEFAULT_MILL_VERSION=1.0.0-RC3 + DEFAULT_MILL_VERSION=1.0.0 fi diff --git a/querson/package.mill b/querson/package.mill new file mode 100644 index 0000000..828671b --- /dev/null +++ b/querson/package.mill @@ -0,0 +1,23 @@ +package build.querson + +import mill.* +import mill.scalalib.* +import build.{SharafPublishModule, ScalaJvmCommonModule, ScalaJSCommonModule, ScalaNativeCommonModule, SharafTestModule} + +object `package` extends Module: + 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: + 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" + ) +end `package` diff --git a/querson/src/ba/sake/querson/IsUnion.scala b/querson/src/ba/sake/querson/IsUnion.scala new file mode 100644 index 0000000..4ecfe49 --- /dev/null +++ b/querson/src/ba/sake/querson/IsUnion.scala @@ -0,0 +1,26 @@ +package ba.sake.querson + +import scala.quoted.* + +// stolen from https://github.com/iRevive/union-derivation/blob/main/modules/core/src/main/scala/io/github/irevive/union/derivation/IsUnion.scala + +@annotation.implicitNotFound("${A} is not a union type") +trait IsUnion[A] + +object IsUnion { + + // the only instance for IsUnion used to avoid overhead + val singleton: IsUnion[Any] = new IsUnion[Any] {} + + transparent inline given derived[A]: IsUnion[A] = ${ deriveImpl[A] } + + private def deriveImpl[A](using quotes: Quotes, t: Type[A]): Expr[IsUnion[A]] = { + import quotes.reflect.* + val tpe: TypeRepr = TypeRepr.of[A] + tpe.dealias match { + case o: OrType => '{ IsUnion.singleton.asInstanceOf[IsUnion[A]] }.asExprOf[IsUnion[A]] + case other => report.errorAndAbort(s"${tpe.show} is not a Union") + } + } + +} diff --git a/querson/src/ba/sake/querson/QueryStringRW.scala b/querson/src/ba/sake/querson/QueryStringRW.scala index 5a1fa58..6f04d0f 100644 --- a/querson/src/ba/sake/querson/QueryStringRW.scala +++ b/querson/src/ba/sake/querson/QueryStringRW.scala @@ -4,8 +4,13 @@ import java.net.* import java.time.* import java.util.UUID -import scala.deriving.* import scala.quoted.* +import scala.compiletime.summonInline +import NamedTuple.AnyNamedTuple +import NamedTuple.Names +import NamedTuple.DropNames +import NamedTuple.withNames +import scala.deriving.Mirror import scala.reflect.ClassTag import scala.collection.mutable.ArrayDeque import scala.util.Try @@ -36,7 +41,7 @@ trait QueryStringRW[T] { } } -object QueryStringRW { +object QueryStringRW extends LowPriorityQueryStringRWInstances { def apply[T](using instance: QueryStringRW[T]): QueryStringRW[T] = instance @@ -341,6 +346,37 @@ object QueryStringRW { case _ => report.errorAndAbort("Sum types are not supported") } + inline given autoderiveUnion[T: IsUnion]: QueryStringRW[T] = ${ deriveUnionTC[T] } + + private def deriveUnionTC[T: Type](using Quotes): Expr[QueryStringRW[T]] = { + import quotes.reflect.* + TypeRepr.of[T] match { + case OrType(left, right) => + left.asType match { + case '[l] => + right.asType match { + case '[r] => + '{ + new QueryStringRW[T] { + override def write(path: String, value: T): QueryStringData = value match { + case a: l => summonInline[QueryStringRW[l]].write(path, a) + case b: r => summonInline[QueryStringRW[r]].write(path, b) + } + override def parse(path: String, qsData: QueryStringData): T = try { + summonInline[QueryStringRW[l]].parse(path, qsData).asInstanceOf[T] + } catch { + case _: QuersonException => + summonInline[QueryStringRW[r]].parse(path, qsData).asInstanceOf[T] + } + } + } + } + } + case _ => + report.errorAndAbort(s"Cannot automatically derive QueryStringRW for non-union type ${Type.show[T]}") + } + } + /* macro utils */ private def summonInstances[Elems: Type](using Quotes): List[Expr[QueryStringRW[?]]] = Type.of[Elems] match @@ -405,3 +441,43 @@ object QueryStringRW { private def parseError(path: String, msg: String): Nothing = throw ParsingException(ParseError(path, msg)) } + +private[querson] trait LowPriorityQueryStringRWInstances { + + inline given autoderiveNamedTuple[T <: AnyNamedTuple]: QueryStringRW[T] = { + val fieldNames = compiletime.constValueTuple[Names[T]].productIterator.asInstanceOf[Iterator[String]].toSeq + val tcInstances = + compiletime + .summonAll[Tuple.Map[DropNames[T], QueryStringRW]] + .productIterator + .asInstanceOf[Iterator[QueryStringRW[Any]]] + .toSeq + deriveNamedTupleTC[T](fieldNames, tcInstances) + } + + private def deriveNamedTupleTC[T](fieldNames: Seq[String], tcInstances: Seq[QueryStringRW[Any]]) = + new QueryStringRW[T] { + override def write(path: String, value: T): QueryStringData = + val fieldValues = value.asInstanceOf[Tuple].productIterator.asInstanceOf[Iterator[Any]] + val qsFields = fieldNames.zip(fieldValues).zip(tcInstances).map { case ((name, v), rw) => + name -> rw.write(s"$path.$name", v) + } + QueryStringData.Obj(qsFields.toMap) + + override def parse(path: String, qsData: QueryStringData): T = qsData match { + case QueryStringData.Obj(fields) => + val fieldMap = fields.toMap + val parsedValues = fieldNames.zip(tcInstances).map { case (name, rw) => + fieldMap.get(name) match { + case Some(jv) => rw.parse(s"$path.$name", jv) + case None => throw QuersonException(s"Missing field '$name' at path '$path'") + } + } + val tupleValue = Tuple.fromArray(parsedValues.toArray) + withNames(tupleValue).asInstanceOf[T] + case _ => + throw QuersonException(s"Expected object at path '$path', found: $qsData") + } + } + +} diff --git a/querson/test/src/ba/sake/querson/QueryStringParseSuite.scala b/querson/test/src/ba/sake/querson/QueryStringParseSuite.scala index 0347479..fb1eacf 100644 --- a/querson/test/src/ba/sake/querson/QueryStringParseSuite.scala +++ b/querson/test/src/ba/sake/querson/QueryStringParseSuite.scala @@ -237,6 +237,34 @@ class QueryStringParseSuite extends munit.FunSuite { assertEquals(res, other_package.PageReq(42)) } + test("parse named tuple") { + val res = Map("q" -> Seq("searchme"), "page" -> Seq("42")).parseQueryStringMap[(q: String, page: Int)] + assertEquals(res, (q = "searchme", page = 42)) + } + + test("parse union type") { + locally { + val res = Map("id" -> Seq("myid1")).parseQueryStringMap[(id: String | Int)] + assertEquals(res, (id = "myid1")) + } + locally { + val res = Map("stuff" -> Seq("true")).parseQueryStringMap[(stuff: Int | Boolean)] + assertEquals(res, (stuff = true)) + } + locally { + val res1 = Map("color" -> Seq("Red")).parseQueryStringMap[QueryEnum | QuerySeq] + assertEquals(res1, QueryEnum(Color.Red)) + val res2 = Map("a" -> Seq("Red")).parseQueryStringMap[QueryEnum | QuerySeq] + assertEquals(res2, QuerySeq(Seq("Red"))) + } + locally { // combining named tuples with a union + val res1 = Map("firstname" -> Seq("Mujo")).parseQueryStringMap[(firstname: String) | (lastname: String)] + assertEquals(res1, (firstname = "Mujo")) + val res2 = Map("lastname" -> Seq("Hrnjica")).parseQueryStringMap[(firstname: String) | (lastname: String)] + assertEquals(res2, (lastname = "Hrnjica")) + } + } + } package other_package_givens { diff --git a/querson/test/src/ba/sake/querson/QueryStringWriteSuite.scala b/querson/test/src/ba/sake/querson/QueryStringWriteSuite.scala index ebffe69..ee4360b 100644 --- a/querson/test/src/ba/sake/querson/QueryStringWriteSuite.scala +++ b/querson/test/src/ba/sake/querson/QueryStringWriteSuite.scala @@ -67,4 +67,9 @@ class QueryStringWriteSuite extends munit.FunSuite { assertEquals(res1, "q=default") } + test("toQueryString should write named tuple to string") { + val res1 = (q = "searchme", page = 42).toQueryString() + assertEquals(res1, "q=searchme&page=42") + } + } diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..f7672c9 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,18 @@ +#!/bin/bash +set -eo pipefail + +VERSION="$1" +if [[ -z "$VERSION" ]]; then + echo "Error: Must specify version!" + exit 1 +fi + +if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then + echo "Tag '$VERSION' already exists!" + exit 1 +fi + +git commit --allow-empty -am "Release $VERSION" +git tag -a $VERSION -m "Release $VERSION" +git push --atomic origin main $VERSION + diff --git a/sharaf-core/src/ba/sake/sharaf/ResponseWritable.scala b/sharaf-core/src/ba/sake/sharaf/ResponseWritable.scala index b86d6e3..fa21a6f 100644 --- a/sharaf-core/src/ba/sake/sharaf/ResponseWritable.scala +++ b/sharaf-core/src/ba/sake/sharaf/ResponseWritable.scala @@ -9,6 +9,8 @@ import ba.sake.tupson.{JsonRW, toJson} private val ContentTypeHttpString = HttpString(HeaderNames.ContentType) private val ContentDispositionHttpString = HttpString(HeaderNames.ContentDisposition) +private val CacheControlHttpString = HttpString(HeaderNames.CacheControl) +private val ConnectionHttpString = HttpString(HeaderNames.Connection) trait ResponseWritable[-T]: def write(value: T, outputStream: OutputStream): Unit @@ -61,9 +63,24 @@ object ResponseWritable extends LowPriResponseWritableInstances { ) } -} + given ResponseWritable[SseSender] with { + override def write(value: SseSender, outputStream: OutputStream): Unit = { + var done = false + while !done do { + val event = value.queue.take() + done = event.isInstanceOf[ServerSentEvent.Done] + outputStream.write(event.sseBytes) + outputStream.flush() + } + } -trait LowPriResponseWritableInstances { + override def headers(value: SseSender): Seq[(HttpString, Seq[String])] = Seq( + ContentTypeHttpString -> Seq("text/event-stream"), + CacheControlHttpString -> Seq("no-cache"), + ConnectionHttpString -> Seq("keep-alive") + ) + } + given ResponseWritable[geny.Writable] with { override def write(value: geny.Writable, outputStream: OutputStream): Unit = value.writeBytesTo(outputStream) @@ -75,3 +92,5 @@ trait LowPriResponseWritableInstances { ) } } + +trait LowPriResponseWritableInstances {} diff --git a/sharaf-core/src/ba/sake/sharaf/sse.scala b/sharaf-core/src/ba/sake/sharaf/sse.scala new file mode 100644 index 0000000..6c755e8 --- /dev/null +++ b/sharaf-core/src/ba/sake/sharaf/sse.scala @@ -0,0 +1,43 @@ +package ba.sake.sharaf + +import java.nio.charset.StandardCharsets + +enum ServerSentEvent { + case Comment(value: String) + case Message( + data: String, + id: Option[String] = None, + event: Option[String] = None, + retry: Option[Int] = None + ) + case Done(event: String = "stop") + + def sseString: String = this match { + case ServerSentEvent.Comment(value) => + s":${value}\n\n" + case msg: ServerSentEvent.Message => + val dataStrings = msg.data.split("\n").map { dataLine => + s"data: ${dataLine}" + } + val msgStr = List( + msg.id.map(i => s"id: ${i}"), + msg.event.map(e => s"event: ${e}"), + Some(dataStrings.mkString("\n")), + msg.retry.map(r => s"retry: ${r}") + ).flatten.mkString("\n") + s"${msgStr}\n\n" + case Done(event) => + s"""event: ${event} + |data:\n\n""".stripMargin + } + + def sseBytes: Array[Byte] = sseString.getBytes(StandardCharsets.UTF_8) +} + +class SseSender { + private[sharaf] val queue = java.util.concurrent.LinkedBlockingQueue[ServerSentEvent] + def send(event: ServerSentEvent): Unit = + queue.put(event) + + // TODO add onComplete, onError +} diff --git a/sharaf-helidon/src/ba/sake/sharaf/helidon/HelidonSharafRequest.scala b/sharaf-helidon/src/ba/sake/sharaf/helidon/HelidonSharafRequest.scala index d4d3354..68a15b0 100644 --- a/sharaf-helidon/src/ba/sake/sharaf/helidon/HelidonSharafRequest.scala +++ b/sharaf-helidon/src/ba/sake/sharaf/helidon/HelidonSharafRequest.scala @@ -7,7 +7,6 @@ import io.helidon.webserver.http.ServerRequest import ba.sake.formson.* import ba.sake.querson.* import ba.sake.sharaf.* -import ba.sake.sharaf.exceptions.* class HelidonSharafRequest(underlyingRequest: ServerRequest) extends Request { diff --git a/sharaf-hepek-components/package.mill b/sharaf-hepek-components/package.mill new file mode 100644 index 0000000..fcee5f4 --- /dev/null +++ b/sharaf-hepek-components/package.mill @@ -0,0 +1,19 @@ +package build.`sharaf-hepek-components` + +import mill.* +import mill.scalalib.* +import build.{SharafPublishModule, ScalaJvmCommonModule, ScalaJSCommonModule, ScalaNativeCommonModule, SharafTestModule} +import build.`sharaf-core` + +object `package` extends Module: + object jvm extends SharafHepekComponentsCoreModule with ScalaJvmCommonModule: + def moduleDeps = Seq(`sharaf-core`.jvm) + //object native extends SharafHepekComponentsCoreModule with ScalaNativeCommonModule: + // def moduleDeps = Seq(`sharaf-core`.native) + + trait SharafHepekComponentsCoreModule extends SharafPublishModule with PlatformScalaModule: + def artifactName = "sharaf-hepek-components" + def mvnDeps = super.mvnDeps() ++ Seq( + mvn"ba.sake::hepek-components:0.33.0" + ) +end `package` diff --git a/sharaf-http4s/src/ba/sake/sharaf/http4s/SharafHttpApp.scala b/sharaf-http4s/src/ba/sake/sharaf/http4s/SharafHttpApp.scala new file mode 100644 index 0000000..4a8ba98 --- /dev/null +++ b/sharaf-http4s/src/ba/sake/sharaf/http4s/SharafHttpApp.scala @@ -0,0 +1,51 @@ +package ba.sake.sharaf.http4s + +import ba.sake.sharaf.* +import cats.data.Kleisli +import cats.effect.* +import org.http4s.* +import org.typelevel.ci.* + +def SharafHttpApp(sharafHandler: SharafHandler) = + Kleisli[IO, Http4sRequest, Http4sResponse] { (http4sRequest: Http4sRequest) => + for + request <- IO.pure(Http4sSharafRequest(http4sRequest)) + path <- IO.pure(Path(http4sRequest.uri.path.segments.map(_.encoded)*)) + method <- IO.pure(http4sRequest.method match { + case org.http4s.Method.GET => HttpMethod.GET + case org.http4s.Method.POST => HttpMethod.POST + case org.http4s.Method.PUT => HttpMethod.PUT + case org.http4s.Method.DELETE => HttpMethod.DELETE + case org.http4s.Method.OPTIONS => HttpMethod.OPTIONS + case org.http4s.Method.PATCH => HttpMethod.PATCH + case org.http4s.Method.HEAD => HttpMethod.HEAD + }) + requestParams <- IO.pure((method, path)) + response <- IO.blocking(sharafHandler.handle(RequestContext(requestParams, request))) + + headers <- IO.pure(Headers(response.headerUpdates.updates.flatMap { + case HeaderUpdate.Set(name, values) => + values.map(value => Header.Raw(CIString(name.value), value)) + case HeaderUpdate.Remove(name) => + Seq.empty // TODO: remove header + })) + + body <- IO.pure(response.body match { + case Some(body) => + fs2.io.readOutputStream(4096)(outputStream => IO.blocking(response.rw.write(body, outputStream))) + case None => + fs2.Stream.empty[IO] + }) + + response <- IO.pure( + Http4sResponse[IO]( + status = Status + .fromInt(response.status.code) + .getOrElse(throw exceptions.SharafException(s"${response.status} can't be converted to org.http4s.Status")), + httpVersion = HttpVersion.`HTTP/1.1`, + headers = headers, + body = body + ) + ) + yield response + } diff --git a/sharaf-http4s/src/ba/sake/sharaf/http4s/SnunitSharafRequest.scala b/sharaf-http4s/src/ba/sake/sharaf/http4s/SnunitSharafRequest.scala new file mode 100644 index 0000000..d614a0b --- /dev/null +++ b/sharaf-http4s/src/ba/sake/sharaf/http4s/SnunitSharafRequest.scala @@ -0,0 +1,59 @@ +package ba.sake.sharaf.http4s + +import cats.effect.* +import cats.effect.unsafe.implicits.global +import ba.sake.formson.* +import ba.sake.querson.* +import ba.sake.sharaf.* +import org.http4s.UrlForm + +import scala.collection.immutable.SeqMap + +class Http4sSharafRequest(underlyingRequest: Http4sRequest) extends Request { + + /* *** HEADERS *** */ + def headers: Map[HttpString, Seq[String]] = + underlyingRequest.headers.headers + .groupBy(_.name) + .map { (name, headers) => + HttpString(name.toString) -> headers.map(_.value) + } + + def cookies: Seq[Cookie] = + underlyingRequest.cookies.map { cookie => + Cookie(name = cookie.name, value = cookie.content) + } + + /* *** QUERY *** */ + override lazy val queryParamsRaw: QueryStringMap = + underlyingRequest.uri.query.multiParams + + /* *** BODY *** */ + override lazy val bodyString: String = + underlyingRequest.body.through(fs2.text.utf8.decode).compile.string.unsafeRunSync() + + def bodyFormRaw: FormDataMap = + val io = for + urlForm <- underlyingRequest.as[UrlForm] + builder <- IO(SeqMap.newBuilder[String, Seq[FormValue]]) + _ <- IO { + urlForm.values.foreach { case (key, values) => + key -> values.map { value => + FormValue.Str(value) + }.toList + } + } + result <- IO(builder.result()) + yield result + + io.unsafeRunSync() + + override def toString(): String = + s"Http4sSharafRequest(headers=${headers}, cookies=${cookies}, queryParamsRaw=${queryParamsRaw}, bodyString=...)" +} + +object Http4sSharafRequest { + + def create(underlyingRequest: Http4sRequest): Http4sSharafRequest = + Http4sSharafRequest(underlyingRequest) +} diff --git a/sharaf-http4s/src/ba/sake/sharaf/http4s/types.scala b/sharaf-http4s/src/ba/sake/sharaf/http4s/types.scala new file mode 100644 index 0000000..991b980 --- /dev/null +++ b/sharaf-http4s/src/ba/sake/sharaf/http4s/types.scala @@ -0,0 +1,8 @@ +package ba.sake.sharaf.http4s + +import cats.effect.* + +type Http4sRequest = org.http4s.Request[IO] + +type Http4sResponse = org.http4s.Response[IO] +val Http4sResponse = org.http4s.Response diff --git a/sharaf-http4s/test/src/ba/sake/sharaf/http4s/SharafHttpAppTest.scala b/sharaf-http4s/test/src/ba/sake/sharaf/http4s/SharafHttpAppTest.scala new file mode 100644 index 0000000..4a63440 --- /dev/null +++ b/sharaf-http4s/test/src/ba/sake/sharaf/http4s/SharafHttpAppTest.scala @@ -0,0 +1,19 @@ +package ba.sake.sharaf.http4s + +import ba.sake.sharaf.* +import ba.sake.sharaf.http4s.* +import cats.effect.unsafe.implicits.global +import org.http4s.client.* + +class SharafHttpAppTest extends munit.FunSuite { + + test("Hello") { + val app = SharafHttpApp(SharafHandler.routes(Routes { case GET -> Path("hello") => + Response.withBody("Hello World!") + })) + + val response = Client.fromHttpApp(app).expect[String]("http://localhost:8080/hello").unsafeRunSync() + + assertEquals(response, "Hello World!") + } +} diff --git a/sharaf-snunit/src/ba/sake/sharaf/snunit/SnunitSharafRequest.scala b/sharaf-snunit/src/ba/sake/sharaf/snunit/SnunitSharafRequest.scala index 79ae10d..99b50e9 100644 --- a/sharaf-snunit/src/ba/sake/sharaf/snunit/SnunitSharafRequest.scala +++ b/sharaf-snunit/src/ba/sake/sharaf/snunit/SnunitSharafRequest.scala @@ -16,8 +16,22 @@ class SnunitSharafRequest(underlyingRequest: SnunitRequest) extends Request { HttpString(headerName) -> Seq(headerValue) } - def cookies: Seq[Cookie] = ??? // TODO - // underlyingHttpServerExchange.requestCookies().asScala.map(CookieUtils.fromUndertow).toSeq + def cookies: Seq[Cookie] = + val builder = Seq.newBuilder[Cookie] + val underlyingHeaders = underlyingRequest.headers + // TODO: Use underlyingRequest.cookieFieldIndex when available + underlyingHeaders.foreach { + case ("Cookie", cookieString) => + cookieString.split(';').foreach { + case s"$n=$v" => + val name = n.trim() + val value = v.trim() + builder += Cookie(name = name, value = value) + case _ => + } + case _ => + } + builder.result() /* *** QUERY *** */ override lazy val queryParamsRaw: QueryStringMap = diff --git a/sharaf-undertow/test/src/ba/sake/sharaf/undertow/CookiesTest.scala b/sharaf-undertow/test/src/ba/sake/sharaf/undertow/CookiesTest.scala index 0cfffed..c51c8f8 100644 --- a/sharaf-undertow/test/src/ba/sake/sharaf/undertow/CookiesTest.scala +++ b/sharaf-undertow/test/src/ba/sake/sharaf/undertow/CookiesTest.scala @@ -27,7 +27,7 @@ class CookiesTest extends munit.FunSuite { val javaClient = java.net.http.HttpClient.newBuilder().cookieHandler(cookieHandler).build() val statefulBackend = sttp.client4.httpclient.HttpClientSyncBackend.usingClient(javaClient) quickRequest.get(uri"${baseUrl}/settingCookie").send(statefulBackend) - val cookie = cookieHandler.getCookieStore.get(uri"${baseUrl}/getopt-session-value".toJavaUri).getFirst + val cookie = cookieHandler.getCookieStore.get(uri"${baseUrl}/getopt-session-value".toJavaUri).iterator().next() assertEquals(cookie.getValue, "cookie1Value") assertEquals(cookie.getMaxAge, -1L) // does not expire } diff --git a/validson/package.mill b/validson/package.mill new file mode 100644 index 0000000..b726824 --- /dev/null +++ b/validson/package.mill @@ -0,0 +1,23 @@ +package build.validson + +import mill.* +import mill.scalalib.* +import build.{SharafPublishModule, ScalaJvmCommonModule, ScalaJSCommonModule, ScalaNativeCommonModule, SharafTestModule} + +object `package` extends Module: + 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: + def artifactName = "validson" + def pomSettings = super.pomSettings().copy(description = "Sharaf validation library") + def mvnDeps = super.mvnDeps() ++ Seq( + mvn"com.lihaoyi::sourcecode::0.4.2" + ) +end `package`