diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c89587..2c252db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,87 +15,82 @@ on: 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 }} + +concurrency: + group: ${{ github.workflow }} @ ${{ github.ref }} + cancel-in-progress: true + jobs: build: name: Build and Test strategy: matrix: os: [ubuntu-latest] - scala: [3.3.1] + scala: [3] java: [temurin@8] + project: [rootJS, rootJVM, rootNative] runs-on: ${{ matrix.os }} + timeout-minutes: 60 steps: - name: Checkout current branch (full) - uses: actions/checkout@v2 + uses: actions/checkout@v4 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) + id: setup-java-temurin-8 if: matrix.java == 'temurin@8' - uses: actions/setup-java@v2 + uses: actions/setup-java@v4 with: - distribution: jdkfile + distribution: temurin java-version: 8 - jdkFile: ${{ steps.download-java-temurin-8.outputs.jdkFile }} + cache: sbt - - 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: sbt update + if: matrix.java == 'temurin@8' && steps.setup-java-temurin-8.outputs.cache-hit == 'false' + run: sbt +update - name: Check that workflows are up to date - run: sbt '++${{ matrix.scala }}' 'project /' githubWorkflowCheck + run: sbt githubWorkflowCheck - name: Check headers and formatting - if: matrix.java == 'temurin@8' - run: sbt '++${{ matrix.scala }}' headerCheckAll scalafmtCheckAll 'project /' scalafmtSbtCheck + if: matrix.java == 'temurin@8' && matrix.os == 'ubuntu-latest' + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' headerCheckAll scalafmtCheckAll 'project /' scalafmtSbtCheck + + - name: scalaJSLink + if: matrix.project == 'rootJS' + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' Test/scalaJSLinkerResult + + - name: nativeLink + if: matrix.project == 'rootNative' + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' Test/nativeLink - name: Test - run: sbt '++${{ matrix.scala }}' test + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' test - name: Check binary compatibility - if: matrix.java == 'temurin@8' - run: sbt '++${{ matrix.scala }}' mimaReportBinaryIssues + if: matrix.java == 'temurin@8' && matrix.os == 'ubuntu-latest' + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' mimaReportBinaryIssues - name: Generate API documentation - if: matrix.java == 'temurin@8' - run: sbt '++${{ matrix.scala }}' doc + if: matrix.java == 'temurin@8' && matrix.os == 'ubuntu-latest' + run: sbt 'project ${{ matrix.project }}' '++ ${{ 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 + run: mkdir -p core/.native/target core/.js/target core/.jvm/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 + run: tar cf targets.tar core/.native/target core/.js/target core/.jvm/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 + uses: actions/upload-artifact@v4 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-${{ matrix.scala }} + name: target-${{ matrix.os }}-${{ matrix.java }}-${{ matrix.scala }}-${{ matrix.project }} path: targets.tar publish: @@ -105,63 +100,110 @@ jobs: 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 + uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Download Java (temurin@8) - id: download-java-temurin-8 + - name: Setup Java (temurin@8) + id: setup-java-temurin-8 if: matrix.java == 'temurin@8' - uses: typelevel/download-java@v1 + uses: actions/setup-java@v4 with: distribution: temurin java-version: 8 + cache: sbt - - name: Setup Java (temurin@8) - if: matrix.java == 'temurin@8' - uses: actions/setup-java@v2 + - name: sbt update + if: matrix.java == 'temurin@8' && steps.setup-java-temurin-8.outputs.cache-hit == 'false' + run: sbt +update + + - name: Download target directories (3, rootJS) + uses: actions/download-artifact@v4 with: - distribution: jdkfile - java-version: 8 - jdkFile: ${{ steps.download-java-temurin-8.outputs.jdkFile }} + name: target-${{ matrix.os }}-${{ matrix.java }}-3-rootJS + + - name: Inflate target directories (3, rootJS) + run: | + tar xf targets.tar + rm targets.tar - - name: Cache sbt - uses: actions/cache@v2 + - name: Download target directories (3, rootJVM) + uses: actions/download-artifact@v4 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 + name: target-${{ matrix.os }}-${{ matrix.java }}-3-rootJVM + + - name: Inflate target directories (3, rootJVM) + run: | + tar xf targets.tar + rm targets.tar + + - name: Download target directories (3, rootNative) + uses: actions/download-artifact@v4 with: - name: target-${{ matrix.os }}-${{ matrix.java }}-3.3.1 + name: target-${{ matrix.os }}-${{ matrix.java }}-3-rootNative - - name: Inflate target directories (3.3.1) + - name: Inflate target directories (3, rootNative) 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 + env: + PGP_SECRET: ${{ secrets.PGP_SECRET }} + PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} + run: echo $PGP_SECRET | base64 -d -i - | gpg --import - name: Import signing key and strip passphrase if: env.PGP_SECRET != '' && env.PGP_PASSPHRASE != '' + env: + PGP_SECRET: ${{ secrets.PGP_SECRET }} + PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} run: | - echo "$PGP_SECRET" | base64 -di > /tmp/signing-key.gpg + echo "$PGP_SECRET" | base64 -d -i - > /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 + env: + SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} + SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} + SONATYPE_CREDENTIAL_HOST: ${{ secrets.SONATYPE_CREDENTIAL_HOST }} + run: sbt tlCiRelease + + dependency-submission: + name: Submit Dependencies + if: github.event_name != 'pull_request' + strategy: + matrix: + os: [ubuntu-latest] + java: [temurin@8] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout current branch (full) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Java (temurin@8) + id: setup-java-temurin-8 + if: matrix.java == 'temurin@8' + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 8 + cache: sbt + + - name: sbt update + if: matrix.java == 'temurin@8' && steps.setup-java-temurin-8.outputs.cache-hit == 'false' + run: sbt +update + + - name: Submit Dependencies + uses: scalacenter/sbt-dependency-submission@v2 + with: + modules-ignore: respectfully-example_sjs1_3 rootjs_3 rootjvm_3 rootnative_3 respectfully-example_native0.4_3 respectfully-example_3 + configs-ignore: test scala-tool scala-doc-tool test-internal diff --git a/.sbtopts b/.sbtopts new file mode 100644 index 0000000..d56f51e --- /dev/null +++ b/.sbtopts @@ -0,0 +1 @@ +-J-Xmx4g diff --git a/build.sbt b/build.sbt index 817ace3..7fde2f1 100644 --- a/build.sbt +++ b/build.sbt @@ -17,26 +17,61 @@ val Scala3 = "3.3.1" ThisBuild / scalaVersion := Scala3 ThisBuild / tlFatalWarnings := false -ThisBuild / tlFatalWarningsInCi := false val commonSettings = Seq( libraryDependencies ++= List( + "org.http4s" %%% "http4s-client" % "0.23.25", + "org.http4s" %%% "http4s-circe" % "0.23.25", + "com.kubukoz" %% "debug-utils" % "1.1.3", + "org.typelevel" %%% "kittens" % "3.2.0" % Test, + "com.disneystreaming" %%% "weaver-cats" % "0.8.4" % Test, + "com.disneystreaming" %%% "weaver-scalacheck" % "0.8.4" % Test, ) ++ compilerPlugins, scalacOptions ++= Seq( "-Wunused:all" ), - Test / fork := true, ) -lazy val core = project +lazy val core = crossProject(JVMPlatform, JSPlatform, NativePlatform) + .crossType(CrossType.Pure) .settings( name := "respectfully", commonSettings, ) + .jvmSettings( + Test / fork := true + ) -lazy val root = project - .in(file(".")) - .aggregate(core) +lazy val example = crossProject(JVMPlatform, JSPlatform, NativePlatform) + .crossType(CrossType.Pure) + .dependsOn(core) + .settings( + name := "respectfully-example", + commonSettings, + libraryDependencies ++= Seq( + "org.http4s" %%% "http4s-ember-client" % "0.23.25", + "org.http4s" %%% "http4s-ember-server" % "0.23.25", + "io.chrisdavenport" %%% "crossplatformioapp" % "0.1.0", + ), + ) + .jsSettings( + scalaJSUseMainModuleInitializer := true, + scalaJSLinkerConfig ~= (_.withModuleKind(ModuleKind.CommonJSModule)), + ) + .jvmSettings( + Compile / fork := true + ) + .nativeSettings( + libraryDependencies ++= Seq( + "com.armanbilge" %%% "epollcat" % "0.1.6" + ) + ) .enablePlugins(NoPublishPlugin) + +lazy val root = tlCrossRootProject + .aggregate( + core, + example, + ) diff --git a/core/src/main/scala/respectfully/API.scala b/core/src/main/scala/respectfully/API.scala new file mode 100644 index 0000000..9eb3602 --- /dev/null +++ b/core/src/main/scala/respectfully/API.scala @@ -0,0 +1,317 @@ +/* + * 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.implicits._ +import io.circe.Codec +import io.circe.Decoder +import io.circe.Encoder +import io.circe.Json +import org.http4s.Header +import org.http4s.HttpApp +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.typelevel.ci.CIString + +import scala.compiletime.summonInline +import scala.quoted.Expr +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 + + inline def derived[Alg]: API[Alg] = ${ derivedImpl[Alg] } + + 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]], + ) + } + case other => + val typeStr = + TypeRepr + .of( + using other + ) + .show + + report.errorAndAbort( + s"Only methods returning IO are supported. Found: $typeStr", + meth.pos.getOrElse(Position.ofMacroExpansion), + ) + } + + '{ + 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) => + ${ + //format: off + algExpr + .asTerm + .select(meth) + .appliedTo('{ input.asInstanceOf[t] }.asTerm) + .asExprOf[IO[Any]] + //format: on + } + } + } + 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 }) } + } + + private 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), + ) + } + + // The definition is experimental and I didn't want to bother. + // If it breaks in 3.4, so be it ;) + val cls = classOf[SymbolModule] + .getDeclaredMethods() + .filter(_.getName == "newClass") + .head + .invoke( + Symbol, + Symbol.spliceOwner, + "Anon", + parents.map(_.tpe), + decls, + None, + ) + .asInstanceOf[Symbol] + + 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))) + } + + // The definition is experimental and I didn't want to bother. + // If it breaks in 3.4, so be it ;) + val clsDef = classOf[ClassDefModule] + .getDeclaredMethods() + .filter(_.getName == "apply") + .head + .invoke( + ClassDef, + cls, + parents, + body, + ) + .asInstanceOf[ClassDef] + + val newCls = Typed( + Apply( + Select(New(TypeIdent(cls)), cls.primaryConstructor), + Nil, + ), + TypeTree.of[Trait], + ) + + Block(List(clsDef), newCls) + } + + private 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")) + .getOrElse(sys.error("missing X-Method header")) + .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(_)) + } + + } + + private case class Endpoint[In_, Out_]( + name: String, + input: Codec[In_], + output: Codec[Out_], + ) { + type In = In_ + type Out = Out_ + } + + private trait AsFunction { + def apply[In, Out](endpointName: String, in: In): IO[Out] + } + +} diff --git a/core/src/test/scala/respectfully/test/ClientTests.scala b/core/src/test/scala/respectfully/test/ClientTests.scala new file mode 100644 index 0000000..8ac3781 --- /dev/null +++ b/core/src/test/scala/respectfully/test/ClientTests.scala @@ -0,0 +1,120 @@ +/* + * 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.test + +import cats.effect.IO +import io.circe.Encoder +import org.http4s.Request +import org.http4s.circe.CirceEntityCodec._ +import respectfully.API +import weaver._ +import org.http4s.Response +import io.circe.Decoder +import cats.kernel.Eq +import cats.derived._ +import io.circe.Codec +import org.http4s.client.Client +import org.typelevel.ci.CIString +import org.http4s.Uri +import org.http4s.implicits._ +import cats.effect.kernel.Ref +import io.circe.Json +import org.typelevel.vault.Key +import cats.effect.SyncIO + +object ClientTests extends SimpleIOSuite { + + private val BodyJson = Key.newKey[SyncIO, Json].unsafeRunSync() + + private def fakeClient[Out: Encoder]( + returnValue: Out + ): IO[(Client[IO], Uri, IO[Request[IO]])] = Ref[IO].of(Option.empty[Request[IO]]).map { ref => + val client: Client[IO] = Client { req => + req + .as[Json] + .flatMap { body => + ref.update { + case None => Some(req.withAttribute(BodyJson, body)) + case Some(_) => sys.error("Request already captured.") + } + } + .as(Response[IO]().withEntity(returnValue)) + .toResource + + } + + ( + client, + uri"/", + ref.get.map(_.getOrElse(sys.error("No request captured."))), + ) + } + + private def methodHeader(req: Request[IO]): String = + req.headers.get(CIString("X-Method")).get.head.value + + private def bodyJsonDecode[A: Decoder](req: Request[IO]): A = + req.attributes.lookup(BodyJson).get.as[A].toTry.get + + test("one op") { + trait SimpleApi derives API { + def op(): IO[Int] + } + + fakeClient(42).flatMap { (client, uri, captured) => + API[SimpleApi].toClient(client, uri).op().map(assert.eql(42, _)) *> + captured.map { req => + assert.eql("op", methodHeader(req)) && + succeed(bodyJsonDecode[Unit](req)) + } + } + } + + test("one op with param") { + trait SimpleApi derives API { + def operation(a: Int): IO[String] + } + + fakeClient("output").flatMap { (client, uri, captured) => + API[SimpleApi].toClient(client, uri).operation(42).map(assert.eql("output", _)) *> + captured.map { req => + assert.eql("operation", methodHeader(req)) && + assert.eql(42, bodyJsonDecode[Int](req)) + } + } + } + + test("one op with more complex param") { + case class Person(name: String, age: Int) derives Codec.AsObject, Eq + + trait SimpleApi derives API { + def operation(a: Person): IO[Person] + } + + fakeClient(Person("John", 43)).flatMap { (client, uri, captured) => + API[SimpleApi] + .toClient(client, uri) + .operation(Person("John", 42)) + .map(assert.eql(Person("John", 43), _)) *> + captured.map { req => + assert.eql("operation", methodHeader(req)) && + assert.eql(Person("John", 42), bodyJsonDecode[Person](req)) + } + } + } + +} diff --git a/core/src/test/scala/respectfully/test/ServerTests.scala b/core/src/test/scala/respectfully/test/ServerTests.scala new file mode 100644 index 0000000..6253c91 --- /dev/null +++ b/core/src/test/scala/respectfully/test/ServerTests.scala @@ -0,0 +1,97 @@ +/* + * 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.test + +import cats.effect.IO +import io.circe.Encoder +import org.http4s.Header +import org.http4s.Method +import org.http4s.Request +import org.http4s.circe.CirceEntityCodec._ +import respectfully.API +import weaver._ +import org.http4s.Status +import cats.syntax.all.* +import org.http4s.Response +import io.circe.Decoder +import cats.kernel.Eq +import cats.derived._ +import io.circe.Codec + +object ServerTests extends SimpleIOSuite { + pureTest("no ops") { + trait SimpleApi derives API + success + } + + private def request[A: Encoder]( + method: String + )( + body: A + ) = Request[IO](Method.POST) + .withEntity(body) + .withHeaders("X-Method" -> method) + + private def assertSuccess[A: Decoder: Eq]( + response: Response[IO], + expected: A, + ) = response.as[A].map { body => + assert(response.status == Status.Ok) && + assert.eql(expected, body) + } + + test("one op") { + trait SimpleApi derives API { + def op(): IO[Int] + } + + val impl: SimpleApi = () => IO.pure(42) + + API[SimpleApi] + .toRoutes(impl) + .run(request("op")(())) + .flatMap(assertSuccess(_, 42)) + } + + test("one op with param") { + trait SimpleApi derives API { + def operation(a: Int): IO[Int] + } + + val impl: SimpleApi = a => IO.pure(a + 1) + + API[SimpleApi] + .toRoutes(impl) + .run(request("operation")(42)) + .flatMap(assertSuccess(_, 43)) + } + + test("one op with more complex param") { + case class Person(name: String, age: Int) derives Codec.AsObject, Eq + + trait SimpleApi derives API { + def operation(a: Person): IO[Person] + } + + val impl: SimpleApi = a => IO.pure(a.copy(age = a.age + 1)) + + API[SimpleApi] + .toRoutes(impl) + .run(request("operation")(Person("John", 42))) + .flatMap(assertSuccess(_, Person("John", 43))) + } +} diff --git a/example/src/main/scala/respectfully/Demo.scala b/example/src/main/scala/respectfully/Demo.scala new file mode 100644 index 0000000..5d3f459 --- /dev/null +++ b/example/src/main/scala/respectfully/Demo.scala @@ -0,0 +1,83 @@ +/* + * 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 io.circe.Codec +import io.circe.Decoder +import io.circe.Encoder +import org.http4s.HttpApp +import org.http4s.Response +import org.http4s.Uri +import org.http4s.circe.CirceEntityCodec._ +import org.http4s.client.Client +import org.http4s.ember.client.EmberClientBuilder +import org.http4s.ember.server.EmberServerBuilder +import io.chrisdavenport.crossplatformioapp.CrossPlatformIOApp + +object Demo extends CrossPlatformIOApp.Simple { + + case class User(id: Int, name: String, age: Int) derives Codec.AsObject + + trait Api { + def getUsers(): IO[List[User]] + def getUser(id: Int): IO[User] + def createUser(user: User): IO[User] + def updateUser(user: User): IO[User] + def deleteUser(id: Int): IO[Unit] + } + + given API[Api] = API.derived + + val impl: Api = + new Api { + def getUsers(): IO[List[User]] = IO(List(User(1, "John", 20))) + def getUser(id: Int): IO[User] = IO(User(1, "John", 20)) + def createUser(user: User): IO[User] = IO(user) + def updateUser(user: User): IO[User] = IO(user) + def deleteUser(id: Int): IO[Unit] = IO.unit + } + + def router(impl: Api): HttpApp[IO] = API[Api].toRoutes(impl) + + def client(c: Client[IO], base: Uri): Api = API[Api].toClient(c, base) + + import com.comcast.ip4s._ + + def run: IO[Unit] = EmberServerBuilder + .default[IO] + .withHttpApp(router(impl)) + .withHost(host"0.0.0.0") + .withPort(port"8080") + .withErrorHandler { case e => IO.consoleForIO.printStackTrace(e) *> IO.raiseError(e) } + .build + .use { server => + IO.println("started server") *> + EmberClientBuilder.default[IO].build.use { c => + val apiClient = client(c, server.baseUri) + + IO.println("started server and client") *> + apiClient + .getUser(42) + .flatMap(IO.println(_)) *> + apiClient + .getUsers() + .flatMap(IO.println(_)) + } + } + +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..f3b2f18 --- /dev/null +++ b/flake.lock @@ -0,0 +1,60 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1705309234, + "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1706141464, + "narHash": "sha256-nS1/rRpF69F0wFUjupgoKiESL7RTfzxtJSr0hL9plS8=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "559ddda570f61c4b66fb53162db7ce781c818f9f", + "type": "github" + }, + "original": { + "owner": "nixos", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..4c13af8 --- /dev/null +++ b/flake.nix @@ -0,0 +1,19 @@ +{ + inputs.nixpkgs.url = "github:nixos/nixpkgs"; + inputs.flake-utils.url = "github:numtide/flake-utils"; + + outputs = { self, nixpkgs, flake-utils, ... }@inputs: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { inherit system; }; + in + { + devShells.default = pkgs.mkShell { + packages = [ pkgs.nodejs ]; + nativeBuildInputs = [ pkgs.s2n-tls ]; + }; + } + ); +} + diff --git a/project/build.properties b/project/build.properties index 22af262..abbbce5 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.7.1 +sbt.version=1.9.8 diff --git a/project/plugins.sbt b/project/plugins.sbt index 62f5aaa..d08652f 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1 +1,3 @@ -addSbtPlugin("org.typelevel" % "sbt-typelevel" % "0.4.13") +addSbtPlugin("org.typelevel" % "sbt-typelevel" % "0.6.5") +addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.4.17") +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.15.0")