From d101e5c0bf27a44cd189a6207166f5cd511865a1 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Wed, 9 Jul 2025 13:40:08 +0200 Subject: [PATCH 01/30] Update docs and examples to 0.13.0 --- docs/_data/project.yaml | 2 +- docs/_includes/hello.sc | 2 +- docs/_includes/path_params.sc | 2 +- docs/_includes/query_params.sc | 2 +- docs/_includes/static_files.sc | 2 +- docs/_includes/validation.sc | 2 +- docs/content/tutorials/forms.md | 2 +- docs/content/tutorials/html.md | 2 +- docs/content/tutorials/htmx.md | 2 +- docs/content/tutorials/json.md | 2 +- docs/content/tutorials/quickstart.md | 4 ++-- docs/content/tutorials/sql.md | 2 +- examples/fullstack/src/views/ShowFormPage.scala | 1 - examples/htmx/htmx_active_search.sc | 2 +- examples/htmx/htmx_animations.sc | 3 +-- examples/htmx/htmx_bulk_update.sc | 4 ++-- examples/htmx/htmx_cascading_selects.sc | 4 ++-- examples/htmx/htmx_click_edit.sc | 4 ++-- examples/htmx/htmx_click_to_load.sc | 4 ++-- examples/htmx/htmx_delete_row.sc | 2 +- examples/htmx/htmx_dialogs_bootstrap_form.sc | 2 +- examples/htmx/htmx_dialogs_browser.sc | 2 +- examples/htmx/htmx_edit_row.sc | 4 ++-- examples/htmx/htmx_file_upload_js.sc | 2 +- examples/htmx/htmx_infinite_scroll.sc | 4 ++-- examples/htmx/htmx_inline_validation.sc | 3 +-- examples/htmx/htmx_lazy_load.sc | 2 +- examples/htmx/htmx_load_snippet.sc | 2 +- examples/htmx/htmx_progress_bar.sc | 2 +- examples/htmx/htmx_tabs_hateoas.sc | 2 +- examples/scala-cli/demo.sc | 2 +- examples/scala-cli/form_handling.sc | 2 +- examples/scala-cli/hello.sc | 2 +- examples/scala-cli/html.sc | 2 +- examples/scala-cli/html_hepek.sc | 2 +- examples/scala-cli/html_scalatags.sc | 2 +- examples/scala-cli/json_api.sc | 2 +- examples/scala-cli/path_params.sc | 2 +- examples/scala-cli/query_params.sc | 2 +- examples/scala-cli/sql_db.sc | 2 +- examples/scala-cli/static_files.sc | 2 +- examples/scala-cli/validation.sc | 2 +- 42 files changed, 48 insertions(+), 51 deletions(-) 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/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/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/content/tutorials/forms.md b/docs/content/tutorials/forms.md index 80f7b1b..1de167f 100644 --- a/docs/content/tutorials/forms.md +++ b/docs/content/tutorials/forms.md @@ -33,7 +33,7 @@ Create a file `form_handling.sc` and paste this code into it: ```scala //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep {{site.data.project.artifact.org}}::{{site.data.project.artifact.name}}:{{site.data.project.artifact.version}} import ba.sake.formson.FormDataRW import ba.sake.sharaf.{*, given} diff --git a/docs/content/tutorials/html.md b/docs/content/tutorials/html.md index 7e9dc01..2694692 100644 --- a/docs/content/tutorials/html.md +++ b/docs/content/tutorials/html.md @@ -38,7 +38,7 @@ Create a file `html.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.sharaf.{*, given} import ba.sake.sharaf.undertow.UndertowSharafServer diff --git a/docs/content/tutorials/htmx.md b/docs/content/tutorials/htmx.md index 5154fe5..c6f189a 100644 --- a/docs/content/tutorials/htmx.md +++ b/docs/content/tutorials/htmx.md @@ -40,7 +40,7 @@ Create a file `htmx_load_snippet.sc` and paste this code into it: ```scala //> using scala "3.7.0" -//> using dep ba.sake::sharaf-undertow:0.12.1 +//> using dep {{site.data.project.artifact.org}}::{{site.data.project.artifact.name}}:{{site.data.project.artifact.version}} import ba.sake.sharaf.{*, given} import ba.sake.sharaf.undertow.UndertowSharafServer 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..3fb0c2c 100644 --- a/docs/content/tutorials/quickstart.md +++ b/docs/content/tutorials/quickstart.md @@ -40,8 +40,8 @@ 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 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/examples/fullstack/src/views/ShowFormPage.scala b/examples/fullstack/src/views/ShowFormPage.scala index 7a347bb..c7bbfd5 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! 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_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/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 From 45b5f7bd7f995980388ce7ab3ac7e9117a31ae65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sakib=20Had=C5=BEiavdi=C4=87?= Date: Wed, 9 Jul 2025 14:09:00 +0200 Subject: [PATCH 02/30] Update ghpages.yml --- .github/workflows/ghpages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ghpages.yml b/.github/workflows/ghpages.yml index b9a6d6c..e3a9369 100644 --- a/.github/workflows/ghpages.yml +++ b/.github/workflows/ghpages.yml @@ -11,7 +11,7 @@ permissions: jobs: build-and-deploy: - runs-on: ubuntu-latest + runs-on: macos-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v3 From a6a0550e601815e280777daf5e89d2204dd647a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sakib=20Had=C5=BEiavdi=C4=87?= Date: Wed, 9 Jul 2025 14:15:35 +0200 Subject: [PATCH 03/30] Update ghpages.yml --- .github/workflows/ghpages.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ghpages.yml b/.github/workflows/ghpages.yml index e3a9369..9fc1600 100644 --- a/.github/workflows/ghpages.yml +++ b/.github/workflows/ghpages.yml @@ -23,8 +23,8 @@ jobs: 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 + 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 flatmark build -i docs - name: Deploy uses: JamesIves/github-pages-deploy-action@v4 From f0d7d23647d967d9c439b4da2111c5f28cce43ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sakib=20Had=C5=BEiavdi=C4=87?= Date: Wed, 9 Jul 2025 14:16:41 +0200 Subject: [PATCH 04/30] Update ghpages.yml --- .github/workflows/ghpages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ghpages.yml b/.github/workflows/ghpages.yml index 9fc1600..f253746 100644 --- a/.github/workflows/ghpages.yml +++ b/.github/workflows/ghpages.yml @@ -24,7 +24,7 @@ jobs: run: | FLATMARK_VERSION=0.0.24 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 + sudo installer -verbose -pkg flatmark.pkg -target / flatmark build -i docs - name: Deploy uses: JamesIves/github-pages-deploy-action@v4 From 0b0b4c0a56c8646e1469d9bbb06653f1b4d91ea7 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Thu, 10 Jul 2025 14:54:21 +0200 Subject: [PATCH 05/30] Update flatmark to 0.0.25 --- .github/workflows/ghpages.yml | 6 +-- docs/_includes/form_handling.sc | 36 +++++++++++++++++ docs/_includes/html.sc | 40 ++++++++++++++++++ docs/_includes/htmx_load_snippet.sc | 34 ++++++++++++++++ docs/_layouts/search-results.html | 63 +++++++++++++++++++++++++++++ docs/content/howtos/upload-file.md | 12 +++--- docs/content/tutorials/forms.md | 43 +------------------- docs/content/tutorials/html.md | 47 +-------------------- docs/content/tutorials/htmx.md | 45 +-------------------- docs/copy-examples.ps1 | 5 ++- 10 files changed, 187 insertions(+), 144 deletions(-) create mode 100644 docs/_includes/form_handling.sc create mode 100644 docs/_includes/html.sc create mode 100644 docs/_includes/htmx_load_snippet.sc create mode 100644 docs/_layouts/search-results.html diff --git a/.github/workflows/ghpages.yml b/.github/workflows/ghpages.yml index f253746..6dcb057 100644 --- a/.github/workflows/ghpages.yml +++ b/.github/workflows/ghpages.yml @@ -14,15 +14,11 @@ jobs: 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 + 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 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/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/_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 1de167f..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 {{site.data.project.artifact.org}}::{{site.data.project.artifact.name}}:{{site.data.project.artifact.version}} - -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 2694692..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 {{site.data.project.artifact.org}}::{{site.data.project.artifact.name}}:{{site.data.project.artifact.version}} - -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 c6f189a..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 {{site.data.project.artifact.org}}::{{site.data.project.artifact.name}}:{{site.data.project.artifact.version}} - -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/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" From 630a3e0a2c087e6a9d50e5de3db4a284b70cd0d7 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Thu, 10 Jul 2025 17:10:45 +0200 Subject: [PATCH 06/30] Update user-pass-form example pac4j to v6 --- build.mill | 4 ++-- .../src/userpassform/AppRoutes.scala | 14 +++++++++----- .../src/userpassform/SecurityService.scala | 6 ++---- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/build.mill b/build.mill index a37f4c6..256c0f6 100644 --- a/build.mill +++ b/build.mill @@ -178,8 +178,8 @@ object examples extends mill.Module: 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.pac4j:undertow-pac4j:6.0.0", + mvn"org.pac4j:pac4j-http:6.2.0", mvn"org.mindrot:jbcrypt:0.4" ) object test extends ScalaTests with SharafTestModule 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) From dff1a1e2a686777bef6f17538334bf2651a57792 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Fri, 11 Jul 2025 12:03:42 +0200 Subject: [PATCH 07/30] Update oauth2 example pac4j to v6 --- build.mill | 70 ++++++++++++++++--- .../fullstack/src/views/ShowFormPage.scala | 2 +- examples/oauth2/src/AppModule.scala | 18 +++-- examples/oauth2/src/AppRoutes.scala | 2 +- examples/oauth2/src/CustomCallbackLogic.scala | 11 ++- examples/oauth2/src/CustomSecurityLogic.scala | 38 ---------- examples/oauth2/src/Main.scala | 14 ++-- examples/oauth2/src/SecurityConfig.scala | 2 + examples/oauth2/src/SecurityService.scala | 6 +- examples/oauth2/test/src/AppTests.scala | 13 ++-- .../oauth2/test/src/IntegrationTest.scala | 21 ++---- mill | 2 +- .../sharaf/helidon/HelidonSharafRequest.scala | 1 - 13 files changed, 101 insertions(+), 99 deletions(-) delete mode 100644 examples/oauth2/src/CustomSecurityLogic.scala diff --git a/build.mill b/build.mill index 256c0f6..669fb71 100644 --- a/build.mill +++ b/build.mill @@ -1,10 +1,11 @@ -//| mill-version: 1.0.0-RC3 +//| mill-version: 1.0.0 package build + 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" @@ -13,18 +14,22 @@ object V: object `sharaf-core` extends Module: object jvm extends SharafCoreModule with ScalaJvmCommonModule: def moduleDeps = Seq(querson.jvm, formson.jvm, validson.jvm) + def mvnDeps = super.mvnDeps() ++ Seq( // TODO move to common when published for native 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 def mvnDeps = super.mvnDeps() ++ Seq( mvn"ba.sake::tupson::${V.tupson}", @@ -34,23 +39,29 @@ object `sharaf-core` extends Module: object `sharaf-undertow` extends SharafPublishModule: def artifactName = "sharaf-undertow" + def mvnDeps = super.mvnDeps() ++ Seq( mvn"io.undertow:undertow-core:2.3.18.Final", 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-helidon` extends SharafPublishModule: def artifactName = "sharaf-helidon" + def mvnDeps = super.mvnDeps() ++ Seq( mvn"io.helidon.webserver:helidon-webserver:4.2.2", mvn"io.helidon.config:helidon-config-yaml:4.2.2" ) + def moduleDeps = Seq(`sharaf-core`.jvm) + object test extends ScalaTests with SharafTestModule: def mvnDeps = super.mvnDeps() ++ Seq( mvn"com.lihaoyi::requests:0.9.0" @@ -59,19 +70,22 @@ object `sharaf-helidon` extends SharafPublishModule: object `sharaf-snunit` extends ScalaNativeCommonModule with SharafPublishModule: def artifactName = "sharaf-snunit" + def mvnDeps = super.mvnDeps() ++ Seq( mvn"com.github.lolgab::snunit::0.10.3" ) + def moduleDeps = Seq(`sharaf-core`.native) object `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) - + // 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}" ) @@ -79,13 +93,18 @@ object `sharaf-hepek-components` extends Module: 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" ) @@ -93,13 +112,17 @@ object querson extends Module: 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 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" ) @@ -108,19 +131,25 @@ object formson extends Module: 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( organization = "ba.sake", url = "https://github.com/sake92/sharaf", @@ -135,6 +164,7 @@ trait SharafPublishModule extends SharafCommonModule with SonatypeCentralPublish trait SharafCommonModule extends ScalaModule: def scalaVersion = "3.7.1" + def scalacOptions = super.scalacOptions() ++ Seq( "-Yretain-trees", // needed for default parameters "-deprecation", @@ -142,16 +172,25 @@ trait SharafCommonModule extends ScalaModule: "-explain" ) + override def runClasspath: T[Seq[PathRef]] = Task { + localClasspath() ++ + transitiveLocalClasspath() ++ + resolvedRunMvnDeps().toSeq ++ + super.runClasspath() + } + trait ScalaJvmCommonModule extends ScalaModule trait ScalaJSCommonModule extends ScalaJSModule: def scalaJSVersion = "1.19.0" + def mvnDeps = super.mvnDeps() ++ Seq( mvn"io.github.cquiroz::scala-java-time::2.6.0" ) trait ScalaNativeCommonModule extends ScalaNativeModule: def scalaNativeVersion = "0.5.7" + def mvnDeps = super.mvnDeps() ++ Seq( mvn"io.github.cquiroz::scala-java-time::2.6.0" ) @@ -171,23 +210,32 @@ trait SharafExampleModule extends SharafCommonModule: 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:6.0.0", - mvn"org.pac4j:pac4j-http:6.2.0", + mvn"org.pac4j:pac4j-http:6.1.2", 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" + 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( diff --git a/examples/fullstack/src/views/ShowFormPage.scala b/examples/fullstack/src/views/ShowFormPage.scala index c7bbfd5..0e6290a 100644 --- a/examples/fullstack/src/views/ShowFormPage.scala +++ b/examples/fullstack/src/views/ShowFormPage.scala @@ -29,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/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!
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..53b78e9 100644 --- a/examples/oauth2/src/SecurityConfig.scala +++ b/examples/oauth2/src/SecurityConfig.scala @@ -3,6 +3,7 @@ package demo import scala.jdk.CollectionConverters.* import org.pac4j.core.client.Clients import org.pac4j.core.config.Config +import org.pac4j.core.engine.DefaultSecurityLogic import org.pac4j.core.matching.matcher.* class SecurityConfig(clients: Clients) { @@ -23,6 +24,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/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/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 { From 4a34ab257b3d26917e35fd31389153145bf22b1f Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Fri, 11 Jul 2025 12:28:38 +0200 Subject: [PATCH 08/30] Split mill build into multiple files --- build.mill | 138 +-------------------------- examples/package.mill | 46 +++++++++ formson/package.mill | 23 +++++ querson/package.mill | 23 +++++ sharaf-hepek-components/package.mill | 19 ++++ validson/package.mill | 23 +++++ 6 files changed, 137 insertions(+), 135 deletions(-) create mode 100644 examples/package.mill create mode 100644 formson/package.mill create mode 100644 querson/package.mill create mode 100644 sharaf-hepek-components/package.mill create mode 100644 validson/package.mill diff --git a/build.mill b/build.mill index 669fb71..b1331c4 100644 --- a/build.mill +++ b/build.mill @@ -1,35 +1,30 @@ //| mill-version: 1.0.0 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.util.VcsVersion object V: val tupson = "0.13.0" - val hepek = "0.33.0" object `sharaf-core` extends Module: object jvm extends SharafCoreModule with ScalaJvmCommonModule: def moduleDeps = Seq(querson.jvm, formson.jvm, validson.jvm) - def mvnDeps = super.mvnDeps() ++ Seq( // TODO move to common when published for native 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 def mvnDeps = super.mvnDeps() ++ Seq( mvn"ba.sake::tupson::${V.tupson}", @@ -39,14 +34,11 @@ object `sharaf-core` extends Module: object `sharaf-undertow` extends SharafPublishModule: def artifactName = "sharaf-undertow" - def mvnDeps = super.mvnDeps() ++ Seq( mvn"io.undertow:undertow-core:2.3.18.Final", mvn"ba.sake::tupson-config:${V.tupson}" ) - def moduleDeps = Seq(`sharaf-core`.jvm) - object test extends ScalaTests with SharafTestModule: def mvnDeps = super.mvnDeps() ++ Seq( mvn"org.webjars:jquery:3.7.1" @@ -54,14 +46,11 @@ object `sharaf-undertow` extends SharafPublishModule: object `sharaf-helidon` extends SharafPublishModule: def artifactName = "sharaf-helidon" - def mvnDeps = super.mvnDeps() ++ Seq( mvn"io.helidon.webserver:helidon-webserver:4.2.2", mvn"io.helidon.config:helidon-config-yaml:4.2.2" ) - def moduleDeps = Seq(`sharaf-core`.jvm) - object test extends ScalaTests with SharafTestModule: def mvnDeps = super.mvnDeps() ++ Seq( mvn"com.lihaoyi::requests:0.9.0" @@ -70,86 +59,13 @@ object `sharaf-helidon` extends SharafPublishModule: object `sharaf-snunit` extends ScalaNativeCommonModule with SharafPublishModule: def artifactName = "sharaf-snunit" - def mvnDeps = super.mvnDeps() ++ Seq( mvn"com.github.lolgab::snunit::0.10.3" ) - def moduleDeps = Seq(`sharaf-core`.native) -object `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( organization = "ba.sake", url = "https://github.com/sake92/sharaf", @@ -164,14 +80,12 @@ trait SharafPublishModule extends SharafCommonModule with SonatypeCentralPublish trait SharafCommonModule extends ScalaModule: def scalaVersion = "3.7.1" - def scalacOptions = super.scalacOptions() ++ Seq( "-Yretain-trees", // needed for default parameters "-deprecation", "-Wunused:all", "-explain" ) - override def runClasspath: T[Seq[PathRef]] = Task { localClasspath() ++ transitiveLocalClasspath() ++ @@ -183,14 +97,12 @@ trait ScalaJvmCommonModule extends ScalaModule trait ScalaJSCommonModule extends ScalaJSModule: def scalaJSVersion = "1.19.0" - def mvnDeps = super.mvnDeps() ++ Seq( mvn"io.github.cquiroz::scala-java-time::2.6.0" ) trait ScalaNativeCommonModule extends ScalaNativeModule: def scalaNativeVersion = "0.5.7" - def mvnDeps = super.mvnDeps() ++ Seq( mvn"io.github.cquiroz::scala-java-time::2.6.0" ) @@ -200,47 +112,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:6.0.0", - mvn"org.pac4j:pac4j-http:6.1.2", - 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: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`) -end examples \ No newline at end of file diff --git a/examples/package.mill b/examples/package.mill new file mode 100644 index 0000000..782e70f --- /dev/null +++ b/examples/package.mill @@ -0,0 +1,46 @@ +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 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`) +end `package` \ No newline at end of file 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/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/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/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` From c5336f09ddcaee1242398cc82e3b66714fb0faa7 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Fri, 11 Jul 2025 16:04:20 +0200 Subject: [PATCH 09/30] Add JWT Pac4j example --- examples/jwt/README.md | 17 +++++++ examples/jwt/src/Main.scala | 91 +++++++++++++++++++++++++++++++++++++ examples/package.mill | 9 ++++ 3 files changed, 117 insertions(+) create mode 100644 examples/jwt/README.md create mode 100644 examples/jwt/src/Main.scala 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..57d0334 --- /dev/null +++ b/examples/jwt/src/Main.scala @@ -0,0 +1,91 @@ +package jwt + +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 ba.sake.sharaf.* +import ba.sake.sharaf.undertow.* +import com.nimbusds.jwt.JWTClaimsSet +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 java.util.Optional + +@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 +} \ No newline at end of file diff --git a/examples/package.mill b/examples/package.mill index 782e70f..db8b3cc 100644 --- a/examples/package.mill +++ b/examples/package.mill @@ -28,6 +28,15 @@ object `package` extends Module: 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`) From 240712676bb072e6948d89035ff4a32d55501eaf Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Fri, 11 Jul 2025 16:10:11 +0200 Subject: [PATCH 10/30] Update quickstart examples --- docs/content/tutorials/quickstart.md | 5 ++++- examples/jwt/src/Main.scala | 8 ++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/content/tutorials/quickstart.md b/docs/content/tutorials/quickstart.md index 3fb0c2c..e990406 100644 --- a/docs/content/tutorials/quickstart.md +++ b/docs/content/tutorials/quickstart.md @@ -45,6 +45,9 @@ scala my_script.sc --scala-option -Yretain-trees - [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 - [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/examples/jwt/src/Main.scala b/examples/jwt/src/Main.scala index 57d0334..aaa198d 100644 --- a/examples/jwt/src/Main.scala +++ b/examples/jwt/src/Main.scala @@ -1,14 +1,12 @@ 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 ba.sake.sharaf.* -import ba.sake.sharaf.undertow.* -import com.nimbusds.jwt.JWTClaimsSet import org.pac4j.core.context.WebContext import org.pac4j.core.context.session.SessionStore import org.pac4j.core.matching.matcher.{DefaultMatchers, PathMatcher} @@ -16,8 +14,10 @@ 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.* -import java.util.Optional +// TODO add a test @main def main(): Unit = val module = JwtModule(8181) From 3032b3acd87ae377cb54612b8b7191fd4049f298 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Sun, 13 Jul 2025 16:56:33 +0200 Subject: [PATCH 11/30] Fix tutorials listing padding --- docs/content/tutorials/index.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 %} From 327754d4aa57e4b78f20dda349b0b1e6acef7087 Mon Sep 17 00:00:00 2001 From: Lorenzo Gabriele Date: Sun, 13 Jul 2025 17:43:58 +0200 Subject: [PATCH 12/30] Support `cookies` in SNUnit (#53) --- .../sharaf/snunit/SnunitSharafRequest.scala | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/sharaf-snunit/src/ba/sake/sharaf/snunit/SnunitSharafRequest.scala b/sharaf-snunit/src/ba/sake/sharaf/snunit/SnunitSharafRequest.scala index 79ae10d..7f9cfeb 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() + result += Cookie(name = name, value = value) + case _ => + } + case _ => + } + builder.result() /* *** QUERY *** */ override lazy val queryParamsRaw: QueryStringMap = From 8f78c1414860aa43c9a480d2dfd0851eea170f62 Mon Sep 17 00:00:00 2001 From: Lorenzo Gabriele Date: Sun, 13 Jul 2025 19:05:43 +0200 Subject: [PATCH 13/30] Fix snunit cookies and compile everything in CI (#54) --- .github/workflows/ci.yml | 5 ++++- .../src/ba/sake/sharaf/snunit/SnunitSharafRequest.scala | 2 +- .../test/src/ba/sake/sharaf/undertow/CookiesTest.scala | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) 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/sharaf-snunit/src/ba/sake/sharaf/snunit/SnunitSharafRequest.scala b/sharaf-snunit/src/ba/sake/sharaf/snunit/SnunitSharafRequest.scala index 7f9cfeb..99b50e9 100644 --- a/sharaf-snunit/src/ba/sake/sharaf/snunit/SnunitSharafRequest.scala +++ b/sharaf-snunit/src/ba/sake/sharaf/snunit/SnunitSharafRequest.scala @@ -26,7 +26,7 @@ class SnunitSharafRequest(underlyingRequest: SnunitRequest) extends Request { case s"$n=$v" => val name = n.trim() val value = v.trim() - result += Cookie(name = name, value = value) + builder += Cookie(name = name, value = value) case _ => } case _ => 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 } From 0232827350b21b90a4012fc37aecaa83fa4d0a1e Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Tue, 7 Oct 2025 20:18:53 +0200 Subject: [PATCH 14/30] Add basic SSE support --- .../src/ba/sake/sharaf/ResponseWritable.scala | 19 +++++++++++++ sharaf-core/src/ba/sake/sharaf/sse.scala | 28 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 sharaf-core/src/ba/sake/sharaf/sse.scala diff --git a/sharaf-core/src/ba/sake/sharaf/ResponseWritable.scala b/sharaf-core/src/ba/sake/sharaf/ResponseWritable.scala index b86d6e3..fc06a4d 100644 --- a/sharaf-core/src/ba/sake/sharaf/ResponseWritable.scala +++ b/sharaf-core/src/ba/sake/sharaf/ResponseWritable.scala @@ -9,6 +9,9 @@ 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,6 +64,22 @@ object ResponseWritable extends LowPriResponseWritableInstances { ) } + given ResponseWritable[geny.Generator[ServerSentEvent]] with { + override def write(value: geny.Generator[ServerSentEvent], outputStream: OutputStream): Unit = { + value.foreach { event => + println(s"Writing '${event.sseString}'") + outputStream.write(event.sseBytes) + outputStream.flush() + } + println(s"ALL DONE") + } + + override def headers(value: geny.Generator[ServerSentEvent]): Seq[(HttpString, Seq[String])] = Seq( + ContentTypeHttpString -> Seq("text/event-stream"), + CacheControlHttpString -> Seq("no-cache"), + ConnectionHttpString -> Seq("keep-alive") + ) + } } 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..fb020d1 --- /dev/null +++ b/sharaf-core/src/ba/sake/sharaf/sse.scala @@ -0,0 +1,28 @@ +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 + ) + + def sseString: String = this match { + case ServerSentEvent.Comment(value) => + s":${value}\n\n" + case msg: ServerSentEvent.Message => + val msgStr = List( + msg.id.map(i => s"id: ${i}"), + msg.event.map(e => s"event: ${e}"), + Some(s"data: ${msg.data}"), + msg.retry.map(r => s"retry: ${r}") + ).flatten.mkString("\n") + s"${msgStr}\n\n" + } + + def sseBytes: Array[Byte] = sseString.getBytes(StandardCharsets.UTF_8) +} From 97a32704c0ce4a245d7b767d902d123f1d3cf3b0 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Tue, 7 Oct 2025 20:27:41 +0200 Subject: [PATCH 15/30] Release 0.13.1 --- DEV.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/DEV.md b/DEV.md index d53958c..e5b1bfd 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.13.1 ``` From 274b20bb894eb0d39db44f6968f931a4662798e5 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Wed, 8 Oct 2025 16:24:51 +0200 Subject: [PATCH 16/30] Add SseSender --- scripts/release.sh | 18 ++++++++++++++++++ .../src/ba/sake/sharaf/ResponseWritable.scala | 13 +++++++------ sharaf-core/src/ba/sake/sharaf/sse.scala | 10 ++++++++++ 3 files changed, 35 insertions(+), 6 deletions(-) create mode 100755 scripts/release.sh 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 fc06a4d..f5b74fc 100644 --- a/sharaf-core/src/ba/sake/sharaf/ResponseWritable.scala +++ b/sharaf-core/src/ba/sake/sharaf/ResponseWritable.scala @@ -64,17 +64,18 @@ object ResponseWritable extends LowPriResponseWritableInstances { ) } - given ResponseWritable[geny.Generator[ServerSentEvent]] with { - override def write(value: geny.Generator[ServerSentEvent], outputStream: OutputStream): Unit = { - value.foreach { event => - println(s"Writing '${event.sseString}'") + 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() } - println(s"ALL DONE") } - override def headers(value: geny.Generator[ServerSentEvent]): Seq[(HttpString, Seq[String])] = Seq( + override def headers(value: SseSender): Seq[(HttpString, Seq[String])] = Seq( ContentTypeHttpString -> Seq("text/event-stream"), CacheControlHttpString -> Seq("no-cache"), ConnectionHttpString -> Seq("keep-alive") diff --git a/sharaf-core/src/ba/sake/sharaf/sse.scala b/sharaf-core/src/ba/sake/sharaf/sse.scala index fb020d1..9a37af0 100644 --- a/sharaf-core/src/ba/sake/sharaf/sse.scala +++ b/sharaf-core/src/ba/sake/sharaf/sse.scala @@ -10,6 +10,7 @@ enum ServerSentEvent { event: Option[String] = None, retry: Option[Int] = None ) + case Done(event: String = "stop") def sseString: String = this match { case ServerSentEvent.Comment(value) => @@ -22,7 +23,16 @@ enum ServerSentEvent { 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) +} \ No newline at end of file From 4bd47996d992f451b1b9ea966fc5457b3f65ae10 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Wed, 8 Oct 2025 16:25:08 +0200 Subject: [PATCH 17/30] Release 0.13.2 --- DEV.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEV.md b/DEV.md index e5b1bfd..e0dfb2a 100644 --- a/DEV.md +++ b/DEV.md @@ -18,7 +18,7 @@ scala-cli compile examples\scala-cli ```sh # RELEASE -./scripts/release.sh 0.13.1 +./scripts/release.sh 0.13.2 ``` From ad7f77806b895bc0432a5ed3de88398b1fe5bf79 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Wed, 8 Oct 2025 16:31:25 +0200 Subject: [PATCH 18/30] Fix multiline SSE message --- sharaf-core/src/ba/sake/sharaf/sse.scala | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sharaf-core/src/ba/sake/sharaf/sse.scala b/sharaf-core/src/ba/sake/sharaf/sse.scala index 9a37af0..b2d6d62 100644 --- a/sharaf-core/src/ba/sake/sharaf/sse.scala +++ b/sharaf-core/src/ba/sake/sharaf/sse.scala @@ -16,10 +16,13 @@ enum ServerSentEvent { 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(s"data: ${msg.data}"), + Some(dataStrings.mkString("\n")), msg.retry.map(r => s"retry: ${r}") ).flatten.mkString("\n") s"${msgStr}\n\n" From 69ca96abe1862eb06ae17221c62e09f583a8a5f0 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Wed, 8 Oct 2025 16:31:28 +0200 Subject: [PATCH 19/30] Release 0.13.3 From 11ac644145b66f7f909ffebb4bc0c84090d26afd Mon Sep 17 00:00:00 2001 From: Lorenzo Gabriele Date: Sat, 18 Oct 2025 12:29:37 +0200 Subject: [PATCH 20/30] Add http4s module to expose a http4s app (#56) --- build.mill | 23 +++++++- docs/content/tutorials/quickstart.md | 1 + examples/http4s/README.md | 17 ++++++ examples/http4s/src/Main.scala | 23 ++++++++ examples/package.mill | 12 +++- .../ba/sake/sharaf/http4s/SharafHttpApp.scala | 51 ++++++++++++++++ .../sharaf/http4s/SnunitSharafRequest.scala | 59 +++++++++++++++++++ .../src/ba/sake/sharaf/http4s/types.scala | 8 +++ .../sharaf/http4s/SharafHttpAppTest.scala | 19 ++++++ 9 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 examples/http4s/README.md create mode 100644 examples/http4s/src/Main.scala create mode 100644 sharaf-http4s/src/ba/sake/sharaf/http4s/SharafHttpApp.scala create mode 100644 sharaf-http4s/src/ba/sake/sharaf/http4s/SnunitSharafRequest.scala create mode 100644 sharaf-http4s/src/ba/sake/sharaf/http4s/types.scala create mode 100644 sharaf-http4s/test/src/ba/sake/sharaf/http4s/SharafHttpAppTest.scala diff --git a/build.mill b/build.mill index b1331c4..0259a88 100644 --- a/build.mill +++ b/build.mill @@ -1,4 +1,4 @@ -//| mill-version: 1.0.0 +//| mill-version: 1.0.6 package build import mill.* @@ -44,6 +44,23 @@ object `sharaf-undertow` extends SharafPublishModule: 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( @@ -79,7 +96,7 @@ 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", @@ -102,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" ) diff --git a/docs/content/tutorials/quickstart.md b/docs/content/tutorials/quickstart.md index e990406..97627b9 100644 --- a/docs/content/tutorials/quickstart.md +++ b/docs/content/tutorials/quickstart.md @@ -49,5 +49,6 @@ scala my_script.sc --scala-option -Yretain-trees - [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/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/package.mill b/examples/package.mill index db8b3cc..413a4cf 100644 --- a/examples/package.mill +++ b/examples/package.mill @@ -52,4 +52,14 @@ object `package` extends Module: object snunit extends SharafExampleModule with ScalaNativeCommonModule: def moduleDeps = Seq(`sharaf-snunit`) -end `package` \ No newline at end of file + 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/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!") + } +} From b478a7da45ff5c8baac950906c9bbd5d85c826d8 Mon Sep 17 00:00:00 2001 From: Lorenzo Gabriele Date: Sat, 18 Oct 2025 22:05:07 +0200 Subject: [PATCH 21/30] Add `FormDataRW` for `Boolean` (#55) --- formson/src/ba/sake/formson/FormDataRW.scala | 9 +++++++++ .../src/ba/sake/formson/FormDataParseSuite.scala | 14 +++++++++----- .../src/ba/sake/formson/FormDataWriteSuite.scala | 10 ++++++---- formson/test/src/ba/sake/formson/types.scala | 11 +++++++++-- 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/formson/src/ba/sake/formson/FormDataRW.scala b/formson/src/ba/sake/formson/FormDataRW.scala index eeff954..d352696 100644 --- a/formson/src/ba/sake/formson/FormDataRW.scala +++ b/formson/src/ba/sake/formson/FormDataRW.scala @@ -79,6 +79,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) diff --git a/formson/test/src/ba/sake/formson/FormDataParseSuite.scala b/formson/test/src/ba/sake/formson/FormDataParseSuite.scala index 335055d..f014011 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) ) ) } diff --git a/formson/test/src/ba/sake/formson/FormDataWriteSuite.scala b/formson/test/src/ba/sake/formson/FormDataWriteSuite.scala index 942d529..e628f47 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) => 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 From cfb65214e80a3ed13fea5ea090508a0982a794df Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Wed, 29 Oct 2025 16:52:23 +0100 Subject: [PATCH 22/30] Add support for named tuples in querson --- .../src/ba/sake/querson/QueryStringRW.scala | 50 ++++++++++++++++++- .../sake/querson/QueryStringParseSuite.scala | 5 ++ .../sake/querson/QueryStringWriteSuite.scala | 5 ++ sharaf-core/src/ba/sake/sharaf/sse.scala | 2 + 4 files changed, 61 insertions(+), 1 deletion(-) diff --git a/querson/src/ba/sake/querson/QueryStringRW.scala b/querson/src/ba/sake/querson/QueryStringRW.scala index 5a1fa58..f58d900 100644 --- a/querson/src/ba/sake/querson/QueryStringRW.scala +++ b/querson/src/ba/sake/querson/QueryStringRW.scala @@ -6,6 +6,13 @@ 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.NamedTuple +import NamedTuple.withNames +import scala.deriving.Mirror import scala.reflect.ClassTag import scala.collection.mutable.ArrayDeque import scala.util.Try @@ -36,7 +43,7 @@ trait QueryStringRW[T] { } } -object QueryStringRW { +object QueryStringRW extends LowPriorityQueryStringRWInstances { def apply[T](using instance: QueryStringRW[T]): QueryStringRW[T] = instance @@ -405,3 +412,44 @@ object QueryStringRW { private def parseError(path: String, msg: String): Nothing = throw ParsingException(ParseError(path, msg)) } + +private[querson] object LowPriorityQueryStringRWInstances { + + 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 jsonFields = fieldNames.zip(fieldValues).zip(tcInstances).map { case ((name, v), rw) => + name -> rw.write(s"$path.$name", v) + } + QueryStringData.Obj(jsonFields.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") + } + } +} + +private[querson] trait LowPriorityQueryStringRWInstances { + // cache instances + private val namedTupleTCsCache = scala.collection.mutable.Map.empty[ClassTag[?], QueryStringRW[?]] + + inline given autoderiveNamedTuple[T <: AnyNamedTuple](using ct: ClassTag[T]): 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 + namedTupleTCsCache.getOrElseUpdate(ct, LowPriorityQueryStringRWInstances.deriveNamedTupleTC[T](fieldNames, tcInstances)).asInstanceOf[QueryStringRW[T]] + } + +} \ No newline at end of file diff --git a/querson/test/src/ba/sake/querson/QueryStringParseSuite.scala b/querson/test/src/ba/sake/querson/QueryStringParseSuite.scala index 0347479..cd5a96e 100644 --- a/querson/test/src/ba/sake/querson/QueryStringParseSuite.scala +++ b/querson/test/src/ba/sake/querson/QueryStringParseSuite.scala @@ -237,6 +237,11 @@ 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)) + } + } 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/sharaf-core/src/ba/sake/sharaf/sse.scala b/sharaf-core/src/ba/sake/sharaf/sse.scala index b2d6d62..c02a872 100644 --- a/sharaf-core/src/ba/sake/sharaf/sse.scala +++ b/sharaf-core/src/ba/sake/sharaf/sse.scala @@ -38,4 +38,6 @@ class SseSender { private[sharaf] val queue = java.util.concurrent.LinkedBlockingQueue[ServerSentEvent] def send(event: ServerSentEvent): Unit = queue.put(event) + + // TODO add onComplete, onError } \ No newline at end of file From 7b728fe36638f397cf1d089c97fb617386609263 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Wed, 29 Oct 2025 17:00:19 +0100 Subject: [PATCH 23/30] Add support for named tuples in formson --- examples/jwt/src/Main.scala | 7 ++- formson/src/ba/sake/formson/FormDataRW.scala | 56 ++++++++++++++++++- .../ba/sake/formson/FormDataParseSuite.scala | 6 ++ .../ba/sake/formson/FormDataWriteSuite.scala | 5 ++ .../src/ba/sake/querson/QueryStringRW.scala | 20 ++++--- .../src/ba/sake/sharaf/ResponseWritable.scala | 1 - sharaf-core/src/ba/sake/sharaf/sse.scala | 2 +- 7 files changed, 84 insertions(+), 13 deletions(-) diff --git a/examples/jwt/src/Main.scala b/examples/jwt/src/Main.scala index aaa198d..48a5c0e 100644 --- a/examples/jwt/src/Main.scala +++ b/examples/jwt/src/Main.scala @@ -63,7 +63,7 @@ class JwtModule(port: Int) { pac4jConfig, clientNames.mkString(","), null, - s"${DefaultMatchers.SECURITYHEADERS},${publicRoutesMatcherName}", + s"${DefaultMatchers.SECURITYHEADERS},${publicRoutesMatcherName}" ) val server = Undertow @@ -77,7 +77,8 @@ class JwtModule(port: Int) { 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 buildFromTrackableSession(context: WebContext, trackableSession: Any): Optional[SessionStore] = + Optional.empty() override def getSessionId(context: WebContext, createSession: Boolean): Optional[String] = Optional.empty() @@ -88,4 +89,4 @@ class NoopSessionStore extends SessionStore { override def destroySession(context: WebContext): Boolean = false override def renewSession(context: WebContext): Boolean = false -} \ No newline at end of file +} diff --git a/formson/src/ba/sake/formson/FormDataRW.scala b/formson/src/ba/sake/formson/FormDataRW.scala index d352696..ed62ef6 100644 --- a/formson/src/ba/sake/formson/FormDataRW.scala +++ b/formson/src/ba/sake/formson/FormDataRW.scala @@ -5,6 +5,13 @@ import java.nio.file.Path 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.NamedTuple +import NamedTuple.withNames import scala.compiletime.* import scala.quoted.* import scala.reflect.ClassTag @@ -37,7 +44,7 @@ trait FormDataRW[T] { } } -object FormDataRW { +object FormDataRW extends LowPriorityFormDataRWInstances { def apply[T](using instance: FormDataRW[T]): FormDataRW[T] = instance @@ -496,3 +503,50 @@ object FormDataRW { private def parseError(path: String, msg: String): Nothing = throw ParsingException(ParseError(path, msg)) } + +private[formson] object LowPriorityFormDataRWInstances { + + 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, qsData: FormData): T = qsData 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 FormsonException(s"Missing field '$name' at path '$path'") + } + } + val tupleValue = Tuple.fromArray(parsedValues.toArray) + withNames(tupleValue).asInstanceOf[T] + case _ => + throw FormsonException(s"Expected object at path '$path', found: $qsData") + } + } +} + +private[formson] trait LowPriorityFormDataRWInstances { + // cache instances + private val namedTupleTCsCache = scala.collection.mutable.Map.empty[ClassTag[?], FormDataRW[?]] + + inline given autoderiveNamedTuple[T <: AnyNamedTuple](using ct: ClassTag[T]): 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 + namedTupleTCsCache + .getOrElseUpdate(ct, LowPriorityFormDataRWInstances.deriveNamedTupleTC[T](fieldNames, tcInstances)) + .asInstanceOf[FormDataRW[T]] + } + +} diff --git a/formson/test/src/ba/sake/formson/FormDataParseSuite.scala b/formson/test/src/ba/sake/formson/FormDataParseSuite.scala index f014011..a71fe17 100644 --- a/formson/test/src/ba/sake/formson/FormDataParseSuite.scala +++ b/formson/test/src/ba/sake/formson/FormDataParseSuite.scala @@ -242,4 +242,10 @@ 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)) + } } diff --git a/formson/test/src/ba/sake/formson/FormDataWriteSuite.scala b/formson/test/src/ba/sake/formson/FormDataWriteSuite.scala index e628f47..704a6cf 100644 --- a/formson/test/src/ba/sake/formson/FormDataWriteSuite.scala +++ b/formson/test/src/ba/sake/formson/FormDataWriteSuite.scala @@ -112,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/querson/src/ba/sake/querson/QueryStringRW.scala b/querson/src/ba/sake/querson/QueryStringRW.scala index f58d900..24ecc05 100644 --- a/querson/src/ba/sake/querson/QueryStringRW.scala +++ b/querson/src/ba/sake/querson/QueryStringRW.scala @@ -414,15 +414,15 @@ object QueryStringRW extends LowPriorityQueryStringRWInstances { } private[querson] object LowPriorityQueryStringRWInstances { - - private def deriveNamedTupleTC[T](fieldNames: Seq[String], tcInstances: Seq[QueryStringRW[Any]]) = + + 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 jsonFields = fieldNames.zip(fieldValues).zip(tcInstances).map { case ((name, v), rw) => + val qsFields = fieldNames.zip(fieldValues).zip(tcInstances).map { case ((name, v), rw) => name -> rw.write(s"$path.$name", v) } - QueryStringData.Obj(jsonFields.toMap) + QueryStringData.Obj(qsFields.toMap) override def parse(path: String, qsData: QueryStringData): T = qsData match { case QueryStringData.Obj(fields) => @@ -448,8 +448,14 @@ private[querson] trait LowPriorityQueryStringRWInstances { inline given autoderiveNamedTuple[T <: AnyNamedTuple](using ct: ClassTag[T]): 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 - namedTupleTCsCache.getOrElseUpdate(ct, LowPriorityQueryStringRWInstances.deriveNamedTupleTC[T](fieldNames, tcInstances)).asInstanceOf[QueryStringRW[T]] + compiletime + .summonAll[Tuple.Map[DropNames[T], QueryStringRW]] + .productIterator + .asInstanceOf[Iterator[QueryStringRW[Any]]] + .toSeq + namedTupleTCsCache + .getOrElseUpdate(ct, LowPriorityQueryStringRWInstances.deriveNamedTupleTC[T](fieldNames, tcInstances)) + .asInstanceOf[QueryStringRW[T]] } -} \ No newline at end of file +} diff --git a/sharaf-core/src/ba/sake/sharaf/ResponseWritable.scala b/sharaf-core/src/ba/sake/sharaf/ResponseWritable.scala index f5b74fc..6bd6c4b 100644 --- a/sharaf-core/src/ba/sake/sharaf/ResponseWritable.scala +++ b/sharaf-core/src/ba/sake/sharaf/ResponseWritable.scala @@ -12,7 +12,6 @@ private val ContentDispositionHttpString = HttpString(HeaderNames.ContentDisposi private val CacheControlHttpString = HttpString(HeaderNames.CacheControl) private val ConnectionHttpString = HttpString(HeaderNames.Connection) - trait ResponseWritable[-T]: def write(value: T, outputStream: OutputStream): Unit def headers(value: T): Seq[(HttpString, Seq[String])] diff --git a/sharaf-core/src/ba/sake/sharaf/sse.scala b/sharaf-core/src/ba/sake/sharaf/sse.scala index c02a872..6c755e8 100644 --- a/sharaf-core/src/ba/sake/sharaf/sse.scala +++ b/sharaf-core/src/ba/sake/sharaf/sse.scala @@ -40,4 +40,4 @@ class SseSender { queue.put(event) // TODO add onComplete, onError -} \ No newline at end of file +} From d01dd6b4a03ed4fcb908d38490ef6348be0395d5 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Thu, 30 Oct 2025 10:11:09 +0100 Subject: [PATCH 24/30] Derive QueryStringRW for union types --- formson/src/ba/sake/formson/FormDataRW.scala | 13 ++--- .../src/ba/sake/querson/QueryStringRW.scala | 50 +++++++++++++++---- .../sake/querson/QueryStringParseSuite.scala | 22 ++++++++ 3 files changed, 70 insertions(+), 15 deletions(-) diff --git a/formson/src/ba/sake/formson/FormDataRW.scala b/formson/src/ba/sake/formson/FormDataRW.scala index ed62ef6..73626b2 100644 --- a/formson/src/ba/sake/formson/FormDataRW.scala +++ b/formson/src/ba/sake/formson/FormDataRW.scala @@ -533,10 +533,10 @@ private[formson] object LowPriorityFormDataRWInstances { } private[formson] trait LowPriorityFormDataRWInstances { - // cache instances - private val namedTupleTCsCache = scala.collection.mutable.Map.empty[ClassTag[?], FormDataRW[?]] + // TODO cache instances + //private val namedTupleTCsCache = scala.collection.mutable.Map.empty[String, FormDataRW[?]] - inline given autoderiveNamedTuple[T <: AnyNamedTuple](using ct: ClassTag[T]): FormDataRW[T] = { + inline given autoderiveNamedTuple[T <: AnyNamedTuple]: FormDataRW[T] = { val fieldNames = compiletime.constValueTuple[Names[T]].productIterator.asInstanceOf[Iterator[String]].toSeq val tcInstances = compiletime @@ -544,9 +544,10 @@ private[formson] trait LowPriorityFormDataRWInstances { .productIterator .asInstanceOf[Iterator[FormDataRW[Any]]] .toSeq - namedTupleTCsCache - .getOrElseUpdate(ct, LowPriorityFormDataRWInstances.deriveNamedTupleTC[T](fieldNames, tcInstances)) - .asInstanceOf[FormDataRW[T]] + LowPriorityFormDataRWInstances.deriveNamedTupleTC[T](fieldNames, tcInstances) + /*namedTupleTCsCache + .getOrElseUpdate(cacheKey, LowPriorityFormDataRWInstances.deriveNamedTupleTC[T](fieldNames, tcInstances)) + .asInstanceOf[FormDataRW[T]]*/ } } diff --git a/querson/src/ba/sake/querson/QueryStringRW.scala b/querson/src/ba/sake/querson/QueryStringRW.scala index 24ecc05..28d55d5 100644 --- a/querson/src/ba/sake/querson/QueryStringRW.scala +++ b/querson/src/ba/sake/querson/QueryStringRW.scala @@ -4,13 +4,11 @@ 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.NamedTuple import NamedTuple.withNames import scala.deriving.Mirror import scala.reflect.ClassTag @@ -415,7 +413,7 @@ object QueryStringRW extends LowPriorityQueryStringRWInstances { private[querson] object LowPriorityQueryStringRWInstances { - private def deriveNamedTupleTC[T](fieldNames: Seq[String], tcInstances: Seq[QueryStringRW[Any]]) = + 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]] @@ -439,13 +437,44 @@ private[querson] object LowPriorityQueryStringRWInstances { throw QuersonException(s"Expected object at path '$path', found: $qsData") } } + + 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]}") + } + } } private[querson] trait LowPriorityQueryStringRWInstances { - // cache instances - private val namedTupleTCsCache = scala.collection.mutable.Map.empty[ClassTag[?], QueryStringRW[?]] + // TODO cache instances + //private val namedTupleTCsCache = scala.collection.mutable.Map.empty[String, QueryStringRW[?]] + + inline given autoderiveUnion[T]: QueryStringRW[T] = ${ LowPriorityQueryStringRWInstances.deriveUnionTC[T] } - inline given autoderiveNamedTuple[T <: AnyNamedTuple](using ct: ClassTag[T]): QueryStringRW[T] = { + inline given autoderiveNamedTuple[T <: AnyNamedTuple]: QueryStringRW[T] = { val fieldNames = compiletime.constValueTuple[Names[T]].productIterator.asInstanceOf[Iterator[String]].toSeq val tcInstances = compiletime @@ -453,9 +482,12 @@ private[querson] trait LowPriorityQueryStringRWInstances { .productIterator .asInstanceOf[Iterator[QueryStringRW[Any]]] .toSeq - namedTupleTCsCache - .getOrElseUpdate(ct, LowPriorityQueryStringRWInstances.deriveNamedTupleTC[T](fieldNames, tcInstances)) - .asInstanceOf[QueryStringRW[T]] + LowPriorityQueryStringRWInstances.deriveNamedTupleTC[T](fieldNames, tcInstances) + //val cacheKey = fieldNames.zip(fieldTypes).map { case (n, t) => s"$n:$t" }.mkString("|") + //namedTupleTCsCache + // .getOrElseUpdate(cacheKey, LowPriorityQueryStringRWInstances.deriveNamedTupleTC[T](fieldNames, tcInstances)) + // .asInstanceOf[QueryStringRW[T]] } + } diff --git a/querson/test/src/ba/sake/querson/QueryStringParseSuite.scala b/querson/test/src/ba/sake/querson/QueryStringParseSuite.scala index cd5a96e..4daef8d 100644 --- a/querson/test/src/ba/sake/querson/QueryStringParseSuite.scala +++ b/querson/test/src/ba/sake/querson/QueryStringParseSuite.scala @@ -242,6 +242,28 @@ class QueryStringParseSuite extends munit.FunSuite { 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 { // combining named tuples with a union + 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 + // TODO fails with "Tuple element types must be known at compile time" + // val res = Map("firstname" -> Seq("Mujo")).parseQueryStringMap[(firstname: String) | (lastname: String)] + // assertEquals(res, (firstname = "Mujo")) + } + } + } package other_package_givens { From 80f7a79ea4ab06b063bc9031cbbeb81978750f70 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Thu, 30 Oct 2025 10:29:07 +0100 Subject: [PATCH 25/30] Derive FormDataRW for union types --- formson/src/ba/sake/formson/FormDataRW.scala | 43 ++++++++++++++++--- .../ba/sake/formson/FormDataParseSuite.scala | 22 ++++++++++ .../sake/querson/QueryStringParseSuite.scala | 2 +- 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/formson/src/ba/sake/formson/FormDataRW.scala b/formson/src/ba/sake/formson/FormDataRW.scala index 73626b2..d4386eb 100644 --- a/formson/src/ba/sake/formson/FormDataRW.scala +++ b/formson/src/ba/sake/formson/FormDataRW.scala @@ -5,12 +5,10 @@ import java.nio.file.Path 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.NamedTuple import NamedTuple.withNames import scala.compiletime.* import scala.quoted.* @@ -515,26 +513,59 @@ private[formson] object LowPriorityFormDataRWInstances { } FormData.Obj(SeqMap.from(fdFields)) - override def parse(path: String, qsData: FormData): T = qsData match { + 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 FormsonException(s"Missing field '$name' at path '$path'") + case None => throw ParsingException(ParseError(s"$path.$name", "is missing")) } } val tupleValue = Tuple.fromArray(parsedValues.toArray) withNames(tupleValue).asInstanceOf[T] case _ => - throw FormsonException(s"Expected object at path '$path', found: $qsData") + throw ParsingException( + ParseError(path, s"should be Object but it is ${formData.tpe}") + ) } } + + 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]}") + } + } } private[formson] trait LowPriorityFormDataRWInstances { // TODO cache instances - //private val namedTupleTCsCache = scala.collection.mutable.Map.empty[String, FormDataRW[?]] + // private val namedTupleTCsCache = scala.collection.mutable.Map.empty[String, FormDataRW[?]] + + inline given autoderiveUnion[T]: FormDataRW[T] = ${ LowPriorityFormDataRWInstances.deriveUnionTC[T] } inline given autoderiveNamedTuple[T <: AnyNamedTuple]: FormDataRW[T] = { val fieldNames = compiletime.constValueTuple[Names[T]].productIterator.asInstanceOf[Iterator[String]].toSeq diff --git a/formson/test/src/ba/sake/formson/FormDataParseSuite.scala b/formson/test/src/ba/sake/formson/FormDataParseSuite.scala index a71fe17..1369af7 100644 --- a/formson/test/src/ba/sake/formson/FormDataParseSuite.scala +++ b/formson/test/src/ba/sake/formson/FormDataParseSuite.scala @@ -248,4 +248,26 @@ class FormDataParseSuite extends munit.FunSuite { .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 + // TODO fails with "Tuple element types must be known at compile time" + // val res = SeqMap("firstname" -> Seq("Mujo")).parseFormDataMap[(firstname: String) | (lastname: String)] + // assertEquals(res, (firstname = "Mujo")) + } + } } diff --git a/querson/test/src/ba/sake/querson/QueryStringParseSuite.scala b/querson/test/src/ba/sake/querson/QueryStringParseSuite.scala index 4daef8d..422d89e 100644 --- a/querson/test/src/ba/sake/querson/QueryStringParseSuite.scala +++ b/querson/test/src/ba/sake/querson/QueryStringParseSuite.scala @@ -251,7 +251,7 @@ class QueryStringParseSuite extends munit.FunSuite { val res = Map("stuff" -> Seq("true")).parseQueryStringMap[(stuff: Int | Boolean)] assertEquals(res, (stuff = true)) } - locally { // combining named tuples with a union + locally { val res1 = Map("color" -> Seq("Red")).parseQueryStringMap[QueryEnum | QuerySeq] assertEquals(res1, QueryEnum(Color.Red)) val res2 = Map("a" -> Seq("Red")).parseQueryStringMap[QueryEnum | QuerySeq] From f3be52c78dde1367089ee6e27ef1adc185618777 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Thu, 30 Oct 2025 10:59:16 +0100 Subject: [PATCH 26/30] Update tupson to 0.16.1 --- build.mill | 2 +- formson/test/src/ba/sake/formson/FormDataParseSuite.scala | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build.mill b/build.mill index 0259a88..a329e19 100644 --- a/build.mill +++ b/build.mill @@ -8,7 +8,7 @@ import mill.javalib.SonatypeCentralPublishModule import mill.util.VcsVersion object V: - val tupson = "0.13.0" + val tupson = "0.16.1" object `sharaf-core` extends Module: object jvm extends SharafCoreModule with ScalaJvmCommonModule: diff --git a/formson/test/src/ba/sake/formson/FormDataParseSuite.scala b/formson/test/src/ba/sake/formson/FormDataParseSuite.scala index 1369af7..6bb22a0 100644 --- a/formson/test/src/ba/sake/formson/FormDataParseSuite.scala +++ b/formson/test/src/ba/sake/formson/FormDataParseSuite.scala @@ -249,7 +249,7 @@ class FormDataParseSuite extends munit.FunSuite { assertEquals(res, (q = "searchme", page = 42)) } - test("parse union type") { + test("parse union type") { locally { val res = SeqMap("id" -> Seq("myid1").map(FormValue.Str.apply)).parseFormDataMap[(id: String | Int)] assertEquals(res, (id = "myid1")) From 9a2145953fe786573b15131ce38f2be852da4981 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Thu, 30 Oct 2025 17:24:54 +0100 Subject: [PATCH 27/30] Fix ResponseWritable implicits --- examples/oauth2/src/SecurityConfig.scala | 1 - sharaf-core/src/ba/sake/sharaf/ResponseWritable.scala | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/examples/oauth2/src/SecurityConfig.scala b/examples/oauth2/src/SecurityConfig.scala index 53b78e9..be125b5 100644 --- a/examples/oauth2/src/SecurityConfig.scala +++ b/examples/oauth2/src/SecurityConfig.scala @@ -3,7 +3,6 @@ package demo import scala.jdk.CollectionConverters.* import org.pac4j.core.client.Clients import org.pac4j.core.config.Config -import org.pac4j.core.engine.DefaultSecurityLogic import org.pac4j.core.matching.matcher.* class SecurityConfig(clients: Clients) { diff --git a/sharaf-core/src/ba/sake/sharaf/ResponseWritable.scala b/sharaf-core/src/ba/sake/sharaf/ResponseWritable.scala index 6bd6c4b..fa21a6f 100644 --- a/sharaf-core/src/ba/sake/sharaf/ResponseWritable.scala +++ b/sharaf-core/src/ba/sake/sharaf/ResponseWritable.scala @@ -80,9 +80,7 @@ object ResponseWritable extends LowPriResponseWritableInstances { ConnectionHttpString -> Seq("keep-alive") ) } -} - -trait LowPriResponseWritableInstances { + given ResponseWritable[geny.Writable] with { override def write(value: geny.Writable, outputStream: OutputStream): Unit = value.writeBytesTo(outputStream) @@ -94,3 +92,5 @@ trait LowPriResponseWritableInstances { ) } } + +trait LowPriResponseWritableInstances {} From 4623a4cabb1dd0947aec81a13929280956427aa4 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Thu, 30 Oct 2025 17:31:05 +0100 Subject: [PATCH 28/30] Add htmx_sse.sc --- examples/htmx/htmx_sse.sc | 51 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 examples/htmx/htmx_sse.sc 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") From dba289418771a24c5e958743af13c050f6b354d2 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Sat, 1 Nov 2025 08:48:06 +0100 Subject: [PATCH 29/30] Fix autoderivation implicits --- build.mill | 2 +- formson/src/ba/sake/formson/FormDataRW.scala | 94 ++++++++---------- formson/src/ba/sake/formson/IsUnion.scala | 26 +++++ .../ba/sake/formson/FormDataParseSuite.scala | 7 +- querson/src/ba/sake/querson/IsUnion.scala | 26 +++++ .../src/ba/sake/querson/QueryStringRW.scala | 98 +++++++++---------- .../sake/querson/QueryStringParseSuite.scala | 7 +- 7 files changed, 148 insertions(+), 112 deletions(-) create mode 100644 formson/src/ba/sake/formson/IsUnion.scala create mode 100644 querson/src/ba/sake/querson/IsUnion.scala diff --git a/build.mill b/build.mill index a329e19..b198d10 100644 --- a/build.mill +++ b/build.mill @@ -8,7 +8,7 @@ import mill.javalib.SonatypeCentralPublishModule import mill.util.VcsVersion object V: - val tupson = "0.16.1" + val tupson = "0.18.0" object `sharaf-core` extends Module: object jvm extends SharafCoreModule with ScalaJvmCommonModule: diff --git a/formson/src/ba/sake/formson/FormDataRW.scala b/formson/src/ba/sake/formson/FormDataRW.scala index d4386eb..73f3d92 100644 --- a/formson/src/ba/sake/formson/FormDataRW.scala +++ b/formson/src/ba/sake/formson/FormDataRW.scala @@ -440,6 +440,37 @@ object FormDataRW extends LowPriorityFormDataRWInstances { 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 @@ -502,7 +533,18 @@ object FormDataRW extends LowPriorityFormDataRWInstances { throw ParsingException(ParseError(path, msg)) } -private[formson] object LowPriorityFormDataRWInstances { +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] { @@ -531,54 +573,4 @@ private[formson] object LowPriorityFormDataRWInstances { } } - 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]}") - } - } -} - -private[formson] trait LowPriorityFormDataRWInstances { - // TODO cache instances - // private val namedTupleTCsCache = scala.collection.mutable.Map.empty[String, FormDataRW[?]] - - inline given autoderiveUnion[T]: FormDataRW[T] = ${ LowPriorityFormDataRWInstances.deriveUnionTC[T] } - - 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 - LowPriorityFormDataRWInstances.deriveNamedTupleTC[T](fieldNames, tcInstances) - /*namedTupleTCsCache - .getOrElseUpdate(cacheKey, LowPriorityFormDataRWInstances.deriveNamedTupleTC[T](fieldNames, tcInstances)) - .asInstanceOf[FormDataRW[T]]*/ - } - } 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 6bb22a0..9386fda 100644 --- a/formson/test/src/ba/sake/formson/FormDataParseSuite.scala +++ b/formson/test/src/ba/sake/formson/FormDataParseSuite.scala @@ -265,9 +265,10 @@ class FormDataParseSuite extends munit.FunSuite { assertEquals(res2, FormSeq(Seq("Red"))) } locally { // combining named tuples with a union - // TODO fails with "Tuple element types must be known at compile time" - // val res = SeqMap("firstname" -> Seq("Mujo")).parseFormDataMap[(firstname: String) | (lastname: String)] - // assertEquals(res, (firstname = "Mujo")) + 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/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 28d55d5..6f04d0f 100644 --- a/querson/src/ba/sake/querson/QueryStringRW.scala +++ b/querson/src/ba/sake/querson/QueryStringRW.scala @@ -346,6 +346,37 @@ object QueryStringRW extends LowPriorityQueryStringRWInstances { 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 @@ -411,9 +442,20 @@ object QueryStringRW extends LowPriorityQueryStringRWInstances { throw ParsingException(ParseError(path, msg)) } -private[querson] object LowPriorityQueryStringRWInstances { +private[querson] trait LowPriorityQueryStringRWInstances { - def deriveNamedTupleTC[T](fieldNames: Seq[String], tcInstances: Seq[QueryStringRW[Any]]) = + 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]] @@ -438,56 +480,4 @@ private[querson] object LowPriorityQueryStringRWInstances { } } - 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]}") - } - } -} - -private[querson] trait LowPriorityQueryStringRWInstances { - // TODO cache instances - //private val namedTupleTCsCache = scala.collection.mutable.Map.empty[String, QueryStringRW[?]] - - inline given autoderiveUnion[T]: QueryStringRW[T] = ${ LowPriorityQueryStringRWInstances.deriveUnionTC[T] } - - 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 - LowPriorityQueryStringRWInstances.deriveNamedTupleTC[T](fieldNames, tcInstances) - //val cacheKey = fieldNames.zip(fieldTypes).map { case (n, t) => s"$n:$t" }.mkString("|") - //namedTupleTCsCache - // .getOrElseUpdate(cacheKey, LowPriorityQueryStringRWInstances.deriveNamedTupleTC[T](fieldNames, tcInstances)) - // .asInstanceOf[QueryStringRW[T]] - } - - } diff --git a/querson/test/src/ba/sake/querson/QueryStringParseSuite.scala b/querson/test/src/ba/sake/querson/QueryStringParseSuite.scala index 422d89e..fb1eacf 100644 --- a/querson/test/src/ba/sake/querson/QueryStringParseSuite.scala +++ b/querson/test/src/ba/sake/querson/QueryStringParseSuite.scala @@ -258,9 +258,10 @@ class QueryStringParseSuite extends munit.FunSuite { assertEquals(res2, QuerySeq(Seq("Red"))) } locally { // combining named tuples with a union - // TODO fails with "Tuple element types must be known at compile time" - // val res = Map("firstname" -> Seq("Mujo")).parseQueryStringMap[(firstname: String) | (lastname: String)] - // assertEquals(res, (firstname = "Mujo")) + 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")) } } From 4b061ff748b691d654f9aba293bec0be2c817656 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Sat, 1 Nov 2025 08:52:17 +0100 Subject: [PATCH 30/30] Release 0.14.0 --- DEV.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEV.md b/DEV.md index e0dfb2a..6ba008f 100644 --- a/DEV.md +++ b/DEV.md @@ -18,7 +18,7 @@ scala-cli compile examples\scala-cli ```sh # RELEASE -./scripts/release.sh 0.13.2 +./scripts/release.sh 0.14.0 ```