Skip to content

Commit

Permalink
simplify transport and provider response; remove peek; remove ObjectM…
Browse files Browse the repository at this point in the history
…apper creation from Jackson codec, ask for it implicitly
  • Loading branch information
yakivy committed Jun 13, 2024
1 parent 0b27b87 commit bc7018f
Show file tree
Hide file tree
Showing 61 changed files with 674 additions and 303 deletions.
47 changes: 19 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ Essential differences from [autowire](https://github.com/lihaoyi/autowire):
### Table of contents
1. [Quick start](#quick-start)
1. [Customizations](#customizations)
1. [Logging](#logging)
1. [Failure handling](#failure-handling)
1. [Manual calls](#manual-calls)
1. [Limitations](#limitations)
Expand Down Expand Up @@ -102,24 +101,12 @@ userService.findById("1")

### Customizations
The library is build on following abstractions:
- `[F[_]]` - is your service HKT, can be any monad (has `cats.Monad` typeclass);
- `[I]` - is an intermediate data type that your coding framework works with, can be any serialization format, but it would be easier to choose from existed codec modules as they come with a bunch of predefined codecs;
- `poppet.consumer.Transport` - used to transfer the data between consumer and provider apps, technically it is just a function from `[I]` to `[F[I]]`, so you can use anything as long as it can receive/pass the chosen data type;
- `poppet.Codec` - used to convert `[I]` to domain models and vice versa. Poppet comes with a bunch of modules, where you will hopefully find a favourite codec. If it is not there, you can always try to write your own by providing 2 basic implicits like [here](https://github.com/yakivy/poppet/blob/master/circe/src/poppet/codec/circe/instances/CirceCodecInstances.scala);
- `poppet.CodecK` - used to convert method return HKT to `[F]` and vice versa. It's needed only if return HKT differs from your service HKT, compilation errors will hint you what codecs are absent;
- `F[_]` - is your service HKT, can be any monad (has `cats.Monad` typeclass);
- `I` - is an intermediate data type that your coding framework works with, can be any serialization format, but it would be easier to choose from existed codec modules as they come with a bunch of predefined codecs;
- `poppet.consumer.Transport` - used to transfer the data between consumer and provider apps, technically it is just a function from `I` to `F[I]`, so you can use anything as long as it can receive/pass the chosen data type;
- `poppet.Codec` - used to convert `I` to domain models and vice versa. Poppet comes with a bunch of modules, where you will hopefully find a favourite codec. If it is not there, you can always try to write your own by providing 2 basic implicits like [here](https://github.com/yakivy/poppet/blob/master/circe/src/poppet/codec/circe/instances/CirceCodecInstances.scala);
- `poppet.CodecK` - used to convert method return HKT to `F` and vice versa. It's needed only if return HKT differs from your service HKT, compilation errors will hint you what codecs are absent;
- `poppet.FailureHandler[F[_]]` - used to handle internal failures, more info you can find [here](#failure-handling);
- `poppet.Peek[F[_], I]` - used to decorate a function from `Request[I]` to `F[Response[I]]`. Good fit for logging, more info you can find [here](#logging).

#### Logging
Both provider and consumer take `Peek[F, I]` as an argument, that allows to inject logging logic around the `Request[I] => F[Response[I]]` function. Let's define simple logging peek:
```scala
val peek: Peek[Id, Json] = f => request => {
println("Request: " + request)
val response = f(request)
println("Response: " + response)
response
}
```

#### Failure handling
All meaningful failures that can appear in the library are being transformed into `poppet.Failure`, after what, handled with `poppet.FailureHandler`. Failure handler is a simple polymorphic function from failure to lifted result:
Expand Down Expand Up @@ -212,34 +199,38 @@ Provider[..., ...]()

### Examples
- run desired example:
- Http4s with Circe: https://github.com/yakivy/poppet/tree/master/example/http4s
- run provider: `./mill example.http4s.provider.run`
- run consumer: `./mill example.http4s.consumer.run`
- Http4s with Circe: https://github.com/yakivy/poppet/tree/master/example/http4s-circe
- run provider: `./mill example.http4s-circe.provider.run`
- run consumer: `./mill example.http4s-circe.consumer.run`
- Play Framework with Play Json: https://github.com/yakivy/poppet/tree/master/example/play
- run provider: `./mill example.play.provider.run`
- run consumer: `./mill example.play.consumer.run`
- remove `RUNNING_PID` file manually if services are conflicting with each other
- And even Spring Framework with Jackson 😲: https://github.com/yakivy/poppet/tree/master/example/spring
- run provider: `./mill example.spring.provider.run`
- run consumer: `./mill example.spring.consumer.run`
- And even Spring Framework with Jackson 😲: https://github.com/yakivy/poppet/tree/master/example/spring-jackson
- run provider: `./mill example.spring-jackson.provider.run`
- run consumer: `./mill example.spring-jackson.consumer.run`
- Tapir with Sttp with FS2 with Circe (supports streaming): https://github.com/yakivy/poppet/tree/master/example/tapir-sttp-fs2-circe
- run provider: `./mill example.tapir-sttp-fs2-circe.provider.run`
- run consumer: `./mill example.tapir-sttp-fs2-circe.consumer.run`
- put `http://localhost:9002/api/user/1` in the address bar
- put `http://localhost:9002/api/user` in the address bar if transport supports streaming

### Roadmap
- simplify transport and provider response, use Request => Response instead of I (remove Peek?)
- add action (including argument name) to codec
- throw an exception on duplicated service processor
- separate `.service[S]` and `.service[G[_], S]` to simplify codec resolution
- don't create ObjectMapper in the lib, use implicit one
- check that passed class is a trait and doesn't have arguments to prevent obscure error from compiler
- check that all abstract methods are public

### Changelog
#### 0.4.x:
- simplify transport and provider response
- remove peek
- remove ObjectMapper creation from Jackson codec, ask for it implicitly

#### 0.3.x:
- fix compilation errors for methods with varargs
- fix several compilation errors for Scala 3
- fix codec resolution for id (`I => I`) codecs
- reset `DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES` jackson object mapping property to default value to close [DoS vulnerability](https://github.com/FasterXML/jackson-module-scala/issues/609)
- add Scala 3 support

#### 0.2.x:
Expand Down
51 changes: 45 additions & 6 deletions build.sc
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import mill.scalalib.publish._
import mill.playlib._

object versions {
val publish = "0.3.5"
val publish = "0.4.0"

val scala212 = "2.12.18"
val scala212 = "2.12.19"
val scala213 = "2.13.12"
val scala3 = "3.3.0"
val scalaJs = "1.13.2"
Expand All @@ -23,8 +23,11 @@ object versions {
val playJson = "2.9.4"
val jackson = "2.13.5"

val catsEffect = "3.4.1"
val http4s = "0.23.12"
val catsEffect = "3.5.4"
val fs2 = "3.10.0"
val http4s = "0.23.16"
val tapir = "1.10.0"
val sttp = "3.9.4"
val play = "2.8.18"
val logback = "1.2.11"
val springBoot = "2.7.5"
Expand Down Expand Up @@ -252,7 +255,7 @@ object jackson extends Module {
}

object example extends Module {
object http4s extends Module {
object `http4s-circe` extends Module {
trait CommonModule extends ScalaModule {
override def scalaVersion = versions.scala3
override def ivyDeps = super.ivyDeps() ++ Agg(
Expand Down Expand Up @@ -307,7 +310,7 @@ object example extends Module {
}
}

object spring extends Module {
object `spring-jackson` extends Module {
trait CommonModule extends ScalaModule {
override def scalaVersion = versions.scala213
override def ivyDeps = super.ivyDeps() ++ Agg(
Expand All @@ -334,4 +337,40 @@ object example extends Module {
override def moduleDeps = super.moduleDeps ++ Seq(api)
}
}

object `tapir-sttp-fs2-circe` extends Module {
trait CommonModule extends ScalaModule {
override def scalaVersion = versions.scala3
override def ivyDeps = super.ivyDeps() ++ Agg(
ivy"org.typelevel::cats-core::${versions.cats}",
ivy"org.typelevel::cats-effect::${versions.catsEffect}",
ivy"io.circe::circe-generic::${versions.circe}",
ivy"co.fs2::fs2-io::${versions.fs2}",
)
override def moduleDeps = super.moduleDeps ++ Seq(circe.jvm(versions.scala3))
}
object api extends CommonModule
object consumer extends CommonModule {
override def ivyDeps = super.ivyDeps() ++ Agg(
ivy"org.http4s::http4s-blaze-server::${versions.http4s}",
ivy"org.http4s::http4s-blaze-client::${versions.http4s}",
ivy"com.softwaremill.sttp.tapir::tapir-http4s-server::${versions.tapir}",
ivy"com.softwaremill.sttp.client3::http4s-backend::${versions.sttp}",
ivy"com.softwaremill.sttp.tapir::tapir-cats::${versions.tapir}",
ivy"com.softwaremill.sttp.tapir::tapir-json-circe::${versions.tapir}",
ivy"ch.qos.logback:logback-classic:${versions.logback}",
)
override def moduleDeps = super.moduleDeps ++ Seq(api)
}
object provider extends CommonModule {
override def ivyDeps = super.ivyDeps() ++ Agg(
ivy"org.http4s::http4s-blaze-server::${versions.http4s}",
ivy"com.softwaremill.sttp.tapir::tapir-http4s-server::${versions.tapir}",
ivy"com.softwaremill.sttp.tapir::tapir-cats::${versions.tapir}",
ivy"com.softwaremill.sttp.tapir::tapir-json-circe::${versions.tapir}",
ivy"ch.qos.logback:logback-classic:${versions.logback}",
)
override def moduleDeps = super.moduleDeps ++ Seq(api)
}
}
}
3 changes: 0 additions & 3 deletions circe/test/src/poppet/codec/circe/CirceCodecSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ import poppet.codec.circe.all._

class CirceCodecSpec extends AnyFreeSpec with CodecSpec {
"Circe codec should parse" - {
"request and response data structures" in {
assertExchangeCodec[Json]
}
"custom data structures" in {
assertCustomCodec[Json, Unit](())
assertCustomCodec[Json, Int](intExample)
Expand Down
8 changes: 4 additions & 4 deletions core/src/poppet/CoreDsl.scala
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
package poppet

import poppet.core.Request
import poppet.core.Response

trait CoreDsl {
type Codec[A, B] = core.Codec[A, B]
type CodecK[F[_], G[_]] = core.CodecK[F, G]
type Failure = core.Failure
type CodecFailure[I] = core.CodecFailure[I]
type FailureHandler[F[_]] = core.FailureHandler[F]
type Peek[F[_], I] = (Request[I] => F[Response[I]]) => (Request[I] => F[Response[I]])
type Request[I] = core.Request[I]
type Response[I] = core.Response[I]

val FailureHandler = core.FailureHandler
val Request = core.Request
val Response = core.Response
}
2 changes: 1 addition & 1 deletion core/src/poppet/consumer/ConsumerDsl.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package poppet.consumer

trait ConsumerDsl {
type Consumer[F[_], I, S] = core.Consumer[F, I, S]
type Transport[F[_], I] = I => F[I]
type Transport[F[_], I] = Request[I] => F[Response[I]]

val Consumer = core.Consumer
}
46 changes: 15 additions & 31 deletions core/src/poppet/consumer/core/Consumer.scala
Original file line number Diff line number Diff line change
@@ -1,57 +1,41 @@
package poppet.consumer.core

import cats.Monad
import cats.implicits._
import cats.Monad
import poppet.consumer.all._
import poppet.core.Request
import poppet.core.Response

/**
* @param transport function that transfers data to the provider
* @param peek function that can decorate given request -> response function without changing the types.
* It is mostly used to peek on parsed dtos, for example for logging.
*
* @tparam F consumer data kind, for example Future[_]
* @tparam I intermediate data type, for example Json
* @tparam S service type, for example HelloService
*/
class Consumer[F[_] : Monad, I, S](
class Consumer[F[_]: Monad, I, S](
transport: Transport[F, I],
peek: Peek[F, I],
fh: FailureHandler[F],
processor: ConsumerProcessor[F, I, S])(
implicit qcodec: Codec[Request[I], I],
scodec: Codec[I, Response[I]],
processor: ConsumerProcessor[F, I, S]
) {
def service: S = processor(
peek(input => for {
request <- qcodec(input).fold(fh.apply, Monad[F].pure)
response <- transport(request)
output <- scodec(response).fold(fh.apply, Monad[F].pure)
} yield output),
fh,
)
def service: S = processor(transport, fh)
}

object Consumer {

def apply[F[_], I](
client: Transport[F, I],
peek: Peek[F, I] = identity[Request[I] => F[Response[I]]](_),
fh: FailureHandler[F] = FailureHandler.throwing[F])(
implicit FM: Monad[F],
qcodec: Codec[Request[I], I],
scodec: Codec[I, Response[I]]
): Builder[F, I] = new Builder[F, I](client, peek, fh)
fh: FailureHandler[F] = FailureHandler.throwing[F]
)(implicit
F: Monad[F],
): Builder[F, I] = new Builder[F, I](client, fh)

class Builder[F[_], I](
client: Transport[F, I],
peek: Peek[F, I],
fh: FailureHandler[F])(
implicit FM: Monad[F],
qcodec: Codec[Request[I], I],
scodec: Codec[I, Response[I]]
fh: FailureHandler[F]
)(implicit
F: Monad[F],
) {
def service[S](implicit processor: ConsumerProcessor[F, I, S]): S =
new Consumer(client, peek, fh, processor).service
new Consumer(client, fh, processor).service
}
}

}
2 changes: 2 additions & 0 deletions core/src/poppet/core/Failure.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ class Failure(message: String, e: Throwable) extends Exception(message, e) {

class CodecFailure[I](message: String, val data: I, e: Throwable) extends Failure(message, e) {
def this(message: String, data: I) = this(message, data, null)

def withData[II](data: II): CodecFailure[II] = new CodecFailure(message, data, e)
}
2 changes: 1 addition & 1 deletion core/src/poppet/provider/ProviderDsl.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package poppet.provider

trait ProviderDsl {
type Provider[F[_], I] = core.Provider[F, I]
type Server[F[_], I] = I => F[I]
type Server[F[_], I] = Request[I] => F[Response[I]]

val Provider = core.Provider
}
50 changes: 17 additions & 33 deletions core/src/poppet/provider/core/Provider.scala
Original file line number Diff line number Diff line change
@@ -1,42 +1,38 @@
package poppet.provider.core

import cats.Monad
import cats.data.OptionT
import cats.implicits._
import cats.Monad
import poppet.core.Request
import poppet.core.Response
import poppet.provider.all._
import poppet.provider.core.Provider._

/**
* @param peek function that can decorate given request -> response function without changing the types.
* It is mostly used to peek on parsed dtos, for example for logging.
*
* @tparam F service data kind, for example Future
* @tparam I intermediate data type, for example Json
*/
class Provider[F[_] : Monad, I](
peek: Peek[F, I],
class Provider[F[_]: Monad, I](
fh: FailureHandler[F],
processors: List[MethodProcessor[F, I]])(
implicit qcodec: Codec[I, Request[I]],
scodec: Codec[Response[I], I],
processors: List[MethodProcessor[F, I]]
) extends Server[F, I] {

private val indexedProcessors: Map[String, Map[String, Map[String, Map[String, I] => F[I]]]] =
processors.groupBy(_.service).mapValues(
_.groupBy(_.name).mapValues(
_.map(m => m.arguments.toList.sorted.mkString(",") -> m.f).toMap
_.map(m => m.arguments.sorted.mkString(",") -> m.f).toMap
).toMap
).toMap

private def processorNotFoundFailure(processor: String, in: String): Failure = new Failure(
s"Requested processor $processor is not in $in. Make sure that desired service is provided and up to date."
)

private def execute(request: Request[I]): F[Response[I]] = for {
def apply(request: Request[I]): F[Response[I]] = for {
serviceProcessors <- OptionT.fromOption[F](indexedProcessors.get(request.service))
.getOrElseF(fh(processorNotFoundFailure(
request.service, s"[${indexedProcessors.keySet.mkString(",")}]"
request.service,
s"[${indexedProcessors.keySet.mkString(",")}]"
)))
methodProcessors <- OptionT.fromOption[F](serviceProcessors.get(request.method))
.getOrElseF(fh(processorNotFoundFailure(
Expand All @@ -51,31 +47,19 @@ class Provider[F[_] : Monad, I](
value <- processor(request.arguments)
} yield Response(value)

def apply(request: I): F[I] = for {
input <- qcodec(request).fold(fh.apply, Monad[F].pure)
output <- peek(execute)(input)
response <- scodec(output).fold(fh.apply, Monad[F].pure)
} yield response

def service[S](s: S)(implicit processor: ProviderProcessor[F, I, S]) =
new Provider[F, I](peek, fh, processors ::: processor(s, fh))
new Provider[F, I](fh, processors ::: processor(s, fh))
}

object Provider {
def apply[F[_] : Monad, I](
peek: Peek[F, I] = identity[Request[I] => F[Response[I]]](_),
fh: FailureHandler[F] = FailureHandler.throwing[F])(
implicit qcodec: Codec[I, Request[I]],
scodec: Codec[Response[I], I],
): Builder[F, I] = new Builder[F, I](peek, fh)

class Builder[F[_] : Monad, I](
peek: Peek[F, I],
fh: FailureHandler[F])(
implicit qcodec: Codec[I, Request[I]],
scodec: Codec[Response[I], I],
) {
def apply[F[_]: Monad, I](
fh: FailureHandler[F] = FailureHandler.throwing[F]
): Builder[F, I] = new Builder[F, I](fh)

class Builder[F[_]: Monad, I](fh: FailureHandler[F]) {
def service[S](s: S)(implicit processor: ProviderProcessor[F, I, S]): Provider[F, I] =
new Provider[F, I](peek, fh, Nil).service(s)
new Provider[F, I](fh, Nil).service(s)
}
}

}
Loading

0 comments on commit bc7018f

Please sign in to comment.