From 529c656609da491f0419a9e426998a0a0d67305b Mon Sep 17 00:00:00 2001 From: Lorenzo Gabriele Date: Wed, 26 Mar 2025 13:35:59 +0100 Subject: [PATCH 1/4] Add `given ResponseWritable[doctype]` --- sharaf/src/ba/sake/sharaf/ResponseWritable.scala | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/sharaf/src/ba/sake/sharaf/ResponseWritable.scala b/sharaf/src/ba/sake/sharaf/ResponseWritable.scala index b48b4b8..2038dcb 100644 --- a/sharaf/src/ba/sake/sharaf/ResponseWritable.scala +++ b/sharaf/src/ba/sake/sharaf/ResponseWritable.scala @@ -5,10 +5,13 @@ import scala.jdk.CollectionConverters.* import io.undertow.server.HttpServerExchange import io.undertow.util.HttpString import io.undertow.util.Headers +import scalatags.Text.all.doctype import scalatags.Text.Frag import ba.sake.hepek.html.HtmlPage import ba.sake.tupson.* import java.io.FileInputStream +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets import scala.util.Using trait ResponseWritable[-T]: @@ -76,6 +79,16 @@ object ResponseWritable { ) } + given ResponseWritable[doctype] with { + override def write(value: doctype, exchange: HttpServerExchange): Unit = + exchange.getResponseSender.send( + Array(StandardCharsets.UTF_8.encode(s""), StandardCharsets.UTF_8.encode(value.render)) + ) + override def headers(value: doctype): Seq[(HttpString, Seq[String])] = Seq( + Headers.CONTENT_TYPE -> Seq("text/html; charset=utf-8") + ) + } + given ResponseWritable[HtmlPage] with { override def write(value: HtmlPage, exchange: HttpServerExchange): Unit = val htmlText = "" + value.contents From 3ee1475634ba4283d5123f938b0a5467fbbcf133 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Sat, 29 Mar 2025 02:18:11 +0100 Subject: [PATCH 2/4] Add ResponseWritable[InputStream]. Add tests for ResponseWritable --- examples/api/test/src/JsonApiSuite.scala | 26 ++-- .../src/ba/sake/sharaf/ResponseWritable.scala | 46 +++---- .../ba/sake/sharaf/{ => utils}/utils.scala | 5 +- sharaf/test/resources/text_file.txt | 1 + .../{routing => }/FormParsingTest.scala | 0 .../ba/sake/sharaf/ResponseWritableTest.scala | 112 ++++++++++++++++++ 6 files changed, 154 insertions(+), 36 deletions(-) rename sharaf/src/ba/sake/sharaf/{ => utils}/utils.scala (93%) create mode 100644 sharaf/test/resources/text_file.txt rename sharaf/test/src/ba/sake/sharaf/{routing => }/FormParsingTest.scala (100%) create mode 100644 sharaf/test/src/ba/sake/sharaf/ResponseWritableTest.scala diff --git a/examples/api/test/src/JsonApiSuite.scala b/examples/api/test/src/JsonApiSuite.scala index 4013f55..7428ca3 100644 --- a/examples/api/test/src/JsonApiSuite.scala +++ b/examples/api/test/src/JsonApiSuite.scala @@ -7,7 +7,20 @@ import ba.sake.sharaf.exceptions.* import ba.sake.sharaf.utils.* class JsonApiSuite extends munit.FunSuite { + + val moduleFixture = new Fixture[JsonApiModule]("JsonApiModule") { + private var module: JsonApiModule = uninitialized + + def apply() = module + + override def beforeEach(context: BeforeEach): Unit = + module = JsonApiModule(getFreePort()) + module.server.start() + override def afterEach(context: AfterEach): Unit = + module.server.stop() + } + override def munitFixtures = List(moduleFixture) test("products can be created and fetched") { @@ -123,17 +136,4 @@ class JsonApiSuite extends munit.FunSuite { ) } - val moduleFixture = new Fixture[JsonApiModule]("JsonApiModule") { - private var module: JsonApiModule = uninitialized - - def apply() = module - - override def beforeEach(context: BeforeEach): Unit = - module = JsonApiModule(getFreePort()) - module.server.start() - - override def afterEach(context: AfterEach): Unit = - module.server.stop() - } - } diff --git a/sharaf/src/ba/sake/sharaf/ResponseWritable.scala b/sharaf/src/ba/sake/sharaf/ResponseWritable.scala index 2038dcb..bec892e 100644 --- a/sharaf/src/ba/sake/sharaf/ResponseWritable.scala +++ b/sharaf/src/ba/sake/sharaf/ResponseWritable.scala @@ -1,24 +1,26 @@ package ba.sake.sharaf import java.nio.file.Path +import java.io.{FileInputStream, InputStream} +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets import scala.jdk.CollectionConverters.* +import scala.util.Using import io.undertow.server.HttpServerExchange import io.undertow.util.HttpString import io.undertow.util.Headers import scalatags.Text.all.doctype import scalatags.Text.Frag import ba.sake.hepek.html.HtmlPage -import ba.sake.tupson.* -import java.io.FileInputStream -import java.nio.ByteBuffer -import java.nio.charset.StandardCharsets -import scala.util.Using +import ba.sake.tupson.{JsonRW, toJson} trait ResponseWritable[-T]: def write(value: T, exchange: HttpServerExchange): Unit def headers(value: T): Seq[(HttpString, Seq[String])] object ResponseWritable { + + def apply[T](using rw: ResponseWritable[T]): ResponseWritable[T] = rw private[sharaf] def writeResponse(response: Response[?], exchange: HttpServerExchange): Unit = { // headers @@ -49,23 +51,29 @@ object ResponseWritable { ) } - given ResponseWritable[Path] with { - override def write(value: Path, exchange: HttpServerExchange): Unit = { - val file = value.toFile() - Using.resources(FileInputStream(file), exchange.getOutputStream()) { (inputStream, outputStream) => - val buf = Array.ofDim[Byte](8192) - var c = 0 - while ({ c = inputStream.read(buf, 0, buf.length); c > 0 }) { - outputStream.write(buf, 0, c) - outputStream.flush() - } + given ResponseWritable[InputStream] with { + override def write(value: InputStream, exchange: HttpServerExchange): Unit = + Using.resources(value, exchange.getOutputStream) { (is, os) => + is.transferTo(os) } - } + + // application/octet-stream says "it can be anything" + override def headers(value: InputStream): Seq[(HttpString, Seq[String])] = Seq( + Headers.CONTENT_TYPE -> Seq("application/octet-stream") + ) + } + + given ResponseWritable[Path] with { + override def write(value: Path, exchange: HttpServerExchange): Unit = + ResponseWritable[InputStream].write( + new FileInputStream(value.toFile), + exchange + ) // https://stackoverflow.com/questions/20508788/do-i-need-content-type-application-octet-stream-for-file-download override def headers(value: Path): Seq[(HttpString, Seq[String])] = Seq( Headers.CONTENT_TYPE -> Seq("application/octet-stream"), - Headers.CONTENT_DISPOSITION -> Seq(s""" attachment; filename="${value.getFileName()}" """.trim) + Headers.CONTENT_DISPOSITION -> Seq(s""" attachment; filename="${value.getFileName}" """.trim) ) } @@ -81,9 +89,7 @@ object ResponseWritable { given ResponseWritable[doctype] with { override def write(value: doctype, exchange: HttpServerExchange): Unit = - exchange.getResponseSender.send( - Array(StandardCharsets.UTF_8.encode(s""), StandardCharsets.UTF_8.encode(value.render)) - ) + exchange.getResponseSender.send(value.render) override def headers(value: doctype): Seq[(HttpString, Seq[String])] = Seq( Headers.CONTENT_TYPE -> Seq("text/html; charset=utf-8") ) diff --git a/sharaf/src/ba/sake/sharaf/utils.scala b/sharaf/src/ba/sake/sharaf/utils/utils.scala similarity index 93% rename from sharaf/src/ba/sake/sharaf/utils.scala rename to sharaf/src/ba/sake/sharaf/utils/utils.scala index b81622a..4f6f6d9 100644 --- a/sharaf/src/ba/sake/sharaf/utils.scala +++ b/sharaf/src/ba/sake/sharaf/utils/utils.scala @@ -2,12 +2,11 @@ package ba.sake.sharaf.utils import java.net.ServerSocket import scala.util.Using -import ba.sake.formson -import ba.sake.querson +import ba.sake.{formson, querson} def getFreePort(): Int = Using.resource(ServerSocket(0)) { ss => - ss.getLocalPort() + ss.getLocalPort } // requests integration diff --git a/sharaf/test/resources/text_file.txt b/sharaf/test/resources/text_file.txt new file mode 100644 index 0000000..510edc4 --- /dev/null +++ b/sharaf/test/resources/text_file.txt @@ -0,0 +1 @@ +a text file \ No newline at end of file diff --git a/sharaf/test/src/ba/sake/sharaf/routing/FormParsingTest.scala b/sharaf/test/src/ba/sake/sharaf/FormParsingTest.scala similarity index 100% rename from sharaf/test/src/ba/sake/sharaf/routing/FormParsingTest.scala rename to sharaf/test/src/ba/sake/sharaf/FormParsingTest.scala diff --git a/sharaf/test/src/ba/sake/sharaf/ResponseWritableTest.scala b/sharaf/test/src/ba/sake/sharaf/ResponseWritableTest.scala new file mode 100644 index 0000000..bd71a71 --- /dev/null +++ b/sharaf/test/src/ba/sake/sharaf/ResponseWritableTest.scala @@ -0,0 +1,112 @@ +package ba.sake.sharaf + +import java.nio.charset.StandardCharsets +import java.nio.file.Paths +import io.undertow.Undertow +import io.undertow.util.Headers +import ba.sake.sharaf.routing.* +import ba.sake.tupson.JsonRW + +class ResponseWritableTest extends munit.FunSuite { + + val testFileResourceDir = Paths.get(sys.env("MILL_TEST_RESOURCE_DIR")) + + val port = utils.getFreePort() + val baseUrl = s"http://localhost:$port" + + val routes = Routes { + case GET() -> Path("string") => + Response.withBody("a string") + case GET() -> Path("inputstream") => + val is = new java.io.ByteArrayInputStream("an inputstream".getBytes(StandardCharsets.UTF_8)) + Response.withBody(is) + case GET() -> Path("file") => + val file = testFileResourceDir.resolve("text_file.txt") + Response.withBody(file) + case GET() -> Path("json") => + case class JsonCaseClass(name: String, age: Int) derives JsonRW + val json = JsonCaseClass("Meho", 40) + Response.withBody(json) + case GET() -> Path("scalatags", "frag") => + import scalatags.Text.all.* + val res = div("this is a div") + Response.withBody(res) + case GET() -> Path("scalatags", "doctype") => + import scalatags.Text.all.{title =>_, *} + import scalatags.Text.tags2.title + val res = doctype("html")( + html( + head( + title("doctype title") + ), + body( + "this is doctype body" + ) + ) + ) + Response.withBody(res) + case GET() -> Path("hepek", "htmlpage") => + import scalatags.Text.all.* + import ba.sake.hepek.html.HtmlPage + val page = new HtmlPage { + override def pageContent = div("this is body") + } + Response.withBody(page) + } + + val server = Undertow + .builder() + .addHttpListener(port, "localhost") + .setHandler(SharafHandler(routes)) + .build() + + override def beforeAll(): Unit = server.start() + + override def afterAll(): Unit = server.stop() + + test("Write response String") { + val res = requests.get(s"${baseUrl}/string") + assertEquals(res.text(), "a string") + assertEquals(res.headers(Headers.CONTENT_TYPE_STRING.toLowerCase), Seq("text/plain")) + } + + test("Write response InputStream") { + val res = requests.get(s"${baseUrl}/inputstream") + assertEquals(res.text(), "an inputstream") + assertEquals(res.headers(Headers.CONTENT_TYPE_STRING.toLowerCase), Seq("application/octet-stream")) + } + + test("Write response file") { + val res = requests.get(s"${baseUrl}/file") + assertEquals(res.text(), "a text file") + assertEquals(res.headers(Headers.CONTENT_TYPE_STRING.toLowerCase), Seq("application/octet-stream")) + assertEquals(res.headers(Headers.CONTENT_DISPOSITION_STRING.toLowerCase), Seq(""" attachment; filename="text_file.txt" """.trim)) + } + + test("Write response JSON") { + val res = requests.get(s"${baseUrl}/json") + assertEquals(res.text(), """ {"name":"Meho","age":40} """.trim) + assertEquals(res.headers(Headers.CONTENT_TYPE_STRING.toLowerCase), Seq("application/json")) + } + + test("Write response scalatags Frag") { + val res = requests.get(s"${baseUrl}/scalatags/frag") + assertEquals(res.text(), """
this is a div
""".trim) + assertEquals(res.headers(Headers.CONTENT_TYPE_STRING.toLowerCase), Seq("text/html; charset=utf-8")) + } + + test("Write response scalatags doctype") { + val res = requests.get(s"${baseUrl}/scalatags/doctype") + assertEquals(res.text(), """ Codestin Search Appthis is doctype body """.trim) + assertEquals(res.headers(Headers.CONTENT_TYPE_STRING.toLowerCase), Seq("text/html; charset=utf-8")) + } + + test("Write response hepek HtmlPage") { + val res = requests.get(s"${baseUrl}/hepek/htmlpage") + assertEquals(res.text(), """ Codestin Search App
this is body
""".trim) + assertEquals(res.headers(Headers.CONTENT_TYPE_STRING.toLowerCase), Seq("text/html; charset=utf-8")) + } + + +} + From d4e6edda59aacd56583ebb88ae5f19e56f30063d Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Sat, 29 Mar 2025 02:30:42 +0100 Subject: [PATCH 3/4] Add HttpMethod enum --- examples/api/src/Main.scala | 8 ++--- examples/fullstack/src/Main.scala | 4 +-- examples/oauth2/src/AppRoutes.scala | 6 ++-- .../sake/sharaf/handlers/RoutesHandler.scala | 5 ++- .../ba/sake/sharaf/routing/httpMethods.scala | 32 +++++-------------- .../src/ba/sake/sharaf/routing/package.scala | 4 +-- .../ba/sake/sharaf/ResponseWritableTest.scala | 24 +++++++------- 7 files changed, 32 insertions(+), 51 deletions(-) diff --git a/examples/api/src/Main.scala b/examples/api/src/Main.scala index 1b26fed..ac778db 100644 --- a/examples/api/src/Main.scala +++ b/examples/api/src/Main.scala @@ -19,24 +19,24 @@ class JsonApiModule(port: Int) { private var db = Seq.empty[ProductRes] private val routes = Routes: - case GET() -> Path("products", param[UUID](id)) => + case GET -> Path("products", param[UUID](id)) => val productOpt = db.find(_.id == id) Response.withBodyOpt(productOpt, s"Product with id=$id") - case GET() -> Path("products") => + case GET -> Path("products") => val query = Request.current.queryParamsValidated[ProductsQuery] val products = if query.name.isEmpty then db else db.filter(c => query.name.contains(c.name) && query.minQuantity.map(c.quantity >= _).getOrElse(true)) Response.withBody(products.toList) - case POST() -> Path("products") => + case POST -> Path("products") => val req = Request.current.bodyJsonValidated[CreateProductReq] val res = ProductRes(UUID.randomUUID(), req.name, req.quantity) db = db.appended(res) Response.withBody(res) - case GET() -> Path("products.json") => + case GET -> Path("products.json") => val tmpFile = Files.createTempFile("product", ".json") tmpFile.toFile().deleteOnExit() Files.writeString(tmpFile, db.toJson) diff --git a/examples/fullstack/src/Main.scala b/examples/fullstack/src/Main.scala index 8067874..9346016 100644 --- a/examples/fullstack/src/Main.scala +++ b/examples/fullstack/src/Main.scala @@ -15,10 +15,10 @@ class FullstackModule(port: Int) { val baseUrl = s"http://localhost:${port}" private val routes = Routes: - case GET() -> Path() => + case GET -> Path() => Response.withBody(ShowFormPage(CreateCustomerForm.empty)) - case POST() -> Path("form-submit") => + case POST -> Path("form-submit") => // note that here we do the validation *manually* !! val formData = Request.current.bodyForm[CreateCustomerForm] formData.validate match diff --git a/examples/oauth2/src/AppRoutes.scala b/examples/oauth2/src/AppRoutes.scala index fdc86e7..5589101 100644 --- a/examples/oauth2/src/AppRoutes.scala +++ b/examples/oauth2/src/AppRoutes.scala @@ -8,13 +8,13 @@ import ba.sake.hepek.html.HtmlPage class AppRoutes(securityService: SecurityService) { val routes = Routes: - case GET() -> Path("protected") => + case GET -> Path("protected") => Response.withBody(ProtectedPage) - case GET() -> Path("login") => + case GET -> Path("login") => Response.redirect("/") - case GET() -> Path() => + case GET -> Path() => Response.withBody(IndexPage(securityService.currentUser)) case _ => diff --git a/sharaf/src/ba/sake/sharaf/handlers/RoutesHandler.scala b/sharaf/src/ba/sake/sharaf/handlers/RoutesHandler.scala index 45114f9..5ab25f3 100644 --- a/sharaf/src/ba/sake/sharaf/handlers/RoutesHandler.scala +++ b/sharaf/src/ba/sake/sharaf/handlers/RoutesHandler.scala @@ -35,17 +35,16 @@ final class RoutesHandler private (routes: Routes, nextHandler: Option[HttpHandl } private def fillReqParams(exchange: HttpServerExchange): RequestParams = { + val method = HttpMethod.valueOf(exchange.getRequestMethod.toString) val relPath = if exchange.getRelativePath.startsWith("/") then exchange.getRelativePath.drop(1) else exchange.getRelativePath val pathSegments = relPath.split("/") - val path = if pathSegments.size == 1 && pathSegments.head == "" then Path() else Path(pathSegments*) - - (exchange.getRequestMethod, path) + (method, path) } } diff --git a/sharaf/src/ba/sake/sharaf/routing/httpMethods.scala b/sharaf/src/ba/sake/sharaf/routing/httpMethods.scala index 490d20d..51c69e2 100644 --- a/sharaf/src/ba/sake/sharaf/routing/httpMethods.scala +++ b/sharaf/src/ba/sake/sharaf/routing/httpMethods.scala @@ -1,28 +1,12 @@ package ba.sake.sharaf.routing import io.undertow.util.Methods -import io.undertow.util.HttpString -object GET: - def unapply(str: HttpString): Boolean = - Methods.GET == str - -object POST: - def unapply(str: HttpString): Boolean = - Methods.POST == str - -object PUT: - def unapply(str: HttpString): Boolean = - Methods.PUT == str - -object DELETE: - def unapply(str: HttpString): Boolean = - Methods.DELETE == str - -object OPTIONS: - def unapply(str: HttpString): Boolean = - Methods.OPTIONS == str - -object PATCH: - def unapply(str: HttpString): Boolean = - Methods.PATCH == str +enum HttpMethod(val name: String) { + case GET extends HttpMethod(Methods.GET_STRING) + case POST extends HttpMethod(Methods.POST_STRING) + case PUT extends HttpMethod(Methods.PUT_STRING) + case DELETE extends HttpMethod(Methods.DELETE_STRING) + case OPTIONS extends HttpMethod(Methods.OPTIONS_STRING) + case PATCH extends HttpMethod(Methods.PATCH_STRING) +} diff --git a/sharaf/src/ba/sake/sharaf/routing/package.scala b/sharaf/src/ba/sake/sharaf/routing/package.scala index 454ad5d..586a609 100644 --- a/sharaf/src/ba/sake/sharaf/routing/package.scala +++ b/sharaf/src/ba/sake/sharaf/routing/package.scala @@ -1,6 +1,6 @@ package ba.sake.sharaf package routing -import io.undertow.util.HttpString +type RequestParams = (HttpMethod, Path) -type RequestParams = (HttpString, Path) +export HttpMethod.* \ No newline at end of file diff --git a/sharaf/test/src/ba/sake/sharaf/ResponseWritableTest.scala b/sharaf/test/src/ba/sake/sharaf/ResponseWritableTest.scala index bd71a71..d6fa09b 100644 --- a/sharaf/test/src/ba/sake/sharaf/ResponseWritableTest.scala +++ b/sharaf/test/src/ba/sake/sharaf/ResponseWritableTest.scala @@ -8,30 +8,30 @@ import ba.sake.sharaf.routing.* import ba.sake.tupson.JsonRW class ResponseWritableTest extends munit.FunSuite { - + val testFileResourceDir = Paths.get(sys.env("MILL_TEST_RESOURCE_DIR")) val port = utils.getFreePort() val baseUrl = s"http://localhost:$port" - + val routes = Routes { - case GET() -> Path("string") => + case GET -> Path("string") => Response.withBody("a string") - case GET() -> Path("inputstream") => + case GET -> Path("inputstream") => val is = new java.io.ByteArrayInputStream("an inputstream".getBytes(StandardCharsets.UTF_8)) Response.withBody(is) - case GET() -> Path("file") => + case GET -> Path("file") => val file = testFileResourceDir.resolve("text_file.txt") Response.withBody(file) - case GET() -> Path("json") => + case GET -> Path("json") => case class JsonCaseClass(name: String, age: Int) derives JsonRW val json = JsonCaseClass("Meho", 40) Response.withBody(json) - case GET() -> Path("scalatags", "frag") => + case GET -> Path("scalatags", "frag") => import scalatags.Text.all.* val res = div("this is a div") Response.withBody(res) - case GET() -> Path("scalatags", "doctype") => + case GET -> Path("scalatags", "doctype") => import scalatags.Text.all.{title =>_, *} import scalatags.Text.tags2.title val res = doctype("html")( @@ -45,7 +45,7 @@ class ResponseWritableTest extends munit.FunSuite { ) ) Response.withBody(res) - case GET() -> Path("hepek", "htmlpage") => + case GET -> Path("hepek", "htmlpage") => import scalatags.Text.all.* import ba.sake.hepek.html.HtmlPage val page = new HtmlPage { @@ -53,13 +53,13 @@ class ResponseWritableTest extends munit.FunSuite { } Response.withBody(page) } - + val server = Undertow .builder() .addHttpListener(port, "localhost") .setHandler(SharafHandler(routes)) .build() - + override def beforeAll(): Unit = server.start() override def afterAll(): Unit = server.stop() @@ -107,6 +107,4 @@ class ResponseWritableTest extends munit.FunSuite { assertEquals(res.headers(Headers.CONTENT_TYPE_STRING.toLowerCase), Seq("text/html; charset=utf-8")) } - } - From 9176127d441d5a43b4a50c8b7bf6d3260b33dae6 Mon Sep 17 00:00:00 2001 From: Sakib Hadziavdic Date: Sat, 29 Mar 2025 02:34:01 +0100 Subject: [PATCH 4/4] Release 0.9.0