diff --git a/build.sbt b/build.sbt index 5e7db57f..9c897caa 100644 --- a/build.sbt +++ b/build.sbt @@ -176,12 +176,17 @@ lazy val docs = project moduleName := "retro-docs", libraryDependencies ++= docsDependencies, mdocVariables := Map( + "CIRCE_VERSION" -> V.circe, + "AKKA_HTTP_VERSION" -> V.akkaHttp, + "TAPIR_VERSION" -> V.tapir, "TOCTOC_SNAPSHOT_VERSION" -> version.in(toctocCore).value, "TOCTOC_STABLE_VERSION" -> version.in(toctocCore).value.replaceFirst("\\+.*", ""), "ENUMERO_SNAPSHOT_VERSION" -> version.in(enumeroCore).value, "ENUMERO_STABLE_VERSION" -> version.in(enumeroCore).value.replaceFirst("\\+.*", ""), "SBT_BUILDO_SNAPSHOT_VERSION" -> version.in(`sbt-buildo`).value, "SBT_BUILDO_STABLE_VERSION" -> version.in(`sbt-buildo`).value.replaceFirst("\\+.*", ""), + "SBT_TAPIRO_SNAPSHOT_VERSION" -> version.in(`sbt-tapiro`).value, + "SBT_TAPIRO_STABLE_VERSION" -> version.in(`sbt-tapiro`).value.replaceFirst("\\+.*", ""), ), ) .dependsOn(toctocCore, enumeroCore, toctocSlickPostgreSql) diff --git a/docs/tapiro/installation.md b/docs/tapiro/installation.md new file mode 100644 index 00000000..ee9507a5 --- /dev/null +++ b/docs/tapiro/installation.md @@ -0,0 +1,89 @@ +--- +id: installation +title: Installation +--- + +`tapiro` can be installed as an Sbt plugin. + +`sbt-tapiro` is an Sbt plugin that uses `tapiro` to generate http/json routes parsing scala traits definitions. + +## Installation + +To start using `sbt-tapiro` simply add this line in `project/plugins.sbt` + +```scala +addSbtPlugin("io.buildo" %% "sbt-tapiro" % "@SBT_TAPIRO_STABLE_VERSION@") +``` + +### Snapshot releases + +We publish a snapshot version on every merge on master. + +The latest snapshot version is `@SBT_TAPIRO_SNAPSHOT_VERSION@` and you can use +it to try the latest unreleased features. For example: + +```scala +addSbtPlugin("io.buildo" %% "sbt-tapiro" % "@SBT_TAPIRO_SNAPSHOT_VERSION@") +resolvers += Resolver.sonatypeRepo("snapshots") +``` + +## Plugin + +To use the code generator, you need to add this to your `build.sbt`. + +```scala +import _root_.io.buildo.tapiro.Server + +lazy val application = project + .settings( + libraryDependencies ++= applicationDependencies ++ tapiroDependencies, + tapiro / tapiroRoutesPaths := List("[path to routes]"), + tapiro / tapiroModelsPaths := List("[path to models]"), + tapiro / tapiroOutputPath := "[path to endpoints]", + tapiro / tapiroEndpointsPackages := List("[package]", "[subpackage]"), + tapiro / tapiroServer := Server.AkkaHttp, //or Server.Http4s + ) + .enablePlugins(SbtTapiro) +``` + +You can now run it with `sbt application/tapiro`. + +```scala +## Dependencies + +The generated code comes with library dependencies. + +In case akka-http version is used: +```scala +val V = new { + val circe = "@CIRCE_VERSION@" + val tapir = "@TAPIR_VERSION@" + val akkaHttp = "@AKKA_HTTP_VERSION@" +} + +val tapiroDependencies = Seq( + "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % V.tapir, + "com.softwaremill.sttp.tapir" %% "tapir-core" % V.tapir, + "com.softwaremill.sttp.tapir" %% "tapir-akka-http-server" % V.tapir, + "com.typesafe.akka" %% "akka-http" % V.akkaHttp, + "io.circe" %% "circe-core" % V.circe, +) +``` + +In case http4s is used: + +```scala +val V = new { + val circe = "@CIRCE_VERSION@" + val tapir = "@TAPIR_VERSION@" +} + +val tapiroDependencies = Seq( + "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % V.tapir, + "com.softwaremill.sttp.tapir" %% "tapir-core" % V.tapir, + "com.softwaremill.sttp.tapir" %% "tapir-http4s-server" % V.tapir, + "io.circe" %% "circe-core" % V.circe, +) +``` + +These dependencies usually go under `project/Dependencies.scala` \ No newline at end of file diff --git a/docs/tapiro/introduction.md b/docs/tapiro/introduction.md new file mode 100644 index 00000000..9c60f350 --- /dev/null +++ b/docs/tapiro/introduction.md @@ -0,0 +1,124 @@ +--- +id: introduction +title: Introduction +--- + +Tapiro parses your Scala controllers to generate HTTP endpoints. + +A Scala controller is a trait defined as follows: + +```scala mdoc +import scala.annotation.StaticAnnotation + +class query extends StaticAnnotation +class command extends StaticAnnotation + +case class Cat(name: String) +case class Error(msg: String) + +trait Cats[F[_], AuthToken] { + @query //translate this to a GET + def findCutestCat(): F[Either[Error, Cat]] + + @command //translate this to a POST + def doSomethingWithTheCat(catId: Int, token: AuthToken): F[Either[Error, Unit]] +} +``` + +For each controller tapiro generates two files: +- `CatsEndpoints.scala` containing the HTTP api description using https://tapir-scala.readthedocs.io/ +- `CatsHttp4sEndpoints.scala` or `CatsAkkaHttpEndpoints.scala` depeneding on the HTTP server the user is using. + +## Complete Example + +Here you have an example implementation of the `Cats` controller definied in the previous section: + +```scala mdoc +import cats.effect._ + +object Cats { + def create[F[_]](implicit F: Sync[F]) = new Cats[F, String] { + override def findCutestCat(): F[Either[Error, Cat]] = + F.delay(Right(Cat("Cheshire"))) + override def doSomethingWithTheCat(catId: Int, token: String): F[Either[Error, Unit]] = + F.delay(Right(())) + } +} + + +``` + +Here you have the autogenerated magic fromo tapiro (This is the content of `CatsHttp4sEndpoints.scala` it will be autogenerated). + +```scala mdoc +import org.http4s.HttpRoutes + +// ---- begins autogenerated code +object CatsHttp4sEndpoints { + def routes(controller: Cats[IO, String]): HttpRoutes[IO] = ??? +} +// ---- ends autogenerated code +``` + +Here is how to run the server: + +```scala mdoc +import org.http4s.server.blaze._ +import org.http4s.implicits._ +import cats.implicits._ + +object Main extends IOApp { + val catsImpl = Cats.create[IO] + val routes = CatsHttp4sEndpoints.routes(catsImpl) + + override def run(args: List[String]): IO[ExitCode] = + BlazeServerBuilder[IO] + .bindHttp(8080, "localhost") + .withHttpApp(routes.orNotFound) + .serve + .compile + .drain + .as(ExitCode.Success) +} +``` + +The resulting server can be queried as follows: +``` +/GET /Cats/findCutestCat +/POST /Cats/doSomethingWithTheCat -d '{ "catId": 1 }' +``` + +## Authentication + +An `AuthToken` type argument is expected in each controller and is added as authorization header. + +`trait Cats[F[_], AuthToken]` + +The actual implementation of the `AuthToken` is left to the user. All tapiro requires is a proper tapir `PlainCodec` such as: + +```scala mdoc +import sttp.tapir._ +import sttp.tapir.Codec._ + +case class CustomAuth(token: String) + +def decodeAuth(s: String): DecodeResult[CustomAuth] = { + val TokenPattern = "Token token=(.+)".r + s match { + case TokenPattern(token) => DecodeResult.Value(CustomAuth(token)) + case _ => DecodeResult.Error(s, new Exception("token not found")) + } +} + +def encodeAuth(auth: CustomAuth): String = auth.token + +implicit val authCodec: PlainCodec[CustomAuth] = Codec.stringPlainCodecUtf8 + .mapDecode(decodeAuth)(encodeAuth) +``` + +The user will find the decoded token as the last argument of the method in the trait. + +```scala +@command //translate this to a POST +def doSomethingWithTheCat(catId: Int, token: AuthToken): F[Either[Error, Unit]] +``` diff --git a/docs/tapiro/migrate.md b/docs/tapiro/migrate.md new file mode 100644 index 00000000..6b20a38b --- /dev/null +++ b/docs/tapiro/migrate.md @@ -0,0 +1,42 @@ +--- +id: migrate +title: Migration from Wiro +--- + +Tapiro is meant to deprecate [wiro](https://github.com/buildo/wiro). + +Tapiro is based on the same concepts of wiro, the migration is pretty straightforward. + +Here is a checklist of what you need to do: +1. Install the plugin (as described in the [guide](installation.md)) +2. Configure your `build.sbt` (as described in the [guide](installation.md)) +3. Add `AuthToken` type parameter to controllers + `trait AccountController[F]` -> `trait AccountController[F[_], AuthToken]` +4. Modify controllers so that wiro `Auth` is replaced with AuthToken and move as last argument + `def read(token: Auth, arg: Int)` -> `def read(arg: Int, token: AuthToken)` +5. Add `**/*Endpoints.scala linguist-generated` to repository's `.gitattributes` to automatically collapse tapiro generated code in GitHub diffs +6. Add required codecs +This is a valid codec for wiro.Auth: + +```scala mdoc +import sttp.tapir._ +import sttp.tapir.Codec._ + +case class Auth(token: String) //should be imported as wiro.Auth instead + +implicit val authCodec: PlainCodec[Auth] = Codec.stringPlainCodecUtf8 + .mapDecode(decodeAuth)(encodeAuth) + +def decodeAuth(s: String): DecodeResult[Auth] = { + val TokenPattern = "Token token=(.+)".r + s match { + case TokenPattern(token) => DecodeResult.Value(Auth(token)) + case _ => DecodeResult.Error(s, new Exception("token not found")) + } +} + +def encodeAuth(auth: Auth): String = auth.token +``` +7. Run `sbt tapiro` + +Using `Server.AkkaHttp` the resulting routes can be added to wiro as custom routes. diff --git a/docs/tapiro/rpc.md b/docs/tapiro/rpc.md new file mode 100644 index 00000000..cc735a43 --- /dev/null +++ b/docs/tapiro/rpc.md @@ -0,0 +1,6 @@ +--- +id: rpc +title: Why? +--- + +> 📖 **NOTE**: Long time ago we wrote a [blogpost](https://blog.buildo.io/http-routes-at-buildo-1424250c41d3) about this. The blogpost is about [another library](https://github.com/buildo/wiro) but the underlying concepts are the same. \ No newline at end of file diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 96957631..a4b8e839 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -37,6 +37,7 @@ object Dependencies { val plantuml = "8059" val pprint = "0.5.9" val sbtLogging = "1.3.3" + val tapir = "0.12.19" } val circeCore = "io.circe" %% "circe-core" % V.circe @@ -80,6 +81,10 @@ object Dependencies { val plantuml = "net.sourceforge.plantuml" % "plantuml" % V.plantuml val pprint = "com.lihaoyi" %% "pprint" % V.pprint val sbtLogging = "org.scala-sbt" %% "util-logging" % V.sbtLogging + val tapir = "com.softwaremill.sttp.tapir" %% "tapir-core" % V.tapir + val tapirJsonCirce = "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % V.tapir + val tapirCore = "com.softwaremill.sttp.tapir" %% "tapir-core" % V.tapir + val tapirHttp4s = "com.softwaremill.sttp.tapir" %% "tapir-http4s-server" % V.tapir val enumeroDependencies = List( scalatest, @@ -191,6 +196,10 @@ object Dependencies { val docsDependencies = List( plantuml, + tapir, + tapirJsonCirce, + tapirCore, + tapirHttp4s ) } diff --git a/website/i18n/en.json b/website/i18n/en.json index 9f83a0ee..c4f7d25d 100644 --- a/website/i18n/en.json +++ b/website/i18n/en.json @@ -49,6 +49,7 @@ "toctoc": "toctoc", "enumero": "enumero", "sbt-buildo": "sbt-buildo", + "tapiro": "tapiro", "GitHub": "GitHub" }, "categories": { diff --git a/website/sidebars.json b/website/sidebars.json index 09a028e4..4a677b17 100644 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -14,6 +14,14 @@ "enumero": { "Getting started": ["enumero/introduction", "enumero/installation"] }, + "tapiro": { + "Setup": ["tapiro/installation"], + "Getting started": [ + "tapiro/introduction", + "tapiro/rpc", + "tapiro/migrate" + ] + }, "sbt-buildo": { "Getting started": ["sbt-buildo/introduction"] }, diff --git a/website/siteConfig.js b/website/siteConfig.js index f95c1ddc..fd1f3191 100644 --- a/website/siteConfig.js +++ b/website/siteConfig.js @@ -29,6 +29,7 @@ const siteConfig = { { doc: "toctoc/installation", label: "toctoc" }, { doc: "enumero/introduction", label: "enumero" }, { doc: "sbt-buildo/introduction", label: "sbt-buildo" }, + { doc: "tapiro/introduction", label: "tapiro" }, { search: true }, { href: "https://github.com/buildo/retro", label: "GitHub", external: true } ],