diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5c89587 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,167 @@ +# This file was automatically generated by sbt-github-actions using the +# githubWorkflowGenerate task. You should add and commit this file to +# your git repository. It goes without saying that you shouldn't edit +# this file by hand! Instead, if you wish to make changes, you should +# change your sbt build configuration to revise the workflow description +# to meet your needs, then regenerate this file. + +name: Continuous Integration + +on: + pull_request: + branches: ['**', '!update/**', '!pr/**'] + push: + branches: ['**', '!update/**', '!pr/**'] + tags: [v*] + +env: + PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} + SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} + SONATYPE_CREDENTIAL_HOST: ${{ secrets.SONATYPE_CREDENTIAL_HOST }} + SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} + PGP_SECRET: ${{ secrets.PGP_SECRET }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +jobs: + build: + name: Build and Test + strategy: + matrix: + os: [ubuntu-latest] + scala: [3.3.1] + java: [temurin@8] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout current branch (full) + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Download Java (temurin@8) + id: download-java-temurin-8 + if: matrix.java == 'temurin@8' + uses: typelevel/download-java@v1 + with: + distribution: temurin + java-version: 8 + + - name: Setup Java (temurin@8) + if: matrix.java == 'temurin@8' + uses: actions/setup-java@v2 + with: + distribution: jdkfile + java-version: 8 + jdkFile: ${{ steps.download-java-temurin-8.outputs.jdkFile }} + + - name: Cache sbt + uses: actions/cache@v2 + with: + path: | + ~/.sbt + ~/.ivy2/cache + ~/.coursier/cache/v1 + ~/.cache/coursier/v1 + ~/AppData/Local/Coursier/Cache/v1 + ~/Library/Caches/Coursier/v1 + key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} + + - name: Check that workflows are up to date + run: sbt '++${{ matrix.scala }}' 'project /' githubWorkflowCheck + + - name: Check headers and formatting + if: matrix.java == 'temurin@8' + run: sbt '++${{ matrix.scala }}' headerCheckAll scalafmtCheckAll 'project /' scalafmtSbtCheck + + - name: Test + run: sbt '++${{ matrix.scala }}' test + + - name: Check binary compatibility + if: matrix.java == 'temurin@8' + run: sbt '++${{ matrix.scala }}' mimaReportBinaryIssues + + - name: Generate API documentation + if: matrix.java == 'temurin@8' + run: sbt '++${{ matrix.scala }}' doc + + - name: Make target directories + if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') + run: mkdir -p target core/target project/target + + - name: Compress target directories + if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') + run: tar cf targets.tar target core/target project/target + + - name: Upload target directories + if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') + uses: actions/upload-artifact@v2 + with: + name: target-${{ matrix.os }}-${{ matrix.java }}-${{ matrix.scala }} + path: targets.tar + + publish: + name: Publish Artifacts + needs: [build] + if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') + strategy: + matrix: + os: [ubuntu-latest] + scala: [3.3.1] + java: [temurin@8] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout current branch (full) + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Download Java (temurin@8) + id: download-java-temurin-8 + if: matrix.java == 'temurin@8' + uses: typelevel/download-java@v1 + with: + distribution: temurin + java-version: 8 + + - name: Setup Java (temurin@8) + if: matrix.java == 'temurin@8' + uses: actions/setup-java@v2 + with: + distribution: jdkfile + java-version: 8 + jdkFile: ${{ steps.download-java-temurin-8.outputs.jdkFile }} + + - name: Cache sbt + uses: actions/cache@v2 + with: + path: | + ~/.sbt + ~/.ivy2/cache + ~/.coursier/cache/v1 + ~/.cache/coursier/v1 + ~/AppData/Local/Coursier/Cache/v1 + ~/Library/Caches/Coursier/v1 + key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} + + - name: Download target directories (3.3.1) + uses: actions/download-artifact@v2 + with: + name: target-${{ matrix.os }}-${{ matrix.java }}-3.3.1 + + - name: Inflate target directories (3.3.1) + run: | + tar xf targets.tar + rm targets.tar + + - name: Import signing key + if: env.PGP_SECRET != '' && env.PGP_PASSPHRASE == '' + run: echo $PGP_SECRET | base64 -di | gpg --import + + - name: Import signing key and strip passphrase + if: env.PGP_SECRET != '' && env.PGP_PASSPHRASE != '' + run: | + echo "$PGP_SECRET" | base64 -di > /tmp/signing-key.gpg + echo "$PGP_PASSPHRASE" | gpg --pinentry-mode loopback --passphrase-fd 0 --import /tmp/signing-key.gpg + (echo "$PGP_PASSPHRASE"; echo; echo) | gpg --command-fd 0 --pinentry-mode loopback --change-passphrase $(gpg --list-secret-keys --with-colons 2> /dev/null | grep '^sec:' | cut --delimiter ':' --fields 5 | tail -n 1) + + - name: Publish + run: sbt '++${{ matrix.scala }}' tlRelease diff --git a/.github/workflows/clean.yml b/.github/workflows/clean.yml new file mode 100644 index 0000000..547aaa4 --- /dev/null +++ b/.github/workflows/clean.yml @@ -0,0 +1,59 @@ +# This file was automatically generated by sbt-github-actions using the +# githubWorkflowGenerate task. You should add and commit this file to +# your git repository. It goes without saying that you shouldn't edit +# this file by hand! Instead, if you wish to make changes, you should +# change your sbt build configuration to revise the workflow description +# to meet your needs, then regenerate this file. + +name: Clean + +on: push + +jobs: + delete-artifacts: + name: Delete Artifacts + runs-on: ubuntu-latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Delete artifacts + run: | + # Customize those three lines with your repository and credentials: + REPO=${GITHUB_API_URL}/repos/${{ github.repository }} + + # A shortcut to call GitHub API. + ghapi() { curl --silent --location --user _:$GITHUB_TOKEN "$@"; } + + # A temporary file which receives HTTP response headers. + TMPFILE=/tmp/tmp.$$ + + # An associative array, key: artifact name, value: number of artifacts of that name. + declare -A ARTCOUNT + + # Process all artifacts on this repository, loop on returned "pages". + URL=$REPO/actions/artifacts + while [[ -n "$URL" ]]; do + + # Get current page, get response headers in a temporary file. + JSON=$(ghapi --dump-header $TMPFILE "$URL") + + # Get URL of next page. Will be empty if we are at the last page. + URL=$(grep '^Link:' "$TMPFILE" | tr ',' '\n' | grep 'rel="next"' | head -1 | sed -e 's/.*.*//') + rm -f $TMPFILE + + # Number of artifacts on this page: + COUNT=$(( $(jq <<<$JSON -r '.artifacts | length') )) + + # Loop on all artifacts on this page. + for ((i=0; $i < $COUNT; i++)); do + + # Get name of artifact and count instances of this name. + name=$(jq <<<$JSON -r ".artifacts[$i].name?") + ARTCOUNT[$name]=$(( $(( ${ARTCOUNT[$name]} )) + 1)) + + id=$(jq <<<$JSON -r ".artifacts[$i].id?") + size=$(( $(jq <<<$JSON -r ".artifacts[$i].size_in_bytes?") )) + printf "Deleting '%s' #%d, %'d bytes\n" $name ${ARTCOUNT[$name]} $size + ghapi -X DELETE $REPO/actions/artifacts/$id + done + done diff --git a/.scalafmt.conf b/.scalafmt.conf index 8df0289..b32145d 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,4 +1,4 @@ -runner.dialect = "scala213" +runner.dialect = "scala3" version = 3.5.8 maxColumn = 100 align.preset = some diff --git a/LICENSE.md b/LICENSE.md index 913aee7..b4796c1 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,4 +1,4 @@ - Copyright 2022 todo contributors + Copyright 2024 Respectfully contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index cb664de..e1b60ed 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ -# library-template +# ResPeCtfully, an instant RPC microlibrary + -Template for polyvariant libraries. Click "use this template" in Github's UI and replace all the TODOs with the right name. diff --git a/build.sbt b/build.sbt index 67c8c5b..805671e 100644 --- a/build.sbt +++ b/build.sbt @@ -1,9 +1,9 @@ ThisBuild / tlBaseVersion := "0.1" ThisBuild / organization := "org.polyvariant" ThisBuild / organizationName := "Polyvariant" -ThisBuild / startYear := Some(2022) +ThisBuild / startYear := Some(2024) ThisBuild / licenses := Seq(License.Apache2) -ThisBuild / developers := List( /* tlGitHubDev() */ ) +ThisBuild / developers := List(tlGitHubDev("kubukoz", "Jakub Kozłowski")) ThisBuild / tlSonatypeUseLegacyHost := false def crossPlugin(x: sbt.librarymanagement.ModuleID) = compilerPlugin(x.cross(CrossVersion.full)) @@ -12,29 +12,35 @@ val compilerPlugins = List( crossPlugin("org.polyvariant" % "better-tostring" % "0.3.17") ) -val Scala213 = "2.13.8" +val Scala3 = "3.3.1" -ThisBuild / scalaVersion := Scala213 - -Global / onChangedBuildSource := ReloadOnSourceChanges +ThisBuild / scalaVersion := Scala3 ThisBuild / tlFatalWarnings := false ThisBuild / tlFatalWarningsInCi := false val commonSettings = Seq( - libraryDependencies ++= compilerPlugins + libraryDependencies ++= + List( + "org.http4s" %%% "http4s-client" % "0.23.25", + "org.http4s" %%% "http4s-circe" % "0.23.25", + "com.kubukoz" %% "debug-utils" % "1.1.3", + "org.http4s" %%% "http4s-ember-client" % "0.23.25" % Test, + "org.http4s" %%% "http4s-ember-server" % "0.23.25" % Test, + ) ++ + compilerPlugins, + scalacOptions ++= Seq( + "-Wunused:all" + ), ) lazy val core = project .settings( - name := "todo-core", + name := "respectfully", commonSettings, ) lazy val root = project .in(file(".")) .aggregate(core) - .settings( - Compile / doc / sources := Seq() - ) .enablePlugins(NoPublishPlugin) diff --git a/core/src/main/scala/respectfully/package.scala b/core/src/main/scala/respectfully/package.scala new file mode 100644 index 0000000..520b6ed --- /dev/null +++ b/core/src/main/scala/respectfully/package.scala @@ -0,0 +1,283 @@ +/* + * Copyright 2024 Polyvariant + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package respectfully + +import cats.effect.IO +import cats.effect.IOApp +import cats.implicits._ +import io.circe.Codec +import io.circe.Decoder +import io.circe.Encoder +import io.circe.Json +import io.circe.syntax._ +import org.http4s.Header +import org.http4s.HttpApp +import org.http4s.HttpRoutes +import org.http4s.Method +import org.http4s.Request +import org.http4s.Response +import org.http4s.Uri +import org.http4s.circe.CirceEntityCodec._ +import org.http4s.client.Client +import org.http4s.implicits._ +import org.typelevel.ci.CIString + +import scala.annotation.experimental +import scala.compiletime.summonInline +import scala.quoted.Expr +import scala.quoted.FromExpr +import scala.quoted.Quotes +import scala.quoted.ToExpr +import scala.quoted.Type +import scala.quoted.quotes + +trait API[Alg] { + def toRoutes: Alg => HttpApp[IO] + def toClient: (Client[IO], Uri) => Alg +} + +object API { + def apply[Alg](using api: API[Alg]): API[Alg] = api + @annotation.experimental + inline def derived[Alg]: API[Alg] = ${ derivedImpl[Alg] } + + @annotation.experimental + private def derivedImpl[Alg: Type](using Quotes): Expr[API[Alg]] = { + import quotes.reflect.{TypeRepr, report, DefDef, Position, asTerm} + + val algTpe = TypeRepr.of[Alg] + val endpoints = algTpe.typeSymbol.declaredMethods.map { meth => + require( + meth.paramSymss.size == 1, + "Only methods with one parameter list are supported, got: " + meth.paramSymss + " for " + meth.name, + ) + + val inputCodec = + meth.paramSymss.head match { + case Nil => '{ Codec.from(Decoder[Unit], Encoder[Unit]) } + + case one :: Nil => /* ok */ + one.termRef.typeSymbol.typeRef.asType match { + case '[t] => + '{ + Codec.from( + summonInline[Decoder[t]], + summonInline[Encoder[t]], + ) + } + } + + case _ => + report.errorAndAbort( + "Only methods with one parameter are supported", + meth.pos.getOrElse(Position.ofMacroExpansion), + ) + } + + val outputCodec = + meth.tree.asInstanceOf[DefDef].returnTpt.tpe.asType match { + case '[IO[t]] => + '{ + Codec.from( + summonInline[Decoder[t]], + summonInline[Encoder[t]], + ) + } + } + + '{ + Endpoint[Any, Any]( + ${ Expr(meth.name) }, + ${ inputCodec }.asInstanceOf[Codec[Any]], + ${ outputCodec }.asInstanceOf[Codec[Any]], + ) + } + } + + def functionsFor(algExpr: Expr[Alg]): Expr[List[(String, Any => IO[Any])]] = Expr.ofList { + algTpe + .typeSymbol + .declaredMethods + .map { meth => + meth.paramSymss.head match { + case Nil => + // special-case: nullary method + Expr(meth.name) -> '{ (_: Any) => + ${ algExpr.asTerm.select(meth).appliedToNone.asExprOf[IO[Any]] } + } + + case sym :: Nil => + sym.termRef.typeSymbol.typeRef.asType match { + case '[t] => + Expr(meth.name) -> '{ (input: Any) => + ${ + algExpr + .asTerm + .select(meth) + .appliedTo('{ input.asInstanceOf[t] }.asTerm) + .asExprOf[IO[Any]] + } + } + } + case _ => + report.errorAndAbort( + "Only methods with one parameter are supported", + meth.pos.getOrElse(Position.ofMacroExpansion), + ) + } + + } + .map(Expr.ofTuple(_)) + } + + val asFunction: Expr[Alg => AsFunction] = + '{ (alg: Alg) => + val functionsByName: Map[String, Any => IO[Any]] = ${ functionsFor('alg) }.toMap + new AsFunction { + def apply[In, Out]( + endpointName: String, + in: In, + ): IO[Out] = functionsByName(endpointName)(in).asInstanceOf[IO[Out]] + + } + } + + val fromFunction: Expr[AsFunction => Alg] = '{ asf => ${ proxy[Alg]('asf).asExprOf[Alg] } } + + '{ API.instance[Alg](${ Expr.ofList(endpoints) }, ${ asFunction }, ${ fromFunction }) } + } + + @experimental + def proxy[Trait: Type](using Quotes)(asf: Expr[AsFunction]) = { + import quotes.reflect.* + val parents = List(TypeTree.of[Object], TypeTree.of[Trait]) + + val meths = TypeRepr.of[Trait].typeSymbol.declaredMethods + + def decls(cls: Symbol): List[Symbol] = meths.map { method => + val methodType = TypeRepr.of[Trait].memberType(method) + + Symbol.newMethod( + cls, + method.name, + methodType, + flags = Flags.EmptyFlags, + privateWithin = method.privateWithin.fold(Symbol.noSymbol)(_.typeSymbol), + ) + } + + val cls = Symbol.newClass( + Symbol.spliceOwner, + "Anon", + parents.map(_.tpe), + decls, + selfType = None, + ) + + val body: List[DefDef] = cls.declaredMethods.map { sym => + def undefinedTerm(args: List[List[Tree]]) = { + args.head match { + case Nil => '{ ${ asf }.apply(${ Expr(sym.name) }, ()) } + case one :: Nil => '{ ${ asf }.apply(${ Expr(sym.name) }, ${ one.asExprOf[Any] }) } + case _ => + report.errorAndAbort( + "Only methods with one parameter are supported", + sym.pos.getOrElse(Position.ofMacroExpansion), + ) + } + + }.asTerm + + DefDef(sym, args => Some(undefinedTerm(args))) + } + val clsDef = ClassDef(cls, parents, body = body) + + val newCls = Typed( + Apply( + Select(New(TypeIdent(cls)), cls.primaryConstructor), + Nil, + ), + TypeTree.of[Trait], + ) + + Block(List(clsDef), newCls) + } + + def instance[Alg]( + endpoints: List[Endpoint[?, ?]], + asFunction: Alg => AsFunction, + fromFunction: AsFunction => Alg, + ): API[Alg] = + new API[Alg] { + private val endpointsByName = endpoints.groupBy(_.name).fmap(_.head) + + override val toClient: (Client[IO], Uri) => Alg = + (c, uri) => + fromFunction { + new AsFunction { + override def apply[In, Out](endpointName: String, in: In): IO[Out] = { + val e = endpointsByName(endpointName).asInstanceOf[Endpoint[In, Out]] + + given Codec[e.Out] = e.output + + def write( + methodName: String, + input: Json, + ): Request[IO] = Request[IO](uri = uri, method = Method.POST) + .withHeaders(Header.Raw(CIString("X-Method"), methodName)) + .withEntity(input) + + c.expect[e.Out](write(e.name, e.input.apply(in))) + } + } + } + + override val toRoutes: Alg => HttpApp[IO] = + impl => + val implFunction = asFunction(impl) + + HttpApp { req => + val methodName: String = req.headers.get(CIString("X-Method")).get.head.value + req + .as[Json] + .flatMap { input => + val e = endpointsByName(methodName) + + e.input + .decodeJson(input) + .liftTo[IO] + .flatMap(implFunction.apply[e.In, e.Out](e.name, _).map(e.output.apply(_))) + } + .map(Response[IO]().withEntity(_)) + } + + } + + case class Endpoint[In_, Out_]( + name: String, + input: Codec[In_], + output: Codec[Out_], + ) { + type In = In_ + type Out = Out_ + } + + trait AsFunction { + def apply[In, Out](endpointName: String, in: In): IO[Out] + } + +}