├── project ├── build.properties ├── plugins.sbt └── Dependencies.scala ├── docs └── src │ └── pages │ ├── model │ ├── directory.conf │ ├── README.md │ └── sqlite.md │ ├── controller │ ├── directory.conf │ ├── route │ │ ├── directory.conf │ │ ├── codecs.md │ │ ├── assets.md │ │ ├── entities.md │ │ ├── response.md │ │ └── request.md │ ├── README.md │ └── handler.md │ ├── overview │ ├── directory.conf │ ├── README.md │ ├── architecture.md │ └── principles.md │ ├── directory.conf │ ├── tools.md │ ├── views.md │ ├── README.md │ ├── application.md │ ├── development.md │ ├── quick-start.md │ └── server.md ├── .scalafmt.conf ├── .scalafix.conf ├── README.md ├── core ├── jvm │ └── src │ │ ├── main │ │ └── scala │ │ │ └── krop │ │ │ ├── syntax │ │ │ └── all.scala │ │ │ ├── Server.scala │ │ │ ├── tool │ │ │ └── cli │ │ │ │ └── Cli.scala │ │ │ ├── JvmRuntime.scala │ │ │ ├── all.scala │ │ │ └── ServerBuilder.scala │ │ └── test │ │ └── scala │ │ └── krop │ │ ├── tool │ │ └── HtmlSuite.scala │ │ └── route │ │ ├── PassthroughBuilderSuite.scala │ │ ├── RequestSuite.scala │ │ ├── TupleApplySuite.scala │ │ ├── ParamSuite.scala │ │ ├── PathUnparseSuite.scala │ │ ├── ResponseSuite.scala │ │ ├── RequestEntitySuite.scala │ │ ├── QueryParamSuite.scala │ │ ├── RequestHeaderSuite.scala │ │ └── PathParseSuite.scala └── shared │ └── src │ ├── main │ └── scala │ │ └── krop │ │ ├── WithRuntime.scala │ │ ├── route │ │ ├── ClientRoute.scala │ │ ├── ParseFailure.scala │ │ ├── InternalRoute.scala │ │ ├── BaseRoute.scala │ │ ├── Segment.scala │ │ ├── DecodeFailure.scala │ │ ├── PassthroughBuilder.scala │ │ ├── QueryParseFailure.scala │ │ ├── RouteHandler.scala │ │ ├── Handlers.scala │ │ ├── Route.scala │ │ ├── HandleableRoute.scala │ │ ├── StringCodec.scala │ │ ├── FormCodec.scala │ │ ├── Query.scala │ │ ├── ReversibleRoute.scala │ │ ├── TupleApply.scala │ │ ├── Param.scala │ │ ├── Handler.scala │ │ ├── Entity.scala │ │ ├── QueryParam.scala │ │ └── SeqStringCodec.scala │ │ ├── tool │ │ ├── Htmx.scala │ │ ├── KropAssets.scala │ │ ├── Html.scala │ │ ├── DefaultAssets.scala │ │ ├── TurboStream.scala │ │ └── Tailwind.scala │ │ ├── BaseRuntime.scala │ │ ├── Types.scala │ │ ├── KropRuntime.scala │ │ ├── Mode.scala │ │ ├── Application.scala │ │ └── raise │ │ └── Raise.scala │ └── test │ └── scala │ └── krop │ └── route │ ├── EntitySuite.scala │ ├── SeqStringCodecSuite.scala │ └── FormCodecSuite.scala ├── asset └── src │ ├── test │ └── scala │ │ └── krop │ │ └── asset │ │ ├── PathSyntaxSuite.scala │ │ ├── AssetRouteSuite.scala │ │ ├── FileNameHasherSuite.scala │ │ └── HashingFileWatcherSuite.scala │ └── main │ └── scala │ └── krop │ └── asset │ ├── HexString.scala │ ├── package.scala │ ├── FileNameHasher.scala │ ├── HashingFileWatcher.scala │ └── AssetRoute.scala ├── examples └── src │ └── main │ └── scala │ ├── Htmx.scala │ └── TurboStream.scala ├── .github └── workflows │ └── clean.yml ├── sqlite └── src │ └── main │ └── scala │ └── krop │ └── sqlite │ ├── Sqlite.scala │ └── package.scala └── .gitignore /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.11.7 2 | -------------------------------------------------------------------------------- /docs/src/pages/model/directory.conf: -------------------------------------------------------------------------------- 1 | laika.navigationOrder = [ 2 | README.md 3 | sqlite.md 4 | ] 5 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = "3.8.1" 2 | runner.dialect = scala3 3 | rewrite.scala3.convertToNewSyntax = true 4 | -------------------------------------------------------------------------------- /docs/src/pages/controller/directory.conf: -------------------------------------------------------------------------------- 1 | laika.navigationOrder = [ 2 | README.md 3 | route 4 | handler.md 5 | ] 6 | -------------------------------------------------------------------------------- /docs/src/pages/overview/directory.conf: -------------------------------------------------------------------------------- 1 | laika.navigationOrder = [ 2 | README.md 3 | architecture.md 4 | principles.md 5 | ] 6 | -------------------------------------------------------------------------------- /.scalafix.conf: -------------------------------------------------------------------------------- 1 | rules = [ 2 | OrganizeImports 3 | ] 4 | OrganizeImports.removeUnused = true 5 | OrganizeImports.coalesceToWildcardImportThreshold = 5 6 | OrganizeImports.targetDialect = Scala3 7 | -------------------------------------------------------------------------------- /docs/src/pages/controller/route/directory.conf: -------------------------------------------------------------------------------- 1 | laika.navigationOrder = [ 2 | README.md 3 | request.md 4 | paths.md 5 | entities.md 6 | response.md 7 | codecs.md 8 | assets.md 9 | ] 10 | -------------------------------------------------------------------------------- /docs/src/pages/directory.conf: -------------------------------------------------------------------------------- 1 | laika.navigationOrder = [ 2 | README.md 3 | quick-start.md 4 | overview 5 | application.md 6 | server.md 7 | model 8 | views.md 9 | controller 10 | tools.md 11 | development.md 12 | ] 13 | -------------------------------------------------------------------------------- /docs/src/pages/tools.md: -------------------------------------------------------------------------------- 1 | # Tools 2 | 3 | Tools are add-ons to Krop that are useful but not central to its functionality. 4 | 5 | 6 | ## NotFound 7 | 8 | Adds the informative page when a route is not found. 9 | 10 | 11 | ## CLI 12 | 13 | Provides the commmand-line parser used by the template project. 14 | -------------------------------------------------------------------------------- /docs/src/pages/model/README.md: -------------------------------------------------------------------------------- 1 | # Models 2 | 3 | Models are the representations of data in your application. You will usually have several different models: 4 | 5 | - for storing data in a database; 6 | - for representing communication with external services; and 7 | - for representing data used internally in your application. 8 | 9 | Krop provides features to help you with these tasks. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Krop 2 | 3 | A compositional web application library that is easy to use. Let a thousand websites bloom. 4 | 5 | [Read more](https://creativescala.org/krop). 6 | 7 | 8 | ## Development 9 | 10 | Use the `build` command in sbt to do everything (build code, run tests, format code, etc.) 11 | 12 | Releases are automatically published whenever a tag like `vX.Y.Z` (where `X`, `Y`, and `Z` are integers) is pushed to `main`. 13 | -------------------------------------------------------------------------------- /docs/src/pages/overview/README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | Krop is a full-stack web framework. This means it includes features for building user interfaces, which can be driven by a server or run entirely on the client. It also adept at handling applications that serve APIs from a server, or run entirely on a client. 4 | 5 | Right now Krop is in a very early stage of development, so you might find some pieces are currently missing. We're working on that, as this is an open source project you're welcome to join in. 6 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.20.1") 2 | addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.6.4") 3 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.6") 4 | addSbtPlugin("com.github.sbt" % "sbt-unidoc" % "0.6.0") 5 | addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.14.4") 6 | addSbtPlugin("org.typelevel" % "sbt-typelevel" % "0.8.3") 7 | addSbtPlugin("org.typelevel" % "sbt-typelevel-site" % "0.8.3") 8 | addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.9") 9 | addSbtPlugin("org.creativescala" % "creative-scala-theme" % "0.4.5") 10 | -------------------------------------------------------------------------------- /docs/src/pages/controller/README.md: -------------------------------------------------------------------------------- 1 | # Controller 2 | 3 | Controllers are responsible for extracting information from a request, making appropriate calls into the model, and constructing a view to return as the response. Of all the parts in a Krop application, controllers are the most concerned with the details of the HTTP protocol. 4 | 5 | Controllers consist of two parts: 6 | 7 | * [Routes](route/README.md), which deal with parsing HTTP requests and assembling HTTP responses; and 8 | * [Handlers](handler.md), which work with the values parsed from an HTTP request to produce the values required to assemble the response. 9 | -------------------------------------------------------------------------------- /docs/src/pages/views.md: -------------------------------------------------------------------------------- 1 | # View 2 | 3 | Views are responsible for generating the user interface of your application. 4 | A user interface could be HTML displayed in a web browser, JSON returned from an API endpoint, or code that runs client-side. 5 | As there are several different kinds of user interfaces there are several different systems for creating views. 6 | 7 | 8 | ## Static Files 9 | 10 | ## Templates 11 | 12 | Krop uses [Twirl][twirl] for templates, which are views that are mostly text with a few pieces of programmatic content. Templates are ideal for generating HTML. 13 | 14 | By default templates are found in `backend//src/views/`. 15 | 16 | 17 | [twirl]: https://www.playframework.com/documentation/3.0.x/ScalaTemplates 18 | -------------------------------------------------------------------------------- /core/jvm/src/main/scala/krop/syntax/all.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.syntax 18 | 19 | object all {} 20 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/WithRuntime.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop 18 | 19 | type WithRuntime[A] = KropRuntime ?=> A 20 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/route/ClientRoute.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | trait ClientRoute[C <: Tuple, P] { self: BaseRoute => } 20 | -------------------------------------------------------------------------------- /docs/src/pages/README.md: -------------------------------------------------------------------------------- 1 | # Krop 2 | 3 | Krop is a Scala 3 web framework. Its goal is to make it delightful to build delightful web applications. It supports every kind of web application, from fast and scalable APIs to rich user experiences. It excels at projects where productivity and feature richness are a priority. See [our principles](overview/principles.md) for more. 4 | 5 | Krop requires at least JDK 17 to run. 6 | 7 | 8 | ## Getting Started 9 | 10 | The fastest way to start with Krop is using the template project, which is described in the [Quick Start](quick-start.md). 11 | 12 | If you want to add Krop to an existing project, add the following to your `build.sbt` 13 | 14 | ```scala 15 | libraryDependencies += "org.creativescala" %% "krop-core" % "@VERSION@" 16 | ``` 17 | 18 | ## ScalaDoc 19 | 20 | See the ScalaDoc @:api(krop.index) for API documentation. 21 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/route/ParseFailure.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | final case class ParseFailure( 20 | stage: ParseStage, 21 | summary: String, 22 | detail: String 23 | ) 24 | 25 | enum ParseStage { 26 | case Method 27 | case Uri 28 | case Header 29 | case Entity 30 | case Other 31 | } 32 | -------------------------------------------------------------------------------- /docs/src/pages/application.md: -------------------------------------------------------------------------------- 1 | # Application 2 | 3 | An @:api(krop.Application) in what a Krop [server](server.md) runs, and thus building an `Application` is the end goal of working with Krop. 4 | 5 | An `Application` consists of any number of [Handlers](controller/handler.md) followed by a catch-all that handles any requests not matched by a route. The usual catch-all is @:api(krop.tool.Application.notFound). 6 | 7 | 8 | ## Development and Production Mode 9 | 10 | Krop can run in one of two modes: development and production. In development 11 | mode it shows output that is useful for debugging and otherwise inspecting 12 | the running state. In production this output is hidden. 13 | 14 | The mode is set by the `krop.mode` JVM system property. If it has the value of 15 | "development" (without the quotes; any capitalization is fine) then the mode 16 | is development. Otherwise it is production. 17 | 18 | The mode is determined when Krop starts, and is available as the value of `krop.Mode`. 19 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/tool/Htmx.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.tool 18 | 19 | import scalatags.Text.Attr 20 | import scalatags.Text.all.attr 21 | 22 | object Htmx { 23 | val hxBoost: Attr = attr("hx-boost") 24 | val hxGet: Attr = attr("hx-get") 25 | val hxInclude: Attr = attr("hx-include") 26 | val hxPost: Attr = attr("hx-post") 27 | val hxTarget: Attr = attr("hx-target") 28 | } 29 | -------------------------------------------------------------------------------- /core/jvm/src/test/scala/krop/tool/HtmlSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.tool 18 | 19 | import munit.FunSuite 20 | 21 | class HtmlSuite extends FunSuite { 22 | test("HTML quoting correctly quotes reserved characters") { 23 | val tests = List("<>" -> "<>", "&" -> "&", "\"" -> """) 24 | 25 | tests.foreach { case (in, out) => assertEquals(Html.quote(in), out) } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /docs/src/pages/controller/route/codecs.md: -------------------------------------------------------------------------------- 1 | # Codecs 2 | 3 | Many tasks in Krop require converting to and from some external representation. For example, dealing with path segments, query parameters, or form submissions all requires converting values to and from strings. Types that handle these conversions are called codecs, which is short for "coder and decoder". 4 | 5 | Some codecs, such as [`Entity`](entities.md), have their own documentation. This section documents @:api(krop.route.StringCodec) and @:api(krop.route.SeqStringCodec). They are usually used as `given` values in constructing @:api(krop.route.Param), @:api(krop.route.Query), and @:api(krop.route.FormCodec) values, so the developer typically doesn't work with them directly if a predefined instance already exists for the types they are using. 6 | 7 | 8 | ## StringCodec 9 | 10 | @:api(krop.route.StringCodec) decodes a `String` to a value and encodes a value as a `String`. 11 | 12 | 13 | ## SeqStringCodec 14 | 15 | @:api(krop.route.SeqStringCodec) decodes a `Seq[String]` to a value and encodes a value as a `Seq[String]`. 16 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/route/InternalRoute.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | /** The internal view of a route, exposing the types that a handler works with. 20 | * 21 | * Just exposes the types, so that other types that want to purely with the 22 | * types without the API can do so. 23 | */ 24 | trait InternalRoute[E <: Tuple, R] extends BaseRoute { 25 | def request: Request[?, ?, ?, E] 26 | def response: Response[R, ?] 27 | } 28 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/route/BaseRoute.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | /** A BaseRoute indicates that something is a route, and has a Request and a 20 | * Response. The types of the Request and Response are erased, so this is only 21 | * useful for runtime introspection. Other types keep more information and are 22 | * useful for other cases. 23 | */ 24 | trait BaseRoute { 25 | def request: Request[?, ?, ?, ?] 26 | def response: Response[?, ?] 27 | } 28 | -------------------------------------------------------------------------------- /docs/src/pages/development.md: -------------------------------------------------------------------------------- 1 | # Developing Krop 2 | 3 | This section contains notes on developing Krop. 4 | 5 | 6 | ## Testing And All That 7 | 8 | Use the sbt `build` task to compile, test, format, etc. 9 | 10 | 11 | ## Builders 12 | 13 | We have a lot of builder methods. The convention is a method starting with `with` will return a copy with an updated value. E.g. `serverBuilder.withPort(...)` returns a copy of a `ServerBuilder` with an updated value of port. 14 | 15 | 16 | ## Wrapping http4s 17 | 18 | We wrap a lot of http4s (and Cats and Cats Effect to a lesser extent) to make things simpler to use. For example, we like to get rid of the `IO` parameters that litter many http4s types. We don't abstract over effect types (and it, arguably, yields no value to do so) so don't need this. 19 | 20 | Where we have a wrapper type, and it doesn't make sense to use an opaque type or a type alias, the value of the wrapped http4s type is, by convention, called `value`. 21 | 22 | 23 | ## Absolute Imports 24 | 25 | We use absolute, not relative, imports. Relative imports require too much knowledge of the code base for the more casual reader. 26 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/tool/KropAssets.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.tool 18 | 19 | import krop.route.Handler 20 | import krop.route.Param 21 | import krop.route.Path 22 | import krop.route.Request 23 | import krop.route.Response 24 | import krop.route.Route 25 | 26 | object KropAssets { 27 | val kropAssets: Handler = 28 | Route( 29 | Request.get(Path / "krop" / "assets" / Param.separatedString("/")), 30 | Response.staticResource("/krop/assets/") 31 | ).passthrough 32 | } 33 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/tool/Html.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.tool 18 | 19 | object Html { 20 | 21 | /** Convert HTML reserved characters to entities so this `String` can be 22 | * embedded into HTML. 23 | */ 24 | def quote(string: String): String = 25 | // Replace & first, otherwise we cannot differentiate entities starting with & from &s that need replacing. 26 | string 27 | .replace("&", "&") 28 | .replace("<", "<") 29 | .replace(">", ">") 30 | .replace("\"", """) 31 | } 32 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/route/Segment.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | /** Matches but does not capture a segment in a URI's path. */ 20 | enum Segment { 21 | case All 22 | case One(value: String) 23 | 24 | /** Gets a human readable description of this Segement */ 25 | def describe: String = 26 | this match { 27 | case All => "rest*" 28 | case One(value) => value 29 | } 30 | 31 | } 32 | object Segment { 33 | val all = Segment.All 34 | def part(value: String): Segment = Segment.One(value) 35 | } 36 | -------------------------------------------------------------------------------- /asset/src/test/scala/krop/asset/PathSyntaxSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.asset 18 | 19 | import fs2.Stream 20 | import fs2.io.file.Files 21 | import munit.CatsEffectSuite 22 | 23 | class PathSyntaxSuite extends CatsEffectSuite { 24 | val files = Files.forIO 25 | 26 | test("md5Hex returns correct value") { 27 | files.tempFile.use { file => 28 | for { 29 | _ <- Stream("bigcats").through(files.writeUtf8(file)).compile.drain 30 | hash <- file.md5Hex 31 | } yield assertEquals(hash.value, "80fe0e83da4321cca20e0cda8a5f86f8") 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/route/DecodeFailure.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | /** Represents a failure to decode a value 20 | * 21 | * @param input 22 | * The input that we attempted to decode. 23 | * @param description 24 | * A description of what was expected from the input. By convention this is 25 | * the name of the type we expected to decode to. 26 | */ 27 | final case class DecodeFailure( 28 | input: String | Seq[String], 29 | description: String 30 | ) { 31 | def describe: String = 32 | s"Decoding input ${input} failed. Expected ${description}." 33 | } 34 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/tool/DefaultAssets.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.tool 18 | 19 | import fs2.io.file.Path as Fs2Path 20 | import krop.route.Handler 21 | import krop.route.Param 22 | import krop.route.Path 23 | import krop.route.Request 24 | import krop.route.Response 25 | import krop.route.Route 26 | 27 | object DefaultAssets { 28 | val assets: Handler = 29 | Route( 30 | Request.get( 31 | Path / "assets" / Param 32 | .separatedString("/") 33 | .imap(Fs2Path.apply)(_.toString) 34 | ), 35 | Response.staticDirectory(Fs2Path("assets/")) 36 | ).passthrough 37 | } 38 | -------------------------------------------------------------------------------- /core/jvm/src/test/scala/krop/route/PassthroughBuilderSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | import munit.FunSuite 20 | 21 | class PassthroughBuilderSuite extends FunSuite { 22 | 23 | val emptyRequest = Request.get(Path.root) 24 | val stringRequest = Request.get(Path.root / Param.string) 25 | 26 | test("Passthrough works for EmptyTuple => Unit") { 27 | val builder = Route(emptyRequest, Response.ok(Entity.unit)) 28 | 29 | builder.passthrough 30 | } 31 | 32 | test("Passthrough works for Tuple1") { 33 | val builder = Route(stringRequest, Response.ok(Entity.text)) 34 | 35 | builder.passthrough 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/BaseRuntime.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop 18 | 19 | import cats.effect.IO 20 | import cats.effect.Resource 21 | import org.typelevel.log4cats.Logger 22 | import org.typelevel.log4cats.LoggerFactory 23 | 24 | /** Provides platform specific services and utilities that are available before 25 | * the http4s server has started. 26 | */ 27 | trait BaseRuntime { 28 | 29 | given loggerFactory: LoggerFactory[IO] 30 | given logger: Logger[IO] 31 | 32 | /** Add a Resource, the value of which will be available in the KropRuntime 33 | * using the given key. 34 | */ 35 | def stageResource[V](key: Key[V], value: Resource[IO, V]): Unit 36 | } 37 | -------------------------------------------------------------------------------- /asset/src/main/scala/krop/asset/HexString.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.asset 18 | 19 | import fs2.hashing.Hash 20 | 21 | import java.util.HexFormat 22 | 23 | /** A hexadecimal formatted String */ 24 | opaque type HexString = String 25 | extension (hex: HexString) { 26 | def value: String = hex 27 | } 28 | object HexString { 29 | private val hexFormat = HexFormat.of() 30 | 31 | def unsafeApply(string: String): HexString = string 32 | 33 | def fromHash(hash: Hash): HexString = { 34 | val bytes = Array.ofDim[Byte](hash.bytes.size) 35 | hash.bytes.copyToArray(bytes) 36 | 37 | fromBytes(bytes) 38 | } 39 | 40 | def fromBytes(bytes: Array[Byte]): HexString = { 41 | hexFormat.formatHex(bytes) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /core/shared/src/test/scala/krop/route/EntitySuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | import cats.effect.IO 20 | import munit.CatsEffectSuite 21 | import org.http4s.Request 22 | 23 | final case class Form(int: Int, string: String) derives FormCodec 24 | 25 | class EntitySuite extends CatsEffectSuite { 26 | test("FormCodec encoding is invertible") { 27 | val entity = Entity.formOf[Form] 28 | val form = Form(42, "Krop") 29 | val request = Request[IO]().withEntity(form)(using entity.encoder) 30 | 31 | entity.decoder 32 | .decode(request, true) 33 | .fold( 34 | error => fail(s"Decoding failed with error: $error"), 35 | value => assertEquals(value, Form(42, "Krop")) 36 | ) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /core/jvm/src/main/scala/krop/Server.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop 18 | 19 | import cats.effect.IO 20 | import cats.effect.Resource 21 | import cats.effect.unsafe.implicits.global 22 | import org.http4s.server.Server as Http4sServer 23 | 24 | /** A HTTP server that will serve requests when run. */ 25 | final case class Server(server: Resource[IO, Http4sServer]) { 26 | 27 | /** Convert this server to a Cats Effect IO for more control over how it is 28 | * run. 29 | */ 30 | def toIO: IO[Unit] = 31 | server.use(_ => IO.never) 32 | 33 | /** Run this server, using the default Cats Effect thread pool. The server is 34 | * run synchronously, so this method will only return when the server has 35 | * finished. 36 | */ 37 | def run(): Unit = 38 | toIO.unsafeRunSync() 39 | } 40 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/tool/TurboStream.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.tool 18 | 19 | import scalatags.Text.TypedTag 20 | import scalatags.Text.all.* 21 | 22 | object TurboStream { 23 | val source: TypedTag[String] = tag("turbo-stream-source") 24 | val stream: TypedTag[String] = tag("turbo-stream") 25 | val template: TypedTag[String] = tag("template") 26 | 27 | object action { 28 | val append = scalatags.Text.all.action := "append" 29 | val prepend = scalatags.Text.all.action := "prepend" 30 | val replace = scalatags.Text.all.action := "replace" 31 | val update = scalatags.Text.all.action := "update" 32 | val remove = scalatags.Text.all.action := "remove" 33 | val before = scalatags.Text.all.action := "before" 34 | val after = scalatags.Text.all.action := "after" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /docs/src/pages/overview/architecture.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | Krop uses a model-view-controller (MVC) architecture, which divides a web application into three components: 4 | 5 | - [models](../model/README.md), which manages the data in the application; 6 | - [views](../views.md), which is responsible for generating the user interface; and 7 | - [controllers](../controller/README.md), which handles actions from the user interface and implements the application logic. 8 | 9 | 10 | ## Directory Structure 11 | 12 | The standard directory structure for a Krop project is described below. It's similar to the standard structure for a Scala project, but it drops the `main/scala` directories that sbt inherits from Maven. This is a bit of unnecessary indirection that makes it harder to find the code and get stuff done, particularly when new to a project. 13 | 14 | ``` 15 | backend - All backend (server) code here 16 | ├── src 17 | │ └── 18 | │ ├── Main.scala - the main entry point to your backend 19 | │ ├── conf - backend configuration 20 | │ ├── models 21 | │ │ └── db - database models 22 | │ └── views - template views using Twirl 23 | ├── resources - static files that are packaged with your application 24 | └── test - tests 25 | 26 | frontend - All frontend (client) code here 27 | 28 | shared - Code that is shared between backend and frontend 29 | └── src 30 | └── 31 | └── routes - your application's routes 32 | ``` 33 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/route/PassthroughBuilder.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | import cats.effect.IO 20 | 21 | /** This type class constructs handler function instances for the `passthrough` 22 | * method on `RouteBuilder`, as these methods don't fit the shapes handled by 23 | * `TupleApply`. 24 | */ 25 | trait PassthroughBuilder[I, O] { 26 | def build: I => IO[O] 27 | } 28 | object PassthroughBuilder { 29 | given identityBuilder[A]: PassthroughBuilder[A, A] with { 30 | def build: A => IO[A] = a => IO.pure(a) 31 | } 32 | 33 | given tuple1Builder[A]: PassthroughBuilder[Tuple1[A], A] with { 34 | def build: Tuple1[A] => IO[A] = a => IO.pure(a(0)) 35 | } 36 | 37 | given toUnitBuilder: PassthroughBuilder[EmptyTuple, Unit] with { 38 | def build: EmptyTuple => IO[Unit] = _ => IO.unit 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /core/shared/src/test/scala/krop/route/SeqStringCodecSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | import munit.FunSuite 20 | 21 | class SeqStringCodecSuite extends FunSuite { 22 | def assertDecodingInvertible[A]( 23 | codec: SeqStringCodec[A], 24 | input: Seq[String], 25 | output: A 26 | )(using munit.Location) = { 27 | val decoded = codec.decode(input) 28 | assertEquals(decoded, Right(output)) 29 | 30 | val encoded = codec.encode(output) 31 | assertEquals(encoded, input) 32 | } 33 | 34 | test("SeqStringCodec.separatedString") { 35 | val comma = SeqStringCodec.separatedString(",") 36 | val ampersand = SeqStringCodec.separatedString("&") 37 | 38 | assertDecodingInvertible(comma, Seq("a", "b", "c"), "a,b,c") 39 | assertDecodingInvertible(ampersand, Seq("a", "b", "c"), "a&b&c") 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/Types.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop 18 | 19 | object Types { 20 | 21 | /** A variant of Tuple.Concat that considers the right-hand type (B) before 22 | * the left-hand type. This enables it to produce smaller types for the 23 | * common case of appending tuples from left-to-right. 24 | */ 25 | type TupleConcat[A <: Tuple, B <: Tuple] <: Tuple = 26 | B match { 27 | case EmptyTuple => A 28 | case _ => 29 | A match { 30 | case EmptyTuple => B 31 | case _ => Tuple.Concat[A, B] 32 | } 33 | } 34 | 35 | /** A variant of Tuple.Append that treats Unit as the empty tuple. */ 36 | type TupleAppend[A <: Tuple, B] <: Tuple = 37 | B match { 38 | case Unit => A 39 | case EmptyTuple => A 40 | case _ => Tuple.Append[A, B] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /core/jvm/src/test/scala/krop/route/RequestSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | import krop.raise.Raise 20 | import munit.CatsEffectSuite 21 | import org.http4s.Method 22 | import org.http4s.Request as Http4sRequest 23 | import org.http4s.Uri 24 | import org.http4s.implicits.* 25 | 26 | class RequestSuite extends CatsEffectSuite { 27 | val simpleRequest = Request.get(Path.root) 28 | test("simple request matches GET /") { 29 | val request = 30 | Http4sRequest(method = Method.GET, uri = uri"http://example.org/") 31 | 32 | simpleRequest.parse(request)(using Raise.toOption).map(_.isDefined).assert 33 | } 34 | 35 | test("simple request doesn't match PUT /") { 36 | val request = 37 | Http4sRequest(method = Method.PUT, uri = uri"http://example.org/") 38 | 39 | simpleRequest.parse(request)(using Raise.toOption).map(_.isEmpty).assert 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /core/shared/src/test/scala/krop/route/FormCodecSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | import cats.data.Chain 20 | import munit.FunSuite 21 | import org.http4s.UrlForm 22 | 23 | class FormCodecSuite extends FunSuite { 24 | final case class Person(name: String, age: Int) 25 | val personCodec: FormCodec[Person] = FormCodec.derived[Person] 26 | 27 | test("encoding of simple case class FormCodec works as expected") { 28 | val person = Person(name = "Bob", age = 47) 29 | val urlForm = personCodec.encode(person) 30 | 31 | assertEquals(urlForm.get("name"), Chain("Bob")) 32 | assertEquals(urlForm.get("age"), Chain("47")) 33 | } 34 | 35 | test("decoding of simple case class FormCodec works as expected") { 36 | val either = 37 | personCodec.decode(UrlForm("name" -> "Bob", "age" -> "47")) 38 | 39 | assertEquals(either, Right(Person("Bob", 47))) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /asset/src/main/scala/krop/asset/package.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.asset 18 | 19 | import cats.effect.IO 20 | import fs2.hashing.* 21 | import fs2.io.file.* 22 | 23 | import java.security.MessageDigest 24 | 25 | extension (path: Path) { 26 | 27 | /** Calculate the MD5 hash of a file. */ 28 | def md5: IO[Hash] = 29 | Files.forIO 30 | .readAll(path) 31 | .through(Hashing.forIO.hash(HashAlgorithm.MD5)) 32 | .compile 33 | .lastOrError 34 | 35 | /** Calculate the MD5 hash of a file as a hexadecimal String */ 36 | def md5Hex: IO[HexString] = 37 | md5.map(hash => HexString.fromHash(hash)) 38 | } 39 | 40 | extension (string: String) { 41 | 42 | /** Calculate the MD5 hash of a file as a hexadecimal String */ 43 | def md5Hex: HexString = { 44 | val md = MessageDigest.getInstance("MD5") 45 | val bytes = md.digest(string.getBytes()) 46 | HexString.fromBytes(bytes) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /docs/src/pages/quick-start.md: -------------------------------------------------------------------------------- 1 | # Quick Start 2 | 3 | In this section we'll go through a quick example that illustrates all the major features of Krop. 4 | 5 | 6 | ## Project Template 7 | 8 | To get started, create a project from the Krop project template. 9 | 10 | ```sh 11 | sbt new creativescala/krop-fullstack.g8 12 | ``` 13 | 14 | This will prompt you for a few values then create a project for you. 15 | Change into the directory it created, and run sbt. Now within sbt run the command 16 | 17 | ```sh 18 | backend / run 19 | ``` 20 | 21 | This will start the server. Visit `http://localhost:8080/` to see the masterpiece you have just created. 22 | 23 | 24 | ## Manual Setup 25 | 26 | If you don't want to use the project template above, the steps for using Krop are: 27 | 28 | 1. Add the Krop dependency to your project's dependencies. 29 | 30 | ```scala 31 | libraryDependencies += "org.creativescala" %% "krop-core" % "@VERSION@" 32 | ``` 33 | 34 | 2. Fork when running the server, otherwise the server's socket will not released when the server finishes. 35 | 36 | ```scala 37 | run / fork := true 38 | ``` 39 | 3. (Optional) Configure Krop so it runs in development mode. 40 | 41 | ```scala 42 | run / javaOptions += "-Dkrop.mode=development" 43 | ``` 44 | 45 | 4. (Optional) Add a logging backend. 46 | 47 | ```scala 48 | libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.5.6" % Runtime 49 | ``` 50 | 51 | 52 | ## Imports 53 | 54 | To start using Krop you need to import the core Krop library. A single import is all you need. 55 | 56 | ```scala 57 | import krop.all.{*, given} 58 | ``` 59 | -------------------------------------------------------------------------------- /core/jvm/src/main/scala/krop/tool/cli/Cli.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.tool.cli 18 | 19 | import cats.syntax.all.* 20 | import com.comcast.ip4s.Port 21 | import com.monovore.decline.* 22 | import krop.all.port 23 | 24 | /** Serve command parameters. */ 25 | final case class Serve(port: Port) 26 | 27 | /** Migrate command parameters. */ 28 | final case class Migrate() 29 | 30 | /** Defines the Krop command-line parser. */ 31 | object Cli { 32 | 33 | /** Parser for the `serve` command */ 34 | val serveOpts: Opts[Serve] = 35 | Opts.subcommand("serve", "Serve the web application.") { 36 | Opts 37 | .option[Int]("port", "The port to use. Defaults to 8080.", short = "p") 38 | .orNone 39 | .map(opt => opt.flatMap(Port.fromInt)) 40 | .map(port => Serve(port.getOrElse(port"8080"))) 41 | } 42 | 43 | /** Parser for the `migrate` command */ 44 | val migrateOpts: Opts[Migrate] = 45 | Opts 46 | .subcommand("migrate", "Run the database migrations.") { 47 | Opts.unit 48 | } 49 | .as(Migrate()) 50 | } 51 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/tool/Tailwind.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.tool 18 | 19 | object Tailwind { 20 | enum Os { 21 | case Windows 22 | case Linux 23 | case MacOs 24 | } 25 | 26 | enum Arch { 27 | case x64 28 | case arm64 29 | } 30 | 31 | val os: Os = { 32 | val osName = System.getProperty("os.name") 33 | if osName.contains("Win") then Os.Windows 34 | else if osName.contains("Linux") then Os.Linux 35 | else if osName.contains("Mac") then Os.MacOs 36 | else 37 | throw new IllegalArgumentException( 38 | s"The os.name property of ${osName} is not one recognized by this tool. Please file a bug report." 39 | ) 40 | } 41 | 42 | val arch: Arch = { 43 | val osArch = System.getProperty("os.arch") 44 | if osArch.contains("amd64") then Arch.x64 45 | else if osArch.contains("aarch64") then Arch.arm64 46 | else 47 | throw new IllegalArgumentException( 48 | s"The os.arch property of ${osArch} is not one recognized by this tool. Please file a bug report." 49 | ) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/route/QueryParseFailure.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | /** Failure raised when query parsing fails. */ 20 | enum QueryParseFailure(val message: String) { 21 | 22 | /** Query parameter parsing failed because no parameter with the given name 23 | * was found in the query parameters. 24 | */ 25 | case NoParameterWithName(name: String) 26 | extends QueryParseFailure( 27 | s"There was no query parameter with the name ${name}." 28 | ) 29 | 30 | /** Query parameter parsing failed because there was a parameter with the 31 | * given name in the query parameters, but that parameter was not associated 32 | * with any values. 33 | */ 34 | case NoValuesForName(name: String) 35 | extends QueryParseFailure( 36 | s"There were no values associated with the name ${name}" 37 | ) 38 | 39 | case ValueParsingFailed(name: String, value: String, description: String) 40 | extends QueryParseFailure( 41 | s"Parsing the value ${value} as ${description} failed for the query parameter ${name}" 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /docs/src/pages/server.md: -------------------------------------------------------------------------------- 1 | # Server 2 | 3 | ```scala mdoc:invisible 4 | import cats.effect.IO 5 | import krop.all.* 6 | ``` 7 | 8 | ## Creating a Server 9 | 10 | A @:api(krop.Server) runs an @:api(krop.Application). Every `Application` needs a `Server`, which is usually constructed via a @:api(krop.ServerBuilder). 11 | 12 | Using a `ServerBuilder` can be as simple as 13 | 14 | ```scala mdoc:silent 15 | val app: Application = Application.notFound 16 | 17 | val builder = ServerBuilder.default.withApplication(app) 18 | ``` 19 | 20 | This uses the default settings (localhost and port 8080). There are builder methods that allow these to be changed. 21 | 22 | Once the builder options have been set, calling the `run` method will construct a `Server` and immediately run it. 23 | 24 | ```scala 25 | // If this wasn't just documentation we'd now have a server listening on port 8080. 26 | builder.run() 27 | ``` 28 | 29 | A `ServerBuilder` can also be converted a `Server` using the `build` method. 30 | 31 | ```scala mdoc:silent 32 | val server: Server = builder.build 33 | ``` 34 | 35 | A `Server` can then be `run`, or converted to an `IO[Unit]` using `toIO`. 36 | 37 | ```scala 38 | // Run the server immediately. 39 | server.run() 40 | ``` 41 | ```scala mdoc:silent 42 | // This IO can be run by an IOApp, for example 43 | val io: IO[Unit] = server.toIO 44 | ``` 45 | 46 | 47 | ## Setting Server Options 48 | 49 | Setting the server options, such as the port and host, are done by the builder methods on the `ServerBuilder`. Custom string contexts are provided for defining host and port. Concretely, this means writing code like 50 | 51 | ```scala mdoc:silent 52 | ServerBuilder.default.withPort(port"4000") 53 | // Host can be a name or an IP address 54 | ServerBuilder.default.withHost(host"127.0.0.1") 55 | ServerBuilder.default.withHost(host"localhost") 56 | ``` 57 | -------------------------------------------------------------------------------- /examples/src/main/scala/Htmx.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package examples 18 | 19 | import krop.all.* 20 | import krop.tool.Htmx.* 21 | import scalatags.Text.all.* 22 | 23 | object Htmx { 24 | val reverseRoute = 25 | Route( 26 | Request.get( 27 | Path / "reverse" :? Query[String]("word") 28 | ), 29 | Response.ok(Entity.scalatags) 30 | ) 31 | 32 | val index = 33 | html( 34 | body( 35 | h1("Htmx Example"), 36 | div(id := "reverse"), 37 | form( 38 | input(id := "word", name := "word", `type` := "text"), 39 | button( 40 | hxGet := reverseRoute.pathTo, 41 | hxInclude := "#word", 42 | hxTarget := "#reverse", 43 | "Reverse" 44 | ) 45 | ), 46 | script(src := "https://unpkg.com/htmx.org@1.9.6") 47 | ) 48 | ) 49 | 50 | val indexHandler = 51 | Route(Request.get(Path.root), Response.ok(Entity.scalatags)).handle(() => 52 | index 53 | ) 54 | 55 | val reverseHandler = 56 | reverseRoute.handle(string => p(string.reverse)) 57 | } 58 | 59 | @main def runHtmx() = 60 | ServerBuilder.default 61 | .withApplication( 62 | Htmx.indexHandler.orElse(Htmx.reverseHandler).orElseNotFound 63 | ) 64 | .run() 65 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/KropRuntime.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop 18 | 19 | import cats.effect.IO 20 | import org.http4s.server.websocket.WebSocketBuilder 21 | import org.typelevel.log4cats.Logger 22 | import org.typelevel.log4cats.LoggerFactory 23 | 24 | import java.util.concurrent.atomic.AtomicInteger 25 | 26 | /** Provides platform and server specific services and utilities that are 27 | * available after the http4s server has started. 28 | */ 29 | trait KropRuntime { 30 | given loggerFactory: LoggerFactory[IO] 31 | given logger: Logger[IO] 32 | 33 | def webSocketBuilder: WebSocketBuilder[IO] 34 | 35 | def getResource[V](key: Key[V]): V 36 | } 37 | 38 | final class Key[V] private (val id: Int, val description: String) { 39 | def get(using runtime: KropRuntime): V = 40 | runtime.getResource(this) 41 | 42 | override def hashCode(): Int = id 43 | override def equals(that: Any): Boolean = 44 | if that.isInstanceOf[Key[?]] 45 | then that.asInstanceOf[Key[?]].id == this.id 46 | else false 47 | } 48 | object Key { 49 | private val counter: AtomicInteger = AtomicInteger(0) 50 | 51 | private def nextId(): Int = { 52 | counter.getAndIncrement() 53 | } 54 | 55 | /** Creates a resource key without staging a resource. */ 56 | def unsafe[V](description: String): Key[V] = 57 | Key(nextId(), description) 58 | } 59 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/route/RouteHandler.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | import cats.effect.IO 20 | import krop.KropRuntime 21 | import krop.WithRuntime 22 | import krop.raise.Raise 23 | import org.http4s.Request as Http4sRequest 24 | import org.http4s.Response as Http4sResponse 25 | 26 | /** A RouteHandler is what actually handles an HTTP request and produces an HTTP 27 | * response. 28 | */ 29 | trait RouteHandler { 30 | 31 | /** Run this RouteHandler on the given request, producing a response or 32 | * possibly failing. 33 | */ 34 | def run[F[_, _]](request: Http4sRequest[IO])(using 35 | handle: Raise.Handler[F], 36 | runtime: KropRuntime 37 | ): IO[F[ParseFailure, Http4sResponse[IO]]] 38 | } 39 | object RouteHandler { 40 | def apply[E <: Tuple, R]( 41 | route: InternalRoute[E, R], 42 | handler: WithRuntime[E => IO[R]] 43 | ): RouteHandler = 44 | new RouteHandler { 45 | def run[F[_, _]](request: Http4sRequest[IO])(using 46 | handle: Raise.Handler[F], 47 | runtime: KropRuntime 48 | ): IO[F[ParseFailure, Http4sResponse[IO]]] = 49 | route.request 50 | .parse(request) 51 | .flatMap(extracted => 52 | Raise 53 | .mapToIO(extracted)(in => 54 | handler(in).flatMap(out => route.response.respond(request, out)) 55 | ) 56 | ) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/Mode.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop 18 | 19 | /** Krop can run in one of two modes: development and production. In development 20 | * mode it shows output that is useful for debugging and otherwise inspecting 21 | * the running state. In production this output is hidden. 22 | * 23 | * The mode is set by the krop.mode JVM system property. If it has the value of 24 | * "development" (without the quotes; any capitalization is fine) then the mode 25 | * is development. Otherwise it is production. 26 | * 27 | * The mode is determined when Krop starts. 28 | */ 29 | enum Mode { 30 | case Production 31 | case Development 32 | 33 | def isProduction: Boolean = 34 | this match { 35 | case Production => true 36 | case Development => false 37 | } 38 | 39 | def isDevelopment: Boolean = 40 | this match { 41 | case Production => false 42 | case Development => true 43 | } 44 | } 45 | object Mode { 46 | 47 | /** The name of the system property used to set the Krop mode. */ 48 | val modeProperty = "krop.mode" 49 | 50 | /** The mode in which Krop is running. */ 51 | val mode: Mode = { 52 | val property = System.getProperty(modeProperty) 53 | val m = 54 | if property == null then Mode.Production 55 | else if property.toLowerCase() == "development" then Mode.Development 56 | else Mode.Production 57 | 58 | m 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /core/jvm/src/test/scala/krop/route/TupleApplySuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | import cats.effect.IO 20 | import munit.FunSuite 21 | 22 | class TupleApplySuite extends FunSuite { 23 | 24 | val emptyRequest = Request.get(Path.root) 25 | val intRequest = Request.get(Path / Param.int) 26 | val intStringRequest = Request.get(Path / Param.int / Param.string) 27 | 28 | test("Type inference works for EmptyTuple") { 29 | val route = Route(emptyRequest, Response.ok(Entity.text)) 30 | 31 | route.handle(() => s"Ok!") 32 | route.handleIO(() => IO.pure(s"Ok!")) 33 | } 34 | 35 | test("Type inference works for Tuple1") { 36 | val route = Route(intRequest, Response.ok(Entity.text)) 37 | 38 | route.handle(i => s"$i Ok!") 39 | route.handleIO(i => IO.pure(s"$i Ok!")) 40 | } 41 | 42 | test("Type inference works for Tuple2") { 43 | val route = Route(intStringRequest, Response.ok(Entity.text)) 44 | 45 | route.handle((int, string) => s"${int.toString}: ${string}") 46 | route.handleIO((int, string) => IO.pure(s"${int.toString}: ${string}")) 47 | } 48 | 49 | test("Conversion works for EmptyTuple") { 50 | val f = TupleApply.emptyTupleFunction0Apply[String].tuple(() => "Yeah!") 51 | 52 | assertEquals(f.apply(EmptyTuple), "Yeah!") 53 | } 54 | 55 | test("Conversion works for Tuple1") { 56 | val f = TupleApply.tuple1Apply[Int, String].tuple(int => int.toString) 57 | 58 | assertEquals(f.apply(Tuple1(42)), "42") 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/Application.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop 18 | 19 | import cats.effect.IO 20 | import cats.effect.Resource 21 | import krop.route.Handlers 22 | import org.http4s.HttpApp 23 | 24 | /** An [[krop.Application]] produces a response for every HTTP request. Compare 25 | * to [[krop.router.Handler]], which may not produce a response for some 26 | * requests. 27 | * 28 | * @param handlers 29 | * The handlers this Application will test against any incoming request 30 | * @param supervisor 31 | * Responsible for constructing a HttpApp that matches all possible requests, 32 | * handling requests that the route doesn't match. 33 | */ 34 | final case class Application( 35 | handlers: Handlers, 36 | supervisor: ( 37 | Handlers, 38 | BaseRuntime 39 | ) => Resource[IO, WithRuntime[HttpApp[IO]]] 40 | ) { 41 | def toHttpApp(runtime: BaseRuntime): Resource[IO, WithRuntime[HttpApp[IO]]] = 42 | supervisor(handlers, runtime) 43 | } 44 | object Application { 45 | 46 | /** Construct an [[Application]] with no route and the given supervisor. */ 47 | def apply( 48 | supervisor: ( 49 | Handlers, 50 | BaseRuntime 51 | ) => Resource[IO, WithRuntime[HttpApp[IO]]] 52 | ): Application = 53 | Application(Handlers.empty, supervisor) 54 | 55 | /** The Application that returns 404 Not Found to all requests. See 56 | * [[krop.tool.NotFound]] for details on the implementation. 57 | */ 58 | val notFound: Application = 59 | krop.tool.NotFound.notFound 60 | } 61 | -------------------------------------------------------------------------------- /docs/src/pages/model/sqlite.md: -------------------------------------------------------------------------------- 1 | # SQLite 2 | 3 | [SQLite][sqlite] is an in-process database that is becoming more popular for web applications due to the simplicity of running it. It's appropriate for applications that don't need to expand beyond a single web server, which covers many applications due to the speed of modern computers. 4 | Krop provides SQLite integration via the [sqlite-jdbc][sqlite-jdbc] and [Magnum][magnum] projects. 5 | 6 | ## Using SQLite 7 | 8 | Krop's integration lives in a separate artifact. To use it you'll need to add the following dependency to your `build.sbt`. 9 | 10 | ```scala 11 | libraryDependencies += "org.creativescala" %% "krop-sqlite" % "@VERSION@" 12 | ``` 13 | 14 | 15 | ## Creating a Database Connection 16 | 17 | Connecting to a database is trivial, as SQLite only requires the name of the file that stores the database. If the file doesn't already exist it will be created. The code below shows how to connect to a database. This uses a default configuration that has been tuned to the needs of a typical web application. 18 | 19 | ```scala mdoc:silent 20 | import cats.effect.{IO, Resource} 21 | import krop.sqlite.{Sqlite, Transactor} 22 | 23 | val dbFile = "./database.sqlite3" 24 | val db: Resource[IO, Transactor] = Sqlite.create(dbFile) 25 | ``` 26 | 27 | If we wanted a custom configuration we could set one ourselves. If the example below we use an empty configuration, but we can call methods on the object returned by `empty` to customise the configuration. 28 | 29 | ```scala mdoc:silent 30 | Sqlite.create(dbFile, Sqlite.config.empty) 31 | ``` 32 | 33 | The value returned by `Sqlite.create` is a Cats Effect [Resource][resource] containing a @:api(krop.sqlite.Transactor). This means that nothing actually happens until we `use` the `Resource`, with code like the following. 34 | 35 | ```scala 36 | db.use { xa => 37 | // Use the Transactor, xa, here 38 | } 39 | ``` 40 | 41 | The @:api(krop.sqlite.Transactor) is a [Magnum][magnum] type to work with databases. 42 | 43 | 44 | [sqlite]: https://sqlite.org/ 45 | [sqlite-jdbc]: https://github.com/xerial/sqlite-jdbc 46 | [magnum]: https://github.com/augustnagro/magnum 47 | [resource]: https://typelevel.org/cats-effect/docs/std/resource 48 | -------------------------------------------------------------------------------- /asset/src/test/scala/krop/asset/AssetRouteSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.asset 18 | 19 | import cats.effect.IO 20 | import fs2.Stream 21 | import fs2.io.file.Files 22 | import fs2.io.file.Path as Fs2Path 23 | import krop.JvmKropRuntime 24 | import krop.JvmRuntime 25 | import krop.route.Path 26 | import munit.CatsEffectSuite 27 | import org.http4s.server.websocket.WebSocketBuilder 28 | 29 | class AssetRouteSuite extends CatsEffectSuite { 30 | val files = Files.forIO 31 | 32 | def makeFiles( 33 | base: Fs2Path, 34 | filesAndContent: List[(String, String)] 35 | ): IO[Unit] = 36 | Stream 37 | .emits(filesAndContent) 38 | .flatMap { case (fileName, content) => 39 | val file = base / fileName 40 | Stream(content).through(files.writeUtf8(file)) 41 | } 42 | .compile 43 | .drain 44 | 45 | test("AssetRoute.asset returns correct href") { 46 | val baseRuntime = JvmRuntime.base 47 | val path = Path / "assets" 48 | val resource = 49 | for { 50 | dir <- files.tempDirectory 51 | route = AssetRoute(path, dir.toString) 52 | _ <- makeFiles(dir, List("a.txt" -> "ocelittle")).toResource 53 | handler <- route.build(baseRuntime) 54 | builder <- WebSocketBuilder[IO].toResource 55 | runtime <- baseRuntime.buildResources.map(r => 56 | JvmKropRuntime(baseRuntime, r, builder) 57 | ) 58 | } yield assertEquals( 59 | route.asset("a.txt")(using runtime), 60 | s"${path.pathTo(EmptyTuple)}/a-${"ocelittle".md5Hex}.txt" 61 | ) 62 | 63 | resource.use_ 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /asset/src/test/scala/krop/asset/FileNameHasherSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.asset 18 | 19 | import cats.effect.IO 20 | import fs2.io.file.Path 21 | import munit.CatsEffectSuite 22 | 23 | class FileNameHasherSuite extends CatsEffectSuite { 24 | test( 25 | "FileNameHasher.hash add given hex string in expected location to path" 26 | ) { 27 | val hashed = 28 | FileNameHasher.hash(Path("/a/b/c.txt"), HexString.unsafeApply("1234")) 29 | 30 | IO(assertEquals(hashed, Path("/a/b/c-1234.txt"))) 31 | } 32 | 33 | test( 34 | "FileNameHasher.hash adds hash to path without extension" 35 | ) { 36 | val hashed = 37 | FileNameHasher.hash(Path("/a/b/c"), HexString.unsafeApply("1234")) 38 | 39 | IO(assertEquals(hashed, Path("/a/b/c-1234"))) 40 | } 41 | 42 | test( 43 | "FileNameHasher.unhash removes hex string from path" 44 | ) { 45 | val original = Path("/a/b/c.txt") 46 | val hashed = FileNameHasher.hash(original, HexString.unsafeApply("1234")) 47 | val unhashed = FileNameHasher.unhash(hashed) 48 | 49 | IO(assertEquals(unhashed, original)) 50 | } 51 | 52 | test( 53 | "FileNameHasher.unhash removes hex string from path without extension" 54 | ) { 55 | val original = Path("/a/b/c") 56 | val hashed = FileNameHasher.hash(original, HexString.unsafeApply("1234")) 57 | val unhashed = FileNameHasher.unhash(hashed) 58 | 59 | IO(assertEquals(unhashed, original)) 60 | } 61 | 62 | test( 63 | "FileNameHasher.unhash does not modify path if hex string not found" 64 | ) { 65 | val original = Path("/a/b/c.txt") 66 | val unhashed = FileNameHasher.unhash(original) 67 | 68 | IO(assertEquals(unhashed, original)) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /core/jvm/src/main/scala/krop/JvmRuntime.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop 18 | 19 | import cats.effect.IO 20 | import cats.effect.Resource 21 | import cats.syntax.all.* 22 | import org.http4s.server.websocket.WebSocketBuilder 23 | import org.typelevel.log4cats.Logger 24 | import org.typelevel.log4cats.LoggerFactory 25 | import org.typelevel.log4cats.slf4j.Slf4jFactory 26 | 27 | import scala.collection.concurrent.TrieMap 28 | 29 | final class JvmKropRuntime( 30 | base: BaseRuntime, 31 | resources: Map[Key[?], Any], 32 | val webSocketBuilder: WebSocketBuilder[IO] 33 | ) extends KropRuntime { 34 | given loggerFactory: LoggerFactory[IO] = base.loggerFactory 35 | given logger: Logger[IO] = base.logger 36 | 37 | def getResource[V](key: Key[V]): V = 38 | resources(key).asInstanceOf[V] 39 | } 40 | 41 | final class JvmBaseRuntime() extends BaseRuntime { 42 | given loggerFactory: LoggerFactory[IO] = Slf4jFactory.create[IO] 43 | given logger: Logger[IO] = loggerFactory.getLoggerFromName("krop-core") 44 | 45 | /** Resources that will be made available in the KropRuntime. */ 46 | private val stagedResources: TrieMap[Key[?], Resource[IO, Any]] = 47 | TrieMap.empty 48 | 49 | def stageResource[V](key: Key[V], value: Resource[IO, V]): Unit = 50 | stagedResources += (key -> value) 51 | 52 | def buildResources: Resource[IO, Map[Key[?], Any]] = 53 | stagedResources.toSeq.foldM(Map.empty[Key[?], Any]) { (map, kv) => 54 | val (key, resource) = kv 55 | resource.map { v => 56 | map + (key -> v) 57 | } 58 | } 59 | } 60 | object JvmRuntime { 61 | val base: JvmBaseRuntime = JvmBaseRuntime() 62 | 63 | def krop(builder: WebSocketBuilder[IO]): JvmKropRuntime = 64 | JvmKropRuntime(base, Map.empty, builder) 65 | } 66 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/route/Handlers.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | import cats.data.Chain 20 | import krop.Application 21 | import krop.tool.NotFound 22 | 23 | /** [[package.Handlers]] are a collection of zero or more [[package.Handler]]. 24 | */ 25 | final class Handlers(val handlers: Chain[Handler]) { 26 | 27 | /** Create a [[package.Handlers]] that tries first these handlers, and if they 28 | * fail to match, the route in the given parameter. 29 | */ 30 | def orElse(that: Handler): Handlers = 31 | Handlers(this.handlers :+ that) 32 | 33 | /** Create a [[package.Handlers]] that tries first these handlers, and if they 34 | * fail to match, the handlers in the given parameter. 35 | */ 36 | def orElse(that: Handlers): Handlers = 37 | Handlers(this.handlers ++ that.handlers) 38 | 39 | /** Convert these [[package.Handlers]] into an [[krop.Application]] that first 40 | * tries these Handlers and, if they fail to match, passes the request to the 41 | * Application. 42 | */ 43 | def orElse(app: Application): Application = 44 | app.copy(handlers = this.orElse(app.handlers)) 45 | 46 | /** Convert these [[package.Handlers]] into an [[krop.Application]] by 47 | * responding to all unmatched requests with a NotFound (404) response. 48 | */ 49 | def orElseNotFound: Application = 50 | this.orElse(NotFound.notFound) 51 | 52 | // /** Convert to the representation used by http4s */ 53 | // def toHttpRoutes(using runtime: KropRuntime): Resource[IO, HttpRoutes[IO]] = 54 | // this.handlers.foldLeft(Resource.eval(IO.pure(HttpRoutes.empty[IO])))( 55 | // (accum, handler) => accum.combineK(handler.build(runtime)) 56 | // ) 57 | } 58 | object Handlers { 59 | 60 | /** The empty [[package.Handlers]], which don't match any request. */ 61 | val empty: Handlers = new Handlers(Chain.empty[Handler]) 62 | } 63 | -------------------------------------------------------------------------------- /core/jvm/src/test/scala/krop/route/ParamSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | import munit.FunSuite 20 | 21 | class ParamSuite extends FunSuite { 22 | def paramOneDecodesValid[A](param: Param.One[A], values: Seq[(String, A)])( 23 | using munit.Location 24 | ) = 25 | values.foreach { case (str, a) => 26 | assertEquals(param.decode(str), Right(a)) 27 | } 28 | 29 | def paramOneDecodesInvalid[A](param: Param.One[A], values: Seq[String])(using 30 | munit.Location 31 | ) = 32 | values.foreach { (str) => assert(param.decode(str).isLeft) } 33 | 34 | def paramAllDecodesValid[A]( 35 | param: Param.All[A], 36 | values: Seq[(Seq[String], A)] 37 | )(using 38 | munit.Location 39 | ) = 40 | values.foreach { case (strings, a) => 41 | assertEquals(param.decode(strings), Right(a)) 42 | } 43 | 44 | test("Param.one decodes valid parameter") { 45 | paramOneDecodesValid( 46 | Param.int, 47 | Seq(("1" -> 1), ("42" -> 42), ("-10" -> -10)) 48 | ) 49 | paramOneDecodesValid( 50 | Param.string, 51 | Seq( 52 | ("a" -> "a"), 53 | ("42" -> "42"), 54 | ("baby you and me" -> "baby you and me") 55 | ) 56 | ) 57 | } 58 | 59 | test("Param.one fails to decode invalid parameter") { 60 | paramOneDecodesInvalid(Param.int, Seq("a", " ", "xyz")) 61 | } 62 | 63 | test("Param.all decodes valid parameters") { 64 | paramAllDecodesValid( 65 | Param.seq, 66 | Seq(Seq() -> Seq(), Seq("a", "b", "c") -> Seq("a", "b", "c")) 67 | ) 68 | paramAllDecodesValid( 69 | Param.separatedString(","), 70 | Seq(Seq() -> "", Seq("a") -> "a", Seq("a", "b", "c") -> "a,b,c") 71 | ) 72 | paramAllDecodesValid( 73 | Param.all[Int], 74 | Seq( 75 | Seq() -> Seq(), 76 | Seq("1") -> Seq(1), 77 | Seq("1", "2", "3") -> Seq(1, 2, 3) 78 | ) 79 | ) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /.github/workflows/clean.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Clean 9 | 10 | on: push 11 | 12 | jobs: 13 | delete-artifacts: 14 | name: Delete Artifacts 15 | runs-on: ubuntu-latest 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | steps: 19 | - name: Delete artifacts 20 | run: | 21 | # Customize those three lines with your repository and credentials: 22 | REPO=${GITHUB_API_URL}/repos/${{ github.repository }} 23 | 24 | # A shortcut to call GitHub API. 25 | ghapi() { curl --silent --location --user _:$GITHUB_TOKEN "$@"; } 26 | 27 | # A temporary file which receives HTTP response headers. 28 | TMPFILE=/tmp/tmp.$$ 29 | 30 | # An associative array, key: artifact name, value: number of artifacts of that name. 31 | declare -A ARTCOUNT 32 | 33 | # Process all artifacts on this repository, loop on returned "pages". 34 | URL=$REPO/actions/artifacts 35 | while [[ -n "$URL" ]]; do 36 | 37 | # Get current page, get response headers in a temporary file. 38 | JSON=$(ghapi --dump-header $TMPFILE "$URL") 39 | 40 | # Get URL of next page. Will be empty if we are at the last page. 41 | URL=$(grep '^Link:' "$TMPFILE" | tr ',' '\n' | grep 'rel="next"' | head -1 | sed -e 's/.*.*//') 42 | rm -f $TMPFILE 43 | 44 | # Number of artifacts on this page: 45 | COUNT=$(( $(jq <<<$JSON -r '.artifacts | length') )) 46 | 47 | # Loop on all artifacts on this page. 48 | for ((i=0; $i < $COUNT; i++)); do 49 | 50 | # Get name of artifact and count instances of this name. 51 | name=$(jq <<<$JSON -r ".artifacts[$i].name?") 52 | ARTCOUNT[$name]=$(( $(( ${ARTCOUNT[$name]} )) + 1)) 53 | 54 | id=$(jq <<<$JSON -r ".artifacts[$i].id?") 55 | size=$(( $(jq <<<$JSON -r ".artifacts[$i].size_in_bytes?") )) 56 | printf "Deleting '%s' #%d, %'d bytes\n" $name ${ARTCOUNT[$name]} $size 57 | ghapi -X DELETE $REPO/actions/artifacts/$id 58 | done 59 | done 60 | -------------------------------------------------------------------------------- /core/jvm/src/test/scala/krop/route/PathUnparseSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | import munit.FunSuite 20 | import org.http4s.Uri 21 | 22 | class PathUnparseSuite extends FunSuite { 23 | val rootPath = Path.root 24 | val nonCapturingPath = Path / "user" / "create" 25 | val nonCapturingAllPath = Path / "assets" / "html" / Segment.all 26 | val capturingAllPath = Path / "assets" / "html" / Param.seq 27 | val simplePath = Path / "user" / Param.int.withName("") / "view" 28 | val pathWithQuery = Path / "user" / "view" :? Query[Int]("id") 29 | 30 | test("Root path unparses to expected Uri") { 31 | assertEquals( 32 | rootPath.unparse(EmptyTuple), 33 | Uri(path = Uri.Path.Root) 34 | ) 35 | } 36 | 37 | test("Non-capturing path unparses to expected Uri") { 38 | assertEquals( 39 | nonCapturingPath.unparse(EmptyTuple), 40 | Uri(path = Uri.Path.Root / "user" / "create") 41 | ) 42 | } 43 | 44 | test("Non-capturing all path unparses to expected Uri") { 45 | assertEquals( 46 | nonCapturingAllPath.unparse(EmptyTuple), 47 | Uri(path = (Uri.Path.Root / "assets" / "html").addEndsWithSlash) 48 | ) 49 | } 50 | 51 | test("Capturing all path unparses to expected Uri") { 52 | assertEquals( 53 | capturingAllPath.unparse(Seq("style.css") *: EmptyTuple), 54 | Uri(path = Uri.Path.Root / "assets" / "html" / "style.css") 55 | ) 56 | } 57 | 58 | test("Capturing path unparses to expected Uri") { 59 | assertEquals( 60 | simplePath.unparse(1234 *: EmptyTuple), 61 | Uri(path = Uri.Path.Root / "user" / "1234" / "view") 62 | ) 63 | } 64 | 65 | test("Path with query unparses to expected Uri") { 66 | assertEquals( 67 | pathWithQuery.unparse(1234 *: EmptyTuple), 68 | Uri( 69 | path = Uri.Path.Root / "user" / "view", 70 | query = org.http4s.Query("id" -> Some("1234")) 71 | ) 72 | ) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /core/jvm/src/test/scala/krop/route/ResponseSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | import cats.effect.IO 20 | import krop.JvmRuntime 21 | import munit.CatsEffectSuite 22 | import org.http4s.Method 23 | import org.http4s.Request as Http4sRequest 24 | import org.http4s.Uri 25 | import org.http4s.implicits.* 26 | import org.http4s.server.websocket.WebSocketBuilder 27 | 28 | class ResponseSuite extends CatsEffectSuite { 29 | val staticResourceResponse = 30 | Response.staticResource("/krop/assets/") 31 | 32 | test("static resource response succeeds when resource exists") { 33 | val request = 34 | Http4sRequest(method = Method.GET, uri = uri"http://example.org/") 35 | 36 | for { 37 | builder <- WebSocketBuilder[IO] 38 | runtime = JvmRuntime.krop(builder) 39 | response <- staticResourceResponse 40 | .respond(request, "pico.min.css")(using runtime) 41 | .map(_.status.isSuccess) 42 | .assert 43 | } yield response 44 | } 45 | 46 | test("static resource response fails when resource does not exist") { 47 | val request = 48 | Http4sRequest(method = Method.GET, uri = uri"http://example.org/") 49 | 50 | for { 51 | builder <- WebSocketBuilder[IO] 52 | runtime = JvmRuntime.krop(builder) 53 | response <- staticResourceResponse 54 | .respond(request, "bogus.css")(using runtime) 55 | .map(!_.status.isSuccess) 56 | .assert 57 | } yield response 58 | } 59 | 60 | test("static file response succeeds when file exists") { 61 | val request = 62 | Http4sRequest(method = Method.GET, uri = uri"http://example.org/") 63 | 64 | for { 65 | builder <- WebSocketBuilder[IO] 66 | runtime = JvmRuntime.krop(builder) 67 | response <- Response 68 | .staticFile("project/plugins.sbt") 69 | .respond(request, ())(using runtime) 70 | .map(_.status.isSuccess) 71 | .assert 72 | } yield response 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /docs/src/pages/controller/route/assets.md: -------------------------------------------------------------------------------- 1 | # Assets 2 | 3 | Asset routes in Krop provide special support for serving assets. Assets are files, such as CSS stylesheets, that are part of your web application and not written in Scala. An asset route does two things: 4 | 5 | 1. It monitors a directory of files, calculating a hash of each file in the directory and updating that hash whenever it changes. 6 | 7 | 2. It constructs names for asset files that include the hash of the file. The means each version of the file is served with a unique file name, preventing web browsers from caching and using out-dated versions of assets. 8 | 9 | 10 | ## Using Assets 11 | 12 | If you aren't using the [project template](../../quick-start.md) you will need to add the following dependency to your `build.sbt`: 13 | 14 | ```scala 15 | libraryDependencies += "org.creativescala" %% "krop-asset" % "@VERSION@" 16 | ``` 17 | 18 | 19 | ## Creating an Asset Route 20 | 21 | An @:api(krop.asset.AssetRoute) is constructed by giving it a @:api(krop.route.Path) under which to serve assets, and a `String` specifying a directory to monitor. So, for example, if we want to serve assets under the path `/assets`, and those assets live under the directory `resources/myapp/assets` (relative the project root directory) we would create an `AssetRoute` as 22 | 23 | ```scala mdoc:silent 24 | import krop.all.* 25 | import krop.asset.AssetRoute 26 | 27 | val assets = AssetRoute(Path / "assets", "resources/myapp/assets") 28 | ``` 29 | 30 | Note we need to `import krop.asset.AssetRoute`; this not part of the core library we import with `krop.all.*`. 31 | 32 | An asset route is also a handler, which we add to our application in the usual way. Code like the following will do. 33 | 34 | ```scala 35 | assets.orElse(theApplication) 36 | ``` 37 | 38 | 39 | ## Linking to an Asset 40 | 41 | Call the `asset` method on an asset route when you want to link to an asset, for example in a template. Pass the method the path to the asset, relative to the directory you passed the asset route on construction. 42 | 43 | For example, if we have a file `resources/myapp/assets/css/myapp.css` we would call 44 | 45 | ```scala 46 | assets.asset("css/myapp.css") 47 | // res: String = /assets/css/myapp-1234.css 48 | ``` 49 | 50 | Notice the result includes the `Path` we created the asset route with. It also includes the value of a hash of the file (in the example replaced with `1234` for simplicity.) This value changes every time the file changes, and thus prevents the browser from using a stale cached copy. 51 | 52 | The `asset` method requires a given `KropRuntime` value, which is available to all handlers when they handle a request. 53 | -------------------------------------------------------------------------------- /docs/src/pages/controller/handler.md: -------------------------------------------------------------------------------- 1 | # Handlers 2 | 3 | ```scala mdoc:invisible 4 | import cats.effect.IO 5 | import krop.all.* 6 | ``` 7 | 8 | A @:api(krop.route.Route) extracts Scala values from an HTTP request, and converts Scala values into an HTTP response. A @:api(krop.route.Handler) adds the functionality that connects these two parts together, giving a complete controller that can be run as part of an application. 9 | 10 | There are three ways to create a handler: using `handle`, `handleIO`, or `passthrough`. Assume the request produces a value of type `A` and the response needs a value of type `B`. Then these three methods have the following meaning: 11 | 12 | - `handle` requires a function `A => B`; 13 | - `handleIO` requires a function `A => IO[B]`; and 14 | - `passthrough`, which can only be called when `A` is the same type as `B`, means that the output of the request is connected directly to the input of the response. This is useful, for example, when the response is loading a static file from the file system and the request produces the name of the file to load. 15 | 16 | Let's say we have the following `Route`. 17 | 18 | ```scala mdoc:silent 19 | val route = 20 | Route( 21 | Request.get(Path / "user" / Param.int), 22 | Response.ok(Entity.text) 23 | ) 24 | ``` 25 | 26 | It produces an `Int` from the request, and requires a `String` to create the response. We can create a `Handler` in the following two ways: 27 | 28 | ```scala mdoc:silent 29 | route.handle(userId => s"You asked for user $userId") 30 | route.handleIO(userId => IO.pure(s"You asked for user $userId")) 31 | ``` 32 | 33 | We cannot use `passthrough` as the value produced from the request has a different type to the value required to create the response. 34 | 35 | 36 | ### Type Transformations for Handlers 37 | 38 | If you dig into the types produced by `Request` you will notice a lot of tuple types are used. Here's an example, showing a `Request` producing a `Tuple2`. 39 | 40 | ```scala mdoc 41 | val request = Request.get(Path / Param.int / Param.string) 42 | ``` 43 | 44 | This `Tuple2` arises because we extract two elements from the HTTP request's path: one `Int` and one `String`. 45 | However, when you come to use a handler with such a request, you can use a normal function with two arguments *not* a function that accepts a single `Tuple2`. 46 | 47 | ```scala mdoc:silent 48 | Route(request, Response.ok(Entity.text)) 49 | .handle((int, string) => s"${int.toString}: ${string}") 50 | ``` 51 | 52 | The conversion between tuples and functions is done by given instances of @:api(krop.route.TupleApply), which allows a function `(A, B, ..., N) => Z` to be applied to a tuple `(A, B, ..., N)` 53 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/route/Route.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | /** A [[krop.Route]] describes an HTTP request and an HTTP response, 20 | * encapsulating the HTTP specific parts of an endpoint. 21 | * 22 | * @tparam C 23 | * The type of the values used to construct a request. 24 | * @tparam Path 25 | * The type of the parameters extracted from the [[package.Path]]. 26 | * @tparam Query 27 | * The type of the query parameters extracted from the [[package.Path]]. 28 | * @tparam E 29 | * The type of all the values extracted from the request. 30 | * @tparam R 31 | * The type of the value used to build the [[package.Response]]. 32 | * @tparam P 33 | * The type of the value produced in the [[package.Response]]. 34 | */ 35 | trait Route[C <: Tuple, Path <: Tuple, Query <: Tuple, E <: Tuple, R, P] 36 | extends ClientRoute[C, P], 37 | HandleableRoute[E, R], 38 | ReversibleRoute[Path, Query], 39 | BaseRoute { 40 | 41 | /** The [[krop.route.Request]] associated with this Route. */ 42 | def request: Request[C, Path, Query, E] 43 | 44 | /** The [[krop.route.Response]] associated with this Route. */ 45 | def response: Response[R, P] 46 | 47 | } 48 | object Route { 49 | 50 | /** Represents the common case of a Route as a simple container for a Request 51 | * and a Response. 52 | */ 53 | private final class BasicRoute[ 54 | C <: Tuple, 55 | Path <: Tuple, 56 | Query <: Tuple, 57 | E <: Tuple, 58 | R, 59 | P 60 | ](val request: Request[C, Path, Query, E], val response: Response[R, P]) 61 | extends Route[C, Path, Query, E, R, P] 62 | 63 | /** Construct a [[krop.route.Route]] from a [[krop.route.Request]] and a 64 | * [[krop.route.Response]]. 65 | */ 66 | def apply[C <: Tuple, Path <: Tuple, Query <: Tuple, E <: Tuple, R, P]( 67 | request: Request[C, Path, Query, E], 68 | response: Response[R, P] 69 | ): Route[C, Path, Query, E, R, P] = 70 | BasicRoute(request, response) 71 | } 72 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/route/HandleableRoute.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | import cats.effect.IO 20 | import krop.KropRuntime 21 | import krop.WithRuntime 22 | 23 | /** Adds the handler API to an internal route. 24 | */ 25 | trait HandleableRoute[E <: Tuple, R] extends InternalRoute[E, R] { 26 | import HandleableRoute.{HandlerIOBuilder, HandlerPureBuilder} 27 | 28 | /** Handler incoming requests with the given function. */ 29 | def handle(using ta: TupleApply[E, R]): HandlerPureBuilder[E, ta.Fun, R] = 30 | HandlerPureBuilder(this, ta) 31 | 32 | /** Handler incoming requests with the given function. */ 33 | def handleIO(using ta: TupleApply[E, IO[R]]): HandlerIOBuilder[E, ta.Fun, R] = 34 | HandlerIOBuilder(this, ta) 35 | 36 | /** Pass the result of parsing the request directly the response with no 37 | * modification. 38 | */ 39 | def passthrough(using pb: PassthroughBuilder[E, R]): Handler = 40 | Handler(this, runtime ?=> pb.build) 41 | } 42 | object HandleableRoute { 43 | 44 | /** This class exists to help type inference when constructing a Handler from 45 | * a Route. 46 | */ 47 | final class HandlerPureBuilder[E <: Tuple, F, R]( 48 | route: HandleableRoute[E, R], 49 | ta: TupleApply.Aux[E, F, R] 50 | ) { 51 | def apply(f: F): Handler = { 52 | val handle = ta.tuple(f) 53 | Handler(route, runtime ?=> i => IO.pure(handle(i))) 54 | } 55 | 56 | def apply(f: WithRuntime[F]): Handler = { 57 | val handle = (runtime: KropRuntime) ?=> ta.tuple(f) 58 | Handler(route, runtime ?=> i => IO.pure(handle(i))) 59 | } 60 | } 61 | 62 | /** This class exists to help type inference when constructing a Handler from 63 | * a Route. 64 | */ 65 | final class HandlerIOBuilder[E <: Tuple, F, R]( 66 | route: HandleableRoute[E, R], 67 | ta: TupleApply.Aux[E, F, IO[R]] 68 | ) { 69 | def apply(f: F): Handler = 70 | Handler(route, runtime ?=> ta.tuple(f)) 71 | 72 | def apply(f: WithRuntime[F]): Handler = 73 | Handler(route, runtime ?=> ta.tuple(f)) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /sqlite/src/main/scala/krop/sqlite/Sqlite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.sqlite 18 | 19 | import cats.effect.IO 20 | import cats.effect.Resource 21 | import org.sqlite.SQLiteConfig 22 | import org.sqlite.SQLiteConfig.JournalMode 23 | import org.sqlite.SQLiteConfig.Pragma 24 | import org.sqlite.SQLiteConfig.SynchronousMode 25 | import org.sqlite.SQLiteDataSource 26 | 27 | final class Sqlite private (filename: String, config: SQLiteConfig) { 28 | def create: Resource[IO, Transactor] = { 29 | val dataSource = SQLiteDataSource(config) 30 | dataSource.setUrl(s"jdbc:sqlite:./${filename}") 31 | Transactor(dataSource).toResource 32 | } 33 | } 34 | object Sqlite { 35 | object config { 36 | 37 | /** A configuration that includes some sensible defaults for web 38 | * applications: 39 | * 40 | * - Write-ahead Log is turned on 41 | * - Sync mode is normal 42 | * - Journal size limit is 64MB 43 | * - Cache size is 2000 pages (8MB) 44 | * - MMAP size is 128MB 45 | * 46 | * The `SQLiteConfig` object is mutable, so this method creates a new value 47 | * each time it is called. 48 | */ 49 | def default: SQLiteConfig = { 50 | val config = SQLiteConfig() 51 | // The values are the defaults that Rails uses, which have been tuned for 52 | // web applications. They are detailed at 53 | // https://github.com/rails/rails/pull/49349 54 | config.setJournalMode(JournalMode.WAL) 55 | config.setSynchronous(SynchronousMode.NORMAL) 56 | config.setJournalSizeLimit(64 * 1024 * 1024) // 64MB 57 | config.setCacheSize(2000) // 8MB assuming 4KB pages 58 | config.setPragma(Pragma.MMAP_SIZE, (128 * 1024 * 1024).toString) // 128 MB 59 | config 60 | } 61 | 62 | /** Return an empty `SQLiteConfig` object. 63 | * 64 | * The `SQLiteConfig` object is mutable, so this method creates a new value 65 | * each time it is called. 66 | */ 67 | def empty: SQLiteConfig = SQLiteConfig() 68 | } 69 | 70 | def create(filename: String): Resource[IO, Transactor] = 71 | Sqlite(filename, config.default).create 72 | 73 | def create( 74 | filename: String, 75 | config: SQLiteConfig 76 | ): Resource[IO, Transactor] = 77 | Sqlite(filename, config).create 78 | } 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/scala,intellij,eclipse,sbt 2 | 3 | ### Scala ### 4 | *.class 5 | *.log 6 | 7 | # sbt specific 8 | .cache 9 | .cache-main 10 | .history 11 | .lib/ 12 | dist/* 13 | target/ 14 | lib_managed/ 15 | src_managed/ 16 | project/boot/ 17 | project/plugins/project/ 18 | project/target 19 | project/project 20 | 21 | # Scala-IDE specific 22 | .scala_dependencies 23 | .worksheet 24 | 25 | # Documentation intermediate files 26 | docs/src/main/paradox 27 | docs/src/main/mdoc/api 28 | 29 | 30 | ### SublimeText ### 31 | doodle.sublime-project 32 | # cache files for sublime text 33 | *.tmlanguage.cache 34 | *.tmPreferences.cache 35 | *.stTheme.cache 36 | 37 | # workspace files are user-specific 38 | *.sublime-workspace 39 | 40 | # project files should be checked into the repository, unless a significant 41 | # proportion of contributors will probably not be using SublimeText 42 | # *.sublime-project 43 | 44 | # sftp configuration file 45 | sftp-config.json 46 | 47 | 48 | ### Intellij ### 49 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio 50 | 51 | *.iml 52 | 53 | ## Directory-based project format: 54 | .idea/ 55 | # if you remove the above rule, at least ignore the following: 56 | 57 | # User-specific stuff: 58 | # .idea/workspace.xml 59 | # .idea/tasks.xml 60 | # .idea/dictionaries 61 | 62 | # Sensitive or high-churn files: 63 | # .idea/dataSources.ids 64 | # .idea/dataSources.xml 65 | # .idea/sqlDataSources.xml 66 | # .idea/dynamic.xml 67 | # .idea/uiDesigner.xml 68 | 69 | # Gradle: 70 | # .idea/gradle.xml 71 | # .idea/libraries 72 | 73 | # Mongo Explorer plugin: 74 | # .idea/mongoSettings.xml 75 | 76 | ## File-based project format: 77 | *.ipr 78 | *.iws 79 | 80 | ## Plugin-specific files: 81 | 82 | # IntelliJ 83 | /out/ 84 | 85 | # mpeltonen/sbt-idea plugin 86 | .idea_modules/ 87 | 88 | # JIRA plugin 89 | atlassian-ide-plugin.xml 90 | 91 | # Crashlytics plugin (for Android Studio and IntelliJ) 92 | com_crashlytics_export_strings.xml 93 | crashlytics.properties 94 | crashlytics-build.properties 95 | 96 | 97 | ### Eclipse ### 98 | *.pydevproject 99 | .metadata 100 | .gradle 101 | bin/ 102 | tmp/ 103 | *.tmp 104 | *.bak 105 | *.swp 106 | *~.nib 107 | local.properties 108 | .settings/ 109 | .loadpath 110 | 111 | # Eclipse Core 112 | .project 113 | 114 | # External tool builders 115 | .externalToolBuilders/ 116 | 117 | # Locally stored "Eclipse launch configurations" 118 | *.launch 119 | 120 | # CDT-specific 121 | .cproject 122 | 123 | # JDT-specific (Eclipse Java Development Tools) 124 | .classpath 125 | 126 | # Java annotation processor (APT) 127 | .factorypath 128 | 129 | # PDT-specific 130 | .buildpath 131 | 132 | # sbteclipse plugin 133 | .target 134 | 135 | # TeXlipse plugin 136 | .texlipse 137 | 138 | # Metals and Bloop 139 | .bsp 140 | .metals 141 | .bloop 142 | project/metals.sbt 143 | 144 | .sbt-hydra-history 145 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/route/StringCodec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | /** A StringCodec encodes a value as a String, and decodes a value from a 20 | * String. StringCodecs are used for handling: 21 | * 22 | * - path segments; 23 | * - query parameters; and 24 | * - form submission. 25 | */ 26 | trait StringCodec[A] { 27 | 28 | /** A short description of this codec. By convention the name of the type this 29 | * codec encodes enclosed within angle brackets. 30 | */ 31 | def name: String 32 | def decode(value: String): Either[DecodeFailure, A] 33 | def encode(value: A): String 34 | 35 | /** Construct a `StringCodec[B]` from a `StringCodec[A]` using functions to 36 | * convert from A to B and B to A. 37 | * 38 | * You should probably call `withName` after `imap`, to give the 39 | * `StringCodec` you just created a more appropriate name. 40 | */ 41 | def imap[B](f: A => B)(g: B => A): StringCodec[B] = { 42 | val self = this 43 | new StringCodec[B] { 44 | val name: String = self.name 45 | 46 | def decode(value: String): Either[DecodeFailure, B] = 47 | self.decode(value).map(f) 48 | 49 | def encode(value: B): String = self.encode(g(value)) 50 | } 51 | } 52 | 53 | /** Create a new [[StringCodec]] that works exactly the same as this 54 | * [[StringCodec]] except it has the given name. 55 | */ 56 | def withName(newName: String): StringCodec[A] = { 57 | val self = this 58 | new StringCodec[A] { 59 | val name: String = newName 60 | 61 | def decode(value: String): Either[DecodeFailure, A] = 62 | self.decode(value) 63 | 64 | def encode(value: A): String = self.encode(value) 65 | } 66 | } 67 | } 68 | object StringCodec { 69 | given int: StringCodec[Int] = 70 | new StringCodec[Int] { 71 | val name: String = "" 72 | 73 | def decode(value: String): Either[DecodeFailure, Int] = 74 | value.toIntOption.toRight(DecodeFailure(value, name)) 75 | 76 | def encode(value: Int): String = value.toString 77 | } 78 | 79 | given string: StringCodec[String] = 80 | new StringCodec[String] { 81 | val name: String = "" 82 | 83 | def decode(value: String): Either[DecodeFailure, String] = 84 | Right(value) 85 | 86 | def encode(value: String): String = value 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /project/Dependencies.scala: -------------------------------------------------------------------------------- 1 | import sbt.* 2 | import org.scalajs.sbtplugin.ScalaJSPlugin 3 | import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport.* 4 | import org.portablescala.sbtplatformdeps.PlatformDepsPlugin.autoImport.* 5 | 6 | object Dependencies { 7 | // Library Versions 8 | val catsVersion = "2.10.0" 9 | val catsEffectVersion = "3.5.1" 10 | val circeVersion = "0.14.13" 11 | val circeGenericVersion = "0.14.15" 12 | val declineVersion = "2.5.0" 13 | val fs2Version = "3.6.1" 14 | val http4sVersion = "1.0.0-M46" 15 | val scalaJsDomVersion = "2.4.0" 16 | val scalaTagsVersion = "0.13.1" 17 | val twirlVersion = "2.0.9" 18 | val log4catsVersion = "2.7.1" 19 | val logbackVersion = "1.5.21" 20 | 21 | val sqliteVersion = "3.51.1.0" 22 | val magnumVersion = "1.3.1" 23 | 24 | val directoryWatcherVersion = "0.19.1" 25 | val betterFilesVersion = "3.9.2" 26 | 27 | val munitVersion = "0.7.29" 28 | val munitCatsVersion = "2.1.0" 29 | 30 | // Libraries 31 | val catsEffect = 32 | Def.setting("org.typelevel" %%% "cats-effect" % catsEffectVersion) 33 | val catsCore = Def.setting("org.typelevel" %%% "cats-core" % catsVersion) 34 | 35 | val declineEffect = 36 | Def.setting("com.monovore" %% "decline-effect" % declineVersion) 37 | 38 | val fs2Core = Def.setting("co.fs2" %%% "fs2-core" % fs2Version) 39 | 40 | val log4cats = 41 | Def.setting("org.typelevel" %%% "log4cats-core" % log4catsVersion) 42 | val log4catsSlf4j = 43 | Def.setting("org.typelevel" %%% "log4cats-slf4j" % log4catsVersion) 44 | val logback = 45 | Def.setting("ch.qos.logback" % "logback-classic" % logbackVersion % Runtime) 46 | 47 | val http4sClient = 48 | Def.setting("org.http4s" %%% "http4s-ember-client" % http4sVersion) 49 | val http4sServer = 50 | Def.setting("org.http4s" %%% "http4s-ember-server" % http4sVersion) 51 | val http4sDsl = Def.setting("org.http4s" %%% "http4s-dsl" % http4sVersion) 52 | val http4sCirce = Def.setting("org.http4s" %%% "http4s-circe" % http4sVersion) 53 | val circeGeneric = 54 | Def.setting("io.circe" %% "circe-generic" % circeGenericVersion) 55 | 56 | val sqlite = Def.setting("org.xerial" % "sqlite-jdbc" % sqliteVersion) 57 | val magnum = Def.setting("com.augustnagro" %% "magnum" % magnumVersion) 58 | 59 | val twirl = 60 | Def.setting("org.playframework.twirl" %%% "twirl-api" % twirlVersion) 61 | val scalaTags = Def.setting("com.lihaoyi" %%% "scalatags" % scalaTagsVersion) 62 | 63 | val munit = Def.setting("org.scalameta" %%% "munit" % munitVersion % "test") 64 | val munitCats = 65 | Def.setting( 66 | "org.typelevel" %%% "munit-cats-effect" % munitCatsVersion % "test" 67 | ) 68 | 69 | val directoryWatcher = Def.setting( 70 | "io.methvin" % "directory-watcher" % directoryWatcherVersion 71 | ) 72 | val directoryWatcherBetterFiles = Def.setting( 73 | "io.methvin" %% "directory-watcher-better-files" % directoryWatcherVersion 74 | ) 75 | val betterFiles = 76 | Def.setting("com.github.pathikrit" %% "better-files" % betterFilesVersion) 77 | } 78 | -------------------------------------------------------------------------------- /core/jvm/src/main/scala/krop/all.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop 18 | 19 | object all { 20 | export krop.Application 21 | export krop.Server 22 | export krop.ServerBuilder 23 | 24 | export krop.route.Entity 25 | export krop.route.FormCodec 26 | export krop.route.Handler 27 | export krop.route.Handlers 28 | export krop.route.Route 29 | export krop.route.Request 30 | export krop.route.Response 31 | export krop.route.Query 32 | export krop.route.QueryParam 33 | export krop.route.Path 34 | export krop.route.Param 35 | export krop.route.Segment 36 | 37 | export krop.tool.DefaultAssets 38 | 39 | export krop.syntax.all.* 40 | 41 | export org.http4s.EntityDecoder 42 | export org.http4s.EntityEncoder 43 | export org.http4s.Method 44 | export org.http4s.Status 45 | 46 | import com.comcast.ip4s.* 47 | // Redefine these here because I don't know how to export an anonymous extension method 48 | extension (inline ctx: StringContext) { 49 | inline def ip(inline args: Any*): IpAddress = 50 | ${ Literals.ip('ctx, 'args) } 51 | 52 | inline def ipv4(inline args: Any*): Ipv4Address = 53 | ${ Literals.ipv4('ctx, 'args) } 54 | 55 | inline def ipv6(inline args: Any*): Ipv6Address = 56 | ${ Literals.ipv6('ctx, 'args) } 57 | 58 | inline def mip(inline args: Any*): Multicast[IpAddress] = 59 | ${ Literals.mip('ctx, 'args) } 60 | 61 | inline def mipv4(inline args: Any*): Multicast[Ipv4Address] = 62 | ${ Literals.mipv4('ctx, 'args) } 63 | 64 | inline def mipv6(inline args: Any*): Multicast[Ipv6Address] = 65 | ${ Literals.mipv6('ctx, 'args) } 66 | 67 | inline def ssmip( 68 | inline args: Any* 69 | ): SourceSpecificMulticast.Strict[IpAddress] = 70 | ${ Literals.ssmip('ctx, 'args) } 71 | 72 | inline def ssmipv4( 73 | inline args: Any* 74 | ): SourceSpecificMulticast.Strict[Ipv4Address] = 75 | ${ Literals.ssmipv4('ctx, 'args) } 76 | 77 | inline def ssmipv6( 78 | inline args: Any* 79 | ): SourceSpecificMulticast.Strict[Ipv6Address] = 80 | ${ Literals.ssmipv6('ctx, 'args) } 81 | 82 | inline def port(inline args: Any*): Port = 83 | ${ Literals.port('ctx, 'args) } 84 | 85 | inline def host(inline args: Any*): Hostname = 86 | ${ Literals.host('ctx, 'args) } 87 | 88 | inline def idn(inline args: Any*): IDN = 89 | ${ Literals.idn('ctx, 'args) } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /asset/src/main/scala/krop/asset/FileNameHasher.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.asset 18 | 19 | import cats.effect.IO 20 | import cats.effect.std.MapRef 21 | import fs2.io.file.Path as Fs2Path 22 | import org.typelevel.log4cats.Logger 23 | 24 | import HashingFileWatcher.Event.{Hashed, Deleted} 25 | 26 | /** Maintains a map of Path to HexString, and provides operations to hash a 27 | * path, adding a HexString, and unhash a path, removing a HexString. 28 | */ 29 | final class FileNameHasher( 30 | logger: Logger[IO], 31 | events: fs2.Stream[IO, HashingFileWatcher.Event], 32 | map: MapRef[IO, Fs2Path, Option[HexString]] 33 | ) { 34 | 35 | /** IO that updates the map from events on the events stream. You must arrange 36 | * for this to run, e.g., by running it in the background. 37 | */ 38 | val update: IO[Unit] = 39 | events 40 | .evalMap(event => 41 | event match { 42 | case Hashed(path, hex) => 43 | logger.info(s"Noticed asset $path") >> map.setKeyValue(path, hex) 44 | case Deleted(path) => 45 | logger.info(s"Noticed asset $path has been deleted") >> map 46 | .unsetKey( 47 | path 48 | ) 49 | } 50 | ) 51 | .compile 52 | .drain 53 | 54 | def hash(path: Fs2Path): IO[Fs2Path] = 55 | map(path).get.flatMap(opt => 56 | opt match 57 | case Some(hex) => IO.pure(FileNameHasher.hash(path, hex)) 58 | case None => 59 | logger.info( 60 | s"Was asked to hash path $path but this path was not found in the map of paths." 61 | ) >> IO.pure(path) 62 | ) 63 | 64 | def unhash(path: Fs2Path): Fs2Path = 65 | FileNameHasher.unhash(path) 66 | } 67 | object FileNameHasher { 68 | def hash(path: Fs2Path, hex: HexString): Fs2Path = { 69 | val str = path.toString 70 | val ext = path.extName 71 | val idx = str.lastIndexOf(ext) 72 | Fs2Path(str.substring(0, idx) ++ "-" ++ hex.value ++ ext) 73 | } 74 | 75 | def unhash(path: Fs2Path): Fs2Path = { 76 | val str = path.toString 77 | val ext = path.extName 78 | val idx = str.lastIndexOf(ext) 79 | val baseAndHash = str.substring(0, idx) 80 | 81 | val hashIdx = baseAndHash.lastIndexOf('-') 82 | // If no hash is found, leave the path alone 83 | val base = 84 | if hashIdx == -1 then baseAndHash else baseAndHash.substring(0, hashIdx) 85 | 86 | Fs2Path(s"$base$ext") 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /core/jvm/src/test/scala/krop/route/RequestEntitySuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | import krop.raise.Raise 20 | import munit.CatsEffectSuite 21 | import org.http4s.Entity as Http4sEntity 22 | import org.http4s.Method 23 | import org.http4s.Request as Http4sRequest 24 | import org.http4s.Uri 25 | import org.http4s.implicits.* 26 | 27 | class RequestEntitySuite extends CatsEffectSuite { 28 | val unitRequest = Request.get(Path.root).withEntity(Entity.unit) 29 | val textRequest = Request.get(Path.root).withEntity(Entity.text) 30 | 31 | test("Unit request parses empty entity") { 32 | val request = 33 | Http4sRequest(method = Method.GET, uri = uri"http://example.org/") 34 | 35 | unitRequest 36 | .parse(request)(using Raise.toOption) 37 | .map(opt => 38 | opt match { 39 | case Some(Tuple1(())) => true 40 | case other => fail(s"Not the expected entity: $other") 41 | } 42 | ) 43 | .assert 44 | } 45 | 46 | test("Unit request unparses unit") { 47 | val request = 48 | Http4sRequest(method = Method.GET, uri = uri"http://example.org/") 49 | 50 | val unparsed = unitRequest.unparse(Tuple1(())) 51 | 52 | assertEquals(unparsed.method, request.method) 53 | assertEquals(unparsed.uri.path, request.uri.path) 54 | assertEquals(unparsed.headers, request.headers) 55 | assertEquals(unparsed.entity, request.entity) 56 | } 57 | 58 | test("Text request parses string") { 59 | val request = 60 | Http4sRequest( 61 | method = Method.GET, 62 | uri = uri"http://example.org/", 63 | entity = Http4sEntity.utf8String("hello") 64 | ) 65 | 66 | textRequest 67 | .parse(request)(using Raise.toOption) 68 | .map(opt => 69 | opt match { 70 | case Some(Tuple1("hello")) => true 71 | case other => fail(s"Not the expected entity: $other") 72 | } 73 | ) 74 | } 75 | 76 | test("Text request unparses text") { 77 | val request = 78 | Http4sRequest( 79 | method = Method.GET, 80 | uri = uri"http://example.org/", 81 | entity = Http4sEntity.utf8String("hello") 82 | ) 83 | 84 | val unparsed = textRequest.unparse(Tuple1("hello")) 85 | 86 | assertEquals(unparsed.method, request.method) 87 | assertEquals(unparsed.uri.path, request.uri.path) 88 | assertEquals(unparsed.headers, request.headers) 89 | assertEquals(unparsed.entity, request.entity) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /core/jvm/src/test/scala/krop/route/QueryParamSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | import munit.FunSuite 20 | 21 | class QueryParamSuite extends FunSuite { 22 | test("Required QueryParam succeeds if first value parses") { 23 | val qp = QueryParam("id", StringCodec.int) 24 | assertEquals( 25 | qp.decode(Map("id" -> List("1"), "name" -> List("Van Gogh"))), 26 | Right(1) 27 | ) 28 | assertEquals( 29 | qp.decode(Map("id" -> List("1", "foobar"), "name" -> List("Van Gogh"))), 30 | Right(1) 31 | ) 32 | } 33 | 34 | test("Required QueryParam fails if first value fails to parse") { 35 | val qp = QueryParam("id", StringCodec.int) 36 | assertEquals( 37 | qp.decode(Map("id" -> List("abc"))), 38 | Left(QueryParseFailure.ValueParsingFailed("id", "abc", "")) 39 | ) 40 | } 41 | 42 | test("Required QueryParam fails if no values are found for name") { 43 | val qp = QueryParam("id", StringCodec.int) 44 | assertEquals( 45 | qp.decode(Map("id" -> List())), 46 | Left(QueryParseFailure.NoValuesForName("id")) 47 | ) 48 | } 49 | 50 | test("Required QueryParam fails if name does not exist") { 51 | val qp = QueryParam("id", StringCodec.int) 52 | assertEquals( 53 | qp.decode(Map("foo" -> List("1"))), 54 | Left(QueryParseFailure.NoParameterWithName("id")) 55 | ) 56 | } 57 | 58 | test("Optional QueryParam succeeds if first value parses") { 59 | val qp = QueryParam.optional[Int]("id") 60 | assertEquals( 61 | qp.decode(Map("id" -> List("1"), "name" -> List("Van Gogh"))), 62 | Right(Some(1)) 63 | ) 64 | assertEquals( 65 | qp.decode(Map("id" -> List("1", "foobar"), "name" -> List("Van Gogh"))), 66 | Right(Some(1)) 67 | ) 68 | } 69 | 70 | test("Optional QueryParam fails if first value fails to parse") { 71 | val qp = QueryParam.optional[Int]("id") 72 | assertEquals( 73 | qp.decode(Map("id" -> List("abc"))), 74 | Left(QueryParseFailure.ValueParsingFailed("id", "abc", "")) 75 | ) 76 | } 77 | 78 | test("Optional QueryParam succeeds if no values are found for name") { 79 | val qp = QueryParam.optional[Int]("id") 80 | assertEquals( 81 | qp.decode(Map("id" -> List())), 82 | Right(None) 83 | ) 84 | } 85 | 86 | test("Optional QueryParam succeeds if name does not exist") { 87 | val qp = QueryParam.optional[Int]("id") 88 | assertEquals( 89 | qp.decode(Map("foo" -> List("1"))), 90 | Right(None) 91 | ) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/route/FormCodec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | import cats.data.Chain 20 | import cats.syntax.all.* 21 | import org.http4s.UrlForm 22 | 23 | import scala.compiletime.* 24 | import scala.deriving.* 25 | 26 | /** A FormCodec is responsible for encoding and decoding values as 27 | * application/x-www-form-urlencoded data. In other words, a `FormCodec[A]` 28 | * converts data submitted from a form in a value of type `A`, and converts a 29 | * value of type `A` into data that could be submitted from a form. 30 | * 31 | * FormCodec works with [[org.http4s.UrlForm]] to represent form data. 32 | */ 33 | final case class FormCodec[A]( 34 | decode: UrlForm => Either[Chain[DecodeFailure], A], 35 | encode: A => UrlForm 36 | ) 37 | object FormCodec { 38 | inline given derived[A](using m: Mirror.Of[A]): FormCodec[A] = 39 | inline m match { 40 | case s: Mirror.SumOf[A] => 41 | error("Derivation of FormCodecs is not implemented for sum types.") 42 | 43 | case p: Mirror.ProductOf[A] => 44 | // We lose type information when we convert tuples to arrays. The 45 | // comments above the arrays give the correct types. 46 | 47 | // Array[String] 48 | val labels = 49 | constValueTuple[p.MirroredElemLabels].toArray 50 | 51 | // Array[SeqStringCodec[?]] 52 | val codecs = 53 | summonAll[Tuple.Map[p.MirroredElemTypes, SeqStringCodec]].toArray 54 | 55 | val decode: UrlForm => Either[Chain[DecodeFailure], A] = 56 | urlForm => { 57 | for { 58 | values <- labels.zip(codecs).toList.parTraverse { 59 | case (name, codec) => 60 | codec 61 | .asInstanceOf[SeqStringCodec[A]] 62 | .decode(urlForm.get(name.toString).toList) 63 | .leftMap(Chain.one) 64 | } 65 | } yield p.fromProduct(Tuple.fromArray(values.toArray[Any])) 66 | } 67 | 68 | val encode: A => UrlForm = 69 | a => { 70 | val product = a.asInstanceOf[Product] 71 | 72 | codecs.zipWithIndex.foldLeft(UrlForm.empty) { 73 | case (urlForm, (codec, idx)) => 74 | codec match { 75 | case c: SeqStringCodec[a] => 76 | urlForm.updateFormFields( 77 | product.productElementName(idx), 78 | Chain.fromSeq( 79 | codec 80 | .asInstanceOf[SeqStringCodec[Any]] 81 | .encode(product.productElement(idx)) 82 | ) 83 | ) 84 | } 85 | } 86 | } 87 | 88 | FormCodec(decode = decode, encode = encode) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /core/jvm/src/main/scala/krop/ServerBuilder.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop 18 | 19 | import cats.effect.IO 20 | import cats.effect.Resource 21 | import com.comcast.ip4s.Host 22 | import com.comcast.ip4s.Port 23 | import com.comcast.ip4s.host 24 | import com.comcast.ip4s.port 25 | import org.http4s.ember.server.* 26 | import org.http4s.server.Server as Http4sServer 27 | import org.typelevel.log4cats.LoggerFactory 28 | import org.typelevel.log4cats.slf4j.Slf4jFactory 29 | 30 | /** A description of how to create a [[krop.Server]]. */ 31 | final class ServerBuilder( 32 | val port: Port, 33 | val host: Host, 34 | val application: Application 35 | ) { 36 | 37 | /** Build a [[krop.Server.Server]] from this description. */ 38 | def build: Server = { 39 | val baseRuntime = JvmRuntime.base 40 | import baseRuntime.given 41 | 42 | val emberServer: Resource[IO, Http4sServer] = 43 | for { 44 | withRuntime <- application.toHttpApp(baseRuntime) 45 | resources <- baseRuntime.buildResources 46 | emberServer <- EmberServerBuilder 47 | .default[IO] 48 | .withPort(port) 49 | .withHost(host) 50 | .withHttp2 51 | .withHttpWebSocketApp { wsBuilder => 52 | withRuntime(using JvmKropRuntime(baseRuntime, resources, wsBuilder)) 53 | } 54 | .build 55 | } yield emberServer 56 | 57 | Server(emberServer) 58 | } 59 | 60 | /** Build a[[krop.Server.Server]] from this description and immediately run 61 | * it. The server is run synchronously, so this method will only return when 62 | * the server has finished. 63 | */ 64 | def run(): Unit = { 65 | this.build.run() 66 | } 67 | 68 | /** Set the port on which the server will listen. Use the `port` string 69 | * context to create a `Port` value. 70 | * 71 | * ``` 72 | * ServerBuilder.default.withPort(port"4000") 73 | * ``` 74 | */ 75 | def withPort(port: Port): ServerBuilder = 76 | ServerBuilder(port, this.host, this.application) 77 | 78 | /** Set the host address on which the server will listen. Use the `host` 79 | * string context to create a `Host` value. 80 | * 81 | * ``` 82 | * ServerBuilder.default.withHost(host"127.0.0.1") 83 | * ServerBuilder.default.withHost(host"localhost") 84 | * ``` 85 | */ 86 | def withHost(host: Host): ServerBuilder = 87 | ServerBuilder(this.port, host, this.application) 88 | 89 | /** Set the application that the server will run. */ 90 | def withApplication(application: Application): ServerBuilder = 91 | ServerBuilder(this.port, this.host, application) 92 | } 93 | object ServerBuilder { 94 | implicit val loggerFactory: LoggerFactory[IO] = Slf4jFactory.create[IO] 95 | 96 | val default = ServerBuilder(port"8080", host"localhost", Application.notFound) 97 | } 98 | -------------------------------------------------------------------------------- /docs/src/pages/controller/route/entities.md: -------------------------------------------------------------------------------- 1 | # Entities 2 | 3 | In HTTP, the term "entity" refers to the body of a request. For example, a JSON request will have JSON data in the entity. An @:api(krop.route.Entity) in Krop is responsible for converting an HTTP entity into a Scala type, and converting a Scala type into an HTTP entity. @:api(krop.route.Entity) is therefore a type of [codec](codecs.md). 4 | 5 | As well an decoding and encoding data, an @:api(krop.route.Entity) also specifies the [Content-Type][content-type] is supports for decoding and encoding. It is usually the case that the decoding Content-Type is more permissive than the encoding Content-Type. For example `Entity.text` will decode `text/*` but encodes `text/plain`. 6 | 7 | The @:api(krop.route.Entity) type is defined as `Entity[D, E]`, where `D` is the type of value that will be decoded from an HTTP request, and `E` is the type of values that will be encoded in a response. Most of the time `D` and `E` are the same, and the entity is called an @:api(krop.route.InvariantEntity). Occassionally, however, they differ. For example, the [ScalaTags][scalatags] entity decodes a `String` but encodes ScalaTags' data structure. This is because there is no parser from `String` to ScalaTags, so there is no way to parse an HTTP request with an HTML entity into ScalaTags. 8 | 9 | 10 | ## Simple Entities 11 | 12 | Simple entities decode and encode Scala types without an intermediaries. You should check the @:api(krop.route.Entity$) companion object for the full list of supported entities, but here are some of the most commonly used: 13 | 14 | - `Entity.json` is an @:api(krop.route.InvariantEntity) for [Circe's][circe] `JSON` type, decoding and encoding Content-Type `application/json`. 15 | - `Entity.html` is an @:api(krop.route.InvariantEntity) for `String`, decoding and encoding Content-Type `text/html`. 16 | - `Entity.scalatags` is an @:api(krop.route.Entity) decoding `String` and encoding [Scalatag's][scalatags] `TypedTag[String]` type, with Content-Type `text/html`. 17 | - `Entity.text` is an @:api(krop.route.InvariantEntity) for `String`, decoding Content-Type `text/*` and encoding Content-Type `text/plain`. 18 | - `Entity.twirl` is an @:api(krop.route.Entity) decoding `String` and encoding [Twirl's][twirl] `Html` type, with Content-Type `text/html`. 19 | 20 | 21 | ## JSON 22 | 23 | Krop, by default, uses [Circe][circe] for JSON decoding and encoding. If you have a type `A` that you want to decode and encode as JSON, assuming you have created given instances of Circe's `Decoder` and `Encoder` type for `A`, creating an @:api(krop.route.InvariantEntity) is as simple as calling 24 | `Entity.jsonOf[A]`. 25 | 26 | Here's a quick example, using Circe's semi-automatic generic derivation to create the `Decoder` and `Encoder` types. 27 | 28 | ```scala mdoc:silent 29 | import io.circe.{Decoder, Encoder} 30 | import krop.all.* 31 | 32 | final case class Cat(name: String) derives Decoder, Encoder 33 | 34 | val jsonEntity = Entity.jsonOf[Cat] 35 | ``` 36 | 37 | 38 | ## Forms 39 | 40 | Decoding and encoding of form data delegates to a @:api(krop.route.FormCodec) given instance. The usual way to create such an instance if with generic derivation. Here is an example, showing both generic derivation of the `FormCodec` instance and creation of an @:api(krop.route.Entity) using `Entity.formOf`. 41 | 42 | ```scala mdoc:silent 43 | final case class Dog(name: String) derives FormCodec 44 | 45 | val formEntity = Entity.formOf[Dog] 46 | ``` 47 | 48 | 49 | [circe]: https://circe.github.io/circe/ 50 | [content-type]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Type 51 | [scalatags]: https://github.com/com-lihaoyi/scalatags 52 | [twirl]: https://index.scala-lang.org/playframework/twirl 53 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/route/Query.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | import cats.syntax.all.* 20 | 21 | final case class Query[A <: Tuple](segments: Vector[QueryParam[?]]) { 22 | // 23 | // Combinators --------------------------------------------------------------- 24 | // 25 | 26 | def ++[B <: Tuple](that: Query[B]): Query[Tuple.Concat[A, B]] = 27 | Query(this.segments ++ that.segments) 28 | 29 | def and[B](param: QueryParam[B]): Query[Tuple.Append[A, B]] = 30 | Query(segments :+ param) 31 | 32 | def and[B](name: String)(using StringCodec[B]): Query[Tuple.Append[A, B]] = 33 | this.and(QueryParam.one(name)) 34 | 35 | // 36 | // Interpreters -------------------------------------------------------------- 37 | // 38 | 39 | def decode(params: Map[String, List[String]]): Either[QueryParseFailure, A] = 40 | segments 41 | .traverse(s => s.decode(params)) 42 | .map(v => Tuple.fromArray(v.toArray).asInstanceOf[A]) 43 | 44 | def encode(a: A): Map[String, Seq[String]] = { 45 | val aArray = a.toArray 46 | 47 | def loop( 48 | idx: Int, 49 | segments: Vector[QueryParam[?]], 50 | accum: Map[String, Seq[String]] 51 | ): Map[String, Seq[String]] = 52 | if segments.isEmpty then accum 53 | else { 54 | val hd = segments.head 55 | val tl = segments.tail 56 | 57 | hd match { 58 | case q: QueryParam.One[a] => 59 | loop( 60 | idx + 1, 61 | tl, 62 | q.encode(aArray(idx).asInstanceOf[a]) 63 | .fold(accum)(p => accum + p) 64 | ) 65 | 66 | case q: QueryParam.All[a] => 67 | loop( 68 | idx + 1, 69 | tl, 70 | q.encode(aArray(idx).asInstanceOf[a]) 71 | .fold(accum)(p => accum + p) 72 | ) 73 | 74 | case q: QueryParam.Optional[a] => 75 | loop( 76 | idx + 1, 77 | tl, 78 | q.encode(aArray(idx).asInstanceOf[Option[a]]) 79 | .fold(accum)(p => accum + p) 80 | ) 81 | 82 | case QueryParam.Everything => loop(idx + 1, tl, accum) 83 | } 84 | } 85 | 86 | loop(0, segments, Map.empty) 87 | } 88 | 89 | def describe: String = 90 | segments.map(_.describe).mkString("&") 91 | } 92 | object Query { 93 | val empty: Query[EmptyTuple] = 94 | Query(Vector.empty) 95 | 96 | def apply[A](param: QueryParam[A]): Query[Tuple1[A]] = 97 | Query(Vector(param)) 98 | 99 | def apply[A](name: String)(using StringCodec[A]): Query[Tuple1[A]] = 100 | Query(Vector(QueryParam.one(name))) 101 | 102 | def all[A](name: String)(using SeqStringCodec[A]): Query[Tuple1[A]] = 103 | Query(Vector(QueryParam.all(name))) 104 | 105 | def optional[A](name: String)(using 106 | StringCodec[A] 107 | ): Query[Tuple1[Option[A]]] = 108 | Query(Vector(QueryParam.optional(name))) 109 | 110 | val everything: Query[Tuple1[Map[String, List[String]]]] = 111 | Query(QueryParam.everything) 112 | } 113 | -------------------------------------------------------------------------------- /docs/src/pages/overview/principles.md: -------------------------------------------------------------------------------- 1 | # Principles 2 | 3 | Krop's goal is to make it delightful to build delightful web applications. This section unpacks those goals, describing in more detail what we're trying to do with Krop and how we're going to do it. 4 | 5 | 6 | ## Being Delightful 7 | 8 | Making it delightful to build is another way of saying we care a lot about developer happiness and productivity. We want simple things (and we think a lot of the the web is simple) to be really simple. In fact we want working with Krop to fade into the background so that you can concentrate on what makes your application distinctive, not the HTTP plumbing. Here are some ways we're trying to achieve this. 9 | 10 | - Scale down. Scala is proven in high scale situations. Every big project starts out small, so we want to make it really easy to get started. Building something should only be a few lines of code. 11 | 12 | - Code is easy to write. When designing APIs we make it easy to follow the types of code, and follow the IDE autocomplete. For example, where the API requires a `Request` the developer can just start typing `Request` and follow methods on the companion object. They don't need to know that they're actually working with a builder type to construct the final `Request`. 13 | 14 | - Krop is featureful. If a user wants to do a common task it should be in Krop. We don't want them to have to chase down a dozen dependencies just to get some basic stuff. This doesn't mean we write everything ourselves though; that is too big a job. If an existing library does the job we bring it into Krop. That why, for example, Krop builds on top of http4s. There is no value is creating yet another HTTP model and web server. 15 | 16 | - Establish conventions. It's a waste of time for the user to have to come up with their own directory structure and other conventions. We establish these so others don't have to think about them. 17 | 18 | - Avoid boilerplate. We use a variety of tactics, including sensible defaults, code generation, and Scala 3's compile-time metaprogramming, so the user can avoid writing boilerplate. 19 | 20 | - Imports are minimized. Users don't need to know the project structure inside and out to get stuff done. We use Scala 3's `export` feature to coaelesce imports into a few chosen areas. 21 | 22 | - Support the run time experience. The types don't tell us everything, so make it easy to debug as well. For example, this is why in development mode we show why routes didn't match a given request. 23 | 24 | - Write documentation. Explain to our users how Krop works. 25 | 26 | 27 | ## Delightful Applications 28 | 29 | We want to make it possible to build applications that feel amazing. Scala has a long history of building web APIs, and Krop fully supports this, but we also want to make it easy to create rich user interfaces. We have two approaches: server-centric and client-centric. 30 | 31 | Server-centric applications are ones where the server provides the majority of the interaction, with a small bit of Javascript where necessary. This is the approach taken by [Phoenix LiveView](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html) and [Hotwired](https://hotwired.dev/). Krop will fully support this approach. 32 | 33 | Client-centric applications build the interactivity on the client. Part of Scala's secret sauce is our compiler: we can produce Javascript and WASM in addition to JVM bytecode and native code. We intend to leverage this to make it possible to create web applications running in the browser or on mobile with just Scala code. Longer-term we want to support local-first applications. 34 | 35 | 36 | ## Be Functional 37 | 38 | We follow a functional paradigm, meaning we emphasize composition and reasoning. This means the user can trace the path of a request to a response without any hidden state. We don't, for example, inject routes into some hidden global state. If a user wants a route they add it to their application. Similarly we use compile-time, not run-time metaprogramming. 39 | -------------------------------------------------------------------------------- /core/jvm/src/test/scala/krop/route/RequestHeaderSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | import krop.raise.Raise 20 | import munit.CatsEffectSuite 21 | import org.http4s.Headers 22 | import org.http4s.MediaType 23 | import org.http4s.Method 24 | import org.http4s.Request as Http4sRequest 25 | import org.http4s.Uri 26 | import org.http4s.headers.`Content-Type` 27 | import org.http4s.implicits.* 28 | 29 | class RequestHeaderSuite extends CatsEffectSuite { 30 | val jsonContentType = `Content-Type`(MediaType.application.json) 31 | val jsonRequest = Http4sRequest( 32 | method = Method.GET, 33 | uri = uri"http://example.org/", 34 | headers = Headers(jsonContentType) 35 | ) 36 | val ensureJsonHeaderRequest = Request 37 | .get(Path.root) 38 | .ensureHeader(`Content-Type`(MediaType.application.json)) 39 | val extractContentTypeRequest = 40 | Request.get(Path.root).extractHeader[`Content-Type`] 41 | val extractContentTypeWithDefaultRequest = 42 | Request.get(Path.root).extractHeader(jsonContentType) 43 | 44 | test("Ensure header fails if header does not exist") { 45 | val request = 46 | Http4sRequest(method = Method.GET, uri = uri"http://example.org/") 47 | 48 | ensureJsonHeaderRequest 49 | .parse(request)(using Raise.toOption) 50 | .map(_.isEmpty) 51 | .assert 52 | } 53 | 54 | test("Ensure header succeeds if header does exist") { 55 | ensureJsonHeaderRequest 56 | .parse(jsonRequest)(using Raise.toOption) 57 | .map(opt => assertEquals(opt, Some(EmptyTuple))) 58 | } 59 | 60 | test("Extract header extracts desired header (by type version)") { 61 | extractContentTypeRequest 62 | .parse(jsonRequest)(using Raise.toOption) 63 | .map(h => 64 | h match { 65 | case None => fail("Did not parse") 66 | case Some(header) => assertEquals(header(0), jsonContentType) 67 | } 68 | ) 69 | } 70 | 71 | test("Extract header extracts desired header (by value version)") { 72 | extractContentTypeWithDefaultRequest 73 | .parse(jsonRequest)(using Raise.toOption) 74 | .map(h => 75 | h match { 76 | case None => fail("Did not parse") 77 | case Some(header) => assertEquals(header(0), jsonContentType) 78 | } 79 | ) 80 | } 81 | 82 | test("Ensure header unparses with expected header") { 83 | val unparsed = ensureJsonHeaderRequest.unparse(EmptyTuple) 84 | 85 | assertEquals(unparsed.method, jsonRequest.method) 86 | assertEquals(unparsed.uri.path, jsonRequest.uri.path) 87 | assertEquals(unparsed.headers, jsonRequest.headers) 88 | } 89 | 90 | test("Extract header unparses with expected header") { 91 | val unparsed = extractContentTypeRequest.unparse(Tuple1(jsonContentType)) 92 | 93 | assertEquals(unparsed.method, jsonRequest.method) 94 | assertEquals(unparsed.uri.path, jsonRequest.uri.path) 95 | assertEquals(unparsed.headers, jsonRequest.headers) 96 | } 97 | 98 | test("Extract header with default unparses with expected header") { 99 | val unparsed = extractContentTypeWithDefaultRequest.unparse(EmptyTuple) 100 | 101 | assertEquals(unparsed.method, jsonRequest.method) 102 | assertEquals(unparsed.uri.path, jsonRequest.uri.path) 103 | assertEquals(unparsed.headers, jsonRequest.headers) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /docs/src/pages/controller/route/response.md: -------------------------------------------------------------------------------- 1 | # Response 2 | 3 | ```scala mdoc:invisible 4 | import krop.all.* 5 | ``` 6 | 7 | A @:api(krop.route.Response) describes how to create a HTTP response from a Scala value. 8 | For example, the following `Response` will produce an HTTP OK response with a `text/plain` entity. 9 | 10 | ```scala mdoc:silent 11 | val ok: Response[String, String] = Response.ok(Entity.text) 12 | ``` 13 | 14 | The entity will be constructed from a `String` that is passed to the `respond` method on the `Response`. 15 | You usually won't do this yourself; it is handled by the `Route` the `Response` is part of. 16 | 17 | 18 | ## Entities 19 | 20 | Entities (response bodies) are handled in the same way as [requests](request.md): by specifying an @:api(krop.route.Entity). In this case the `Entity` is responsible for encoding Scala values as data in the HTTP response. 21 | 22 | Use `Entity.unit` to indicate that your response has no entity. For example: 23 | 24 | ```scala mdoc:silent 25 | val noBody: Response[Unit, Unit] = Response.ok(Entity.unit) 26 | ``` 27 | 28 | 29 | ## Headers 30 | 31 | Headers can be added using the `withHeader` method. This method accepts one of more values in any of the following forms: 32 | 33 | - A value of type `A` which has a `Header[A]` in scope 34 | - A `(name, value)` pair of `String`, which is treated as a `Recurring` 35 | header 36 | - A `Header.Raw` 37 | - A `Foldable` (`List`, `Option`, etc) of the above. 38 | 39 | In the example below we add a header using the `(name, value)` form, and a value with a `Header[A]` in scope. 40 | 41 | ```scala mdoc:silent 42 | import org.http4s.headers.Allow 43 | 44 | val headers = 45 | Response.ok(Entity.html).withHeader("X-Awesomeness" -> "10.0", Allow(Method.GET)) 46 | ``` 47 | 48 | 49 | ## Error Handling 50 | 51 | In many cases you'll need to generate an error response despite a valid request. For example, you could generate a 404 Not Found if the client has sent a well-formed request for a user but that user does not exist. This situation can be handled using the `orNotFound` method, which converts a `Response[A]` to a `Response[Option[A]]`. When passed a `None` the `Response` responds with a 404. This is shown in the example below. If the user ID is not 1 (the only valid ID) a 404 will be returned. 52 | 53 | ```scala mdoc:silent 54 | val getUser = 55 | Route( 56 | Request.get(Path / "user" / Param.int), 57 | Response.ok(Entity.text).orNotFound 58 | ).handle(id => 59 | if id == 1 then Some("Found the user!") else None 60 | ) 61 | ``` 62 | 63 | For more complex cases you can use `orElse`, which allows you to handle an `Either[A, B]` and introduce custom error handling. The example below shows complex error handling combining `orElse` and `orNotFound`. A 404 Not Found is returned if the user id does not correspond to an existing user, and a 400 Bad Request is returned if the `Name` [entity](entities.md) is not valid. 64 | 65 | ```scala mdoc:silent 66 | import io.circe.{Decoder, Encoder} 67 | 68 | final case class Name(name: String) derives Decoder, Encoder 69 | final case class User(id: Int, name: String) derives Decoder, Encoder 70 | 71 | val postUser = 72 | Route( 73 | Request.post(Path / "user" / Param.int).withEntity(Entity.jsonOf[Name]), 74 | Response.ok(Entity.jsonOf[User]) 75 | .orElse(Response.status(Status.BadRequest, Entity.text)) 76 | .orNotFound 77 | ).handle((id, name) => 78 | // Check ID is valid 79 | if id == 1 then 80 | // Check name is valid 81 | if name.name == "Hieronymus Bosch" then Some(Right(User(1, name.name))) 82 | else Some(Left("$name is not an allowed name")) 83 | else None 84 | ) 85 | ``` 86 | 87 | Not that when using `orElse` the *first* `Response` is the successful one, and the second is the error case. This follows the usual convention in English, but means that when written the `Response` on the left-hand side corresponds to a `Right` value. In other words, we write 88 | 89 | ```scala 90 | success.orElse(error) 91 | ``` 92 | 93 | not 94 | 95 | ```scala 96 | error.orElse(success) 97 | ``` 98 | 99 | -------------------------------------------------------------------------------- /asset/src/main/scala/krop/asset/HashingFileWatcher.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.asset 18 | 19 | import cats.effect.IO 20 | import cats.effect.Resource 21 | import fs2.Stream 22 | import fs2.io.file.Files 23 | import fs2.io.file.Path 24 | import fs2.io.file.Watcher 25 | 26 | import scala.concurrent.duration.* 27 | 28 | /** A utility to watch a directory for changes and emit paths and hashes for 29 | * changed files. 30 | */ 31 | object HashingFileWatcher { 32 | 33 | /** Events produced by the HashingFileWatcher. Paths are always relative to 34 | * the watched path. 35 | */ 36 | enum Event { 37 | 38 | /** Emitted when we encounter a file for the first time (on the initial scan 39 | * of the directory, or the file has been created) or a file has been 40 | * modified. This will only be produced for regular files, not directories 41 | * or other kinds of files. 42 | */ 43 | case Hashed(file: Path, hash: HexString) 44 | 45 | /** Emitted when a path been deleted. This could refer to a path that is not 46 | * a regular file, such as a directory, as we cannot tell after the fact 47 | * what kind of file it was. 48 | */ 49 | case Deleted(file: Path) 50 | } 51 | 52 | /** Produces a `Resource` that, when used, will construct an infinite stream 53 | * of `Event` for the given directory. If the given path is not a directory 54 | * the `Resource` will fail with an `IllegalArgumentException`. 55 | * 56 | * An `Event` will be produced on startup for all regular files that are 57 | * found recursively within the directory, and then subsequent events will be 58 | * produced when files are created, modified, or deleted. 59 | */ 60 | def watch( 61 | directory: Path, 62 | pollTimeout: FiniteDuration = 1.second 63 | ): Resource[IO, Stream[IO, Event]] = { 64 | val files = Files.forIO 65 | 66 | def relativize(path: Path): Path = 67 | directory.relativize(path) 68 | 69 | def maybeHash(path: Path): IO[Option[Event]] = 70 | files.isRegularFile(path).flatMap { isFile => 71 | if isFile then 72 | path.md5Hex.map(hex => Some(Event.Hashed(relativize(path), hex))) 73 | else IO.pure(None) 74 | } 75 | 76 | val initialize: IO[Stream[IO, Event]] = 77 | files.isDirectory(directory).map { isDir => 78 | if isDir then 79 | files 80 | .walk(directory) 81 | .evalFilter(path => files.isRegularFile(path)) 82 | .evalMap(path => 83 | path.md5Hex.map(hex => Event.Hashed(relativize(path), hex)) 84 | ) 85 | else 86 | Stream.raiseError( 87 | IllegalArgumentException( 88 | s"Cannot create a HashingFileWatcher watching $directory as this path is not a directory." 89 | ) 90 | ) 91 | } 92 | 93 | val watcher: Stream[IO, Event] = 94 | Stream 95 | .resource(Watcher.default[IO]) 96 | .evalTap(_.watch(directory)) 97 | .flatMap(_.events(pollTimeout = pollTimeout)) 98 | .evalMapFilter(event => 99 | event match { 100 | case Watcher.Event.Created(path, _) => maybeHash(path) 101 | case Watcher.Event.Modified(path, _) => maybeHash(path) 102 | case Watcher.Event.Deleted(path, _) => 103 | IO.pure(Some(Event.Deleted(relativize(path)))) 104 | case other => 105 | IO.pure(None) 106 | } 107 | ) 108 | 109 | initialize 110 | .map(init => init ++ watcher) 111 | .toResource 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/route/ReversibleRoute.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | /** The type of Routes that allow reverse routing. That is, constructing paths 20 | * that link to this route. 21 | */ 22 | trait ReversibleRoute[Path <: Tuple, Query <: Tuple] { 23 | def request: Request[?, Path, Query, ?] 24 | 25 | /** Overload of `pathTo` for the case where the path has no parameters. 26 | */ 27 | def pathTo(using ev: EmptyTuple =:= Path): String = 28 | pathTo(ev(EmptyTuple)) 29 | 30 | /** Overload of `pathTo` for the case where the path has a single parameter. 31 | */ 32 | def pathTo[B](param: B)(using ev: Tuple1[B] =:= Path): String = 33 | pathTo(ev(Tuple1(param))) 34 | 35 | /** Create a [[scala.String]] path suitable for embedding in HTML that links 36 | * to the path described by this [[package.Route]] with the given parameters. 37 | * Use this to create hyperlinks or form actions that call a route, without 38 | * needing to hardcode the route in the HTML. 39 | * 40 | * For example, with the Route 41 | * 42 | * ```scala 43 | * val route = 44 | * Route( 45 | * Request.get(Path / "user" / Param.id / "edit"), 46 | * Request.ok(Entity.html) 47 | * ) 48 | * ``` 49 | * 50 | * calling 51 | * 52 | * ```scala 53 | * route.pathTo(1234) 54 | * ``` 55 | * 56 | * produces the `String` `"/user/1234/edit"`. 57 | * 58 | * This version of `pathTo` takes the parameters as a tuple. There are two 59 | * overloads that take unwrapped parameters for the case where there are no 60 | * or a single parameter. 61 | */ 62 | def pathTo(params: Path): String = 63 | request.pathTo(params) 64 | 65 | /** Overload of `pathAndQueryTo` for the case where the path has no 66 | * parameters. 67 | */ 68 | def pathAndQueryTo(queryParams: Query)(using 69 | ev: EmptyTuple =:= Path 70 | ): String = 71 | pathAndQueryTo(ev(EmptyTuple), queryParams) 72 | 73 | /** Overload of `pathAndQueryTo` for the case where the path has a single 74 | * parameter. 75 | */ 76 | def pathAndQueryTo[B](pathParam: B, queryParams: Query)(using 77 | ev: Tuple1[B] =:= Path 78 | ): String = 79 | pathAndQueryTo(ev(Tuple1(pathParam)), queryParams) 80 | 81 | /** Overload of `pathAndQueryTo` for the case where the query has a single 82 | * parameter. 83 | */ 84 | def pathAndQueryTo[B](pathParams: Path, queryParam: B)(using 85 | ev: Tuple1[B] =:= Query 86 | ): String = 87 | pathAndQueryTo(pathParams, ev(Tuple1(queryParam))) 88 | 89 | /** Overload of `pathAndQueryTo` for the case where the path and query have a 90 | * single parameter. 91 | */ 92 | def pathAndQueryTo[B, C](pathParam: B, queryParam: C)(using 93 | evP: Tuple1[B] =:= Path, 94 | evQ: Tuple1[C] =:= Query 95 | ): String = 96 | pathAndQueryTo(evP(Tuple1(pathParam)), evQ(Tuple1(queryParam))) 97 | 98 | /** Create a [[scala.String]] path suitable for embedding in HTML that links 99 | * to the path described by this [[package.Request]] and also includes query 100 | * parameters. Use this to create hyperlinks or form actions that call a 101 | * route, without needing to hardcode the route in the HTML. 102 | * 103 | * This path will not include settings like the entity or headers that this 104 | * [[package.Request]] may require. It is assumed this will be handled 105 | * elsewhere. 106 | */ 107 | def pathAndQueryTo(pathParams: Path, queryParams: Query): String = 108 | request.pathAndQueryTo(pathParams, queryParams) 109 | 110 | } 111 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/route/TupleApply.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | /** This type class connects tuples of values and functions, specifically 20 | * allowing application of functions to tuples that don't exactly match their 21 | * type signature. For example, it allows a function of no arguments to be 22 | * applied to the empty tuple, and a function of a single argument to be 23 | * applied to a tuple of one value. 24 | */ 25 | trait TupleApply[I, O] { 26 | type Fun 27 | 28 | def tuple(f: Fun): I => O 29 | } 30 | object TupleApply { 31 | type Aux[I, F, O] = TupleApply[I, O] { type Fun = F } 32 | 33 | given emptyTupleFunction0Apply[C]: TupleApply[EmptyTuple, C] with { 34 | type Fun = () => C 35 | def tuple(f: () => C): EmptyTuple => C = (_) => f() 36 | } 37 | 38 | given tuple1Apply[A, C]: TupleApply[Tuple1[A], C] with { 39 | type Fun = A => C 40 | def tuple(f: A => C): Tuple1[A] => C = a => f(a(0)) 41 | } 42 | 43 | given tuple2Apply[A, B, C]: TupleApply[ 44 | Tuple2[A, B], 45 | C 46 | ] with { 47 | type Fun = (A, B) => C 48 | 49 | def tuple( 50 | f: (A, B) => C 51 | ): Tuple2[A, B] => C = f.tupled 52 | } 53 | 54 | given tuple3Apply[A, B, C, D]: TupleApply[ 55 | Tuple3[A, B, C], 56 | D 57 | ] with { 58 | type Fun = (A, B, C) => D 59 | 60 | def tuple( 61 | f: (A, B, C) => D 62 | ): Tuple3[A, B, C] => D = f.tupled 63 | } 64 | 65 | given tuple4Apply[A, B, C, D, E]: TupleApply[ 66 | Tuple4[A, B, C, D], 67 | E 68 | ] with { 69 | type Fun = (A, B, C, D) => E 70 | 71 | def tuple( 72 | f: (A, B, C, D) => E 73 | ): Tuple4[A, B, C, D] => E = f.tupled 74 | } 75 | 76 | given tuple5Apply[A, B, C, D, E, F]: TupleApply[ 77 | Tuple5[A, B, C, D, E], 78 | F 79 | ] with { 80 | type Fun = (A, B, C, D, E) => F 81 | 82 | def tuple( 83 | f: (A, B, C, D, E) => F 84 | ): Tuple5[A, B, C, D, E] => F = f.tupled 85 | } 86 | 87 | given tuple6Apply[A, B, C, D, E, F, G]: TupleApply[ 88 | Tuple6[A, B, C, D, E, F], 89 | G 90 | ] with { 91 | type Fun = (A, B, C, D, E, F) => G 92 | 93 | def tuple( 94 | f: (A, B, C, D, E, F) => G 95 | ): Tuple6[A, B, C, D, E, F] => G = f.tupled 96 | } 97 | 98 | given tuple7Apply[A, B, C, D, E, F, G, H]: TupleApply[ 99 | Tuple7[A, B, C, D, E, F, G], 100 | H 101 | ] with { 102 | type Fun = (A, B, C, D, E, F, G) => H 103 | 104 | def tuple( 105 | f: (A, B, C, D, E, F, G) => H 106 | ): Tuple7[A, B, C, D, E, F, G] => H = f.tupled 107 | } 108 | 109 | given tuple8Apply[A, B, C, D, E, F, G, H, I]: TupleApply[ 110 | Tuple8[A, B, C, D, E, F, G, H], 111 | I 112 | ] with { 113 | type Fun = (A, B, C, D, E, F, G, H) => I 114 | 115 | def tuple( 116 | f: (A, B, C, D, E, F, G, H) => I 117 | ): Tuple8[A, B, C, D, E, F, G, H] => I = f.tupled 118 | } 119 | 120 | given tuple9Apply[A, B, C, D, E, F, G, H, I, J]: TupleApply[ 121 | Tuple9[A, B, C, D, E, F, G, H, I], 122 | J 123 | ] with { 124 | type Fun = (A, B, C, D, E, F, G, H, I) => J 125 | 126 | def tuple( 127 | f: (A, B, C, D, E, F, G, H, I) => J 128 | ): Tuple9[A, B, C, D, E, F, G, H, I] => J = f.tupled 129 | } 130 | 131 | given tuple10Apply[A, B, C, D, E, F, G, H, I, J, K]: TupleApply[ 132 | Tuple10[A, B, C, D, E, F, G, H, I, J], 133 | K 134 | ] with { 135 | type Fun = (A, B, C, D, E, F, G, H, I, J) => K 136 | 137 | def tuple( 138 | f: (A, B, C, D, E, F, G, H, I, J) => K 139 | ): Tuple10[A, B, C, D, E, F, G, H, I, J] => K = f.tupled 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /examples/src/main/scala/TurboStream.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package examples 18 | 19 | import cats.effect.ExitCode 20 | import cats.effect.IO 21 | import cats.effect.IOApp 22 | import cats.effect.std.Queue 23 | import fs2.Pipe 24 | import fs2.Stream 25 | import krop.all.* 26 | import krop.tool 27 | import org.http4s.UrlForm 28 | import org.http4s.websocket.WebSocketFrame 29 | import scalatags.Text.TypedTag 30 | import scalatags.Text.all.* 31 | 32 | object TurboStream extends IOApp { 33 | final case class Message(content: String) 34 | 35 | def application(queue: Queue[IO, Message]): Application = { 36 | val indexRoute = 37 | Route(Request.get(Path.root), Response.ok(Entity.scalatags)) 38 | 39 | val messageRoute = 40 | Route( 41 | Request.post(Path.root / "message").withEntity(Entity.urlForm), 42 | Response.ok(Entity.unit) 43 | ) 44 | 45 | val streamRoute = 46 | Route( 47 | Request.get(Path.root / "stream"), 48 | Response.websocket 49 | ) 50 | 51 | val assetRoute = 52 | Route( 53 | Request.get(Path.root / "asset" / Param.separatedString("/")), 54 | Response.staticResource("/asset/") 55 | ) 56 | 57 | val index = 58 | html( 59 | head( 60 | meta(charset := "utf-8"), 61 | script( 62 | `type` := "module", 63 | src := "/asset/turbo-8.0.12.js" 64 | ) 65 | ), 66 | body( 67 | h1("Turbo Stream Example"), 68 | tool.TurboStream.source( 69 | src := s"ws://localhost:8080${streamRoute.pathTo}" 70 | ), 71 | div(id := "messages", div(id := "message")), 72 | form( 73 | action := messageRoute.pathTo, 74 | method := "post", 75 | input(id := "message", name := "message", `type` := "text"), 76 | input(`type` := "Submit") 77 | ) 78 | ) 79 | ) 80 | 81 | val indexController: () => TypedTag[String] = 82 | () => index 83 | 84 | val messageController: UrlForm => IO[Unit] = 85 | (form: UrlForm) => 86 | form.get("message").headOption match { 87 | case None => IO.unit 88 | case Some(value) => 89 | queue.offer(Message(value)) 90 | } 91 | 92 | val streamController: () => IO[ 93 | (Stream[IO, WebSocketFrame], Pipe[IO, WebSocketFrame, Unit]) 94 | ] = 95 | () => { 96 | val send: Stream[IO, WebSocketFrame] = 97 | Stream 98 | .repeatEval(queue.take) 99 | .map(message => 100 | WebSocketFrame.Text( 101 | tool.TurboStream 102 | .stream( 103 | tool.TurboStream.action.append, 104 | target := "messages", 105 | tool.TurboStream.template(p(message.content)) 106 | ) 107 | .toString, 108 | last = false 109 | ) 110 | ) 111 | 112 | val receive: Pipe[IO, WebSocketFrame, Unit] = 113 | stream => 114 | stream.evalMap(frame => 115 | IO.print("Stream received frame: ") >> IO.println(frame) 116 | ) 117 | 118 | IO.pure((send, receive)) 119 | } 120 | 121 | val routes = 122 | indexRoute 123 | .handle(indexController) 124 | .orElse(messageRoute.handleIO(messageController)) 125 | .orElse(streamRoute.handleIO(streamController)) 126 | .orElse(assetRoute.passthrough) 127 | 128 | routes.orElseNotFound 129 | } 130 | 131 | def run(args: List[String]): IO[ExitCode] = 132 | Queue 133 | .circularBuffer[IO, Message](8) 134 | .map(queue => application(queue)) 135 | .flatMap(application => 136 | ServerBuilder.default 137 | .withApplication(application) 138 | .build 139 | .toIO 140 | .as(ExitCode.Success) 141 | ) 142 | } 143 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/route/Param.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | /** A [[package.Param]] is used to extract values from a URI's path or query 20 | * parameters. 21 | * 22 | * Params can also be inverted, going from a value of type `A` to a `String` or 23 | * sequence of `String`. This allows so-called reverse routing, constructing a 24 | * URI from the parameters. 25 | * 26 | * There are two types of `Param`: 27 | * 28 | * * those that handle a single value (`Param.One`); and 29 | * 30 | * * those that handle as many values as are available (`Param.All`). 31 | */ 32 | sealed abstract class Param[A] extends Product, Serializable { 33 | import Param.* 34 | 35 | /** Gets the name of this `Param`. By convention it describes the type within 36 | * angle brackets. 37 | */ 38 | def name: String = 39 | this match { 40 | case All(codec) => codec.name 41 | case One(codec) => codec.name 42 | } 43 | 44 | /** Create a `Param` with a more informative name. For example, you might use 45 | * this method to note that an Int is in fact a user id. 46 | * 47 | * ```scala 48 | * Param.int.withName("") 49 | * ``` 50 | */ 51 | def withName(name: String): Param[A] = 52 | this match { 53 | case All(codec) => All(codec.withName(name)) 54 | case One(codec) => One(codec.withName(name)) 55 | } 56 | } 57 | object Param { 58 | /* A `Param` that transforms a sequence of `String` to a value of type `A`. 59 | * 60 | * @param name 61 | * The name used when printing this `Param`. Usually a short word in angle 62 | * brackets, like "" or "". 63 | * @param codec 64 | * The [[SeqStringCodec]] that does encoding and decoding 65 | */ 66 | final case class All[A](codec: SeqStringCodec[A]) extends Param[A] { 67 | export codec.{decode, encode} 68 | 69 | /** Construct a `Param.All[B]` from a `Param.All[A]` using functions to 70 | * convert from A to B and B to A. 71 | */ 72 | def imap[B](f: A => B)(g: B => A): All[B] = 73 | All(codec.imap(f)(g)) 74 | } 75 | 76 | /* A `Param` that matches a single parameter. 77 | * 78 | * @param name 79 | * The name used when printing this `Param`. Usually a short word in angle 80 | * brackets, like "" or "". 81 | * @param codec 82 | * The [[StringCodec]] that does encoding and decoding 83 | */ 84 | final case class One[A](codec: StringCodec[A]) extends Param[A] { 85 | export codec.{decode, encode} 86 | 87 | /** Construct a `Param.One[B]` from a `Param.One[A]` using functions to 88 | * convert from A to B and B to A. 89 | */ 90 | def imap[B](f: A => B)(g: B => A): One[B] = 91 | One(codec.imap(f)(g)) 92 | } 93 | 94 | /** A `Param` that matches a single `Int` parameter */ 95 | val int: Param.One[Int] = 96 | Param.One(StringCodec.int) 97 | 98 | /** A `Param` that matches a single `String` parameter */ 99 | val string: Param.One[String] = 100 | Param.One(StringCodec.string) 101 | 102 | /** `Param` that simply accumulates all parameters as a `Seq[String]`. 103 | */ 104 | val seq: Param.All[Seq[String]] = 105 | Param.All(SeqStringCodec.seqString) 106 | 107 | /** Constructs a [[Param]] that decodes input into a `String` by appending all 108 | * the input together with `separator` inbetween each element. Encodes data 109 | * by splitting on `separator`. 110 | * 111 | * For example, 112 | * 113 | * ```scala 114 | * val slash = Param.separatedString("/") 115 | * ``` 116 | * 117 | * decodes `Seq("a", "b", "c")` to `"a/b/c"` and encodes `"a/b/c"` as 118 | * `Seq("a", "b", "c")`. 119 | */ 120 | def separatedString(separator: String): Param.All[String] = 121 | Param.All(SeqStringCodec.separatedString(separator)) 122 | 123 | def all[A](using codec: StringCodec[A]): Param.All[Seq[A]] = 124 | Param.All(SeqStringCodec.all(using codec)) 125 | } 126 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/route/Handler.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | import cats.data.Chain 20 | import cats.effect.IO 21 | import cats.effect.Resource 22 | import krop.Application 23 | import krop.BaseRuntime 24 | import krop.WithRuntime 25 | 26 | /** A [[krop.route.Handler]] describes how to build an endpoint that can parse a 27 | * request and produce a response. It combines a [[krop.request.Route]] with 28 | * the business logic that handles the request and produces the response. 29 | * 30 | * A Handler is the basic unit for building a web service. The majority of the 31 | * service will consist of handlers, with a final catch-all to deal with any 32 | * requests that are not handled by any of the handlers. 33 | * 34 | * A Handler is a description, which means it can build a 35 | * [[krop.route.RouteHandler]] that does the actual work. This is similar to 36 | * how `IO` is a description of a program, that is only run when we call a 37 | * method like `unsafeRunSync`. When the Handler builds the RouteHandler it can 38 | * also create any resources that are needed to do its work. 39 | */ 40 | trait Handler { 41 | 42 | /** To allow introspection, the handler must provide the 43 | * [[krop.route.BaseRoute]] it works with. 44 | */ 45 | def route: BaseRoute 46 | 47 | /** Try this Handler. If it fails to match, pass control to the given 48 | * [[krop.Application]]. 49 | */ 50 | def orElse(that: Application): Application = 51 | this.toHandlers.orElse(that) 52 | 53 | /** Try this Handler. If it fails to match, pass control to the given 54 | * [[package.Route]]. 55 | */ 56 | def orElse(that: Handler): Handlers = 57 | this.orElse(that.toHandlers) 58 | 59 | /** Try this Handler. If it fails to match, pass control to the 60 | * [[package.Handlers]]. 61 | */ 62 | def orElse(that: Handlers): Handlers = 63 | Handlers(this +: that.handlers) 64 | 65 | def toHandlers: Handlers = 66 | Handlers(Chain(this)) 67 | 68 | /** Convert this Handler in a [[kropu.route.RouteHandler]] that can process 69 | * an HTTP request and produce a HTTP response. The Handler can also create 70 | * resources, possibly registered on the provided [[krop.KropRuntime]], 71 | * which will last for the life-time of the server. 72 | */ 73 | def build(runtime: BaseRuntime): Resource[IO, RouteHandler] 74 | 75 | } 76 | object Handler { 77 | 78 | /** Implementation of the common case when a Handler is a container of a Route 79 | * and a handler function. It also a RouteHandler. 80 | */ 81 | private final class BasicHandler[E <: Tuple, R]( 82 | val route: InternalRoute[E, R], 83 | handler: WithRuntime[E => IO[R]] 84 | ) extends Handler { 85 | def build(runtime: BaseRuntime): Resource[IO, RouteHandler] = 86 | Resource.eval(IO.pure(RouteHandler(route, handler))) 87 | } 88 | 89 | /** Implementation of the case when a Handler is a container of a Route and a 90 | * Resource generating the handler function. 91 | */ 92 | private final class ResourceHandler[E <: Tuple, R]( 93 | val route: InternalRoute[E, R], 94 | handler: Resource[IO, WithRuntime[E => IO[R]]] 95 | ) extends Handler { self => 96 | def build(runtime: BaseRuntime): Resource[IO, RouteHandler] = 97 | handler.map(h => RouteHandler(route, h)) 98 | } 99 | 100 | /** Construct a [[krop.route.Handler]] from a [[krop.route.Route]] and a 101 | * handler function. 102 | */ 103 | def apply[E <: Tuple, R]( 104 | route: InternalRoute[E, R], 105 | handler: WithRuntime[E => IO[R]] 106 | ): Handler = BasicHandler(route, handler) 107 | 108 | /** Construct a [[krop.route.Handler]] from a [[krop.route.Route]] and a 109 | * Resource that will produce the handler function when used. 110 | */ 111 | def apply[E <: Tuple, R]( 112 | route: InternalRoute[E, R], 113 | handler: Resource[IO, WithRuntime[E => IO[R]]] 114 | ): Handler = ResourceHandler(route, handler) 115 | } 116 | -------------------------------------------------------------------------------- /sqlite/src/main/scala/krop/sqlite/package.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.sqlite 18 | 19 | import cats.effect.IO 20 | import com.augustnagro.magnum.SqlLogger 21 | import com.augustnagro.magnum.magcats 22 | 23 | import java.sql.Connection 24 | import javax.sql.DataSource 25 | 26 | /** Simplify the magcats.Transcator type by fixing F to IO. */ 27 | type Transactor = magcats.Transactor[IO] 28 | object Transactor { 29 | 30 | /** Construct a Transactor 31 | * 32 | * @param dataSource 33 | * Datasource to be used 34 | * @param sqlLogger 35 | * Logging configuration 36 | * @param connectionConfig 37 | * Customize the underlying JDBC Connections 38 | * @param maxBlockingThreads 39 | * Number of threads in your connection pool. This helps magcats be more 40 | * memory efficient by limiting the number of blocking pool threads used. 41 | * Not needed if using a virtual-thread based blocking executor (e.g. via 42 | * evalOn) 43 | * @return 44 | * IO[Transactor] 45 | */ 46 | def apply( 47 | dataSource: DataSource, 48 | sqlLogger: SqlLogger, 49 | connectionConfig: Connection => Unit, 50 | maxBlockingThreads: Int 51 | ): IO[Transactor] = 52 | magcats.Transactor.apply[IO]( 53 | dataSource, 54 | sqlLogger, 55 | connectionConfig, 56 | maxBlockingThreads 57 | ) 58 | 59 | /** Construct a Transactor 60 | * 61 | * @param dataSource 62 | * Datasource to be used 63 | * @param sqlLogger 64 | * Logging configuration 65 | * @param maxBlockingThreads 66 | * Number of threads in your connection pool. This helps magcats be more 67 | * memory efficient by limiting the number of blocking pool threads used. 68 | * Not needed if using a virtual-thread based blocking executor (e.g. via 69 | * evalOn) 70 | * @return 71 | * IO[Transactor] 72 | */ 73 | def apply( 74 | dataSource: DataSource, 75 | sqlLogger: SqlLogger, 76 | maxBlockingThreads: Int 77 | ): IO[Transactor] = 78 | magcats.Transactor.apply[IO](dataSource, sqlLogger, maxBlockingThreads) 79 | 80 | /** Construct a Transactor 81 | * 82 | * @param dataSource 83 | * Datasource to be used 84 | * @param maxBlockingThreads 85 | * Number of threads in your connection pool. This helps magcats be more 86 | * memory efficient by limiting the number of blocking pool threads used. 87 | * Not needed if using a virtual-thread based blocking executor (e.g. via 88 | * evalOn) 89 | * @return 90 | * IO[Transactor] 91 | */ 92 | def apply( 93 | dataSource: DataSource, 94 | maxBlockingThreads: Int 95 | ): IO[Transactor] = 96 | magcats.Transactor.apply[IO]( 97 | dataSource, 98 | SqlLogger.Default, 99 | maxBlockingThreads 100 | ) 101 | 102 | /** Construct a Transactor 103 | * 104 | * @param dataSource 105 | * Datasource to be used 106 | * @param sqlLogger 107 | * Logging configuration 108 | * @param connectionConfig 109 | * Customize the underlying JDBC Connections 110 | * @return 111 | * IO[Transactor] 112 | */ 113 | def apply( 114 | dataSource: DataSource, 115 | sqlLogger: SqlLogger, 116 | connectionConfig: Connection => Unit 117 | ): IO[Transactor] = 118 | magcats.Transactor.apply[IO](dataSource, sqlLogger, connectionConfig) 119 | 120 | /** Construct a Transactor 121 | * 122 | * @param dataSource 123 | * Datasource to be used 124 | * @param sqlLogger 125 | * Logging configuration 126 | * @return 127 | * IO[Transactor] 128 | */ 129 | def apply( 130 | dataSource: DataSource, 131 | sqlLogger: SqlLogger 132 | ): IO[Transactor] = 133 | magcats.Transactor.apply[IO](dataSource, sqlLogger) 134 | 135 | /** Construct a Transactor 136 | * 137 | * @param dataSource 138 | * Datasource to be used 139 | * @return 140 | * IO[Transactor] 141 | */ 142 | def apply( 143 | dataSource: DataSource 144 | ): IO[Transactor] = 145 | magcats.Transactor 146 | .apply[IO](dataSource) 147 | } 148 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/route/Entity.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | import cats.data.EitherT 20 | import cats.effect.IO 21 | import io.circe.Decoder 22 | import io.circe.Encoder 23 | import io.circe.Json 24 | import org.http4s.DecodeResult 25 | import org.http4s.EntityDecoder 26 | import org.http4s.EntityEncoder 27 | import org.http4s.InvalidMessageBodyFailure 28 | import org.http4s.Media 29 | import org.http4s.MediaRange 30 | import org.http4s.UrlForm 31 | import org.http4s.circe.CirceEntityDecoder 32 | import org.http4s.circe.CirceEntityEncoder 33 | import org.http4s.headers.`Content-Type` 34 | import org.http4s.syntax.all.* 35 | import play.twirl.api.Html 36 | import scalatags.Text.TypedTag 37 | 38 | /** Type alias for an Entity where the decoded and encoded type are the same. */ 39 | type InvariantEntity[A] = Entity[A, A] 40 | 41 | /** An Entity describes how to decode data from an HTTP entity, and encode data 42 | * to an HTTP entity. 43 | * 44 | * @tparam E 45 | * The type of data that this entity will encode in a response 46 | * @tparam D 47 | * The type of data that this entity will decode from a request 48 | */ 49 | final case class Entity[E, D]( 50 | encoder: EntityEncoder[IO, E], 51 | decoder: EntityDecoder[IO, D] 52 | ) { 53 | 54 | /** Transform the decoded request data with the given function. */ 55 | def map[D2](f: D => D2): Entity[E, D2] = 56 | this.copy( 57 | decoder = decoder.map(f) 58 | ) 59 | 60 | /** Transform the encoded response data with the given function. */ 61 | def contramap[E2](f: E2 => E): Entity[E2, D] = 62 | this.copy( 63 | encoder = encoder.contramap(f) 64 | ) 65 | 66 | def withContentType(tpe: `Content-Type`): Entity[E, D] = 67 | this.copy( 68 | decoder = 69 | if decoder.consumes.exists(_.satisfies(tpe.mediaType)) then decoder 70 | else 71 | new EntityDecoder[IO, D] { 72 | val consumes: Set[MediaRange] = decoder.consumes + tpe.mediaType 73 | def decode(m: Media[IO], strict: Boolean): DecodeResult[IO, D] = 74 | decoder.decode(m, strict) 75 | } 76 | , 77 | encoder = encoder.withContentType(tpe) 78 | ) 79 | } 80 | object Entity { 81 | val json: InvariantEntity[Json] = 82 | Entity( 83 | CirceEntityEncoder.circeEntityEncoder, 84 | CirceEntityDecoder.circeEntityDecoder 85 | ) 86 | 87 | def formOf[A](using codec: FormCodec[A]): InvariantEntity[A] = 88 | Entity( 89 | UrlForm.entityEncoder.contramap(codec.encode), 90 | UrlForm 91 | .entityDecoder[IO] 92 | .flatMapR(urlForm => 93 | codec.decode(urlForm) match { 94 | case Left(errors) => 95 | EitherT( 96 | IO.pure( 97 | Left( 98 | InvalidMessageBodyFailure( 99 | errors.toList.map(_.describe).mkString("\n") 100 | ) 101 | ) 102 | ) 103 | ) 104 | case Right(value) => EitherT(IO.pure(Right(value))) 105 | } 106 | ) 107 | ) 108 | 109 | def jsonOf[A: Encoder: Decoder]: InvariantEntity[A] = 110 | Entity( 111 | CirceEntityEncoder.circeEntityEncoder, 112 | CirceEntityDecoder.circeEntityDecoder 113 | ) 114 | 115 | val unit: InvariantEntity[Unit] = 116 | Entity( 117 | EntityEncoder.unitEncoder, 118 | EntityDecoder.decodeBy[IO, Unit](MediaRange.`*/*`)(_ => 119 | DecodeResult.success(IO.unit) 120 | ) 121 | ) 122 | 123 | val text: InvariantEntity[String] = 124 | Entity( 125 | EntityEncoder.stringEncoder(), 126 | EntityDecoder.text[IO] 127 | ) 128 | 129 | val urlForm: InvariantEntity[UrlForm] = 130 | Entity( 131 | UrlForm.entityEncoder, 132 | UrlForm.entityDecoder[IO] 133 | ) 134 | 135 | val html: InvariantEntity[String] = 136 | text.withContentType(`Content-Type`(mediaType"text/html")) 137 | 138 | val scalatags: Entity[TypedTag[String], String] = 139 | html.contramap(tags => "" + tags.toString) 140 | 141 | val twirl: Entity[Html, String] = 142 | html.contramap(html => html.body) 143 | } 144 | -------------------------------------------------------------------------------- /asset/src/main/scala/krop/asset/AssetRoute.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.asset 18 | 19 | import cats.effect.IO 20 | import cats.effect.Resource 21 | import cats.effect.std.MapRef 22 | import fs2.* 23 | import fs2.io.file.Files 24 | import fs2.io.file.Path as Fs2Path 25 | import krop.BaseRuntime 26 | import krop.Key 27 | import krop.KropRuntime 28 | import krop.route.BaseRoute 29 | import krop.route.ClientRoute 30 | import krop.route.Handler 31 | import krop.route.InternalRoute 32 | import krop.route.Param 33 | import krop.route.Path 34 | import krop.route.Request 35 | import krop.route.Response 36 | import krop.route.ReversibleRoute 37 | import krop.route.Route 38 | import krop.route.RouteHandler 39 | 40 | final class AssetRoute(base: Path[EmptyTuple, EmptyTuple], directory: Fs2Path) 41 | extends ReversibleRoute[Tuple1[Fs2Path], EmptyTuple], 42 | ClientRoute[Tuple1[Fs2Path], Array[Byte]], 43 | InternalRoute[Tuple1[Fs2Path], Fs2Path], 44 | Handler { self => 45 | val request 46 | : Request[Tuple1[Fs2Path], Tuple1[Fs2Path], EmptyTuple, Tuple1[Fs2Path]] = 47 | Request.get( 48 | base / Param 49 | .separatedString("/") 50 | .imap(str => Fs2Path(str))(path => path.toString) 51 | ) 52 | 53 | val response: Response[Fs2Path, Array[Byte]] = 54 | Response.staticDirectory(directory) 55 | 56 | val route: BaseRoute = 57 | new BaseRoute { 58 | def request = self.request 59 | def response = self.response 60 | } 61 | 62 | private final class Asset(hasher: FileNameHasher) { 63 | def apply(path: Fs2Path): IO[Fs2Path] = 64 | hasher.hash(path) 65 | def apply(path: String): IO[String] = 66 | apply(Fs2Path(path)).map(_.toString) 67 | } 68 | 69 | private val key: Key[Asset] = Key.unsafe(s"Assets for ${directory.toString}") 70 | private val basePath: String = base.pathTo(EmptyTuple) 71 | 72 | /** Given a relative filesystem path to an asset, return a relative path that 73 | * is suitable for using as a hyperlink, containing the original file plus 74 | * added hash for cache busting. 75 | * 76 | * The relative path parameter is the path on the filesystem where the asset 77 | * is found. It must be relative to the `directory` this `AssetRoute` was 78 | * constructed with. 79 | * 80 | * The result is the path under which the asset is served. That is, it 81 | * includes the `pathTo` of this `AssetRoute`'s `base`. 82 | * 83 | * This method is only safe to be called once this `AssetRoute` has been 84 | * built (i.e. the `build` method has been called, and the resulting 85 | * `Resource` has been used.) This will naturally occur if you use an 86 | * `AssetRoute` in a Krop application. 87 | */ 88 | def asset(path: String)(using KropRuntime): String = 89 | key.get 90 | .apply(Fs2Path(path)) 91 | .map(p => basePath ++ "/" ++ p.toString) 92 | .unsafeRunSync()(using cats.effect.unsafe.implicits.global) 93 | 94 | def build(runtime: BaseRuntime): Resource[IO, RouteHandler] = { 95 | val files = Files[IO] 96 | 97 | val hasher: Resource[IO, FileNameHasher] = 98 | for { 99 | logger <- runtime.loggerFactory 100 | .fromName(s"krop-asset-route($directory)") 101 | .toResource 102 | isDir <- files.isDirectory(directory).toResource 103 | _ <- 104 | (if isDir then 105 | logger.info("Creating asset route for directory $directory") 106 | else { 107 | logger 108 | .error( 109 | s"Cannot create an asset route for $directory as this path is not a directory." 110 | ) >> IO.raiseError( 111 | IllegalArgumentException( 112 | s"Cannot create an asset route for $directory as this path is not a directory." 113 | ) 114 | ) 115 | }).toResource 116 | // Table maps file name to hex encoded hash 117 | map <- MapRef[IO, Fs2Path, HexString].toResource 118 | events <- HashingFileWatcher.watch(directory) 119 | hasher = FileNameHasher(logger, events, map) 120 | _ <- hasher.update.background 121 | assets = Resource.pure[IO, Asset](Asset(hasher)) 122 | _ = runtime.stageResource(key, assets) 123 | } yield hasher 124 | 125 | hasher.flatMap(h => 126 | (Route(request, response).handle(path => h.unhash(path)).build(runtime)) 127 | ) 128 | } 129 | } 130 | object AssetRoute { 131 | def apply(base: Path[EmptyTuple, EmptyTuple], directory: String): AssetRoute = 132 | new AssetRoute(base, Fs2Path(directory)) 133 | } 134 | -------------------------------------------------------------------------------- /asset/src/test/scala/krop/asset/HashingFileWatcherSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.asset 18 | 19 | import cats.effect.Deferred 20 | import cats.effect.IO 21 | import fs2.Stream 22 | import fs2.io.file.Files 23 | import fs2.io.file.Path 24 | import munit.CatsEffectSuite 25 | 26 | import scala.concurrent.duration.* 27 | 28 | class HashingFileWatcherSuite extends CatsEffectSuite { 29 | val files = Files.forIO 30 | 31 | // Overwrite (truncats) files if they already exist. 32 | def makeFiles(base: Path, filesAndContent: List[(String, String)]): IO[Unit] = 33 | Stream 34 | .emits(filesAndContent) 35 | .flatMap { case (fileName, content) => 36 | val file = base / fileName 37 | Stream(content).through(files.writeUtf8(file)) 38 | } 39 | .compile 40 | .drain 41 | 42 | test("HashingFileWatcher emits hashes of already extant files") { 43 | files.tempDirectory.use { dir => 44 | val create: IO[Unit] = 45 | makeFiles(dir, List("a.txt" -> "bigcats", "b.txt" -> "littlecats")) 46 | 47 | val fileHashes: IO[List[(Path, HexString)]] = 48 | HashingFileWatcher.watch(dir).use { stream => 49 | stream 50 | .take(2) 51 | .compile 52 | .toList 53 | .map(_.collect { case HashingFileWatcher.Event.Hashed(file, path) => 54 | file -> path 55 | }) 56 | } 57 | 58 | val program = create >> fileHashes 59 | 60 | program.map { fileHashes => 61 | assert(fileHashes.size == 2) 62 | assert( 63 | fileHashes.contains( 64 | Path("a.txt") -> "80fe0e83da4321cca20e0cda8a5f86f8" 65 | ) 66 | ) 67 | assert( 68 | fileHashes.contains( 69 | Path("b.txt") -> "485e307791ace28ccad2df0cfeab31ad" 70 | ) 71 | ) 72 | } 73 | } 74 | } 75 | 76 | test("HashingFileWatcher emits hashes of changed files") { 77 | files.tempDirectory.use { dir => 78 | val create: IO[Unit] = 79 | makeFiles(dir, List("a.txt" -> "bigcats", "b.txt" -> "littlecats")) 80 | 81 | val overwrite: IO[Unit] = 82 | makeFiles( 83 | dir, 84 | List("a.txt" -> "largeaardvarks", "b.txt" -> "petitecapybaras") 85 | ) 86 | 87 | val expected = 88 | List( 89 | (Path("a.txt") -> "bigcats".md5Hex), 90 | (Path("b.txt") -> "littlecats".md5Hex), 91 | (Path("a.txt") -> "largeaardvarks".md5Hex), 92 | (Path("b.txt") -> "petitecapybaras".md5Hex) 93 | ) 94 | 95 | // The number of events emitted is nondeterministic. E.g. sometimes we get 96 | // two change events for a file change. To work around this we keep a 97 | // running total of the events we're looking for (expected) and halt when 98 | // we have found them all, or we timeout. 99 | // 100 | // MacOS uses a polling implementation, so for this test to run in a 101 | // reasonable time on MacOS we need to poll somewhat rapidly. See 102 | // https://bugs.openjdk.org/browse/JDK-7133447 103 | val program: IO[Unit] = 104 | for { 105 | _ <- create 106 | deferred <- Deferred[IO, Either[Throwable, Unit]] 107 | watcher = 108 | HashingFileWatcher.watch(dir, 200.milliseconds).use { stream => 109 | stream 110 | .collect { case HashingFileWatcher.Event.Hashed(path, hash) => 111 | path -> hash 112 | } 113 | .scan(expected) { (expected, hash) => 114 | // println(s"looking for $expected and observed $hash") 115 | expected.filterNot(_ == hash) 116 | } 117 | .evalMap(expected => 118 | if expected.isEmpty then 119 | // IO.println("Caught them all") >> 120 | deferred 121 | .complete(Right(())) 122 | .as(List.empty) 123 | else IO.pure(expected) 124 | ) 125 | .interruptWhen(deferred) 126 | .compile 127 | .drain 128 | } 129 | // Sleep overwrite so we don't get a race between it and the watcher initialization 130 | writer = IO.sleep(250.milliseconds) >> overwrite 131 | process = (watcher, writer).parTupled.as(true) 132 | // Timeout after 10 seconds if we haven't seen all the events we're after 133 | complete <- process.race(IO.sleep(10.seconds)) 134 | } yield assertEquals(complete, Left(true)) 135 | 136 | program 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/route/QueryParam.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | import cats.syntax.all.* 20 | 21 | /** A [[package.QueryParam]] extracts values from a URI's query parameters. It 22 | * consists of a [[package.Param]], which does the necessary type conversion, 23 | * and the name under which the parameters should be found. 24 | * 25 | * There are three types of `QueryParam`: 26 | * 27 | * * required params, which fail if there are no values associated with the 28 | * name; 29 | * 30 | * * optional parameters, that return `None` if there is no value for the name; 31 | * and 32 | * 33 | * * the `QueryParam` that returns all the query parameters. 34 | */ 35 | enum QueryParam[A] { 36 | import QueryParseFailure.* 37 | 38 | /** Get a human-readable description of this `QueryParam`. */ 39 | def describe: String = ??? 40 | 41 | def decode(params: Map[String, List[String]]): Either[QueryParseFailure, A] = 42 | this match { 43 | case One(name, codec) => 44 | params.get(name) match { 45 | case Some(values) => 46 | values.headOption match { 47 | case None => NoValuesForName(name).asLeft 48 | case Some(value) => 49 | codec.decode(value) match { 50 | case Right(value) => Right(value) 51 | case Left(error) => 52 | ValueParsingFailed(name, value, error.description).asLeft 53 | } 54 | } 55 | 56 | case None => NoParameterWithName(name).asLeft 57 | } 58 | 59 | case All(name, codec) => 60 | params.get(name) match { 61 | case Some(values) => 62 | codec.decode(values) match { 63 | case Right(value) => Right(value) 64 | case Left(error) => 65 | ValueParsingFailed( 66 | name, 67 | values.toString, 68 | error.description 69 | ).asLeft 70 | } 71 | 72 | case None => NoParameterWithName(name).asLeft 73 | } 74 | 75 | case Optional(name, codec) => 76 | params.get(name) match { 77 | case Some(values) => 78 | if values.isEmpty then None.asRight 79 | else { 80 | val hd = values.head 81 | codec 82 | .decode(hd) 83 | .map(Some(_)) 84 | .leftMap(error => 85 | ValueParsingFailed(name, hd, error.description) 86 | ) 87 | } 88 | 89 | case None => None.asRight 90 | } 91 | 92 | case Everything => params.asRight 93 | } 94 | 95 | def encode(a: A): Option[(String, Seq[String])] = 96 | this match { 97 | case One(name, codec) => 98 | Some(name -> List(codec.encode(a))) 99 | 100 | case All(name, codec) => 101 | Some(name -> codec.encode(a)) 102 | 103 | case Optional(name, codec) => 104 | a match { 105 | case Some(a1) => Some(name -> List(codec.encode(a1))) 106 | case None => None 107 | } 108 | 109 | case Everything => None 110 | } 111 | 112 | case One(name: String, codec: StringCodec[A]) 113 | case All(name: String, codec: SeqStringCodec[A]) 114 | case Optional(name: String, codec: StringCodec[A]) 115 | extends QueryParam[Option[A]] 116 | case Everything extends QueryParam[Map[String, List[String]]] 117 | } 118 | object QueryParam { 119 | 120 | /** Construct a [[QueryParam]] that decodes the first value from any query 121 | * parameters with the given name. 122 | */ 123 | def one[A](name: String)(using codec: StringCodec[A]): QueryParam[A] = 124 | QueryParam.One(name, codec) 125 | 126 | /** Construct a [[QueryParam]] that decodes all the values from any query 127 | * parameters with the given name. 128 | */ 129 | def all[A](name: String)(using codec: SeqStringCodec[A]): QueryParam[A] = 130 | QueryParam.All(name, codec) 131 | 132 | /** Construct a [[QueryParam]] that decodes the first value from any query 133 | * parameters with the given name, and returns None if there are no values. 134 | */ 135 | def optional[A](name: String)(using 136 | codec: StringCodec[A] 137 | ): QueryParam[Option[A]] = 138 | QueryParam.Optional(name, codec) 139 | 140 | def int(name: String)(using StringCodec[Int]): QueryParam[Int] = 141 | one[Int](name) 142 | 143 | def string(name: String)(using StringCodec[String]): QueryParam[String] = 144 | one[String](name) 145 | 146 | /** A QueryParam that returns all the query parameters unchanged. */ 147 | val everything: QueryParam[Map[String, List[String]]] = 148 | QueryParam.Everything 149 | 150 | def apply[A](name: String, codec: StringCodec[A]): QueryParam[A] = 151 | QueryParam.One(name, codec) 152 | } 153 | -------------------------------------------------------------------------------- /docs/src/pages/controller/route/request.md: -------------------------------------------------------------------------------- 1 | # Request 2 | 3 | ```scala mdoc:invisible 4 | import krop.all.* 5 | ``` 6 | 7 | A @:api(krop.route.Request) describes a pattern within an HTTP request that a @:api(krop.route.Route) attempts to match. It can also create an HTTP request that the `Request` will match. This so-called *reverse routing* allows creating clients that will call an endpoint. 8 | 9 | A `Request` always matches an HTTP method and a [Path](paths.md). It may match other components of a request as well. It may also extract elements from the path, query parameters, and other parts of the request. These are passed to the `Route` handler. 10 | 11 | ## The Request Type 12 | 13 | You can usually avoid writing @:api(krop.route.Request) types explicitly, but in case you have to write them down a @:api(krop.route.Request) is defined as 14 | 15 | ```scala 16 | Request[P <: Tuple, Q <: Tuple, I <: Tuple, O <: Tuple] 17 | ``` 18 | 19 | where 20 | 21 | * `P` is the type of any path parameters extracted from the @:api(krop.route.Path); 22 | * `Q` is the type of any query parameters extracted from the @:api(krop.route.Path); 23 | * `I` is the type of all the values extracted from the @:api(krop.route.Request); and 24 | * `O` is all type of all the values need to construct a HTTP request matched by this @:api(krop.route.Request). 25 | 26 | 27 | ## Creating Requests 28 | 29 | `Requests` are created by calling the method on the `Request` object that corresponds to the HTTP method of interest. For example, `Request.get` for a GET request, `Request.post` for a POST request, and so on. Thesse methods all accept a @:api(krop.route.Path), as described in the [next section](paths.md), which is the only required part of a `Request` in addition to the HTTP method. 30 | 31 | Here is an example that matches a GET request to the path `/user/`, where `` is an integer. 32 | 33 | ```scala mdoc:silent 34 | Request.get(Path / "user" / Param.int) 35 | ``` 36 | 37 | Working with paths is quite complex, so this has [it's own documentation](paths.md). 38 | 39 | You can optionally match and extract values from the headers and entity of a HTTP request. If you want to extract match or extract values from the headers, you must call these methods before you call methods that deal with the entity. This design makes it a bit easier to deal with the types inside @:api(krop.route.Request). 40 | 41 | 42 | ### Headers 43 | 44 | You can extract the value of any particular header in the HTTP request, and make that value available to the request handler. Alternatively you can ensure that the header exists and has a particular value, but not make that value availabe to the handler. 45 | 46 | There are two variants of the `extractHeader` method, which will get the value of a header and make it available to the handler. In the first variant you specify just the type of the header, which is usually found in the `org.https4s.headers` package. 47 | 48 | Here is an example the extracts the value associated with the `Content-Type` header. 49 | 50 | ```scala mdoc:silent 51 | import org.http4s.headers.* 52 | 53 | Request.get(Path / "user" / Param.int) 54 | .extractHeader[`Content-Type`] 55 | ``` 56 | 57 | If we want to extract more than one header we call `andExtractHeader` for each additional header after the first. 58 | 59 | ```scala mdoc:silent 60 | Request.get(Path / "user" / Param.int) 61 | .extractHeader[`Content-Type`] 62 | .andExtractHeader[Referer] 63 | ``` 64 | 65 | This variant of `extractHeader` requires us to specify a value for the header when we do reverse routing. We can avoid this by providing a header when we call `extractHeader`. 66 | 67 | In this example we construct a JSON `Content-Type` header and pass that value to `extractHeader`. Now `jsonContentType` will be used when constructing an HTTP request matching this request. 68 | 69 | ```scala mdoc:silent 70 | import org.http4s.MediaType 71 | 72 | val jsonContentType = `Content-Type`(MediaType.application.json) 73 | 74 | Request.get(Path / "user" / Param.int) 75 | .extractHeader(jsonContentType) 76 | ``` 77 | 78 | We often want to ensure that a header matches a particular value, but don't want to otherwise do anything with the value. In other words, we don't want to the header's value passed to the handler once we have verified it exists. For these cases we can use `ensureHeader`. 79 | 80 | ```scala mdoc:silent 81 | Request.get(Path / "user" / Param.int) 82 | .ensureHeader(jsonContentType) 83 | ``` 84 | 85 | As with `extractHeader`, we use `andEnsureHeader` to ensure two or more headers. 86 | 87 | Finally, not that although we've used Content-Type headers in the examples you don't normally have to deal with them. If you specify a @:api(krop.route.Entity) that will check the headers are correct. We've used them in this examples as they are probably the headers that are most familiar to most web developers. 88 | 89 | 90 | ### Entities 91 | 92 | Calling the `withEntity` method on a `Request` allows you to specify an @:api(krop.route.Entity), which is responsible for extracting data from an HTTP request. The `Entity` is responsible for checking the HTTP Content-Type header, and, if it matches, decoding the HTTP entity into a Scala value. 93 | 94 | Here's an example of a `Request` that extracts HTML content as a `String` value. 95 | 96 | ```scala mdoc:silent 97 | Request.get(Path.root).withEntity(Entity.html) 98 | ``` 99 | 100 | There are several predefined `Entity` values on the companion object, but you can easily create your own if needed. The [documentation for entities](entities.md) has more details. 101 | -------------------------------------------------------------------------------- /core/jvm/src/test/scala/krop/route/PathParseSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | import munit.FunSuite 20 | import org.http4s.Uri 21 | import org.http4s.implicits.* 22 | 23 | class PathParseSuite extends FunSuite { 24 | val nonCapturingPath = Path / "user" / "create" 25 | val nonCapturingAllPath = Path / "assets" / "html" / Segment.all 26 | val capturingAllPath = Path / "assets" / "html" / Param.seq 27 | val simplePath = Path / "user" / Param.int.withName("") / "view" 28 | val simpleQueryPath = Path / "user" / Param.int :? Query[String]("mode") 29 | val multipleQueryPath = 30 | Path / "users" :? Query[Int]("page").and[Int]("count") 31 | 32 | test("Path description is as expected") { 33 | assertEquals(simplePath.describe, "/user//view") 34 | } 35 | 36 | test("Non-capturing path succeeds with empty tuple") { 37 | val okUri = uri"http://example.org/user/create" 38 | 39 | assertEquals(nonCapturingPath.parseToOption(okUri), Some(EmptyTuple)) 40 | } 41 | 42 | test("Path parses expected element from http4s path") { 43 | val okUri = uri"http://example.org/user/1234/view" 44 | 45 | assertEquals(simplePath.parseToOption(okUri), Some(1234 *: EmptyTuple)) 46 | } 47 | 48 | test("Path parses expected element and query parameters from http4s path") { 49 | val okUri = uri"http://example.org/user/1234?mode=all" 50 | 51 | assertEquals( 52 | simpleQueryPath.parseToOption(okUri), 53 | Some(1234 *: "all" *: EmptyTuple) 54 | ) 55 | } 56 | 57 | test("Path parses all query parameters from http4s path") { 58 | val okUri = uri"http://example.org/users?page=3&count=50" 59 | 60 | assertEquals( 61 | multipleQueryPath.parseToOption(okUri), 62 | Some((3, 50)) 63 | ) 64 | } 65 | 66 | test("Path fails when cannot parse element from URI path") { 67 | val badUri = uri"http://example.org/user/foobar/view" 68 | 69 | assertEquals(simplePath.parseToOption(badUri), None) 70 | } 71 | 72 | test("Path fails when insufficient segments in URI path") { 73 | val badUri = uri"http://example.org/user/" 74 | 75 | assertEquals(simplePath.parseToOption(badUri), None) 76 | } 77 | 78 | test("Path fails when too many segments in URI path") { 79 | val badUri = uri"http://example.org/user/1234/view/this/that" 80 | 81 | assertEquals(simplePath.parseToOption(badUri), None) 82 | } 83 | 84 | test("Path with all segment matches all extra segments") { 85 | val okUri = uri"http://example.org/assets/html/this/that/theother.html" 86 | 87 | assertEquals(nonCapturingAllPath.parseToOption(okUri), Some(EmptyTuple)) 88 | } 89 | 90 | test("Path with all param captures all extra segments") { 91 | val okUri = uri"http://example.org/assets/html/this/that/theother.html" 92 | 93 | assertEquals( 94 | capturingAllPath.parseToOption(okUri), 95 | Some(Vector("this", "that", "theother.html") *: EmptyTuple) 96 | ) 97 | } 98 | 99 | test("Path with all segment matches zero or more segments") { 100 | val zeroUri = uri"http://example.org/assets/html" 101 | val oneUri = uri"http://example.org/assets/html/example.html" 102 | val manyUri = uri"http://example.org/assets/html/a/b/c/example.html" 103 | 104 | assertEquals(nonCapturingAllPath.parseToOption(zeroUri), Some(EmptyTuple)) 105 | assertEquals(nonCapturingAllPath.parseToOption(oneUri), Some(EmptyTuple)) 106 | assertEquals(nonCapturingAllPath.parseToOption(manyUri), Some(EmptyTuple)) 107 | } 108 | 109 | test("Path with all param matches zero or more segments") { 110 | val zeroUri = uri"http://example.org/assets/html" 111 | val oneUri = uri"http://example.org/assets/html/example.html" 112 | val manyUri = uri"http://example.org/assets/html/a/b/c/example.html" 113 | 114 | assertEquals( 115 | capturingAllPath.parseToOption(zeroUri), 116 | Some(Vector.empty[String] *: EmptyTuple) 117 | ) 118 | assertEquals( 119 | capturingAllPath.parseToOption(oneUri), 120 | Some(Vector("example.html") *: EmptyTuple) 121 | ) 122 | assertEquals( 123 | capturingAllPath.parseToOption(manyUri), 124 | Some(Vector("a", "b", "c", "example.html") *: EmptyTuple) 125 | ) 126 | } 127 | 128 | test("Closed path raises exception when adding additional segments") { 129 | intercept[IllegalStateException](nonCapturingAllPath / "crash") 130 | intercept[IllegalStateException](capturingAllPath / "crash") 131 | } 132 | 133 | test("Path.pathTo produces expected path") { 134 | assertEquals(nonCapturingPath.pathTo(EmptyTuple), "/user/create") 135 | 136 | assertEquals(nonCapturingAllPath.pathTo(EmptyTuple), "/assets/html/") 137 | 138 | assertEquals( 139 | capturingAllPath.pathTo(Tuple1(Vector("css", "main.css"))), 140 | "/assets/html/css/main.css" 141 | ) 142 | 143 | assertEquals(simplePath.pathTo(Tuple1(1234)), "/user/1234/view") 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/raise/Raise.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.raise 18 | 19 | import cats.effect.IO 20 | 21 | import scala.util.boundary 22 | import scala.util.boundary.Label 23 | import scala.util.boundary.break 24 | 25 | /** Effect that can raise an error of type `E`. */ 26 | trait Raise[E] { 27 | 28 | /** Raise an error of type E. The error parameter is call-by-need as the 29 | * handler may decide to not evaluate it and instead replace it with another 30 | * value. For example, it could be replaced with None if the context is 31 | * expecting an Option. 32 | */ 33 | def raise(error: => E): Nothing 34 | } 35 | 36 | object Raise { 37 | 38 | /** A handler for Raise effects. */ 39 | trait Handler[F[_, _]] { 40 | def apply[E, A](body: Raise[E] ?=> A): F[E, A] 41 | 42 | def succeed[E, A](success: A): F[E, A] 43 | 44 | /** Interoperate with IO */ 45 | def mapToIO[E, A, B](value: F[E, A])(f: A => IO[B]): IO[F[E, B]] 46 | 47 | /** Interoperate with IO */ 48 | def flatMapToIO[E, A, B](value: F[E, A])(f: A => IO[F[E, B]]): IO[F[E, B]] 49 | } 50 | 51 | def raise[E](error: => E): Raise[E] ?=> Nothing = 52 | raise ?=> raise.raise(error) 53 | 54 | /** Lift a successful value */ 55 | def succeed[F[_, _], E, A](success: A)(using handler: Handler[F]): F[E, A] = 56 | handler.succeed(success) 57 | 58 | def mapToIO[F[_, _], E, A, B](result: F[E, A])(f: A => IO[B])(using 59 | handler: Handler[F] 60 | ): IO[F[E, B]] = 61 | handler.mapToIO(result)(f) 62 | 63 | def flatMapToIO[F[_, _], E, A, B](result: F[E, A])(f: A => IO[F[E, B]])(using 64 | handler: Handler[F] 65 | ): IO[F[E, B]] = 66 | handler.flatMapToIO(result)(f) 67 | 68 | def handle[F[_, _], E, A](body: Raise[E] ?=> A)(using 69 | handler: Handler[F] 70 | ): F[E, A] = 71 | handler.apply(body) 72 | 73 | /** Construct a Raise[E] that produces a value in a context expecting a value 74 | * of type A. 75 | */ 76 | def apply[E, A](f: E => A)(using Label[A]): Raise[E] = 77 | new Raise[E] { 78 | def raise(error: => E): Nothing = 79 | break(f(error)) 80 | } 81 | 82 | /** Construct a Raise[E] that produces a value in a context expecting a value 83 | * of type A, where the Raise instance ignores the value of type E and simply 84 | * produces a constant value of type A. 85 | */ 86 | def const[E, A](a: A)(using Label[A]): Raise[E] = 87 | new Raise[E] { 88 | def raise(error: => E): Nothing = 89 | break(a) 90 | } 91 | 92 | def raiseToEither[E, A](using label: Label[Either[E, A]]): Raise[E] = 93 | Raise(e => Left(e)) 94 | 95 | def raiseToOption[E, A](using label: Label[Option[A]]): Raise[E] = 96 | const(None) 97 | 98 | val toEither: Handler[Either] = 99 | new Handler[Either] { 100 | def apply[E, A](body: (Raise[E]) ?=> A): Either[E, A] = 101 | boundary { 102 | given Raise[E] = raiseToEither 103 | Right(body) 104 | } 105 | 106 | def succeed[E, A](success: A): Either[E, A] = 107 | Right(success) 108 | 109 | def mapToIO[E, A, B]( 110 | value: Either[E, A] 111 | )(f: A => IO[B]): IO[Either[E, B]] = 112 | value match { 113 | case Left(e) => IO.pure(Left(e)) 114 | case Right(a) => f(a).map(b => Right(b)) 115 | } 116 | 117 | def flatMapToIO[E, A, B]( 118 | value: Either[E, A] 119 | )(f: A => IO[Either[E, B]]): IO[Either[E, B]] = 120 | value match { 121 | case Left(e) => IO.pure(Left(e)) 122 | case Right(a) => f(a) 123 | } 124 | } 125 | 126 | type ToOption[E, A] = Option[A] 127 | val toOption: Handler[ToOption] = 128 | new Handler[ToOption] { 129 | def apply[E, A](body: (Raise[E]) ?=> A): ToOption[E, A] = 130 | boundary { 131 | given Raise[E] = raiseToOption 132 | succeed(body) 133 | } 134 | 135 | def succeed[E, A](success: A): ToOption[E, A] = 136 | Some(success) 137 | 138 | def mapToIO[E, A, B](value: ToOption[E, A])( 139 | f: A => IO[B] 140 | ): IO[ToOption[E, B]] = 141 | value match { 142 | case None => IO.pure(None) 143 | case Some(a) => f(a).map(b => Some(b)) 144 | } 145 | 146 | def flatMapToIO[E, A, B](value: ToOption[E, A])( 147 | f: A => IO[ToOption[E, B]] 148 | ): IO[ToOption[E, B]] = 149 | value match { 150 | case None => IO.pure(None) 151 | case Some(a) => f(a) 152 | } 153 | } 154 | 155 | type ToNull[E, A] = A | Null 156 | val toNull: Handler[ToNull] = 157 | new Handler[ToNull] { 158 | def apply[E, A](body: (Raise[E]) ?=> A): ToNull[E, A] = 159 | boundary { 160 | given Raise[E] = const(null) 161 | body 162 | } 163 | 164 | def succeed[E, A](success: A): ToNull[E, A] = 165 | success 166 | 167 | def mapToIO[E, A, B]( 168 | value: ToNull[E, A] 169 | )(f: A => IO[B]): IO[ToNull[E, B]] = 170 | if value == null then IO.pure(null) 171 | else f(value.asInstanceOf[A]) 172 | 173 | def flatMapToIO[E, A, B]( 174 | value: ToNull[E, A] 175 | )(f: A => IO[ToNull[E, B]]): IO[ToNull[E, B]] = 176 | if value == null then IO.pure(null) 177 | else f(value.asInstanceOf[A]) 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /core/shared/src/main/scala/krop/route/SeqStringCodec.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023 Creative Scala 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package krop.route 18 | 19 | import cats.syntax.all.* 20 | 21 | import java.util.regex.Pattern 22 | import scala.collection.immutable.ArraySeq 23 | 24 | /** A SeqStringCodec encodes a value as a sequence of String, and decodes a 25 | * value from a sequence of String. SeqStringCodecs are used for handling: 26 | * 27 | * - path segments; 28 | * - query parameters; and 29 | * - form submission. 30 | */ 31 | trait SeqStringCodec[A] { 32 | 33 | /** A short description of this codec. By convention the name of the type this 34 | * codec encodes enclosed within angle brackets, and using * (0 or more) or + 35 | * (1 or more) to indicate the expected number of repetitions. 36 | */ 37 | def name: String 38 | def decode(value: Seq[String]): Either[DecodeFailure, A] 39 | def encode(value: A): Seq[String] 40 | 41 | /** Construct a `SeqStringCodec[B]` from a `SeqStringCodec[A]` using functions 42 | * to convert from A to B and B to A. 43 | * 44 | * You should probably call `withName` after `imap`, to give the 45 | * `SeqStringCodec` you just created a more appropriate name. 46 | */ 47 | def imap[B](f: A => B)(g: B => A): SeqStringCodec[B] = { 48 | val self = this 49 | new SeqStringCodec[B] { 50 | val name: String = self.name 51 | 52 | def decode(value: Seq[String]): Either[DecodeFailure, B] = 53 | self.decode(value).map(f) 54 | 55 | def encode(value: B): Seq[String] = self.encode(g(value)) 56 | } 57 | } 58 | 59 | /** Create a new [[SeqStringCodec]] that works exactly the same as this 60 | * [[SeqStringCodec]] except it has the given name. 61 | */ 62 | def withName(newName: String): SeqStringCodec[A] = { 63 | val self = this 64 | new SeqStringCodec[A] { 65 | val name: String = newName 66 | 67 | def decode(value: Seq[String]): Either[DecodeFailure, A] = 68 | self.decode(value) 69 | 70 | def encode(value: A): Seq[String] = self.encode(value) 71 | } 72 | } 73 | } 74 | object SeqStringCodec { 75 | 76 | /** A [[SeqStringCodec]] that passes through its input unchanged. */ 77 | given seqString: SeqStringCodec[Seq[String]] = 78 | new SeqStringCodec[Seq[String]] { 79 | val name: String = "*" 80 | 81 | def decode( 82 | value: Seq[String] 83 | ): Either[DecodeFailure, Seq[String]] = 84 | Right(value) 85 | 86 | def encode(value: Seq[String]): Seq[String] = value 87 | } 88 | 89 | /** Constructs a [[SeqStringCodec]] from a given [[StringCodec]]. Decoding 90 | * fails if no values are available. 91 | */ 92 | given fromStringCodec[A](using codec: StringCodec[A]): SeqStringCodec[A] = 93 | new SeqStringCodec[A] { 94 | val name: String = s"${codec.name}+" 95 | 96 | def decode(value: Seq[String]): Either[DecodeFailure, A] = 97 | value.headOption 98 | .map(codec.decode(_)) 99 | .getOrElse(Left(DecodeFailure(value, codec.name))) 100 | 101 | def encode(value: A): Seq[String] = 102 | Seq(codec.encode(value)) 103 | } 104 | 105 | /** Constructs a [[SeqStringCodec]] from a given [[StringCodec]]. Decoding 106 | * returns None if no values are available; otherwise the value is Some of 107 | * the decoded values. 108 | */ 109 | given optional[A](using codec: StringCodec[A]): SeqStringCodec[Option[A]] = 110 | new SeqStringCodec[Option[A]] { 111 | val name: String = s"${codec.name}*" 112 | 113 | def decode( 114 | value: Seq[String] 115 | ): Either[DecodeFailure, Option[A]] = 116 | value.headOption 117 | .map( 118 | codec 119 | .decode(_) 120 | .map(_.some) 121 | ) 122 | .getOrElse(Right(None)) 123 | 124 | def encode(value: Option[A]): Seq[String] = 125 | value.map(codec.encode).toSeq 126 | } 127 | 128 | /** Constructs a [[SeqStringCodec]] that decodes input into a `String` by 129 | * appending all the input together with `separator` inbetween each element. 130 | * Encodes data by splitting on `separator`. 131 | * 132 | * For example, 133 | * 134 | * ```scala 135 | * val comma = SeqStringCodec.separatedString(",") 136 | * ``` 137 | * 138 | * decodes `Seq("a", "b", "c")` to `"a,b,c"` and encodes `"a,b,c"` as 139 | * `Seq("a", "b", "c")`. 140 | */ 141 | def separatedString(separator: String): SeqStringCodec[String] = { 142 | val quotedSeparator = Pattern.quote(separator) 143 | new SeqStringCodec[String] { 144 | val name: String = s"($separator)*" 145 | 146 | def decode(value: Seq[String]): Either[DecodeFailure, String] = 147 | value.mkString(separator).asRight 148 | 149 | def encode(value: String): Seq[String] = 150 | ArraySeq.unsafeWrapArray(value.split(quotedSeparator)) 151 | } 152 | } 153 | 154 | /** Constructs a [[SeqStringCodec]] from a [[StringCodec]], decoding only the 155 | * first value in the input, if one exists, and encoding values as a single 156 | * element `Seq`. 157 | */ 158 | def one[A](using codec: StringCodec[A]): SeqStringCodec[A] = { 159 | new SeqStringCodec[A] { 160 | val name: String = codec.name 161 | 162 | def decode(value: Seq[String]): Either[DecodeFailure, A] = 163 | value.headOption 164 | .map(codec.decode(_)) 165 | .getOrElse(Left(DecodeFailure(value, codec.name))) 166 | 167 | def encode(value: A): Seq[String] = 168 | Seq(codec.encode(value)) 169 | } 170 | } 171 | 172 | /** Constructs a [[SeqStringCodec]] from a [[StringCodec]], decoding all the 173 | * values in the input. 174 | */ 175 | def all[A](using codec: StringCodec[A]): SeqStringCodec[Seq[A]] = { 176 | new SeqStringCodec[Seq[A]] { 177 | val name: String = "${codec.name}*" 178 | 179 | def decode(value: Seq[String]): Either[DecodeFailure, Seq[A]] = 180 | value.traverse(codec.decode) 181 | 182 | def encode(value: Seq[A]): Seq[String] = 183 | value.map(codec.encode) 184 | } 185 | } 186 | } 187 | --------------------------------------------------------------------------------