From 5f09a1357f3fadbe8f6569b08383f81cd6ae4ea4 Mon Sep 17 00:00:00 2001 From: Alex Liubymov Date: Wed, 13 Dec 2023 19:04:53 +0200 Subject: [PATCH 01/24] context split, remove over-complicated logic --- build.sbt | 320 +++++++++--------- .../rpc/http4s/HttpRequestContext.scala | 4 +- .../runtime/rpc/http4s/HttpServer.scala | 253 +++++++------- .../runtime/rpc/http4s/IRTAuthenticator.scala | 18 + .../rpc/http4s/IRTContextServices.scala | 68 ++++ .../rpc/http4s/IRTContextServicesMuxer.scala | 15 + .../clients/WsRpcDispatcherFactory.scala | 53 ++- .../runtime/rpc/http4s/ws/WsClientId.scala | 9 - .../rpc/http4s/ws/WsClientRequests.scala | 52 +++ .../rpc/http4s/ws/WsClientSession.scala | 99 +++--- .../rpc/http4s/ws/WsContextProvider.scala | 41 --- .../rpc/http4s/ws/WsRequestState.scala | 4 +- .../runtime/rpc/http4s/ws/WsRpcHandler.scala | 70 ++-- .../runtime/rpc/http4s/ws/WsSessionId.scala | 5 + .../rpc/http4s/ws/WsSessionListener.scala | 19 +- .../rpc/http4s/ws/WsSessionsStorage.scala | 63 ++-- .../rpc/http4s/Http4sTransportTest.scala | 24 +- .../http4s/fixtures/DummyRequestContext.scala | 5 +- .../rpc/http4s/fixtures/DummyServices.scala | 12 +- .../http4s/fixtures/Http4sTestContext.scala | 78 +---- .../runtime/rpc/http4s/fixtures/RT.scala | 2 +- .../runtime/rpc/IRTServerMultiplexor.scala | 76 ++--- .../test/GreeterRunnerExample.scala | 4 +- project/plugins.sbt | 11 - 24 files changed, 662 insertions(+), 643 deletions(-) create mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTAuthenticator.scala create mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala create mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServicesMuxer.scala delete mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientId.scala create mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientRequests.scala delete mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextProvider.scala create mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionId.scala diff --git a/build.sbt b/build.sbt index b8b2a667..f1aa5ef0 100644 --- a/build.sbt +++ b/build.sbt @@ -3,8 +3,6 @@ // ALL CHANGES WILL BE LOST -import sbtcrossproject.CrossPlugin.autoImport.{crossProject, CrossType} - enablePlugins(SbtgenVerificationPlugin) @@ -13,13 +11,13 @@ ThisBuild / libraryDependencySchemes += "io.circe" %% "circe-core" % VersionSche ThisBuild / libraryDependencySchemes += "io.circe" %% "circe-core_sjs1" % VersionScheme.Always -lazy val `idealingua-v1-model` = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).in(file("idealingua-v1/idealingua-v1-model")) +lazy val `idealingua-v1-model` = project.in(file("idealingua-v1/idealingua-v1-model")) .settings( libraryDependencies ++= Seq( - "org.scalatest" %%% "scalatest" % V.scalatest % Test, - "io.7mind.izumi" %%% "fundamentals-collections" % Izumi.version, - "io.7mind.izumi" %%% "fundamentals-platform" % Izumi.version, - "io.7mind.izumi" %%% "fundamentals-functional" % Izumi.version + "org.scalatest" %% "scalatest" % V.scalatest % Test, + "io.7mind.izumi" %% "fundamentals-collections" % Izumi.version, + "io.7mind.izumi" %% "fundamentals-platform" % Izumi.version, + "io.7mind.izumi" %% "fundamentals-functional" % Izumi.version ), libraryDependencies ++= { if (scalaVersion.value.startsWith("2.")) Seq( compilerPlugin("org.typelevel" % "kind-projector" % V.kind_projector cross CrossVersion.full), @@ -27,7 +25,21 @@ lazy val `idealingua-v1-model` = crossProject(JVMPlatform, JSPlatform).crossType ) else Seq.empty } ) .settings( + crossScalaVersions := Seq( + "3.3.1", + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -139,44 +151,37 @@ lazy val `idealingua-v1-model` = crossProject(JVMPlatform, JSPlatform).crossType } }, scalacOptions -= "-Wconf:any:error" ) - .jvmSettings( - crossScalaVersions := Seq( - "3.3.1", - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head - ) - .jsSettings( - crossScalaVersions := Seq( - "3.3.1", - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, - coverageEnabled := false, - scalaJSLinkerConfig := { scalaJSLinkerConfig.value.withModuleKind(ModuleKind.CommonJSModule) } - ) .enablePlugins(IzumiPlugin) -lazy val `idealingua-v1-modelJVM` = `idealingua-v1-model`.jvm -lazy val `idealingua-v1-modelJS` = `idealingua-v1-model`.js -lazy val `idealingua-v1-core` = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).in(file("idealingua-v1/idealingua-v1-core")) +lazy val `idealingua-v1-core` = project.in(file("idealingua-v1/idealingua-v1-core")) .dependsOn( `idealingua-v1-model` % "test->compile;compile->compile" ) .settings( libraryDependencies ++= Seq( - "org.scalatest" %%% "scalatest" % V.scalatest % Test, - "com.lihaoyi" %%% "fastparse" % V.fastparse, - "io.7mind.izumi" %%% "fundamentals-reflection" % Izumi.version + "org.scalatest" %% "scalatest" % V.scalatest % Test, + "com.lihaoyi" %% "fastparse" % V.fastparse, + "io.7mind.izumi" %% "fundamentals-reflection" % Izumi.version ), libraryDependencies ++= { if (scalaVersion.value.startsWith("2.")) Seq( compilerPlugin("org.typelevel" % "kind-projector" % V.kind_projector cross CrossVersion.full) ) else Seq.empty } ) .settings( + crossScalaVersions := Seq( + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -288,57 +293,53 @@ lazy val `idealingua-v1-core` = crossProject(JVMPlatform, JSPlatform).crossType( } }, scalacOptions -= "-Wconf:any:error" ) - .jvmSettings( - crossScalaVersions := Seq( - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head - ) - .jsSettings( - crossScalaVersions := Seq( - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, - coverageEnabled := false, - scalaJSLinkerConfig := { scalaJSLinkerConfig.value.withModuleKind(ModuleKind.CommonJSModule) } - ) .enablePlugins(IzumiPlugin) -lazy val `idealingua-v1-coreJVM` = `idealingua-v1-core`.jvm -lazy val `idealingua-v1-coreJS` = `idealingua-v1-core`.js -lazy val `idealingua-v1-runtime-rpc-scala` = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).in(file("idealingua-v1/idealingua-v1-runtime-rpc-scala")) +lazy val `idealingua-v1-runtime-rpc-scala` = project.in(file("idealingua-v1/idealingua-v1-runtime-rpc-scala")) .settings( libraryDependencies ++= Seq( - "org.scalatest" %%% "scalatest" % V.scalatest % Test, - "io.7mind.izumi" %%% "fundamentals-bio" % Izumi.version, - "io.7mind.izumi" %%% "fundamentals-platform" % Izumi.version, - "org.typelevel" %%% "cats-core" % Izumi.Deps.fundamentals_bioJVM.org_typelevel_cats_core_version, - "org.typelevel" %%% "cats-effect" % Izumi.Deps.fundamentals_bioJVM.org_typelevel_cats_effect_version, - "io.circe" %%% "circe-parser" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, - "io.circe" %%% "circe-literal" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, - "dev.zio" %%% "zio" % Izumi.Deps.fundamentals_bioJVM.dev_zio_zio_version % Test, - "dev.zio" %%% "zio-interop-cats" % Izumi.Deps.fundamentals_bioJVM.dev_zio_zio_interop_cats_version % Test, - "dev.zio" %%% "izumi-reflect" % Izumi.Deps.fundamentals_bioJVM.dev_zio_izumi_reflect_version % Test + "org.scalatest" %% "scalatest" % V.scalatest % Test, + "io.7mind.izumi" %% "fundamentals-bio" % Izumi.version, + "io.7mind.izumi" %% "fundamentals-platform" % Izumi.version, + "org.typelevel" %% "cats-core" % Izumi.Deps.fundamentals_bioJVM.org_typelevel_cats_core_version, + "org.typelevel" %% "cats-effect" % Izumi.Deps.fundamentals_bioJVM.org_typelevel_cats_effect_version, + "io.circe" %% "circe-parser" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, + "io.circe" %% "circe-literal" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, + "dev.zio" %% "zio" % Izumi.Deps.fundamentals_bioJVM.dev_zio_zio_version % Test, + "dev.zio" %% "zio-interop-cats" % Izumi.Deps.fundamentals_bioJVM.dev_zio_zio_interop_cats_version % Test, + "dev.zio" %% "izumi-reflect" % Izumi.Deps.fundamentals_bioJVM.dev_zio_izumi_reflect_version % Test ), libraryDependencies ++= { if (scalaVersion.value.startsWith("2.")) Seq( compilerPlugin("org.typelevel" % "kind-projector" % V.kind_projector cross CrossVersion.full), "org.scala-lang" % "scala-reflect" % scalaVersion.value % Provided, - "io.circe" %%% "circe-generic-extras" % V.circe_generic_extras, - "io.circe" %%% "circe-derivation" % V.circe_derivation + "io.circe" %% "circe-generic-extras" % V.circe_generic_extras, + "io.circe" %% "circe-derivation" % V.circe_derivation ) else Seq.empty }, libraryDependencies ++= { val version = scalaVersion.value if (version.startsWith("0.") || version.startsWith("3.")) { Seq( - "io.circe" %%% "circe-generic" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version + "io.circe" %% "circe-generic" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version ) } else Seq.empty } ) .settings( + crossScalaVersions := Seq( + "3.3.1", + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -450,37 +451,11 @@ lazy val `idealingua-v1-runtime-rpc-scala` = crossProject(JVMPlatform, JSPlatfor } }, scalacOptions -= "-Wconf:any:error" ) - .jvmSettings( - crossScalaVersions := Seq( - "3.3.1", - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head - ) - .jsSettings( - crossScalaVersions := Seq( - "3.3.1", - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, - coverageEnabled := false, - scalaJSLinkerConfig := { scalaJSLinkerConfig.value.withModuleKind(ModuleKind.CommonJSModule) } - ) .enablePlugins(IzumiPlugin) -lazy val `idealingua-v1-runtime-rpc-scalaJVM` = `idealingua-v1-runtime-rpc-scala`.jvm -lazy val `idealingua-v1-runtime-rpc-scalaJS` = `idealingua-v1-runtime-rpc-scala`.js - .settings( - libraryDependencies ++= Seq( - "org.typelevel" %% "jawn-parser" % Izumi.Deps.fundamentals_json_circeJVM.org_typelevel_jawn_parser_version, - "io.github.cquiroz" %%% "scala-java-time" % V.scala_java_time % Test - ) - ) lazy val `idealingua-v1-runtime-rpc-http4s` = project.in(file("idealingua-v1/idealingua-v1-runtime-rpc-http4s")) .dependsOn( - `idealingua-v1-runtime-rpc-scalaJVM` % "test->compile;compile->compile", + `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile", `idealingua-v1-test-defs` % "test->compile" ) .settings( @@ -506,6 +481,14 @@ lazy val `idealingua-v1-runtime-rpc-http4s` = project.in(file("idealingua-v1/ide ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -619,36 +602,54 @@ lazy val `idealingua-v1-runtime-rpc-http4s` = project.in(file("idealingua-v1/ide ) .enablePlugins(IzumiPlugin) -lazy val `idealingua-v1-transpilers` = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).in(file("idealingua-v1/idealingua-v1-transpilers")) +lazy val `idealingua-v1-transpilers` = project.in(file("idealingua-v1/idealingua-v1-transpilers")) .dependsOn( `idealingua-v1-core` % "test->compile;compile->compile", - `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile" + `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile", + `idealingua-v1-test-defs` % "test->compile", + `idealingua-v1-runtime-rpc-typescript` % "test->compile", + `idealingua-v1-runtime-rpc-go` % "test->compile", + `idealingua-v1-runtime-rpc-csharp` % "test->compile" ) .settings( libraryDependencies ++= Seq( - "org.scalatest" %%% "scalatest" % V.scalatest % Test, - "org.scala-lang.modules" %%% "scala-xml" % V.scala_xml, - "org.scalameta" %%% "scalameta" % V.scalameta, - "io.7mind.izumi" %%% "fundamentals-bio" % Izumi.version, - "io.circe" %%% "circe-parser" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, - "io.circe" %%% "circe-literal" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version + "org.scalatest" %% "scalatest" % V.scalatest % Test, + "org.scala-lang.modules" %% "scala-xml" % V.scala_xml, + "org.scalameta" %% "scalameta" % V.scalameta, + "io.7mind.izumi" %% "fundamentals-bio" % Izumi.version, + "io.circe" %% "circe-parser" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, + "io.circe" %% "circe-literal" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version ), libraryDependencies ++= { if (scalaVersion.value.startsWith("2.")) Seq( compilerPlugin("org.typelevel" % "kind-projector" % V.kind_projector cross CrossVersion.full), - "io.circe" %%% "circe-generic-extras" % V.circe_generic_extras, - "io.circe" %%% "circe-derivation" % V.circe_derivation + "io.circe" %% "circe-generic-extras" % V.circe_generic_extras, + "io.circe" %% "circe-derivation" % V.circe_derivation ) else Seq.empty }, libraryDependencies ++= { val version = scalaVersion.value if (version.startsWith("0.") || version.startsWith("3.")) { Seq( - "io.circe" %%% "circe-generic" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version + "io.circe" %% "circe-generic" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version ) } else Seq.empty } ) .settings( + crossScalaVersions := Seq( + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, + Test / fork := true, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -760,41 +761,11 @@ lazy val `idealingua-v1-transpilers` = crossProject(JVMPlatform, JSPlatform).cro } }, scalacOptions -= "-Wconf:any:error" ) - .jvmSettings( - crossScalaVersions := Seq( - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, - Test / fork := true - ) - .jsSettings( - crossScalaVersions := Seq( - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, - coverageEnabled := false, - scalaJSLinkerConfig := { scalaJSLinkerConfig.value.withModuleKind(ModuleKind.CommonJSModule) } - ) .enablePlugins(IzumiPlugin) -lazy val `idealingua-v1-transpilersJVM` = `idealingua-v1-transpilers`.jvm - .dependsOn( - `idealingua-v1-test-defs` % "test->compile", - `idealingua-v1-runtime-rpc-typescript` % "test->compile", - `idealingua-v1-runtime-rpc-go` % "test->compile", - `idealingua-v1-runtime-rpc-csharp` % "test->compile" - ) -lazy val `idealingua-v1-transpilersJS` = `idealingua-v1-transpilers`.js - .settings( - libraryDependencies ++= Seq( - "org.typelevel" %% "jawn-parser" % Izumi.Deps.fundamentals_json_circeJVM.org_typelevel_jawn_parser_version - ) - ) lazy val `idealingua-v1-test-defs` = project.in(file("idealingua-v1/idealingua-v1-test-defs")) .dependsOn( - `idealingua-v1-runtime-rpc-scalaJVM` % "test->compile;compile->compile" + `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile" ) .settings( libraryDependencies ++= Seq( @@ -815,6 +786,14 @@ lazy val `idealingua-v1-test-defs` = project.in(file("idealingua-v1/idealingua-v ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -945,6 +924,14 @@ lazy val `idealingua-v1-runtime-rpc-typescript` = project.in(file("idealingua-v1 ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -1075,6 +1062,14 @@ lazy val `idealingua-v1-runtime-rpc-go` = project.in(file("idealingua-v1/idealin ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -1205,6 +1200,14 @@ lazy val `idealingua-v1-runtime-rpc-csharp` = project.in(file("idealingua-v1/ide ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -1320,8 +1323,8 @@ lazy val `idealingua-v1-runtime-rpc-csharp` = project.in(file("idealingua-v1/ide lazy val `idealingua-v1-compiler` = project.in(file("idealingua-v1/idealingua-v1-compiler")) .dependsOn( - `idealingua-v1-transpilersJVM` % "test->compile;compile->compile", - `idealingua-v1-runtime-rpc-scalaJVM` % "test->compile;compile->compile", + `idealingua-v1-transpilers` % "test->compile;compile->compile", + `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile", `idealingua-v1-runtime-rpc-typescript` % "test->compile;compile->compile", `idealingua-v1-runtime-rpc-go` % "test->compile;compile->compile", `idealingua-v1-runtime-rpc-csharp` % "test->compile;compile->compile", @@ -1343,6 +1346,14 @@ lazy val `idealingua-v1-compiler` = project.in(file("idealingua-v1/idealingua-v1 ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -1463,15 +1474,11 @@ lazy val `idealingua` = (project in file(".agg/idealingua-v1-idealingua")) ) .enablePlugins(IzumiPlugin) .aggregate( - `idealingua-v1-modelJVM`, - `idealingua-v1-modelJS`, - `idealingua-v1-coreJVM`, - `idealingua-v1-coreJS`, - `idealingua-v1-runtime-rpc-scalaJVM`, - `idealingua-v1-runtime-rpc-scalaJS`, + `idealingua-v1-model`, + `idealingua-v1-core`, + `idealingua-v1-runtime-rpc-scala`, `idealingua-v1-runtime-rpc-http4s`, - `idealingua-v1-transpilersJVM`, - `idealingua-v1-transpilersJS`, + `idealingua-v1-transpilers`, `idealingua-v1-test-defs`, `idealingua-v1-runtime-rpc-typescript`, `idealingua-v1-runtime-rpc-go`, @@ -1485,11 +1492,11 @@ lazy val `idealingua-jvm` = (project in file(".agg/idealingua-v1-idealingua-jvm" crossScalaVersions := Nil ) .aggregate( - `idealingua-v1-modelJVM`, - `idealingua-v1-coreJVM`, - `idealingua-v1-runtime-rpc-scalaJVM`, + `idealingua-v1-model`, + `idealingua-v1-core`, + `idealingua-v1-runtime-rpc-scala`, `idealingua-v1-runtime-rpc-http4s`, - `idealingua-v1-transpilersJVM`, + `idealingua-v1-transpilers`, `idealingua-v1-test-defs`, `idealingua-v1-runtime-rpc-typescript`, `idealingua-v1-runtime-rpc-go`, @@ -1497,18 +1504,6 @@ lazy val `idealingua-jvm` = (project in file(".agg/idealingua-v1-idealingua-jvm" `idealingua-v1-compiler` ) -lazy val `idealingua-js` = (project in file(".agg/idealingua-v1-idealingua-js")) - .settings( - publish / skip := true, - crossScalaVersions := Nil - ) - .aggregate( - `idealingua-v1-modelJS`, - `idealingua-v1-coreJS`, - `idealingua-v1-runtime-rpc-scalaJS`, - `idealingua-v1-transpilersJS` - ) - lazy val `idealingua-v1-jvm` = (project in file(".agg/.agg-jvm")) .settings( publish / skip := true, @@ -1518,15 +1513,6 @@ lazy val `idealingua-v1-jvm` = (project in file(".agg/.agg-jvm")) `idealingua-jvm` ) -lazy val `idealingua-v1-js` = (project in file(".agg/.agg-js")) - .settings( - publish / skip := true, - crossScalaVersions := Nil - ) - .aggregate( - `idealingua-js` - ) - lazy val `idealingua-v1` = (project in file(".")) .settings( publish / skip := true, diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpRequestContext.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpRequestContext.scala index d9c55e10..d2187792 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpRequestContext.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpRequestContext.scala @@ -1,6 +1,6 @@ package izumi.idealingua.runtime.rpc.http4s -import org.http4s.AuthedRequest +import org.http4s.Request // we can't make it a case class, see https://github.com/scala/bug/issues/11239 -class HttpRequestContext[F[_], Ctx](val request: AuthedRequest[F, Ctx], val context: Ctx) +class HttpRequestContext[F[_]](val request: Request[F], val context: Any) diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala index 7705c577..ea4e5879 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala @@ -1,41 +1,39 @@ package izumi.idealingua.runtime.rpc.http4s -import _root_.io.circe.parser.* import cats.data.OptionT import cats.effect.Async import cats.effect.std.Queue import fs2.Stream import io.circe +import io.circe.Printer import io.circe.syntax.EncoderOps -import io.circe.{Json, Printer} import izumi.functional.bio.Exit.{Error, Interruption, Success, Termination} -import izumi.functional.bio.{Exit, F, IO2, Primitives2, Temporal2, UnsafeRun2} +import izumi.functional.bio.{F, IO2, Primitives2, Temporal2, UnsafeRun2} import izumi.fundamentals.platform.language.Quirks import izumi.fundamentals.platform.time.IzTime import izumi.idealingua.runtime.rpc.* import izumi.idealingua.runtime.rpc.http4s.HttpServer.{ServerWsRpcHandler, WsResponseMarker} +import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext +import izumi.idealingua.runtime.rpc.http4s.IRTContextServices.AuthInvokeResult import izumi.idealingua.runtime.rpc.http4s.ws.* +import izumi.idealingua.runtime.rpc.http4s.ws.WsClientRequests.WsClientRequestsImpl import izumi.idealingua.runtime.rpc.http4s.ws.WsClientSession.WsClientSessionImpl -import izumi.idealingua.runtime.rpc.http4s.ws.WsContextProvider.WsAuthResult import logstage.LogIO2 import org.http4s.* import org.http4s.dsl.Http4sDsl -import org.http4s.server.AuthMiddleware +import org.http4s.headers.`X-Forwarded-For` import org.http4s.server.websocket.WebSocketBuilder2 import org.http4s.websocket.WebSocketFrame +import org.typelevel.ci.CIString import org.typelevel.vault.Key import java.time.ZonedDateTime import java.util.concurrent.RejectedExecutionException import scala.concurrent.duration.DurationInt -class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2, RequestCtx, MethodCtx, ClientId]( - val muxer: IRTServerMultiplexor[F, RequestCtx], - val codec: IRTClientMultiplexor[F], - val contextProvider: AuthMiddleware[F[Throwable, _], RequestCtx], - val wsContextProvider: WsContextProvider[F, RequestCtx, ClientId], - val wsSessionStorage: WsSessionsStorage[F, RequestCtx, ClientId], - val listeners: Seq[WsSessionListener[F, ClientId]], +class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2]( + val contextServicesMuxer: IRTContextServicesMuxer[F], + val wsSessionsStorage: WsSessionsStorage[F], dsl: Http4sDsl[F[Throwable, _]], logger: LogIO2[F], printer: Printer, @@ -46,71 +44,37 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2, RequestCtx, // WS Response attribute key, to differ from usual HTTP responses private val wsAttributeKey = UnsafeRun2[F].unsafeRun(Key.newKey[F[Throwable, _], WsResponseMarker.type]) - protected def loggingMiddle(service: HttpRoutes[F[Throwable, _]]): HttpRoutes[F[Throwable, _]] = { - cats.data.Kleisli { - (req: Request[F[Throwable, _]]) => - OptionT.apply { - (for { - _ <- logger.trace(s"${req.method.name -> "method"} ${req.pathInfo -> "path"}: initiated") - resp <- service(req).value - _ <- F.traverse(resp) { - case Status.Successful(resp) => - logger.debug(s"${req.method.name -> "method"} ${req.pathInfo -> "path"}: success, ${resp.status.code -> "code"} ${resp.status.reason -> "reason"}") - case resp if resp.attributes.contains(wsAttributeKey) => - logger.debug(s"${req.method.name -> "method"} ${req.pathInfo -> "path"}: websocket request") - case resp => - logger.info(s"${req.method.name -> "method"} ${req.pathInfo -> "uri"}: rejection, ${resp.status.code -> "code"} ${resp.status.reason -> "reason"}") - } - } yield resp).tapError { - cause => - logger.error(s"${req.method.name -> "method"} ${req.pathInfo -> "path"}: failure, $cause") - } - } - } - } - def service(ws: WebSocketBuilder2[F[Throwable, _]]): HttpRoutes[F[Throwable, _]] = { - val svc = AuthedRoutes.of(router(ws)) - val aservice: HttpRoutes[F[Throwable, _]] = contextProvider(svc) - loggingMiddle(aservice) + val svc = HttpRoutes.of(router(ws)) + loggingMiddle(svc) } - protected def router(ws: WebSocketBuilder2[F[Throwable, _]]): PartialFunction[AuthedRequest[F[Throwable, _], RequestCtx], F[Throwable, Response[F[Throwable, _]]]] = { - case request @ GET -> Root / "ws" as ctx => - setupWs(request, ctx, ws) - - case request @ GET -> Root / service / method as ctx => - val methodId = IRTMethodId(IRTServiceId(service), IRTMethodName(method)) - processHttpRequest(new HttpRequestContext(request, ctx), body = "{}", methodId) - - case request @ POST -> Root / service / method as ctx => - val methodId = IRTMethodId(IRTServiceId(service), IRTMethodName(method)) - request.req.decode[String] { - body => - processHttpRequest(new HttpRequestContext(request, ctx), body, methodId) - } + protected def router(ws: WebSocketBuilder2[F[Throwable, _]]): PartialFunction[Request[F[Throwable, _]], F[Throwable, Response[F[Throwable, _]]]] = { + case request @ GET -> Root / "ws" => setupWs(request, ws) + case request @ GET -> Root / service / method => processHttpRequest(request, service, method)("{}") + case request @ POST -> Root / service / method => request.decode[String](processHttpRequest(request, service, method)) } - protected def handleWsClose(context: WsClientSession[F, RequestCtx, ClientId]): F[Throwable, Unit] = { - logger.debug(s"WS Session: Websocket client disconnected ${context.id}.") *> - context.finish() + protected def handleWsClose(session: WsClientSession[F]): F[Throwable, Unit] = { + logger.debug(s"WS Session: Websocket client disconnected ${session.sessionId}.") *> + session.finish() *> + session.requests.finish() } - protected def globalWsListener: WsSessionListener[F, ClientId] = new WsSessionListener[F, ClientId] { - def onSessionOpened(context: WsClientId[ClientId]): F[Throwable, Unit] = { - logger.debug(s"WS Session: opened ${context.id}.") + protected def globalWsListener[Ctx]: WsSessionListener[F, Ctx] = new WsSessionListener[F, Ctx] { + override def onSessionOpened(sessionId: WsSessionId, context: Option[Ctx]): F[Throwable, Unit] = { + logger.debug(s"WS Session: $sessionId opened $context.") } - def onClientIdUpdate(context: WsClientId[ClientId], old: WsClientId[ClientId]): F[Throwable, Unit] = { - logger.debug(s"WS Session: Id updated to ${context.id}, was: ${old.id}.") + override def onSessionAuthUpdate(sessionId: WsSessionId, context: Option[Ctx]): F[Throwable, Unit] = { + logger.debug(s"WS Session: $sessionId updated to $context.") } - def onSessionClosed(context: WsClientId[ClientId]): F[Throwable, Unit] = { - logger.debug(s"WS Session: closed ${context.id}.") + override def onSessionClosed(sessionId: WsSessionId, context: Option[Ctx]): F[Throwable, Unit] = { + logger.debug(s"WS Session: $sessionId closed $context.") } } protected def setupWs( - request: AuthedRequest[F[Throwable, _], RequestCtx], - initialContext: RequestCtx, + request: Request[F[Throwable, _]], ws: WebSocketBuilder2[F[Throwable, _]], ): F[Throwable, Response[F[Throwable, _]]] = { Quirks.discard(request) @@ -120,33 +84,34 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2, RequestCtx, .evalMap(_ => logger.debug("WS Server: Sending ping frame.").as(WebSocketFrame.Ping())) } for { - outQueue <- Queue.unbounded[F[Throwable, _], WebSocketFrame] - listenersWithGlobal = Seq(globalWsListener) ++ listeners - context = new WsClientSessionImpl(outQueue, initialContext, listenersWithGlobal, wsSessionStorage, printer, logger) - _ <- context.start() - outStream = Stream.fromQueueUnterminated(outQueue).merge(pingStream) + outQueue <- Queue.unbounded[F[Throwable, _], WebSocketFrame] + authContext <- F.syncThrowable(extractAuthContext(request)) + clientRequests = new WsClientRequestsImpl(outQueue, printer, logger) + clientSession = new WsClientSessionImpl(authContext, contextServicesMuxer, clientRequests, wsSessionsStorage) + _ <- clientSession.start() + outStream = Stream.fromQueueUnterminated(outQueue).merge(pingStream) inStream = { (inputStream: Stream[F[Throwable, _], WebSocketFrame]) => inputStream.evalMap { - processWsRequest(context, IzTime.utcNow)(_).flatMap { + processWsRequest(clientSession, IzTime.utcNow)(_).flatMap { case Some(v) => outQueue.offer(WebSocketFrame.Text(v)) case None => F.unit } } } - response <- ws.withOnClose(handleWsClose(context)).build(outStream, inStream) + response <- ws.withOnClose(handleWsClose(clientSession)).build(outStream, inStream) } yield { response.withAttribute(wsAttributeKey, WsResponseMarker) } } protected def processWsRequest( - context: WsClientSession[F, RequestCtx, ClientId], + clientSession: WsClientSession[F], requestTime: ZonedDateTime, )(frame: WebSocketFrame ): F[Throwable, Option[String]] = { (frame match { - case WebSocketFrame.Text(msg, _) => wsHandler(context).processRpcMessage(msg) + case WebSocketFrame.Text(msg, _) => wsHandler(clientSession).processRpcMessage(msg) case WebSocketFrame.Close(_) => F.pure(None) case _: WebSocketFrame.Pong => onWsHeartbeat(requestTime).as(None) case unknownMessage => @@ -157,8 +122,8 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2, RequestCtx, }).map(_.map(p => printer.print(p.asJson))) } - protected def wsHandler(context: WsClientSession[F, RequestCtx, ClientId]): WsRpcHandler[F, RequestCtx] = { - new ServerWsRpcHandler(muxer, wsContextProvider, context, logger) + protected def wsHandler(clientSession: WsClientSession[F]): WsRpcHandler[F] = { + new ServerWsRpcHandler(clientSession, contextServicesMuxer, logger) } protected def onWsHeartbeat(requestTime: ZonedDateTime): F[Throwable, Unit] = { @@ -166,92 +131,134 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2, RequestCtx, } protected def processHttpRequest( - context: HttpRequestContext[F[Throwable, _], RequestCtx], - body: String, - method: IRTMethodId, + request: Request[F[Throwable, _]], + serviceName: String, + methodName: String, + )(body: String ): F[Throwable, Response[F[Throwable, _]]] = { - val ioR = for { - parsed <- F.fromEither(parse(body)) - maybeResult <- muxer.doInvoke(parsed, context.context, method) - } yield { - maybeResult - } + val methodId = IRTMethodId(IRTServiceId(serviceName), IRTMethodName(methodName)) + contextServicesMuxer.getContextService(methodId.service) match { + case Some(contextServiceGroup) => + for { + authContext <- F.syncThrowable(extractAuthContext(request)) + parsedBody <- F.fromEither(io.circe.parser.parse(body)) + invokeRes <- contextServiceGroup.doAuthInvoke(methodId, authContext, parsedBody) + res <- handleHttpResult(request, methodId, invokeRes) + } yield res - ioR.sandboxExit.flatMap(handleHttpResult(context, method, _)) + case None => + logger.warn(s"No context service for $methodId") *> + NotFound() + } } - private def handleHttpResult( - context: HttpRequestContext[F[Throwable, _], RequestCtx], + protected def handleHttpResult( + request: Request[F[Throwable, _]], method: IRTMethodId, - result: Exit[Throwable, Option[Json]], + result: AuthInvokeResult[Any], ): F[Throwable, Response[F[Throwable, _]]] = { result match { - case Success(Some(value)) => - dsl.Ok(printer.print(value)) + case AuthInvokeResult.Success(_, Success(Some(value))) => + Ok(printer.print(value)) - case Success(None) => + case AuthInvokeResult.Success(context, Success(None)) => logger.warn(s"${context -> null}: No service handler for $method") *> - dsl.NotFound() + NotFound() - case Error(error: circe.Error, trace) => + case AuthInvokeResult.Success(context, Error(error: circe.Error, trace)) => logger.info(s"${context -> null}: Parsing failure while handling $method: $error $trace") *> - dsl.BadRequest() + BadRequest() - case Error(error: IRTDecodingException, trace) => + case AuthInvokeResult.Success(context, Error(error: IRTDecodingException, trace)) => logger.info(s"${context -> null}: Parsing failure while handling $method: $error $trace") *> - dsl.BadRequest() + BadRequest() - case Error(error: IRTLimitReachedException, trace) => + case AuthInvokeResult.Success(context, Error(error: IRTLimitReachedException, trace)) => logger.debug(s"${context -> null}: Request failed because of request limit reached $method: $error $trace") *> - dsl.TooManyRequests() + TooManyRequests() - case Error(error: IRTUnathorizedRequestContextException, trace) => - // workarount because implicits conflict + case AuthInvokeResult.Success(context, Error(error: IRTUnathorizedRequestContextException, trace)) => logger.debug(s"${context -> null}: Request failed because of unexpected request context reached $method: $error $trace") *> - dsl.Forbidden().map(_.copy(status = dsl.Unauthorized)) + F.pure(Response(status = Status.Unauthorized)) - case Error(error, trace) => + case AuthInvokeResult.Success(context, Error(error, trace)) => logger.info(s"${context -> null}: Unexpected failure while handling $method: $error $trace") *> - dsl.InternalServerError() + InternalServerError() - case Termination(_, (cause: IRTHttpFailureException) :: _, trace) => - logger.debug(s"${context -> null}: Request rejected, $method, ${context.request}, $cause, $trace") *> + case AuthInvokeResult.Success(context, Termination(_, (cause: IRTHttpFailureException) :: _, trace)) => + logger.debug(s"${context -> null}: Request rejected, $method, $request, $cause, $trace") *> F.pure(Response(status = cause.status)) - case Termination(_, (cause: RejectedExecutionException) :: _, trace) => + case AuthInvokeResult.Success(context, Termination(_, (cause: RejectedExecutionException) :: _, trace)) => logger.warn(s"${context -> null}: Not enough capacity to handle $method: $cause $trace") *> - dsl.TooManyRequests() + TooManyRequests() - case Termination(cause, _, trace) => - logger.error(s"${context -> null}: Execution failed, termination, $method, ${context.request}, $cause, $trace") *> - dsl.InternalServerError() + case AuthInvokeResult.Success(context, Termination(cause, _, trace)) => + logger.error(s"${context -> null}: Execution failed, termination, $method, $request, $cause, $trace") *> + InternalServerError() - case Interruption(cause, _, trace) => + case AuthInvokeResult.Success(context, Interruption(cause, _, trace)) => logger.info(s"${context -> null}: Unexpected interruption while handling $method: $cause $trace") *> - dsl.InternalServerError() + InternalServerError() + + case AuthInvokeResult.Failed => + F.pure(Response(status = Status.Unauthorized)) } } + protected def extractAuthContext(request: Request[F[Throwable, _]]): AuthContext = { + val networkAddress = request.headers + .get[`X-Forwarded-For`] + .flatMap(_.values.head.map(_.toInetAddress)) + .orElse(request.remote.map(_.host.toInetAddress)) + val headers = request.headers + AuthContext(headers, networkAddress) + } + + protected def loggingMiddle(service: HttpRoutes[F[Throwable, _]]): HttpRoutes[F[Throwable, _]] = { + cats.data.Kleisli { + (req: Request[F[Throwable, _]]) => + OptionT.apply { + (for { + _ <- logger.trace(s"${req.method.name -> "method"} ${req.pathInfo -> "path"}: initiated") + resp <- service(req).value + _ <- F.traverse(resp) { + case Status.Successful(resp) => + logger.debug(s"${req.method.name -> "method"} ${req.pathInfo -> "path"}: success, ${resp.status.code -> "code"} ${resp.status.reason -> "reason"}") + case resp if resp.attributes.contains(wsAttributeKey) => + logger.debug(s"${req.method.name -> "method"} ${req.pathInfo -> "path"}: websocket request") + case resp => + logger.info(s"${req.method.name -> "method"} ${req.pathInfo -> "uri"}: rejection, ${resp.status.code -> "code"} ${resp.status.reason -> "reason"}") + } + } yield resp).tapError { + cause => + logger.error(s"${req.method.name -> "method"} ${req.pathInfo -> "path"}: failure, $cause") + } + } + } + } } object HttpServer { case object WsResponseMarker class ServerWsRpcHandler[F[+_, +_]: IO2, RequestCtx, ClientId]( - muxer: IRTServerMultiplexor[F, RequestCtx], - wsContextProvider: WsContextProvider[F, RequestCtx, ClientId], - context: WsClientSession[F, RequestCtx, ClientId], + clientSession: WsClientSession[F], + contextServicesMuxer: IRTContextServicesMuxer[F], logger: LogIO2[F], - ) extends WsRpcHandler[F, RequestCtx](muxer, context, logger) { + ) extends WsRpcHandler[F](contextServicesMuxer, clientSession.requests, logger) { + override protected def getAuthContext: AuthContext = clientSession.getAuthContext + override def handlePacket(packet: RpcPacket): F[Throwable, Unit] = { - wsContextProvider.toId(context.initialContext, context.id, packet).flatMap(context.updateId) - } - override def handleAuthRequest(packet: RpcPacket): F[Throwable, Option[RpcPacket]] = { - wsContextProvider.handleAuthorizationPacket(context.id, context.initialContext, packet).flatMap { - case WsAuthResult(id, packet) => context.updateId(id).as(Some(packet)) + F.traverse_(packet.headers) { + headersMap => + val headers = Headers.apply(headersMap.toSeq.map { case (k, v) => Header.Raw(CIString(k), v) }) + val authContext = AuthContext(headers, None) + clientSession.updateAuthContext(authContext) } } - override def extractContext(packet: RpcPacket): F[Throwable, RequestCtx] = { - wsContextProvider.toContext(context.id, context.initialContext, packet) + + override protected def handleAuthRequest(packet: RpcPacket): F[Throwable, Option[RpcPacket]] = { + F.pure(Some(RpcPacket(RPCPacketKind.RpcResponse, None, None, packet.id, None, None, None))) } } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTAuthenticator.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTAuthenticator.scala new file mode 100644 index 00000000..ada8d4f7 --- /dev/null +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTAuthenticator.scala @@ -0,0 +1,18 @@ +package izumi.idealingua.runtime.rpc.http4s + +import izumi.functional.bio.{Applicative2, F} +import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext +import org.http4s.Headers + +import java.net.InetAddress + +abstract class IRTAuthenticator[F[+_, +_], RequestCtx] { + def authenticate(request: AuthContext): F[Throwable, Option[RequestCtx]] +} + +object IRTAuthenticator { + def unit[F[+_, +_]: Applicative2]: IRTAuthenticator[F, Unit] = new IRTAuthenticator[F, Unit] { + override def authenticate(request: AuthContext): F[Throwable, Option[Unit]] = F.pure(Some(())) + } + final case class AuthContext(headers: Headers, networkAddress: Option[InetAddress]) +} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala new file mode 100644 index 00000000..9bafcdf0 --- /dev/null +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala @@ -0,0 +1,68 @@ +package izumi.idealingua.runtime.rpc.http4s + +import io.circe.Json +import izumi.functional.bio.{Exit, F, Panic2, Temporal2} +import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext +import izumi.idealingua.runtime.rpc.http4s.IRTContextServices.AuthInvokeResult +import izumi.idealingua.runtime.rpc.http4s.ws.* +import izumi.idealingua.runtime.rpc.{IRTMethodId, IRTServerMultiplexor} + +final class IRTContextServices[F[+_, +_], RequestCtx]( + val serverMuxer: IRTServerMultiplexor[F, RequestCtx], + val authenticator: IRTAuthenticator[F, RequestCtx], + val wsSessionListeners: Set[WsSessionListener[F, RequestCtx]], +) { + def doAuthInvoke( + methodId: IRTMethodId, + authContext: AuthContext, + body: Json, + )(implicit E: Panic2[F] + ): F[Throwable, AuthInvokeResult[RequestCtx]] = { + authenticator.authenticate(authContext).attempt.flatMap { + case Right(Some(context)) => + serverMuxer.doInvoke(body, context, methodId).sandboxExit.map(AuthInvokeResult.Success(context, _)) + case _ => + F.pure(AuthInvokeResult.Failed) + } + } + + def onWsSessionUpdate( + wsSessionId: WsSessionId, + authContext: AuthContext, + )(implicit E: Panic2[F] + ): F[Throwable, Unit] = { + authenticator.authenticate(authContext).flatMap { + maybeRequestContext => + F.traverse_(wsSessionListeners)(_.onSessionClosed(wsSessionId, maybeRequestContext)) + } + } + def onWsSessionOpened( + wsSessionId: WsSessionId, + authContext: AuthContext, + )(implicit E: Panic2[F] + ): F[Throwable, Unit] = { + authenticator.authenticate(authContext).flatMap { + maybeRequestContext => + F.traverse_(wsSessionListeners)(_.onSessionOpened(wsSessionId, maybeRequestContext)) + } + } + + def onWsSessionClosed( + wsSessionId: WsSessionId, + authContext: AuthContext, + )(implicit E: Panic2[F] + ): F[Throwable, Unit] = { + authenticator.authenticate(authContext).flatMap { + maybeRequestContext => + F.traverse_(wsSessionListeners)(_.onSessionClosed(wsSessionId, maybeRequestContext)) + } + } +} + +object IRTContextServices { + sealed trait AuthInvokeResult[+Ctx] + object AuthInvokeResult { + final case class Success[Ctx](context: Ctx, invocationResult: Exit[Throwable, Option[Json]]) extends AuthInvokeResult[Ctx] + case object Failed extends AuthInvokeResult[Nothing] + } +} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServicesMuxer.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServicesMuxer.scala new file mode 100644 index 00000000..8cf242c6 --- /dev/null +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServicesMuxer.scala @@ -0,0 +1,15 @@ +package izumi.idealingua.runtime.rpc.http4s + +import izumi.idealingua.runtime.rpc.IRTServiceId + +final class IRTContextServicesMuxer[F[+_, +_]]( + val contextServices: Set[IRTContextServices[F, ?]] +) { + private[this] val serviceToContext: Map[IRTServiceId, IRTContextServices[F, ?]] = { + contextServices.flatMap(m => m.serverMuxer.services.map(s => s.serviceId -> m)).toMap + } + + def getContextService(id: IRTServiceId): Option[IRTContextServices[F, ?]] = { + serviceToContext.get(id) + } +} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala index 170a3350..6effa38a 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala @@ -6,15 +6,17 @@ import izumi.functional.bio.{Async2, Exit, F, IO2, Primitives2, Temporal2, Unsaf import izumi.functional.lifecycle.Lifecycle import izumi.fundamentals.platform.language.Quirks.Discarder import izumi.idealingua.runtime.rpc.* +import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext +import izumi.idealingua.runtime.rpc.http4s.IRTContextServicesMuxer import izumi.idealingua.runtime.rpc.http4s.clients.WsRpcDispatcher.IRTDispatcherWs -import izumi.idealingua.runtime.rpc.http4s.clients.WsRpcDispatcherFactory.{ClientWsRpcHandler, WsRpcClientConnection, WsRpcContextProvider, fromNettyFuture} +import izumi.idealingua.runtime.rpc.http4s.clients.WsRpcDispatcherFactory.{ClientWsRpcHandler, WsRpcClientConnection, fromNettyFuture} import izumi.idealingua.runtime.rpc.http4s.ws.{RawResponse, WsRequestState, WsRpcHandler} import izumi.logstage.api.IzLogger import logstage.LogIO2 import org.asynchttpclient.netty.ws.NettyWebSocket import org.asynchttpclient.ws.{WebSocket, WebSocketListener, WebSocketUpgradeHandler} import org.asynchttpclient.{DefaultAsyncHttpClient, DefaultAsyncHttpClientConfig} -import org.http4s.Uri +import org.http4s.{Headers, Uri} import java.util.concurrent.atomic.AtomicReference import scala.concurrent.duration.{DurationInt, FiniteDuration} @@ -27,15 +29,14 @@ class WsRpcDispatcherFactory[F[+_, +_]: Async2: Temporal2: Primitives2: UnsafeRu izLogger: IzLogger, ) { - def connect[ServerContext]( + def connect( uri: Uri, - muxer: IRTServerMultiplexor[F, ServerContext], - contextProvider: WsRpcContextProvider[ServerContext], + muxer: IRTContextServicesMuxer[F], ): Lifecycle[F[Throwable, _], WsRpcClientConnection[F]] = { for { client <- WsRpcDispatcherFactory.asyncHttpClient[F] requestState <- Lifecycle.liftF(F.syncThrowable(WsRequestState.create[F])) - listener <- Lifecycle.liftF(F.syncThrowable(createListener(muxer, contextProvider, requestState, dispatcherLogger(uri, logger)))) + listener <- Lifecycle.liftF(F.syncThrowable(createListener(muxer, requestState, dispatcherLogger(uri, logger)))) handler <- Lifecycle.liftF(F.syncThrowable(new WebSocketUpgradeHandler(List(listener).asJava))) nettyWebSocket <- Lifecycle.make( F.fromFutureJava(client.prepareGet(uri.toString()).execute(handler).toCompletableFuture) @@ -49,12 +50,11 @@ class WsRpcDispatcherFactory[F[+_, +_]: Async2: Temporal2: Primitives2: UnsafeRu def dispatcher[ServerContext]( uri: Uri, - muxer: IRTServerMultiplexor[F, ServerContext], - contextProvider: WsRpcContextProvider[ServerContext], + muxer: IRTContextServicesMuxer[F], tweakRequest: RpcPacket => RpcPacket = identity, timeout: FiniteDuration = 30.seconds, ): Lifecycle[F[Throwable, _], IRTDispatcherWs[F]] = { - connect(uri, muxer, contextProvider).map { + connect(uri, muxer).map { new WsRpcDispatcher(_, timeout, codec, dispatcherLogger(uri, logger)) { override protected def buildRequest(rpcPacketId: RpcPacketId, method: IRTMethodId, body: Json): RpcPacket = { tweakRequest(super.buildRequest(rpcPacketId, method, body)) @@ -64,21 +64,19 @@ class WsRpcDispatcherFactory[F[+_, +_]: Async2: Temporal2: Primitives2: UnsafeRu } protected def wsHandler[ServerContext]( - logger: LogIO2[F], - muxer: IRTServerMultiplexor[F, ServerContext], - contextProvider: WsRpcContextProvider[ServerContext], + muxer: IRTContextServicesMuxer[F], requestState: WsRequestState[F], - ): WsRpcHandler[F, ServerContext] = { - new ClientWsRpcHandler(muxer, requestState, contextProvider, logger) + logger: LogIO2[F], + ): WsRpcHandler[F] = { + new ClientWsRpcHandler(muxer, requestState, logger) } - protected def createListener[ServerContext]( - muxer: IRTServerMultiplexor[F, ServerContext], - contextProvider: WsRpcContextProvider[ServerContext], + protected def createListener( + muxer: IRTContextServicesMuxer[F], requestState: WsRequestState[F], logger: LogIO2[F], ): WebSocketListener = new WebSocketListener() { - private val handler = wsHandler(logger, muxer, contextProvider, requestState) + private val handler = wsHandler(muxer, requestState, logger) private val socketRef = new AtomicReference[Option[WebSocket]](None) override def onOpen(websocket: WebSocket): Unit = { @@ -102,7 +100,7 @@ class WsRpcDispatcherFactory[F[+_, +_]: Async2: Temporal2: Primitives2: UnsafeRu override def onTextFrame(payload: String, finalFragment: Boolean, rsv: Int): Unit = { UnsafeRun2[F].unsafeRunAsync(handler.processRpcMessage(payload)) { exit => - val maybeResponse = exit match { + val maybeResponse: Option[RpcPacket] = exit match { case Exit.Success(response) => response case Exit.Error(error, _) => handleWsError(List(error), "errored") case Exit.Termination(error, _, _) => handleWsError(List(error), "terminated") @@ -154,20 +152,21 @@ object WsRpcDispatcherFactory { }) } - class ClientWsRpcHandler[F[+_, +_]: IO2, ServerCtx]( - muxer: IRTServerMultiplexor[F, ServerCtx], + class ClientWsRpcHandler[F[+_, +_]: IO2]( + muxer: IRTContextServicesMuxer[F], requestState: WsRequestState[F], - contextProvider: WsRpcContextProvider[ServerCtx], logger: LogIO2[F], - ) extends WsRpcHandler[F, ServerCtx](muxer, requestState, logger) { - override def handlePacket(packet: RpcPacket): F[Throwable, Unit] = { + ) extends WsRpcHandler[F](muxer, requestState, logger) { + override protected def handlePacket(packet: RpcPacket): F[Throwable, Unit] = { F.unit } - override def handleAuthRequest(packet: RpcPacket): F[Throwable, Option[RpcPacket]] = { + + override protected def handleAuthRequest(packet: RpcPacket): F[Throwable, Option[RpcPacket]] = { F.pure(None) } - override def extractContext(packet: RpcPacket): F[Throwable, ServerCtx] = { - F.sync(contextProvider.toContext(packet)) + + override protected def getAuthContext: AuthContext = { + AuthContext(Headers.empty, None) } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientId.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientId.scala deleted file mode 100644 index a4b1582a..00000000 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientId.scala +++ /dev/null @@ -1,9 +0,0 @@ -package izumi.idealingua.runtime.rpc.http4s.ws - -import java.util.UUID - -case class WsSessionId(sessionId: UUID) extends AnyVal - -case class WsClientId[ClientId](sessionId: WsSessionId, id: Option[ClientId]) { - override def toString: String = s"${id.getOrElse("?")} / ${sessionId.sessionId}" -} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientRequests.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientRequests.scala new file mode 100644 index 00000000..48773f17 --- /dev/null +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientRequests.scala @@ -0,0 +1,52 @@ +package izumi.idealingua.runtime.rpc.http4s.ws + +import cats.effect.std.Queue +import io.circe.syntax.* +import io.circe.{Json, Printer} +import izumi.functional.bio.{F, IO2, Primitives2, Temporal2} +import izumi.idealingua.runtime.rpc.* +import izumi.idealingua.runtime.rpc.http4s.ws.WsRpcHandler.WsResponder +import logstage.LogIO2 +import org.http4s.websocket.WebSocketFrame +import org.http4s.websocket.WebSocketFrame.Text + +import scala.concurrent.duration.* + +trait WsClientRequests[F[+_, +_]] extends WsResponder[F] { + def requestAndAwaitResponse(method: IRTMethodId, data: Json, timeout: FiniteDuration): F[Throwable, Option[RawResponse]] + def finish(): F[Throwable, Unit] +} + +object WsClientRequests { + class WsClientRequestsImpl[F[+_, +_]: IO2: Temporal2: Primitives2]( + val outQueue: Queue[F[Throwable, _], WebSocketFrame], + printer: Printer, + logger: LogIO2[F], + ) extends WsClientRequests[F] { + private val requestState: WsRequestState[F] = WsRequestState.create[F] + def requestAndAwaitResponse(method: IRTMethodId, data: Json, timeout: FiniteDuration): F[Throwable, Option[RawResponse]] = { + val id = RpcPacketId.random() + val request = RpcPacket.buzzerRequest(id, method, data) + for { + _ <- logger.debug(s"WS Session: enqueue $request with $id to request state & send queue.") + response <- requestState.requestAndAwait(id, Some(method), timeout) { + outQueue.offer(Text(printer.print(request.asJson))) + } + _ <- logger.debug(s"WS Session: $method, ${id -> "id"}: cleaning request state.") + } yield response + } + + override def responseWith(id: RpcPacketId, response: RawResponse): F[Throwable, Unit] = { + requestState.responseWith(id, response) + } + + override def responseWithData(id: RpcPacketId, data: Json): F[Throwable, Unit] = { + requestState.responseWithData(id, data) + } + + override def finish(): F[Throwable, Unit] = { + F.fromEither(WebSocketFrame.Close(1000)).flatMap(outQueue.offer(_)) *> + requestState.clear() + } + } +} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala index 636c41ef..460370e3 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala @@ -1,92 +1,73 @@ package izumi.idealingua.runtime.rpc.http4s.ws -import cats.effect.std.Queue -import io.circe.syntax.* -import io.circe.{Json, Printer} -import izumi.functional.bio.{F, IO2, Primitives2, Temporal2} +import izumi.functional.bio.{F, IO2, Temporal2} import izumi.fundamentals.platform.time.IzTime import izumi.fundamentals.platform.uuid.UUIDGen -import izumi.idealingua.runtime.rpc.* -import izumi.idealingua.runtime.rpc.http4s.ws.WsRpcHandler.WsClientResponder -import logstage.LogIO2 -import org.http4s.websocket.WebSocketFrame -import org.http4s.websocket.WebSocketFrame.Text +import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext +import izumi.idealingua.runtime.rpc.http4s.IRTContextServicesMuxer import java.time.ZonedDateTime import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicReference import scala.concurrent.duration.* -trait WsClientSession[F[+_, +_], RequestCtx, ClientId] extends WsClientResponder[F] { - def id: WsClientId[ClientId] - def initialContext: RequestCtx +trait WsClientSession[F[+_, +_]] { + def sessionId: WsSessionId + def getAuthContext: AuthContext + def requests: WsClientRequests[F] - def updateId(maybeNewId: Option[ClientId]): F[Throwable, Unit] - def outQueue: Queue[F[Throwable, _], WebSocketFrame] + def servicesMuxer: IRTContextServicesMuxer[F] - def requestAndAwaitResponse(method: IRTMethodId, data: Json, timeout: FiniteDuration): F[Throwable, Option[RawResponse]] + def updateAuthContext(newContext: AuthContext): F[Throwable, Unit] + def start(): F[Throwable, Unit] def finish(): F[Throwable, Unit] } object WsClientSession { - class WsClientSessionImpl[F[+_, +_]: IO2: Temporal2: Primitives2, RequestCtx, ClientId]( - val outQueue: Queue[F[Throwable, _], WebSocketFrame], - val initialContext: RequestCtx, - listeners: Seq[WsSessionListener[F, ClientId]], - wsSessionStorage: WsSessionsStorage[F, RequestCtx, ClientId], - printer: Printer, - logger: LogIO2[F], - ) extends WsClientSession[F, RequestCtx, ClientId] { - private val openingTime: ZonedDateTime = IzTime.utcNow - private val sessionId: WsSessionId = WsSessionId(UUIDGen.getTimeUUID()) - private val clientId: AtomicReference[Option[ClientId]] = new AtomicReference[Option[ClientId]](None) - private val requestState: WsRequestState[F] = WsRequestState.create[F] - def id: WsClientId[ClientId] = WsClientId(sessionId, clientId.get()) + class WsClientSessionImpl[F[+_, +_]: IO2]( + val initialContext: AuthContext, + val servicesMuxer: IRTContextServicesMuxer[F], + val requests: WsClientRequests[F], + wsSessionStorage: WsSessionsStorage[F], + ) extends WsClientSession[F] { + private val authContextRef = new AtomicReference[AuthContext](initialContext) + private val openingTime: ZonedDateTime = IzTime.utcNow - def requestAndAwaitResponse(method: IRTMethodId, data: Json, timeout: FiniteDuration): F[Throwable, Option[RawResponse]] = { - val id = RpcPacketId.random() - val request = RpcPacket.buzzerRequest(id, method, data) - for { - _ <- logger.debug(s"WS Session: enqueue $request with $id to request state & send queue.") - response <- requestState.requestAndAwait(id, Some(method), timeout) { - outQueue.offer(Text(printer.print(request.asJson))) - } - _ <- logger.debug(s"WS Session: $method, ${id -> "id"}: cleaning request state.") - } yield response - } - - override def responseWith(id: RpcPacketId, response: RawResponse): F[Throwable, Unit] = { - requestState.responseWith(id, response) - } + override val sessionId: WsSessionId = WsSessionId(UUIDGen.getTimeUUID()) - override def responseWithData(id: RpcPacketId, data: Json): F[Throwable, Unit] = { - requestState.responseWithData(id, data) - } + override def getAuthContext: AuthContext = authContextRef.get() - override def updateId(maybeNewId: Option[ClientId]): F[Throwable, Unit] = { + override def updateAuthContext(newContext: AuthContext): F[Throwable, Unit] = { for { - old <- F.sync(id) - _ <- F.sync(clientId.set(maybeNewId)) - current <- F.sync(id) - _ <- F.when(old != current)(F.traverse_(listeners)(_.onClientIdUpdate(current, old))) + contexts <- F.sync { + authContextRef.synchronized { + val oldContext = authContextRef.get() + val updatedContext = authContextRef.updateAndGet { + old => AuthContext(old.headers ++ newContext.headers, old.networkAddress.orElse(newContext.networkAddress)) + } + oldContext -> updatedContext + } + } + (oldContext, updatedContext) = contexts + _ <- F.when(oldContext != updatedContext) { + F.traverse_(servicesMuxer.contextServices)(_.onWsSessionUpdate(sessionId, updatedContext)) + } } yield () } override def finish(): F[Throwable, Unit] = { - F.fromEither(WebSocketFrame.Close(1000)).flatMap(outQueue.offer(_)) *> - wsSessionStorage.deleteClient(sessionId) *> - F.traverse_(listeners)(_.onSessionClosed(id)) *> - requestState.clear() + wsSessionStorage.deleteSession(sessionId) *> + F.traverse_(servicesMuxer.contextServices)(_.onWsSessionClosed(sessionId, getAuthContext)) } - protected[http4s] def start(): F[Throwable, Unit] = { - wsSessionStorage.addClient(this) *> - F.traverse_(listeners)(_.onSessionOpened(id)) + override def start(): F[Throwable, Unit] = { + wsSessionStorage.addSession(this) *> + F.traverse_(servicesMuxer.contextServices)(_.onWsSessionOpened(sessionId, getAuthContext)) } - override def toString: String = s"[${id.toString}, ${duration().toSeconds}s]" + override def toString: String = s"[$sessionId, ${duration().toSeconds}s]" private[this] def duration(): FiniteDuration = { val now = IzTime.utcNow diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextProvider.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextProvider.scala deleted file mode 100644 index 92a1e2da..00000000 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextProvider.scala +++ /dev/null @@ -1,41 +0,0 @@ -package izumi.idealingua.runtime.rpc.http4s.ws - -import izumi.functional.bio.{Applicative2, F} -import izumi.fundamentals.platform.language.Quirks -import izumi.idealingua.runtime.rpc.http4s.ws.WsContextProvider.WsAuthResult -import izumi.idealingua.runtime.rpc.{RPCPacketKind, RpcPacket} - -trait WsContextProvider[F[+_, +_], RequestCtx, ClientId] { - def toContext(id: WsClientId[ClientId], initial: RequestCtx, packet: RpcPacket): F[Throwable, RequestCtx] - - def toId(initial: RequestCtx, currentId: WsClientId[ClientId], packet: RpcPacket): F[Throwable, Option[ClientId]] - - // TODO: we use this to mangle with authorization but it's dirty - def handleAuthorizationPacket(id: WsClientId[ClientId], initial: RequestCtx, packet: RpcPacket): F[Throwable, WsAuthResult[ClientId]] -} - -object WsContextProvider { - - final case class WsAuthResult[ClientId](client: Option[ClientId], response: RpcPacket) - - class IdContextProvider[F[+_, +_]: Applicative2, RequestCtx, ClientId] extends WsContextProvider[F, RequestCtx, ClientId] { - override def handleAuthorizationPacket( - id: WsClientId[ClientId], - initial: RequestCtx, - packet: RpcPacket, - ): F[Throwable, WsAuthResult[ClientId]] = { - val res = RpcPacket(RPCPacketKind.RpcResponse, None, None, packet.id, None, None, None) - F.pure(WsAuthResult[ClientId](None, res)) - } - - override def toContext(id: WsClientId[ClientId], initial: RequestCtx, packet: RpcPacket): F[Throwable, RequestCtx] = { - Quirks.discard(packet, id) - F.pure(initial) - } - - override def toId(initial: RequestCtx, currentId: WsClientId[ClientId], packet: RpcPacket): F[Throwable, Option[ClientId]] = { - Quirks.discard(initial, packet) - F.pure(None) - } - } -} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRequestState.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRequestState.scala index a094cf4e..39142463 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRequestState.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRequestState.scala @@ -5,7 +5,7 @@ import izumi.functional.bio.{Clock1, Clock2, F, IO2, Primitives2, Promise2, Temp import izumi.fundamentals.platform.language.Quirks.* import izumi.idealingua.runtime.rpc.* import izumi.idealingua.runtime.rpc.http4s.ws.RawResponse.BadRawResponse -import izumi.idealingua.runtime.rpc.http4s.ws.WsRpcHandler.WsClientResponder +import izumi.idealingua.runtime.rpc.http4s.ws.WsRpcHandler.WsResponder import java.time.OffsetDateTime import java.time.temporal.ChronoUnit @@ -14,7 +14,7 @@ import scala.collection.mutable import scala.concurrent.duration.FiniteDuration import scala.jdk.CollectionConverters.* -trait WsRequestState[F[_, _]] extends WsClientResponder[F] { +trait WsRequestState[F[_, _]] extends WsResponder[F] { def requestAndAwait[A]( id: RpcPacketId, methodId: Option[IRTMethodId], diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala index 90c1c8d2..5940354e 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala @@ -5,25 +5,32 @@ import izumi.functional.bio.Exit.Success import izumi.functional.bio.{Exit, F, IO2} import izumi.fundamentals.platform.language.Quirks.Discarder import izumi.idealingua.runtime.rpc.* -import izumi.idealingua.runtime.rpc.http4s.ws.WsRpcHandler.WsClientResponder +import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext +import izumi.idealingua.runtime.rpc.http4s.IRTContextServices.AuthInvokeResult +import izumi.idealingua.runtime.rpc.http4s.IRTContextServicesMuxer +import izumi.idealingua.runtime.rpc.http4s.ws.WsRpcHandler.WsResponder import logstage.LogIO2 -abstract class WsRpcHandler[F[+_, +_]: IO2, RequestCtx]( - muxer: IRTServerMultiplexor[F, RequestCtx], - responder: WsClientResponder[F], +abstract class WsRpcHandler[F[+_, +_]: IO2]( + contextServices: IRTContextServicesMuxer[F], + responder: WsResponder[F], logger: LogIO2[F], ) { protected def handlePacket(packet: RpcPacket): F[Throwable, Unit] + protected def handleAuthRequest(packet: RpcPacket): F[Throwable, Option[RpcPacket]] - protected def extractContext(packet: RpcPacket): F[Throwable, RequestCtx] + + protected def getAuthContext: AuthContext protected def handleAuthResponse(ref: RpcPacketId, packet: RpcPacket): F[Throwable, Option[RpcPacket]] = { packet.discard() responder.responseWith(ref, RawResponse.EmptyRawResponse()).as(None) } - def processRpcMessage(message: String): F[Throwable, Option[RpcPacket]] = { + def processRpcMessage( + message: String + ): F[Throwable, Option[RpcPacket]] = { for { packet <- F.fromEither(io.circe.parser.decode[RpcPacket](message)) _ <- handlePacket(packet) @@ -37,7 +44,7 @@ abstract class WsRpcHandler[F[+_, +_]: IO2, RequestCtx]( // rpc case RpcPacket(RPCPacketKind.RpcRequest, Some(data), Some(id), _, Some(service), Some(method), _) => - handleWsRequest(packet, data, IRTMethodId(IRTServiceId(service), IRTMethodName(method)))( + handleWsRequest(data, IRTMethodId(IRTServiceId(service), IRTMethodName(method)))( onSuccess = RpcPacket.rpcResponse(id, _), onFail = RpcPacket.rpcFail(Some(id), _), ) @@ -50,7 +57,7 @@ abstract class WsRpcHandler[F[+_, +_]: IO2, RequestCtx]( // buzzer case RpcPacket(RPCPacketKind.BuzzRequest, Some(data), Some(id), _, Some(service), Some(method), _) => - handleWsRequest(packet, data, IRTMethodId(IRTServiceId(service), IRTMethodName(method)))( + handleWsRequest(data, IRTMethodId(IRTServiceId(service), IRTMethodName(method)))( onSuccess = RpcPacket.buzzerResponse(id, _), onFail = RpcPacket.buzzerFail(Some(id), _), ) @@ -78,36 +85,41 @@ abstract class WsRpcHandler[F[+_, +_]: IO2, RequestCtx]( } protected def handleWsRequest( - input: RpcPacket, data: Json, methodId: IRTMethodId, )(onSuccess: Json => RpcPacket, onFail: String => RpcPacket, ): F[Throwable, Option[RpcPacket]] = { - for { - userCtx <- extractContext(input) - res <- muxer.doInvoke(data, userCtx, methodId).sandboxExit.flatMap { - case Success(Some(res)) => - F.pure(Some(onSuccess(res))) - - case Success(None) => - logger.error(s"WS request errored: No rpc handler for $methodId").as(Some(onFail("No rpc handler."))) - - case Exit.Termination(exception, allExceptions, trace) => - logger.error(s"WS request terminated, $exception, $allExceptions, $trace").as(Some(onFail(exception.getMessage))) - - case Exit.Error(exception, trace) => - logger.error(s"WS request failed, $exception $trace").as(Some(onFail(exception.getMessage))) - - case Exit.Interruption(exception, allExceptions, trace) => - logger.error(s"WS request interrupted, $exception $allExceptions $trace").as(Some(onFail(exception.getMessage))) - } - } yield res + contextServices.getContextService(methodId.service) match { + case Some(contextService) => + contextService.doAuthInvoke(methodId, getAuthContext, data).flatMap { + case AuthInvokeResult.Success(_, Success(Some(res))) => + F.pure(Some(onSuccess(res))) + + case AuthInvokeResult.Success(context, Success(None)) => + logger.error(s"WS request errored for ${context -> null}: No rpc handler for $methodId.").as(Some(onFail("No rpc handler."))) + + case AuthInvokeResult.Success(context, Exit.Termination(exception, allExceptions, trace)) => + logger.error(s"WS request terminated for ${context -> null}: $exception, $allExceptions, $trace").as(Some(onFail(exception.getMessage))) + + case AuthInvokeResult.Success(context, Exit.Error(exception, trace)) => + logger.error(s"WS request failed for ${context -> null}: $exception $trace").as(Some(onFail(exception.getMessage))) + + case AuthInvokeResult.Success(context, Exit.Interruption(exception, allExceptions, trace)) => + logger.error(s"WS request interrupted for ${context -> null}: $exception $allExceptions $trace").as(Some(onFail(exception.getMessage))) + + case AuthInvokeResult.Failed => + F.pure(Some(onFail("Unauthorized."))) + } + case None => + val message = "Missing WS client context session." + logger.error(s"WS request failed, $message").as(Some(onFail(message))) + } } } object WsRpcHandler { - trait WsClientResponder[F[_, _]] { + trait WsResponder[F[_, _]] { def responseWith(id: RpcPacketId, response: RawResponse): F[Throwable, Unit] def responseWithData(id: RpcPacketId, data: Json): F[Throwable, Unit] } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionId.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionId.scala new file mode 100644 index 00000000..6ad56c61 --- /dev/null +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionId.scala @@ -0,0 +1,5 @@ +package izumi.idealingua.runtime.rpc.http4s.ws + +import java.util.UUID + +final case class WsSessionId(sessionId: UUID) extends AnyVal diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionListener.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionListener.scala index 2e20d04c..125f1651 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionListener.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionListener.scala @@ -1,18 +1,17 @@ package izumi.idealingua.runtime.rpc.http4s.ws -import izumi.functional.bio.Applicative2 +import izumi.functional.bio.{Applicative2, F} -trait WsSessionListener[F[+_, +_], ClientId] { - def onSessionOpened(context: WsClientId[ClientId]): F[Throwable, Unit] - def onClientIdUpdate(context: WsClientId[ClientId], old: WsClientId[ClientId]): F[Throwable, Unit] - def onSessionClosed(context: WsClientId[ClientId]): F[Throwable, Unit] +trait WsSessionListener[F[+_, +_], RequestCtx] { + def onSessionOpened(sessionId: WsSessionId, context: Option[RequestCtx]): F[Throwable, Unit] + def onSessionAuthUpdate(sessionId: WsSessionId, context: Option[RequestCtx]): F[Throwable, Unit] + def onSessionClosed(sessionId: WsSessionId, context: Option[RequestCtx]): F[Throwable, Unit] } object WsSessionListener { - def empty[F[+_, +_]: Applicative2, ClientId]: WsSessionListener[F, ClientId] = new WsSessionListener[F, ClientId] { - import izumi.functional.bio.F - override def onSessionOpened(context: WsClientId[ClientId]): F[Throwable, Unit] = F.unit - override def onClientIdUpdate(context: WsClientId[ClientId], old: WsClientId[ClientId]): F[Throwable, Unit] = F.unit - override def onSessionClosed(context: WsClientId[ClientId]): F[Throwable, Unit] = F.unit + def empty[F[+_, +_]: Applicative2, RequestCtx]: WsSessionListener[F, RequestCtx] = new WsSessionListener[F, RequestCtx] { + override def onSessionOpened(sessionId: WsSessionId, context: Option[RequestCtx]): F[Throwable, Unit] = F.unit + override def onSessionAuthUpdate(sessionId: WsSessionId, context: Option[RequestCtx]): F[Throwable, Unit] = F.unit + override def onSessionClosed(sessionId: WsSessionId, context: Option[RequestCtx]): F[Throwable, Unit] = F.unit } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionsStorage.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionsStorage.scala index fcd030c4..1796eb73 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionsStorage.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionsStorage.scala @@ -8,58 +8,52 @@ import java.util.concurrent.{ConcurrentHashMap, TimeoutException} import scala.concurrent.duration.* import scala.jdk.CollectionConverters.* -trait WsSessionsStorage[F[+_, +_], RequestCtx, ClientId] { - def addClient(ctx: WsClientSession[F, RequestCtx, ClientId]): F[Throwable, WsClientSession[F, RequestCtx, ClientId]] - def deleteClient(id: WsSessionId): F[Throwable, Option[WsClientSession[F, RequestCtx, ClientId]]] - def allClients(): F[Throwable, Seq[WsClientSession[F, RequestCtx, ClientId]]] +trait WsSessionsStorage[F[+_, +_]] { + def addSession(session: WsClientSession[F]): F[Throwable, WsClientSession[F]] + def deleteSession(sessionId: WsSessionId): F[Throwable, Option[WsClientSession[F]]] + def allSessions(): F[Throwable, Seq[WsClientSession[F]]] - def dispatcherForSession(id: WsSessionId, timeout: FiniteDuration = 20.seconds): F[Throwable, Option[IRTDispatcher[F]]] - def dispatcherForClient(id: ClientId, timeout: FiniteDuration = 20.seconds): F[Throwable, Option[IRTDispatcher[F]]] + def dispatcherForSession( + sessionId: WsSessionId, + codec: IRTClientMultiplexor[F], + timeout: FiniteDuration = 20.seconds, + ): F[Throwable, Option[IRTDispatcher[F]]] } object WsSessionsStorage { - class WsSessionsStorageImpl[F[+_, +_]: IO2, RequestContext, ClientId]( - logger: LogIO2[F], - codec: IRTClientMultiplexor[F], - ) extends WsSessionsStorage[F, RequestContext, ClientId] { - - protected val sessions = new ConcurrentHashMap[WsSessionId, WsClientSession[F, RequestContext, ClientId]]() + class WsSessionsStorageImpl[F[+_, +_]: IO2](logger: LogIO2[F]) extends WsSessionsStorage[F] { + protected val sessions = new ConcurrentHashMap[WsSessionId, WsClientSession[F]]() - override def addClient(ctx: WsClientSession[F, RequestContext, ClientId]): F[Throwable, WsClientSession[F, RequestContext, ClientId]] = { + override def addSession(session: WsClientSession[F]): F[Throwable, WsClientSession[F]] = { for { - _ <- logger.debug(s"Adding a client with session - ${ctx.id}") - _ <- F.sync(sessions.put(ctx.id.sessionId, ctx)) - } yield ctx + _ <- logger.debug(s"Adding a client with session - ${session.sessionId}") + _ <- F.sync(sessions.put(session.sessionId, session)) + } yield session } - override def deleteClient(id: WsSessionId): F[Throwable, Option[WsClientSession[F, RequestContext, ClientId]]] = { + override def deleteSession(sessionId: WsSessionId): F[Throwable, Option[WsClientSession[F]]] = { for { - _ <- logger.debug(s"Deleting a client with session - $id") - res <- F.sync(Option(sessions.remove(id))) + _ <- logger.debug(s"Deleting a client with session - $sessionId") + res <- F.sync(Option(sessions.remove(sessionId))) } yield res } - override def allClients(): F[Throwable, Seq[WsClientSession[F, RequestContext, ClientId]]] = F.sync { + override def allSessions(): F[Throwable, Seq[WsClientSession[F]]] = F.sync { sessions.values().asScala.toSeq } - override def dispatcherForClient(clientId: ClientId, timeout: FiniteDuration): F[Throwable, Option[WsClientDispatcher[F, RequestContext, ClientId]]] = { - F.sync(sessions.values().asScala.find(_.id.id.contains(clientId))).flatMap { - F.traverse(_) { - session => - dispatcherForSession(session.id.sessionId, timeout) - }.map(_.flatten) - } - } - - override def dispatcherForSession(id: WsSessionId, timeout: FiniteDuration): F[Throwable, Option[WsClientDispatcher[F, RequestContext, ClientId]]] = F.sync { - Option(sessions.get(id)).map(new WsClientDispatcher(_, codec, logger, timeout)) + override def dispatcherForSession( + sessionId: WsSessionId, + codec: IRTClientMultiplexor[F], + timeout: FiniteDuration, + ): F[Throwable, Option[WsClientDispatcher[F]]] = F.sync { + Option(sessions.get(sessionId)).map(new WsClientDispatcher(_, codec, logger, timeout)) } } - class WsClientDispatcher[F[+_, +_]: IO2, RequestContext, ClientId]( - session: WsClientSession[F, RequestContext, ClientId], + class WsClientDispatcher[F[+_, +_]: IO2]( + session: WsClientSession[F], codec: IRTClientMultiplexor[F], logger: LogIO2[F], timeout: FiniteDuration, @@ -67,7 +61,7 @@ object WsSessionsStorage { override def dispatch(request: IRTMuxRequest): F[Throwable, IRTMuxResponse] = { for { json <- codec.encode(request) - response <- session.requestAndAwaitResponse(request.method, json, timeout) + response <- session.requests.requestAndAwaitResponse(request.method, json, timeout) res <- response match { case Some(value: RawResponse.EmptyRawResponse) => F.fail(new IRTGenericFailure(s"${request.method -> "method"}: empty response: $value")) @@ -88,5 +82,4 @@ object WsSessionsStorage { } } - } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala index c0d1f0ca..dd407c18 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala @@ -64,18 +64,18 @@ class Http4sTransportTest extends AnyWordSpec { greeterClient = new GreeterServiceClientWrapped(dispatcher) _ <- greeterClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) _ <- greeterClient.alternative().either.map(res => assert(res == Right("value"))) - buzzers <- ioService.wsSessionStorage.dispatcherForClient(id1) - _ = assert(buzzers.nonEmpty) - _ <- ZIO.foreach(buzzers) { - buzzer => - val client = new GreeterServiceClientWrapped(buzzer) - client.greet("John", "Buzzer").map(res => assert(res == "Hi, John Buzzer!")) - } - _ <- dispatcher.authorize(Map("Authorization" -> s"Basic ${Base64.getEncoder.encodeToString("user:badpass".getBytes)}")) - _ <- F.sandboxExit(greeterClient.alternative()).map { - case Termination(_: IRTGenericFailure, _, _) => - case o => F.fail(s"Expected IRTGenericFailure but got $o") - } +// buzzers <- ioService.wsSessionsStorage.dispatcherForClient(id1) +// _ = assert(buzzers.nonEmpty) +// _ <- ZIO.foreach(buzzers) { +// buzzer => +// val client = new GreeterServiceClientWrapped(buzzer) +// client.greet("John", "Buzzer").map(res => assert(res == "Hi, John Buzzer!")) +// } +// _ <- dispatcher.authorize(Map("Authorization" -> s"Basic ${Base64.getEncoder.encodeToString("user:badpass".getBytes)}")) +// _ <- F.sandboxExit(greeterClient.alternative()).map { +// case Termination(_: IRTGenericFailure, _, _) => +// case o => F.fail(s"Expected IRTGenericFailure but got $o") +// } } yield () } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/DummyRequestContext.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/DummyRequestContext.scala index cf6a75b8..e5247f6b 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/DummyRequestContext.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/DummyRequestContext.scala @@ -1,6 +1,7 @@ package izumi.idealingua.runtime.rpc.http4s.fixtures -import com.comcast.ip4s.IpAddress import org.http4s.Credentials -final case class DummyRequestContext(ip: IpAddress, credentials: Option[Credentials]) +import java.net.InetAddress + +final case class DummyRequestContext(ip: Option[InetAddress], credentials: Option[Credentials]) diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/DummyServices.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/DummyServices.scala index 9843ae77..a5230183 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/DummyServices.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/DummyServices.scala @@ -2,6 +2,8 @@ package izumi.idealingua.runtime.rpc.http4s.fixtures import izumi.functional.bio.IO2 import izumi.idealingua.runtime.rpc.* +import izumi.idealingua.runtime.rpc.IRTServerMultiplexor.IRTServerMultiplexorImpl +import izumi.idealingua.runtime.rpc.http4s.{IRTAuthenticator, IRTContextServices, IRTContextServicesMuxer} import izumi.r2.idealingua.test.generated.{GreeterServiceClientWrapped, GreeterServiceServerWrapped} import izumi.r2.idealingua.test.impls.AbstractGreeterServer @@ -11,7 +13,7 @@ class DummyServices[F[+_, +_]: IO2, Ctx] { private val greeterService = new AbstractGreeterServer.Impl[F, Ctx] private val greeterDispatcher = new GreeterServiceServerWrapped(greeterService) private val dispatchers: Set[IRTWrappedService[F, Ctx]] = Set(greeterDispatcher).map(d => new DummyAuthorizingDispatcher(d)) - val multiplexor = new IRTServerMultiplexorImpl[F, Ctx, Ctx](dispatchers, ContextExtender.id) + val multiplexor = new IRTServerMultiplexorImpl[F, Ctx](dispatchers) private val clients: Set[IRTWrappedClient] = Set(GreeterServiceClientWrapped) val codec = new IRTClientMultiplexorImpl[F](clients) @@ -23,7 +25,11 @@ class DummyServices[F[+_, +_]: IO2, Ctx] { private val dispatchers: Set[IRTWrappedService[F, Unit]] = Set(greeterDispatcher) private val clients: Set[IRTWrappedClient] = Set(GreeterServiceClientWrapped) - val codec = new IRTClientMultiplexorImpl[F](clients) - val buzzerMultiplexor = new IRTServerMultiplexorImpl[F, Unit, Unit](dispatchers, ContextExtender.id) + val codec: IRTClientMultiplexorImpl[F] = new IRTClientMultiplexorImpl[F](clients) + val buzzerMultiplexor: IRTContextServicesMuxer[F] = { + val contextMuxer = new IRTServerMultiplexorImpl[F, Unit](dispatchers) + val contextServices = new IRTContextServices[F, Unit](contextMuxer, IRTAuthenticator.unit, Set.empty) + new IRTContextServicesMuxer[F](Set(contextServices)) + } } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/Http4sTestContext.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/Http4sTestContext.scala index c988b71d..a8269f80 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/Http4sTestContext.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/Http4sTestContext.scala @@ -1,20 +1,12 @@ package izumi.idealingua.runtime.rpc.http4s.fixtures -import cats.data.{Kleisli, OptionT} -import com.comcast.ip4s.* import izumi.functional.lifecycle.Lifecycle -import izumi.fundamentals.platform.language.Quirks import izumi.fundamentals.platform.network.IzSockets import izumi.idealingua.runtime.rpc.http4s.* -import izumi.idealingua.runtime.rpc.http4s.clients.WsRpcDispatcherFactory.WsRpcContextProvider import izumi.idealingua.runtime.rpc.http4s.clients.{HttpRpcDispatcher, HttpRpcDispatcherFactory, WsRpcDispatcher, WsRpcDispatcherFactory} -import izumi.idealingua.runtime.rpc.http4s.ws.WsContextProvider.WsAuthResult import izumi.idealingua.runtime.rpc.http4s.ws.WsSessionsStorage.WsSessionsStorageImpl -import izumi.idealingua.runtime.rpc.http4s.ws.{WsClientId, WsContextProvider, WsSessionListener} -import izumi.idealingua.runtime.rpc.{RPCPacketKind, RpcPacket} import org.http4s.* import org.http4s.headers.Authorization -import org.http4s.server.AuthMiddleware import zio.interop.catz.* import zio.{IO, ZIO} @@ -35,68 +27,18 @@ object Http4sTestContext { final val demo = new DummyServices[IO, DummyRequestContext]() - // - final val authUser: Kleisli[OptionT[IO[Throwable, _], _], Request[IO[Throwable, _]], DummyRequestContext] = - Kleisli { - (request: Request[IO[Throwable, _]]) => - val context = DummyRequestContext(request.remoteAddr.getOrElse(ipv4"0.0.0.0"), request.headers.get[Authorization].map(_.credentials)) - OptionT.liftF(ZIO.attempt(context)) - } - - final val wsContextProvider: WsContextProvider[IO, DummyRequestContext, String] = new WsContextProvider[IO, DummyRequestContext, String] { - override def toContext(id: WsClientId[String], initial: DummyRequestContext, packet: RpcPacket): zio.IO[Throwable, DummyRequestContext] = { - ZIO.succeed { - val fromState = id.id.map(header => Map("Authorization" -> header)).getOrElse(Map.empty) - val allHeaders = fromState ++ packet.headers.getOrElse(Map.empty) - val creds = allHeaders.get("Authorization").flatMap(Authorization.parse(_).toOption).map(_.credentials) - DummyRequestContext(initial.ip, creds.orElse(initial.credentials)) - } - } - - override def toId(initial: DummyRequestContext, currentId: WsClientId[String], packet: RpcPacket): zio.IO[Throwable, Option[String]] = { - ZIO.attempt { - val fromState = currentId.id.map(header => Map("Authorization" -> header)).getOrElse(Map.empty) - val allHeaders = fromState ++ packet.headers.getOrElse(Map.empty) - allHeaders.get("Authorization") - } - } - - override def handleAuthorizationPacket( - id: WsClientId[String], - initial: DummyRequestContext, - packet: RpcPacket, - ): IO[Throwable, WsAuthResult[String]] = { - Quirks.discard(id, initial) - - packet.headers.flatMap(_.get("Authorization")) match { - case Some(value) if value.isEmpty => - // here we may clear internal state - ZIO.succeed(WsAuthResult(None, RpcPacket(RPCPacketKind.RpcResponse, None, None, packet.id, None, None, None))) - - case Some(_) => - toId(initial, id, packet).flatMap { - case Some(header) => - // here we may set internal state - ZIO.succeed(WsAuthResult(Some(header), RpcPacket(RPCPacketKind.RpcResponse, None, None, packet.id, None, None, None))) - - case None => - ZIO.succeed(WsAuthResult(None, RpcPacket.rpcFail(packet.id, "Authorization failed"))) - } - - case None => - ZIO.succeed(WsAuthResult(None, RpcPacket(RPCPacketKind.RpcResponse, None, None, packet.id, None, None, None))) - } + final val authenticator = new IRTAuthenticator[IO, DummyRequestContext] { + override def authenticate(request: IRTAuthenticator.AuthContext): IO[Throwable, Option[DummyRequestContext]] = ZIO.succeed { + val creds = request.headers.get[Authorization].map(_.credentials) + Some(DummyRequestContext(request.networkAddress, creds)) } } - - final val storage = new WsSessionsStorageImpl[IO, DummyRequestContext, String](RT.logger, demo.Server.codec) - final val ioService = new HttpServer[IO, DummyRequestContext, DummyRequestContext, String]( - demo.Server.multiplexor, - demo.Server.codec, - AuthMiddleware(authUser), - wsContextProvider, + final val storage = new WsSessionsStorageImpl[IO](RT.logger) + final val contextService = new IRTContextServices[IO, DummyRequestContext](demo.Server.multiplexor, authenticator, Set.empty) + final val contextMuxer = new IRTContextServicesMuxer[IO](Set(contextService)) + final val ioService = new HttpServer[IO]( + contextMuxer, storage, - Seq(WsSessionListener.empty[IO, String]), RT.dsl, RT.logger, RT.printer, @@ -113,6 +55,6 @@ object Http4sTestContext { new WsRpcDispatcherFactory[IO](demo.Client.codec, RT.printer, RT.logger, RT.izLogger) } final def wsRpcClientDispatcher(): Lifecycle[IO[Throwable, _], WsRpcDispatcher.IRTDispatcherWs[IO]] = { - wsClientFactory.dispatcher(wsUri, demo.Client.buzzerMultiplexor, WsRpcContextProvider.unit) + wsClientFactory.dispatcher(wsUri, demo.Client.buzzerMultiplexor) } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/RT.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/RT.scala index e80b3b7e..51a92a62 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/RT.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/RT.scala @@ -9,7 +9,7 @@ import logstage.LogIO import org.http4s.dsl.Http4sDsl import zio.IO -import java.util.concurrent.{Executor, Executors} +import java.util.concurrent.Executors import scala.concurrent.ExecutionContext.global object RT { diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala index d0d07242..44c642b5 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala @@ -3,51 +3,47 @@ package izumi.idealingua.runtime.rpc import io.circe.Json import izumi.functional.bio.{Exit, F, IO2} -trait ContextExtender[-Ctx, +Ctx2] { - def extend(context: Ctx, body: Json, irtMethodId: IRTMethodId): Ctx2 -} - -object ContextExtender { - def id[Ctx]: ContextExtender[Ctx, Ctx] = (context, _, _) => context -} - -trait IRTServerMultiplexor[F[+_, +_], -C] { +trait IRTServerMultiplexor[F[+_, +_], C] { + def services: Set[IRTWrappedService[F, C]] def doInvoke(parsedBody: Json, context: C, toInvoke: IRTMethodId): F[Throwable, Option[Json]] } -class IRTServerMultiplexorImpl[F[+_, +_]: IO2, -C, -C2]( - list: Set[IRTWrappedService[F, C2]], - extender: ContextExtender[C, C2], -) extends IRTServerMultiplexor[F, C] { - val services: Map[IRTServiceId, IRTWrappedService[F, C2]] = list.map(s => s.serviceId -> s).toMap - - def doInvoke(parsedBody: Json, context: C, toInvoke: IRTMethodId): F[Throwable, Option[Json]] = { - (for { - service <- services.get(toInvoke.service) - method <- service.allMethods.get(toInvoke) - } yield method) match { - case Some(value) => - invoke(extender.extend(context, parsedBody, toInvoke), toInvoke, value, parsedBody).map(Some.apply) - case None => - F.pure(None) +object IRTServerMultiplexor { + class IRTServerMultiplexorImpl[F[+_, +_]: IO2, C]( + val services: Set[IRTWrappedService[F, C]] + ) extends IRTServerMultiplexor[F, C] { + private val serviceToWrapped: Map[IRTServiceId, IRTWrappedService[F, C]] = { + services.map(s => s.serviceId -> s).toMap } - } - @inline private[this] def invoke(context: C2, toInvoke: IRTMethodId, method: IRTMethodWrapper[F, C2], parsedBody: Json): F[Throwable, Json] = { - for { - decodeAction <- F.syncThrowable(method.marshaller.decodeRequest[F].apply(IRTJsonBody(toInvoke, parsedBody))) - safeDecoded <- decodeAction.sandbox.catchAll { - case Exit.Interruption(decodingFailure, _, trace) => - F.fail(new IRTDecodingException(s"$toInvoke: Failed to decode JSON ${parsedBody.toString()} $trace", Some(decodingFailure))) - case Exit.Termination(_, exceptions, trace) => - F.fail(new IRTDecodingException(s"$toInvoke: Failed to decode JSON ${parsedBody.toString()} $trace", exceptions.headOption)) - case Exit.Error(decodingFailure, trace) => - F.fail(new IRTDecodingException(s"$toInvoke: Failed to decode JSON ${parsedBody.toString()} $trace", Some(decodingFailure))) + def doInvoke(parsedBody: Json, context: C, toInvoke: IRTMethodId): F[Throwable, Option[Json]] = { + (for { + service <- serviceToWrapped.get(toInvoke.service) + method <- service.allMethods.get(toInvoke) + } yield method) match { + case Some(value) => + invoke(context, toInvoke, value, parsedBody).map(Some.apply) + case None => + F.pure(None) } - casted = safeDecoded.value.asInstanceOf[method.signature.Input] - resultAction <- F.syncThrowable(method.invoke(context, casted)) - safeResult <- resultAction - encoded <- F.syncThrowable(method.marshaller.encodeResponse.apply(IRTResBody(safeResult))) - } yield encoded + } + + @inline private[this] def invoke(context: C, toInvoke: IRTMethodId, method: IRTMethodWrapper[F, C], parsedBody: Json): F[Throwable, Json] = { + for { + decodeAction <- F.syncThrowable(method.marshaller.decodeRequest[F].apply(IRTJsonBody(toInvoke, parsedBody))) + safeDecoded <- decodeAction.sandbox.catchAll { + case Exit.Interruption(decodingFailure, _, trace) => + F.fail(new IRTDecodingException(s"$toInvoke: Failed to decode JSON ${parsedBody.toString()} $trace", Some(decodingFailure))) + case Exit.Termination(_, exceptions, trace) => + F.fail(new IRTDecodingException(s"$toInvoke: Failed to decode JSON ${parsedBody.toString()} $trace", exceptions.headOption)) + case Exit.Error(decodingFailure, trace) => + F.fail(new IRTDecodingException(s"$toInvoke: Failed to decode JSON ${parsedBody.toString()} $trace", Some(decodingFailure))) + } + casted = safeDecoded.value.asInstanceOf[method.signature.Input] + resultAction <- F.syncThrowable(method.invoke(context, casted)) + safeResult <- resultAction + encoded <- F.syncThrowable(method.marshaller.encodeResponse.apply(IRTResBody(safeResult))) + } yield encoded + } } } diff --git a/idealingua-v1/idealingua-v1-test-defs/src/main/scala/izumi/r2/idealingua/test/GreeterRunnerExample.scala b/idealingua-v1/idealingua-v1-test-defs/src/main/scala/izumi/r2/idealingua/test/GreeterRunnerExample.scala index 9fd6a2b6..26ba7cf5 100644 --- a/idealingua-v1/idealingua-v1-test-defs/src/main/scala/izumi/r2/idealingua/test/GreeterRunnerExample.scala +++ b/idealingua-v1/idealingua-v1-test-defs/src/main/scala/izumi/r2/idealingua/test/GreeterRunnerExample.scala @@ -1,14 +1,14 @@ package izumi.r2.idealingua.test import _root_.io.circe.syntax.* -import izumi.idealingua.runtime.rpc.{ContextExtender, IRTServerMultiplexorImpl} +import izumi.idealingua.runtime.rpc.IRTServerMultiplexor.IRTServerMultiplexorImpl import izumi.r2.idealingua.test.generated.GreeterServiceServerWrapped import zio.* object GreeterRunnerExample { def main(args: Array[String]): Unit = { val greeter = new GreeterServiceServerWrapped[IO, Unit](new impls.AbstractGreeterServer.Impl[IO, Unit]()) - val multiplexor = new IRTServerMultiplexorImpl[IO, Unit, Unit](Set(greeter), ContextExtender.id) + val multiplexor = new IRTServerMultiplexorImpl[IO, Unit](Set(greeter)) val req1 = new greeter.greet.signature.Input("John", "Doe") val json1 = req1.asJson diff --git a/project/plugins.sbt b/project/plugins.sbt index 578656af..a0d6b66c 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,17 +1,6 @@ // DO NOT EDIT THIS FILE // IT IS AUTOGENERATED BY `sbtgen.sc` SCRIPT // ALL CHANGES WILL BE LOST -// https://www.scala-js.org/ -addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.13.0") - -// https://github.com/portable-scala/sbt-crossproject -addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.1") - -// https://scalacenter.github.io/scalajs-bundler/ -addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % "0.21.1") - -// https://github.com/scala-js/jsdependencies -addSbtPlugin("org.scala-js" % "sbt-jsdependencies" % "1.0.2") //////////////////////////////////////////////////////////////////////////////// From 101289df64c6d78b2db91f47c1950623ceb28969 Mon Sep 17 00:00:00 2001 From: Alex Liubymov Date: Thu, 14 Dec 2023 17:25:47 +0200 Subject: [PATCH 02/24] contexts tests; wip ws listeners tests --- .../runtime/rpc/http4s/HttpServer.scala | 116 ++++---- .../runtime/rpc/http4s/IRTAuthenticator.scala | 7 +- .../rpc/http4s/IRTContextServices.scala | 68 ----- .../rpc/http4s/IRTContextServicesMuxer.scala | 15 -- .../rpc/http4s/IRTServicesContext.scala | 67 +++++ .../IRTServicesContextMultiplexor.scala | 46 ++++ .../clients/WsRpcDispatcherFactory.scala | 12 +- .../rpc/http4s/ws/WsClientRequests.scala | 52 ---- .../rpc/http4s/ws/WsClientSession.scala | 60 ++++- .../rpc/http4s/ws/WsContextExtractor.scala | 11 + .../runtime/rpc/http4s/ws/WsRpcHandler.scala | 54 ++-- .../rpc/http4s/ws/WsSessionListener.scala | 16 +- .../rpc/http4s/ws/WsSessionsContext.scala | 80 ++++++ .../rpc/http4s/ws/WsSessionsStorage.scala | 13 +- .../rpc/http4s/Http4sTransportTest.scala | 255 ++++++++++++++---- .../fixtures/DummyAuthorizingDispatcher.scala | 34 --- .../http4s/fixtures/DummyRequestContext.scala | 7 - .../rpc/http4s/fixtures/DummyServices.scala | 35 --- .../http4s/fixtures/Http4sTestContext.scala | 60 ----- .../runtime/rpc/http4s/fixtures/RT.scala | 48 ---- .../rpc/http4s/fixtures/TestContext.scala | 15 ++ .../rpc/http4s/fixtures/TestDispatcher.scala | 18 -- .../rpc/http4s/fixtures/TestServices.scala | 92 +++++++ .../defs/PrivateTestServiceServer.scala | 110 ++++++++ .../defs/ProtectedTestServiceServer.scala | 110 ++++++++ .../test/impls/AbstractGreeterServer.scala | 26 +- 26 files changed, 892 insertions(+), 535 deletions(-) delete mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala delete mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServicesMuxer.scala create mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTServicesContext.scala create mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTServicesContextMultiplexor.scala delete mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientRequests.scala create mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextExtractor.scala create mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionsContext.scala delete mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/DummyAuthorizingDispatcher.scala delete mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/DummyRequestContext.scala delete mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/DummyServices.scala delete mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/Http4sTestContext.scala delete mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/RT.scala create mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestContext.scala delete mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestDispatcher.scala create mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala create mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/defs/PrivateTestServiceServer.scala create mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/defs/ProtectedTestServiceServer.scala diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala index ea4e5879..5ad6e42f 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala @@ -8,15 +8,14 @@ import io.circe import io.circe.Printer import io.circe.syntax.EncoderOps import izumi.functional.bio.Exit.{Error, Interruption, Success, Termination} -import izumi.functional.bio.{F, IO2, Primitives2, Temporal2, UnsafeRun2} +import izumi.functional.bio.{Exit, F, IO2, Primitives2, Temporal2, UnsafeRun2} import izumi.fundamentals.platform.language.Quirks import izumi.fundamentals.platform.time.IzTime import izumi.idealingua.runtime.rpc.* import izumi.idealingua.runtime.rpc.http4s.HttpServer.{ServerWsRpcHandler, WsResponseMarker} import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext -import izumi.idealingua.runtime.rpc.http4s.IRTContextServices.AuthInvokeResult +import izumi.idealingua.runtime.rpc.http4s.IRTServicesContext.{InvokeMethodFailure, InvokeMethodResult} import izumi.idealingua.runtime.rpc.http4s.ws.* -import izumi.idealingua.runtime.rpc.http4s.ws.WsClientRequests.WsClientRequestsImpl import izumi.idealingua.runtime.rpc.http4s.ws.WsClientSession.WsClientSessionImpl import logstage.LogIO2 import org.http4s.* @@ -32,7 +31,7 @@ import java.util.concurrent.RejectedExecutionException import scala.concurrent.duration.DurationInt class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2]( - val contextServicesMuxer: IRTContextServicesMuxer[F], + val muxer: IRTServicesContextMultiplexor[F], val wsSessionsStorage: WsSessionsStorage[F], dsl: Http4sDsl[F[Throwable, _]], logger: LogIO2[F], @@ -57,19 +56,18 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2]( protected def handleWsClose(session: WsClientSession[F]): F[Throwable, Unit] = { logger.debug(s"WS Session: Websocket client disconnected ${session.sessionId}.") *> - session.finish() *> - session.requests.finish() + session.finish() } - protected def globalWsListener[Ctx]: WsSessionListener[F, Ctx] = new WsSessionListener[F, Ctx] { - override def onSessionOpened(sessionId: WsSessionId, context: Option[Ctx]): F[Throwable, Unit] = { - logger.debug(s"WS Session: $sessionId opened $context.") + protected def globalWsListener[Ctx, WsCtx]: WsSessionListener[F, Ctx, WsCtx] = new WsSessionListener[F, Ctx, WsCtx] { + override def onSessionOpened(sessionId: WsSessionId, reqCtx: Ctx, wsCtx: WsCtx): F[Throwable, Unit] = { + logger.debug(s"WS Session: $sessionId opened $wsCtx on $reqCtx.") } - override def onSessionAuthUpdate(sessionId: WsSessionId, context: Option[Ctx]): F[Throwable, Unit] = { - logger.debug(s"WS Session: $sessionId updated to $context.") + override def onSessionUpdated(sessionId: WsSessionId, reqCtx: Ctx, prevStx: WsCtx, newCtx: WsCtx): F[Throwable, Unit] = { + logger.debug(s"WS Session: $sessionId updated $newCtx from $prevStx on $reqCtx.") } - override def onSessionClosed(sessionId: WsSessionId, context: Option[Ctx]): F[Throwable, Unit] = { - logger.debug(s"WS Session: $sessionId closed $context.") + override def onSessionClosed(sessionId: WsSessionId, wsCtx: WsCtx): F[Throwable, Unit] = { + logger.debug(s"WS Session: $sessionId closed $wsCtx .") } } @@ -84,12 +82,12 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2]( .evalMap(_ => logger.debug("WS Server: Sending ping frame.").as(WebSocketFrame.Ping())) } for { - outQueue <- Queue.unbounded[F[Throwable, _], WebSocketFrame] - authContext <- F.syncThrowable(extractAuthContext(request)) - clientRequests = new WsClientRequestsImpl(outQueue, printer, logger) - clientSession = new WsClientSessionImpl(authContext, contextServicesMuxer, clientRequests, wsSessionsStorage) - _ <- clientSession.start() - outStream = Stream.fromQueueUnterminated(outQueue).merge(pingStream) + outQueue <- Queue.unbounded[F[Throwable, _], WebSocketFrame] + authContext <- F.syncThrowable(extractAuthContext(request)) + clientSession = new WsClientSessionImpl(outQueue, authContext, muxer, wsSessionsStorage, logger, printer) + _ <- clientSession.start() + + outStream = Stream.fromQueueUnterminated(outQueue).merge(pingStream) inStream = { (inputStream: Stream[F[Throwable, _], WebSocketFrame]) => inputStream.evalMap { @@ -123,7 +121,7 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2]( } protected def wsHandler(clientSession: WsClientSession[F]): WsRpcHandler[F] = { - new ServerWsRpcHandler(clientSession, contextServicesMuxer, logger) + new ServerWsRpcHandler(clientSession, muxer, logger) } protected def onWsHeartbeat(requestTime: ZonedDateTime): F[Throwable, Unit] = { @@ -137,72 +135,66 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2]( )(body: String ): F[Throwable, Response[F[Throwable, _]]] = { val methodId = IRTMethodId(IRTServiceId(serviceName), IRTMethodName(methodName)) - contextServicesMuxer.getContextService(methodId.service) match { - case Some(contextServiceGroup) => - for { - authContext <- F.syncThrowable(extractAuthContext(request)) - parsedBody <- F.fromEither(io.circe.parser.parse(body)) - invokeRes <- contextServiceGroup.doAuthInvoke(methodId, authContext, parsedBody) - res <- handleHttpResult(request, methodId, invokeRes) - } yield res - - case None => - logger.warn(s"No context service for $methodId") *> - NotFound() - } + (for { + authContext <- F.syncThrowable(extractAuthContext(request)) + parsedBody <- F.fromEither(io.circe.parser.parse(body)) + invokeRes <- muxer.invokeMethodWithAuth(methodId)(authContext, parsedBody) + } yield invokeRes).sandboxExit.flatMap(handleHttpResult(request, methodId)) } protected def handleHttpResult( request: Request[F[Throwable, _]], method: IRTMethodId, - result: AuthInvokeResult[Any], + )(result: Exit[Throwable, InvokeMethodResult] ): F[Throwable, Response[F[Throwable, _]]] = { result match { - case AuthInvokeResult.Success(_, Success(Some(value))) => - Ok(printer.print(value)) + case Success(InvokeMethodResult(_, res)) => + Ok(printer.print(res)) + + case Error(err: InvokeMethodFailure.ServiceNotFound, _) => + logger.warn(s"No service handler for $method: $err") *> NotFound() - case AuthInvokeResult.Success(context, Success(None)) => - logger.warn(s"${context -> null}: No service handler for $method") *> - NotFound() + case Error(err: InvokeMethodFailure.MethodNotFound, _) => + logger.warn(s"No method handler for $method: $err") *> NotFound() - case AuthInvokeResult.Success(context, Error(error: circe.Error, trace)) => - logger.info(s"${context -> null}: Parsing failure while handling $method: $error $trace") *> + case Error(err: InvokeMethodFailure.AuthFailed, _) => + logger.warn(s"Auth failed for $method: $err") *> F.pure(Response(Status.Unauthorized)) + + case Error(error: circe.Error, trace) => + logger.info(s"Parsing failure while handling $method: $error $trace") *> BadRequest() - case AuthInvokeResult.Success(context, Error(error: IRTDecodingException, trace)) => - logger.info(s"${context -> null}: Parsing failure while handling $method: $error $trace") *> + case Error(error: IRTDecodingException, trace) => + logger.info(s"Parsing failure while handling $method: $error $trace") *> BadRequest() - case AuthInvokeResult.Success(context, Error(error: IRTLimitReachedException, trace)) => - logger.debug(s"${context -> null}: Request failed because of request limit reached $method: $error $trace") *> + case Error(error: IRTLimitReachedException, trace) => + logger.debug(s"$Request failed because of request limit reached $method: $error $trace") *> TooManyRequests() - case AuthInvokeResult.Success(context, Error(error: IRTUnathorizedRequestContextException, trace)) => - logger.debug(s"${context -> null}: Request failed because of unexpected request context reached $method: $error $trace") *> + case Error(error: IRTUnathorizedRequestContextException, trace) => + logger.debug(s"$Request failed because of unexpected request context reached $method: $error $trace") *> F.pure(Response(status = Status.Unauthorized)) - case AuthInvokeResult.Success(context, Error(error, trace)) => - logger.info(s"${context -> null}: Unexpected failure while handling $method: $error $trace") *> + case Error(error, trace) => + logger.info(s"Unexpected failure while handling $method: $error $trace") *> InternalServerError() - case AuthInvokeResult.Success(context, Termination(_, (cause: IRTHttpFailureException) :: _, trace)) => - logger.debug(s"${context -> null}: Request rejected, $method, $request, $cause, $trace") *> + case Termination(_, (cause: IRTHttpFailureException) :: _, trace) => + logger.debug(s"Request rejected, $method, $request, $cause, $trace") *> F.pure(Response(status = cause.status)) - case AuthInvokeResult.Success(context, Termination(_, (cause: RejectedExecutionException) :: _, trace)) => - logger.warn(s"${context -> null}: Not enough capacity to handle $method: $cause $trace") *> + case Termination(_, (cause: RejectedExecutionException) :: _, trace) => + logger.warn(s"Not enough capacity to handle $method: $cause $trace") *> TooManyRequests() - case AuthInvokeResult.Success(context, Termination(cause, _, trace)) => - logger.error(s"${context -> null}: Execution failed, termination, $method, $request, $cause, $trace") *> + case Termination(cause, _, trace) => + logger.error(s"Execution failed, termination, $method, $request, $cause, $trace") *> InternalServerError() - case AuthInvokeResult.Success(context, Interruption(cause, _, trace)) => - logger.info(s"${context -> null}: Unexpected interruption while handling $method: $cause $trace") *> + case Interruption(cause, _, trace) => + logger.info(s"Unexpected interruption while handling $method: $cause $trace") *> InternalServerError() - - case AuthInvokeResult.Failed => - F.pure(Response(status = Status.Unauthorized)) } } @@ -243,9 +235,9 @@ object HttpServer { case object WsResponseMarker class ServerWsRpcHandler[F[+_, +_]: IO2, RequestCtx, ClientId]( clientSession: WsClientSession[F], - contextServicesMuxer: IRTContextServicesMuxer[F], + contextServicesMuxer: IRTServicesContextMultiplexor[F], logger: LogIO2[F], - ) extends WsRpcHandler[F](contextServicesMuxer, clientSession.requests, logger) { + ) extends WsRpcHandler[F](contextServicesMuxer, clientSession, logger) { override protected def getAuthContext: AuthContext = clientSession.getAuthContext override def handlePacket(packet: RpcPacket): F[Throwable, Unit] = { diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTAuthenticator.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTAuthenticator.scala index ada8d4f7..7e825a46 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTAuthenticator.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTAuthenticator.scala @@ -1,18 +1,19 @@ package izumi.idealingua.runtime.rpc.http4s +import io.circe.Json import izumi.functional.bio.{Applicative2, F} import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext import org.http4s.Headers import java.net.InetAddress -abstract class IRTAuthenticator[F[+_, +_], RequestCtx] { - def authenticate(request: AuthContext): F[Throwable, Option[RequestCtx]] +abstract class IRTAuthenticator[F[_, _], RequestCtx] { + def authenticate(authContext: AuthContext, body: Option[Json]): F[Nothing, Option[RequestCtx]] } object IRTAuthenticator { def unit[F[+_, +_]: Applicative2]: IRTAuthenticator[F, Unit] = new IRTAuthenticator[F, Unit] { - override def authenticate(request: AuthContext): F[Throwable, Option[Unit]] = F.pure(Some(())) + override def authenticate(authContext: AuthContext, body: Option[Json]): F[Nothing, Option[Unit]] = F.pure(Some(())) } final case class AuthContext(headers: Headers, networkAddress: Option[InetAddress]) } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala deleted file mode 100644 index 9bafcdf0..00000000 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala +++ /dev/null @@ -1,68 +0,0 @@ -package izumi.idealingua.runtime.rpc.http4s - -import io.circe.Json -import izumi.functional.bio.{Exit, F, Panic2, Temporal2} -import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext -import izumi.idealingua.runtime.rpc.http4s.IRTContextServices.AuthInvokeResult -import izumi.idealingua.runtime.rpc.http4s.ws.* -import izumi.idealingua.runtime.rpc.{IRTMethodId, IRTServerMultiplexor} - -final class IRTContextServices[F[+_, +_], RequestCtx]( - val serverMuxer: IRTServerMultiplexor[F, RequestCtx], - val authenticator: IRTAuthenticator[F, RequestCtx], - val wsSessionListeners: Set[WsSessionListener[F, RequestCtx]], -) { - def doAuthInvoke( - methodId: IRTMethodId, - authContext: AuthContext, - body: Json, - )(implicit E: Panic2[F] - ): F[Throwable, AuthInvokeResult[RequestCtx]] = { - authenticator.authenticate(authContext).attempt.flatMap { - case Right(Some(context)) => - serverMuxer.doInvoke(body, context, methodId).sandboxExit.map(AuthInvokeResult.Success(context, _)) - case _ => - F.pure(AuthInvokeResult.Failed) - } - } - - def onWsSessionUpdate( - wsSessionId: WsSessionId, - authContext: AuthContext, - )(implicit E: Panic2[F] - ): F[Throwable, Unit] = { - authenticator.authenticate(authContext).flatMap { - maybeRequestContext => - F.traverse_(wsSessionListeners)(_.onSessionClosed(wsSessionId, maybeRequestContext)) - } - } - def onWsSessionOpened( - wsSessionId: WsSessionId, - authContext: AuthContext, - )(implicit E: Panic2[F] - ): F[Throwable, Unit] = { - authenticator.authenticate(authContext).flatMap { - maybeRequestContext => - F.traverse_(wsSessionListeners)(_.onSessionOpened(wsSessionId, maybeRequestContext)) - } - } - - def onWsSessionClosed( - wsSessionId: WsSessionId, - authContext: AuthContext, - )(implicit E: Panic2[F] - ): F[Throwable, Unit] = { - authenticator.authenticate(authContext).flatMap { - maybeRequestContext => - F.traverse_(wsSessionListeners)(_.onSessionClosed(wsSessionId, maybeRequestContext)) - } - } -} - -object IRTContextServices { - sealed trait AuthInvokeResult[+Ctx] - object AuthInvokeResult { - final case class Success[Ctx](context: Ctx, invocationResult: Exit[Throwable, Option[Json]]) extends AuthInvokeResult[Ctx] - case object Failed extends AuthInvokeResult[Nothing] - } -} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServicesMuxer.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServicesMuxer.scala deleted file mode 100644 index 8cf242c6..00000000 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServicesMuxer.scala +++ /dev/null @@ -1,15 +0,0 @@ -package izumi.idealingua.runtime.rpc.http4s - -import izumi.idealingua.runtime.rpc.IRTServiceId - -final class IRTContextServicesMuxer[F[+_, +_]]( - val contextServices: Set[IRTContextServices[F, ?]] -) { - private[this] val serviceToContext: Map[IRTServiceId, IRTContextServices[F, ?]] = { - contextServices.flatMap(m => m.serverMuxer.services.map(s => s.serviceId -> m)).toMap - } - - def getContextService(id: IRTServiceId): Option[IRTContextServices[F, ?]] = { - serviceToContext.get(id) - } -} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTServicesContext.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTServicesContext.scala new file mode 100644 index 00000000..7ec9fd3f --- /dev/null +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTServicesContext.scala @@ -0,0 +1,67 @@ +package izumi.idealingua.runtime.rpc.http4s + +import io.circe.Json +import izumi.functional.bio.{Exit, F, IO2} +import izumi.idealingua.runtime.rpc.* +import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext +import izumi.idealingua.runtime.rpc.http4s.IRTServicesContext.InvokeMethodResult +import izumi.idealingua.runtime.rpc.http4s.ws.* + +trait IRTServicesContext[F[_, _], RequestCtx, WsCtx] { + val services: Set[IRTWrappedService[F, RequestCtx]] + def updateWsSession(wsSessionId: WsSessionId, authContext: Option[AuthContext]): F[Throwable, Unit] + def invokeMethodWithAuth(method: IRTMethodId)(authContext: AuthContext, body: Json): F[Throwable, InvokeMethodResult] +} + +object IRTServicesContext { + final case class InvokeMethodResult(context: Any, res: Json) + + abstract class InvokeMethodFailure(message: String) extends RuntimeException(s"Method invokation failed: $message.") + object InvokeMethodFailure { + final case class ServiceNotFound(serviceId: IRTServiceId) extends InvokeMethodFailure(s"Service $serviceId not found .") + final case class MethodNotFound(methodId: IRTMethodId) extends InvokeMethodFailure(s"Method $methodId not found .") + final case class AuthFailed(context: AuthContext) extends InvokeMethodFailure(s"Authorization with $context failed.") + } + + final class IRTServicesContextImpl[F[+_, +_]: IO2, RequestCtx, WsCtx]( + val services: Set[IRTWrappedService[F, RequestCtx]], + val authenticator: IRTAuthenticator[F, RequestCtx], + val wsContext: WsSessionsContext[F, RequestCtx, WsCtx], + ) extends IRTServicesContext[F, RequestCtx, WsCtx] { + def methods: Map[IRTMethodId, IRTMethodWrapper[F, RequestCtx]] = services.flatMap(_.allMethods).toMap + + override def updateWsSession(wsSessionId: WsSessionId, authContext: Option[AuthContext]): F[Throwable, Unit] = { + F.traverse(authContext)(authenticator.authenticate(_, None)).map(_.flatten).sandboxExit.flatMap { + case Exit.Success(ctx) => wsContext.updateSession(wsSessionId, ctx) + case _ => wsContext.updateSession(wsSessionId, None) + } + } + + override def invokeMethodWithAuth(method: IRTMethodId)(authContext: AuthContext, body: Json): F[Throwable, InvokeMethodResult] = { + for { + wrappedMethod <- F.fromOption(InvokeMethodFailure.MethodNotFound(method))(methods.get(method)) + requestCtx <- authenticator.authenticate(authContext, Some(body)).fromOption(InvokeMethodFailure.AuthFailed(authContext)) + res <- invoke(wrappedMethod)(requestCtx, body) + } yield InvokeMethodResult(requestCtx, res) + } + + @inline private[this] def invoke[C](method: IRTMethodWrapper[F, C])(context: C, parsedBody: Json): F[Throwable, Json] = { + val methodId = method.signature.id + for { + decodeAction <- F.syncThrowable(method.marshaller.decodeRequest[F].apply(IRTJsonBody(methodId, parsedBody))) + safeDecoded <- decodeAction.sandbox.catchAll { + case Exit.Interruption(decodingFailure, _, trace) => + F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON ${parsedBody.toString()} $trace", Some(decodingFailure))) + case Exit.Termination(_, exceptions, trace) => + F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON ${parsedBody.toString()} $trace", exceptions.headOption)) + case Exit.Error(decodingFailure, trace) => + F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON ${parsedBody.toString()} $trace", Some(decodingFailure))) + } + casted = safeDecoded.value.asInstanceOf[method.signature.Input] + resultAction <- F.syncThrowable(method.invoke(context, casted)) + safeResult <- resultAction + encoded <- F.syncThrowable(method.marshaller.encodeResponse.apply(IRTResBody(safeResult))) + } yield encoded + } + } +} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTServicesContextMultiplexor.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTServicesContextMultiplexor.scala new file mode 100644 index 00000000..8eb8e562 --- /dev/null +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTServicesContextMultiplexor.scala @@ -0,0 +1,46 @@ +package izumi.idealingua.runtime.rpc.http4s + +import io.circe.Json +import izumi.functional.bio.{F, IO2} +import izumi.idealingua.runtime.rpc.* +import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext +import izumi.idealingua.runtime.rpc.http4s.IRTServicesContext.{IRTServicesContextImpl, InvokeMethodFailure, InvokeMethodResult} +import izumi.idealingua.runtime.rpc.http4s.ws.{WsSessionId, WsSessionsContext} + +trait IRTServicesContextMultiplexor[F[+_, +_]] { + def updateWsSession(wsSessionId: WsSessionId, authContext: Option[AuthContext]): F[Throwable, Unit] + def invokeMethodWithAuth(method: IRTMethodId)(authContext: AuthContext, body: Json): F[Throwable, InvokeMethodResult] +} + +object IRTServicesContextMultiplexor { + class Single[F[+_, +_]: IO2, RequestCtx]( + val services: Set[IRTWrappedService[F, RequestCtx]], + val authenticator: IRTAuthenticator[F, RequestCtx], + ) extends IRTServicesContextMultiplexor[F] { + private val inner: IRTServicesContext[F, RequestCtx, Unit] = new IRTServicesContextImpl(services, authenticator, WsSessionsContext.empty) + override def updateWsSession(wsSessionId: WsSessionId, authContext: Option[AuthContext]): F[Throwable, Unit] = { + F.unit + } + override def invokeMethodWithAuth(method: IRTMethodId)(authContext: AuthContext, body: Json): F[Throwable, InvokeMethodResult] = { + inner.invokeMethodWithAuth(method)(authContext, body) + } + } + + class MultiContext[F[+_, +_]: IO2]( + servicesContexts: Set[IRTServicesContext[F, ?, ?]] + ) extends IRTServicesContextMultiplexor[F] { + + private val services: Map[IRTServiceId, IRTServicesContext[F, ?, ?]] = { + servicesContexts.flatMap(c => c.services.map(_.serviceId -> c)).toMap + } + + override def updateWsSession(wsSessionId: WsSessionId, authContext: Option[AuthContext]): F[Throwable, Unit] = { + F.traverse_(servicesContexts)(_.updateWsSession(wsSessionId, authContext)) + } + + def invokeMethodWithAuth(method: IRTMethodId)(authContext: AuthContext, body: Json): F[Throwable, InvokeMethodResult] = { + F.fromOption(InvokeMethodFailure.ServiceNotFound(method.service))(services.get(method.service)) + .flatMap(_.invokeMethodWithAuth(method)(authContext, body)) + } + } +} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala index 6effa38a..b1fd7043 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala @@ -7,7 +7,7 @@ import izumi.functional.lifecycle.Lifecycle import izumi.fundamentals.platform.language.Quirks.Discarder import izumi.idealingua.runtime.rpc.* import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext -import izumi.idealingua.runtime.rpc.http4s.IRTContextServicesMuxer +import izumi.idealingua.runtime.rpc.http4s.IRTServicesContextMultiplexor import izumi.idealingua.runtime.rpc.http4s.clients.WsRpcDispatcher.IRTDispatcherWs import izumi.idealingua.runtime.rpc.http4s.clients.WsRpcDispatcherFactory.{ClientWsRpcHandler, WsRpcClientConnection, fromNettyFuture} import izumi.idealingua.runtime.rpc.http4s.ws.{RawResponse, WsRequestState, WsRpcHandler} @@ -31,7 +31,7 @@ class WsRpcDispatcherFactory[F[+_, +_]: Async2: Temporal2: Primitives2: UnsafeRu def connect( uri: Uri, - muxer: IRTContextServicesMuxer[F], + muxer: IRTServicesContextMultiplexor[F], ): Lifecycle[F[Throwable, _], WsRpcClientConnection[F]] = { for { client <- WsRpcDispatcherFactory.asyncHttpClient[F] @@ -50,7 +50,7 @@ class WsRpcDispatcherFactory[F[+_, +_]: Async2: Temporal2: Primitives2: UnsafeRu def dispatcher[ServerContext]( uri: Uri, - muxer: IRTContextServicesMuxer[F], + muxer: IRTServicesContextMultiplexor[F], tweakRequest: RpcPacket => RpcPacket = identity, timeout: FiniteDuration = 30.seconds, ): Lifecycle[F[Throwable, _], IRTDispatcherWs[F]] = { @@ -64,7 +64,7 @@ class WsRpcDispatcherFactory[F[+_, +_]: Async2: Temporal2: Primitives2: UnsafeRu } protected def wsHandler[ServerContext]( - muxer: IRTContextServicesMuxer[F], + muxer: IRTServicesContextMultiplexor[F], requestState: WsRequestState[F], logger: LogIO2[F], ): WsRpcHandler[F] = { @@ -72,7 +72,7 @@ class WsRpcDispatcherFactory[F[+_, +_]: Async2: Temporal2: Primitives2: UnsafeRu } protected def createListener( - muxer: IRTContextServicesMuxer[F], + muxer: IRTServicesContextMultiplexor[F], requestState: WsRequestState[F], logger: LogIO2[F], ): WebSocketListener = new WebSocketListener() { @@ -153,7 +153,7 @@ object WsRpcDispatcherFactory { } class ClientWsRpcHandler[F[+_, +_]: IO2]( - muxer: IRTContextServicesMuxer[F], + muxer: IRTServicesContextMultiplexor[F], requestState: WsRequestState[F], logger: LogIO2[F], ) extends WsRpcHandler[F](muxer, requestState, logger) { diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientRequests.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientRequests.scala deleted file mode 100644 index 48773f17..00000000 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientRequests.scala +++ /dev/null @@ -1,52 +0,0 @@ -package izumi.idealingua.runtime.rpc.http4s.ws - -import cats.effect.std.Queue -import io.circe.syntax.* -import io.circe.{Json, Printer} -import izumi.functional.bio.{F, IO2, Primitives2, Temporal2} -import izumi.idealingua.runtime.rpc.* -import izumi.idealingua.runtime.rpc.http4s.ws.WsRpcHandler.WsResponder -import logstage.LogIO2 -import org.http4s.websocket.WebSocketFrame -import org.http4s.websocket.WebSocketFrame.Text - -import scala.concurrent.duration.* - -trait WsClientRequests[F[+_, +_]] extends WsResponder[F] { - def requestAndAwaitResponse(method: IRTMethodId, data: Json, timeout: FiniteDuration): F[Throwable, Option[RawResponse]] - def finish(): F[Throwable, Unit] -} - -object WsClientRequests { - class WsClientRequestsImpl[F[+_, +_]: IO2: Temporal2: Primitives2]( - val outQueue: Queue[F[Throwable, _], WebSocketFrame], - printer: Printer, - logger: LogIO2[F], - ) extends WsClientRequests[F] { - private val requestState: WsRequestState[F] = WsRequestState.create[F] - def requestAndAwaitResponse(method: IRTMethodId, data: Json, timeout: FiniteDuration): F[Throwable, Option[RawResponse]] = { - val id = RpcPacketId.random() - val request = RpcPacket.buzzerRequest(id, method, data) - for { - _ <- logger.debug(s"WS Session: enqueue $request with $id to request state & send queue.") - response <- requestState.requestAndAwait(id, Some(method), timeout) { - outQueue.offer(Text(printer.print(request.asJson))) - } - _ <- logger.debug(s"WS Session: $method, ${id -> "id"}: cleaning request state.") - } yield response - } - - override def responseWith(id: RpcPacketId, response: RawResponse): F[Throwable, Unit] = { - requestState.responseWith(id, response) - } - - override def responseWithData(id: RpcPacketId, data: Json): F[Throwable, Unit] = { - requestState.responseWithData(id, data) - } - - override def finish(): F[Throwable, Unit] = { - F.fromEither(WebSocketFrame.Close(1000)).flatMap(outQueue.offer(_)) *> - requestState.clear() - } - } -} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala index 460370e3..29d61d06 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala @@ -1,22 +1,29 @@ package izumi.idealingua.runtime.rpc.http4s.ws -import izumi.functional.bio.{F, IO2, Temporal2} +import cats.effect.std.Queue +import io.circe.syntax.EncoderOps +import io.circe.{Json, Printer} +import izumi.functional.bio.{F, IO2, Primitives2, Temporal2} import izumi.fundamentals.platform.time.IzTime import izumi.fundamentals.platform.uuid.UUIDGen import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext -import izumi.idealingua.runtime.rpc.http4s.IRTContextServicesMuxer +import izumi.idealingua.runtime.rpc.http4s.IRTServicesContextMultiplexor +import izumi.idealingua.runtime.rpc.http4s.ws.WsRpcHandler.WsResponder +import izumi.idealingua.runtime.rpc.{IRTMethodId, RpcPacket, RpcPacketId} +import logstage.LogIO2 +import org.http4s.websocket.WebSocketFrame +import org.http4s.websocket.WebSocketFrame.Text import java.time.ZonedDateTime import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicReference import scala.concurrent.duration.* -trait WsClientSession[F[+_, +_]] { +trait WsClientSession[F[+_, +_]] extends WsResponder[F] { def sessionId: WsSessionId def getAuthContext: AuthContext - def requests: WsClientRequests[F] - def servicesMuxer: IRTContextServicesMuxer[F] + def requestAndAwaitResponse(method: IRTMethodId, data: Json, timeout: FiniteDuration): F[Throwable, Option[RawResponse]] def updateAuthContext(newContext: AuthContext): F[Throwable, Unit] @@ -26,14 +33,17 @@ trait WsClientSession[F[+_, +_]] { object WsClientSession { - class WsClientSessionImpl[F[+_, +_]: IO2]( - val initialContext: AuthContext, - val servicesMuxer: IRTContextServicesMuxer[F], - val requests: WsClientRequests[F], + class WsClientSessionImpl[F[+_, +_]: IO2: Temporal2: Primitives2]( + outQueue: Queue[F[Throwable, _], WebSocketFrame], + initialContext: AuthContext, + muxer: IRTServicesContextMultiplexor[F], wsSessionStorage: WsSessionsStorage[F], + logger: LogIO2[F], + printer: Printer, ) extends WsClientSession[F] { - private val authContextRef = new AtomicReference[AuthContext](initialContext) - private val openingTime: ZonedDateTime = IzTime.utcNow + private val authContextRef = new AtomicReference[AuthContext](initialContext) + private val openingTime: ZonedDateTime = IzTime.utcNow + private val requestState: WsRequestState[F] = WsRequestState.create[F] override val sessionId: WsSessionId = WsSessionId(UUIDGen.getTimeUUID()) @@ -52,19 +62,41 @@ object WsClientSession { } (oldContext, updatedContext) = contexts _ <- F.when(oldContext != updatedContext) { - F.traverse_(servicesMuxer.contextServices)(_.onWsSessionUpdate(sessionId, updatedContext)) + muxer.updateWsSession(sessionId, Some(updatedContext)) } } yield () } + def requestAndAwaitResponse(method: IRTMethodId, data: Json, timeout: FiniteDuration): F[Throwable, Option[RawResponse]] = { + val id = RpcPacketId.random() + val request = RpcPacket.buzzerRequest(id, method, data) + for { + _ <- logger.debug(s"WS Session: enqueue $request with $id to request state & send queue.") + response <- requestState.requestAndAwait(id, Some(method), timeout) { + outQueue.offer(Text(printer.print(request.asJson))) + } + _ <- logger.debug(s"WS Session: $method, ${id -> "id"}: cleaning request state.") + } yield response + } + + override def responseWith(id: RpcPacketId, response: RawResponse): F[Throwable, Unit] = { + requestState.responseWith(id, response) + } + + override def responseWithData(id: RpcPacketId, data: Json): F[Throwable, Unit] = { + requestState.responseWithData(id, data) + } + override def finish(): F[Throwable, Unit] = { + F.fromEither(WebSocketFrame.Close(1000)).flatMap(outQueue.offer(_)) *> + requestState.clear() *> wsSessionStorage.deleteSession(sessionId) *> - F.traverse_(servicesMuxer.contextServices)(_.onWsSessionClosed(sessionId, getAuthContext)) + muxer.updateWsSession(sessionId, None) } override def start(): F[Throwable, Unit] = { wsSessionStorage.addSession(this) *> - F.traverse_(servicesMuxer.contextServices)(_.onWsSessionOpened(sessionId, getAuthContext)) + muxer.updateWsSession(sessionId, Some(getAuthContext)) } override def toString: String = s"[$sessionId, ${duration().toSeconds}s]" diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextExtractor.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextExtractor.scala new file mode 100644 index 00000000..8cb91fcd --- /dev/null +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextExtractor.scala @@ -0,0 +1,11 @@ +package izumi.idealingua.runtime.rpc.http4s.ws + +trait WsContextExtractor[RequestCtx, WsCtx] { + def extract(ctx: RequestCtx): Option[WsCtx] +} + +object WsContextExtractor { + def id[Ctx]: WsContextExtractor[Ctx, Ctx] = new WsContextExtractor[Ctx, Ctx] { + override def extract(ctx: Ctx): Option[Ctx] = Some(ctx) + } +} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala index 5940354e..d182af21 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala @@ -6,13 +6,13 @@ import izumi.functional.bio.{Exit, F, IO2} import izumi.fundamentals.platform.language.Quirks.Discarder import izumi.idealingua.runtime.rpc.* import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext -import izumi.idealingua.runtime.rpc.http4s.IRTContextServices.AuthInvokeResult -import izumi.idealingua.runtime.rpc.http4s.IRTContextServicesMuxer +import izumi.idealingua.runtime.rpc.http4s.IRTServicesContext.{InvokeMethodFailure, InvokeMethodResult} +import izumi.idealingua.runtime.rpc.http4s.IRTServicesContextMultiplexor import izumi.idealingua.runtime.rpc.http4s.ws.WsRpcHandler.WsResponder import logstage.LogIO2 abstract class WsRpcHandler[F[+_, +_]: IO2]( - contextServices: IRTContextServicesMuxer[F], + muxer: IRTServicesContextMultiplexor[F], responder: WsResponder[F], logger: LogIO2[F], ) { @@ -90,31 +90,29 @@ abstract class WsRpcHandler[F[+_, +_]: IO2]( )(onSuccess: Json => RpcPacket, onFail: String => RpcPacket, ): F[Throwable, Option[RpcPacket]] = { - contextServices.getContextService(methodId.service) match { - case Some(contextService) => - contextService.doAuthInvoke(methodId, getAuthContext, data).flatMap { - case AuthInvokeResult.Success(_, Success(Some(res))) => - F.pure(Some(onSuccess(res))) - - case AuthInvokeResult.Success(context, Success(None)) => - logger.error(s"WS request errored for ${context -> null}: No rpc handler for $methodId.").as(Some(onFail("No rpc handler."))) - - case AuthInvokeResult.Success(context, Exit.Termination(exception, allExceptions, trace)) => - logger.error(s"WS request terminated for ${context -> null}: $exception, $allExceptions, $trace").as(Some(onFail(exception.getMessage))) - - case AuthInvokeResult.Success(context, Exit.Error(exception, trace)) => - logger.error(s"WS request failed for ${context -> null}: $exception $trace").as(Some(onFail(exception.getMessage))) - - case AuthInvokeResult.Success(context, Exit.Interruption(exception, allExceptions, trace)) => - logger.error(s"WS request interrupted for ${context -> null}: $exception $allExceptions $trace").as(Some(onFail(exception.getMessage))) - - case AuthInvokeResult.Failed => - F.pure(Some(onFail("Unauthorized."))) - } - case None => - val message = "Missing WS client context session." - logger.error(s"WS request failed, $message").as(Some(onFail(message))) - } + muxer + .invokeMethodWithAuth(methodId)(getAuthContext, data).sandboxExit.flatMap { + case Success(InvokeMethodResult(_, res)) => + F.pure(Some(onSuccess(res))) + + case Exit.Error(_: InvokeMethodFailure.ServiceNotFound, _) => + logger.error(s"WS request errored: No service handler for $methodId.").as(Some(onFail("Service not found."))) + + case Exit.Error(_: InvokeMethodFailure.MethodNotFound, _) => + logger.error(s"WS request errored: No method handler for $methodId.").as(Some(onFail("Method not found."))) + + case Exit.Error(err: InvokeMethodFailure.AuthFailed, _) => + logger.warn(s"WS request errored: unauthorized - ${err.getMessage -> "message"}.").as(Some(onFail("Unauthorized."))) + + case Exit.Termination(exception, allExceptions, trace) => + logger.error(s"WS request terminated: $exception, $allExceptions, $trace").as(Some(onFail(exception.getMessage))) + + case Exit.Error(exception, trace) => + logger.error(s"WS request failed: $exception $trace").as(Some(onFail(exception.getMessage))) + + case Exit.Interruption(exception, allExceptions, trace) => + logger.error(s"WS request interrupted: $exception $allExceptions $trace").as(Some(onFail(exception.getMessage))) + } } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionListener.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionListener.scala index 125f1651..5a66d144 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionListener.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionListener.scala @@ -2,16 +2,16 @@ package izumi.idealingua.runtime.rpc.http4s.ws import izumi.functional.bio.{Applicative2, F} -trait WsSessionListener[F[+_, +_], RequestCtx] { - def onSessionOpened(sessionId: WsSessionId, context: Option[RequestCtx]): F[Throwable, Unit] - def onSessionAuthUpdate(sessionId: WsSessionId, context: Option[RequestCtx]): F[Throwable, Unit] - def onSessionClosed(sessionId: WsSessionId, context: Option[RequestCtx]): F[Throwable, Unit] +trait WsSessionListener[F[_, _], RequestCtx, WsCtx] { + def onSessionOpened(sessionId: WsSessionId, reqCtx: RequestCtx, wsCtx: WsCtx): F[Throwable, Unit] + def onSessionUpdated(sessionId: WsSessionId, reqCtx: RequestCtx, prevStx: WsCtx, newCtx: WsCtx): F[Throwable, Unit] + def onSessionClosed(sessionId: WsSessionId, wsCtx: WsCtx): F[Throwable, Unit] } object WsSessionListener { - def empty[F[+_, +_]: Applicative2, RequestCtx]: WsSessionListener[F, RequestCtx] = new WsSessionListener[F, RequestCtx] { - override def onSessionOpened(sessionId: WsSessionId, context: Option[RequestCtx]): F[Throwable, Unit] = F.unit - override def onSessionAuthUpdate(sessionId: WsSessionId, context: Option[RequestCtx]): F[Throwable, Unit] = F.unit - override def onSessionClosed(sessionId: WsSessionId, context: Option[RequestCtx]): F[Throwable, Unit] = F.unit + def empty[F[+_, +_]: Applicative2, RequestCtx, WsCtx]: WsSessionListener[F, RequestCtx, WsCtx] = new WsSessionListener[F, RequestCtx, WsCtx] { + override def onSessionOpened(sessionId: WsSessionId, reqCtx: RequestCtx, wsCtx: WsCtx): F[Throwable, Unit] = F.unit + override def onSessionUpdated(sessionId: WsSessionId, reqCtx: RequestCtx, prevStx: WsCtx, newCtx: WsCtx): F[Throwable, Unit] = F.unit + override def onSessionClosed(sessionId: WsSessionId, wsCtx: WsCtx): F[Throwable, Unit] = F.unit } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionsContext.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionsContext.scala new file mode 100644 index 00000000..fc6a2377 --- /dev/null +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionsContext.scala @@ -0,0 +1,80 @@ +package izumi.idealingua.runtime.rpc.http4s.ws + +import izumi.functional.bio.{F, IO2} +import izumi.idealingua.runtime.rpc.{IRTClientMultiplexor, IRTDispatcher} + +import java.util.concurrent.ConcurrentHashMap +import scala.concurrent.duration.{DurationInt, FiniteDuration} + +trait WsSessionsContext[F[+_, +_], RequestCtx, WsCtx] { + def updateSession(wsSessionId: WsSessionId, requestContext: Option[RequestCtx]): F[Throwable, Unit] + def dispatcherFor( + ctx: WsCtx, + codec: IRTClientMultiplexor[F], + timeout: FiniteDuration = 20.seconds, + ): F[Throwable, Option[IRTDispatcher[F]]] +} + +object WsSessionsContext { + def empty[F[+_, +_]: IO2, RequestCtx]: WsSessionsContext[F, RequestCtx, Unit] = new WsSessionsContext[F, RequestCtx, Unit] { + override def updateSession(wsSessionId: WsSessionId, requestContext: Option[RequestCtx]): F[Throwable, Unit] = { + F.unit + } + override def dispatcherFor(ctx: Unit, codec: IRTClientMultiplexor[F], timeout: FiniteDuration): F[Throwable, Option[IRTDispatcher[F]]] = { + F.pure(None) + } + } + + class WsSessionsContextImpl[F[+_, +_]: IO2, RequestCtx, WsCtx]( + wsSessionsStorage: WsSessionsStorage[F], + wsSessionListeners: Set[WsSessionListener[F, RequestCtx, WsCtx]], + wsContextExtractor: WsContextExtractor[RequestCtx, WsCtx], + ) extends WsSessionsContext[F, RequestCtx, WsCtx] { + private val sessionToId = new ConcurrentHashMap[WsSessionId, WsCtx]() + private val idToSession = new ConcurrentHashMap[WsCtx, WsSessionId]() + + override def updateSession(wsSessionId: WsSessionId, requestContext: Option[RequestCtx]): F[Throwable, Unit] = { + updateCtx(wsSessionId, requestContext).flatMap { + case (Some(ctx), Some(previous), Some(updated)) if previous != updated => + F.traverse_(wsSessionListeners)(_.onSessionUpdated(wsSessionId, ctx, previous, updated)) + case (Some(ctx), None, Some(updated)) => + F.traverse_(wsSessionListeners)(_.onSessionOpened(wsSessionId, ctx, updated)) + case (_, Some(prev), None) => + F.traverse_(wsSessionListeners)(_.onSessionClosed(wsSessionId, prev)) + case _ => + F.unit + } + } + + override def dispatcherFor(ctx: WsCtx, codec: IRTClientMultiplexor[F], timeout: FiniteDuration): F[Throwable, Option[IRTDispatcher[F]]] = { + F.sync(Option(idToSession.get(ctx))).flatMap { + F.traverse(_) { + wsSessionsStorage.dispatcherForSession(_, codec, timeout) + } + }.map(_.flatten) + } + + private def updateCtx( + wsSessionId: WsSessionId, + requestContext: Option[RequestCtx], + ): F[Nothing, (Option[RequestCtx], Option[WsCtx], Option[WsCtx])] = F.sync { + synchronized { + val previous = Option(sessionToId.get(wsSessionId)) + val updated = requestContext.flatMap(wsContextExtractor.extract) + (updated, previous) match { + case (Some(upd), _) => + sessionToId.put(wsSessionId, upd) + idToSession.put(upd, wsSessionId) + () + case (None, Some(prev)) => + sessionToId.remove(wsSessionId) + idToSession.remove(prev) + () + case _ => + () + } + (requestContext, previous, updated) + } + } + } +} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionsStorage.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionsStorage.scala index 1796eb73..bfa98997 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionsStorage.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionsStorage.scala @@ -12,6 +12,7 @@ trait WsSessionsStorage[F[+_, +_]] { def addSession(session: WsClientSession[F]): F[Throwable, WsClientSession[F]] def deleteSession(sessionId: WsSessionId): F[Throwable, Option[WsClientSession[F]]] def allSessions(): F[Throwable, Seq[WsClientSession[F]]] + def getSession(sessionId: WsSessionId): F[Throwable, Option[WsClientSession[F]]] def dispatcherForSession( sessionId: WsSessionId, @@ -39,6 +40,10 @@ object WsSessionsStorage { } yield res } + override def getSession(sessionId: WsSessionId): F[Throwable, Option[WsClientSession[F]]] = { + F.sync(Option(sessions.get(sessionId))) + } + override def allSessions(): F[Throwable, Seq[WsClientSession[F]]] = F.sync { sessions.values().asScala.toSeq } @@ -61,10 +66,10 @@ object WsSessionsStorage { override def dispatch(request: IRTMuxRequest): F[Throwable, IRTMuxResponse] = { for { json <- codec.encode(request) - response <- session.requests.requestAndAwaitResponse(request.method, json, timeout) + response <- session.requestAndAwaitResponse(request.method, json, timeout) res <- response match { case Some(value: RawResponse.EmptyRawResponse) => - F.fail(new IRTGenericFailure(s"${request.method -> "method"}: empty response: $value")) + F.fail(new IRTGenericFailure(s"${request.method}: empty response: $value")) case Some(value: RawResponse.GoodRawResponse) => logger.debug(s"WS Session: ${request.method -> "method"}: Have response: $value.") *> @@ -72,11 +77,11 @@ object WsSessionsStorage { case Some(value: RawResponse.BadRawResponse) => logger.debug(s"WS Session: ${request.method -> "method"}: Generic failure response: ${value.error}.") *> - F.fail(new IRTGenericFailure(s"${request.method -> "method"}: generic failure: ${value.error}")) + F.fail(new IRTGenericFailure(s"${request.method}: generic failure: ${value.error}")) case None => logger.warn(s"WS Session: ${request.method -> "method"}: Timeout exception $timeout.") *> - F.fail(new TimeoutException(s"${request.method -> "method"}: No response in $timeout")) + F.fail(new TimeoutException(s"${request.method}: No response in $timeout")) } } yield res } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala index dd407c18..263e0b51 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala @@ -1,55 +1,161 @@ package izumi.idealingua.runtime.rpc.http4s -import io.circe.Json +import cats.effect.Async +import io.circe.{Json, Printer} import izumi.functional.bio.Exit.{Error, Interruption, Success, Termination} -import izumi.functional.bio.{Exit, F} +import izumi.functional.bio.UnsafeRun2.FailureHandler +import izumi.functional.bio.{Async2, Exit, F, Primitives2, Temporal2, UnsafeRun2} +import izumi.functional.lifecycle.Lifecycle import izumi.fundamentals.platform.language.Quirks.* +import izumi.fundamentals.platform.network.IzSockets import izumi.idealingua.runtime.rpc.* +import izumi.idealingua.runtime.rpc.http4s.Http4sTransportTest.{Ctx, IO2R} import izumi.idealingua.runtime.rpc.http4s.clients.HttpRpcDispatcher.IRTDispatcherRaw +import izumi.idealingua.runtime.rpc.http4s.clients.{HttpRpcDispatcher, HttpRpcDispatcherFactory, WsRpcDispatcher, WsRpcDispatcherFactory} +import izumi.idealingua.runtime.rpc.http4s.fixtures.TestServices +import izumi.idealingua.runtime.rpc.http4s.fixtures.defs.{PrivateTestServiceWrappedClient, ProtectedTestServiceWrappedClient} import izumi.idealingua.runtime.rpc.http4s.ws.{RawResponse, WsRequestState} +import izumi.logstage.api.routing.{ConfigurableLogRouter, StaticLogRouter} +import izumi.logstage.api.{IzLogger, Log} import izumi.r2.idealingua.test.generated.{GreeterServiceClientWrapped, GreeterServiceMethods} +import logstage.LogIO import org.http4s.* import org.http4s.blaze.server.* +import org.http4s.dsl.Http4sDsl import org.http4s.headers.Authorization import org.http4s.server.Router import org.scalatest.wordspec.AnyWordSpec -import zio.interop.catz.asyncInstance -import zio.{IO, ZIO} +import zio.IO +import zio.interop.catz.* -import java.util.Base64 +import java.util.concurrent.Executors +import scala.concurrent.ExecutionContext.global import scala.concurrent.duration.DurationInt -class Http4sTransportTest extends AnyWordSpec { +final class Http4sTransportTest extends Http4sTransportTestBase[IO] +object Http4sTransportTest { + final val izLogger: IzLogger = makeLogger() + final val handler: FailureHandler.Custom = UnsafeRun2.FailureHandler.Custom(message => izLogger.trace(s"Fiber failed: $message")) + implicit val IO2R: UnsafeRun2[zio.IO] = UnsafeRun2.createZIO( + handler = handler, + customCpuPool = Some( + zio.Executor.fromJavaExecutor( + Executors.newFixedThreadPool(2) + ) + ), + ) + final class Ctx[F[+_, +_]: Async2: Temporal2: Primitives2: UnsafeRun2](implicit asyncThrowable: Async[F[Throwable, _]]) { + private val logger = LogIO.fromLogger[F[Nothing, _]](makeLogger()) + private val printer: Printer = Printer.noSpaces.copy(dropNullValues = true) + + final val dsl = Http4sDsl.apply[F[Throwable, _]] + final val execCtx = HttpExecutionContext(global) + + final val addr = IzSockets.temporaryServerAddress() + final val port = addr.getPort + final val host = addr.getHostName + final val baseUri = Uri(Some(Uri.Scheme.http), Some(Uri.Authority(host = Uri.RegName(host), port = Some(port)))) + final val wsUri = Uri.unsafeFromString(s"ws://$host:$port/ws") + + final val demo = new TestServices[F](logger) + + final val ioService = new HttpServer[F](demo.Server.contextMuxer, demo.Server.wsStorage, dsl, logger, printer) + + def badAuth(user: String): Header.ToRaw = Authorization(BasicCredentials(user, "badpass")) + def publicAuth(user: String): Header.ToRaw = Authorization(BasicCredentials(user, "public")) + def protectedAuth(user: String): Header.ToRaw = Authorization(BasicCredentials(user, "protected")) + def privateAuth(user: String): Header.ToRaw = Authorization(BasicCredentials(user, "private")) + + val httpClientFactory: HttpRpcDispatcherFactory[F] = { + new HttpRpcDispatcherFactory[F](demo.Client.codec, execCtx, printer, logger) + } + def httpRpcClientDispatcher(headers: Headers): HttpRpcDispatcher.IRTDispatcherRaw[F] = { + httpClientFactory.dispatcher(baseUri, headers) + } + + val wsClientFactory: WsRpcDispatcherFactory[F] = { + new WsRpcDispatcherFactory[F](demo.Client.codec, printer, logger, izLogger) + } + def wsRpcClientDispatcher(): Lifecycle[F[Throwable, _], WsRpcDispatcher.IRTDispatcherWs[F]] = { + wsClientFactory.dispatcher(wsUri, demo.Client.buzzerMultiplexor) + } + } + + private def makeLogger(): IzLogger = { + val router = ConfigurableLogRouter( + Log.Level.Debug, + levels = Map( + "io.netty" -> Log.Level.Info, + "org.http4s.blaze.channel.nio1" -> Log.Level.Info, + "org.http4s" -> Log.Level.Info, + "org.asynchttpclient" -> Log.Level.Info, + ), + ) + + val out = IzLogger(router) + StaticLogRouter.instance.setup(router) + out + } +} + +abstract class Http4sTransportTestBase[F[+_, +_]: Async2: Primitives2: Temporal2: UnsafeRun2]( + implicit asyncThrowable: Async[F[Throwable, _]] +) extends AnyWordSpec { + private val ctx = new Ctx[F] + + import ctx.* import fixtures.* - import Http4sTestContext.* - import RT.* "Http4s transport" should { "support http" in { withServer { for { // with credentials - httpClient1 <- F.sync(httpRpcClientDispatcher(Headers(Authorization(BasicCredentials("user", "pass"))))) - greeterClient1 = new GreeterServiceClientWrapped(httpClient1) - _ <- greeterClient1.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) - _ <- greeterClient1.alternative().either.map(res => assert(res == Right("value"))) - _ <- checkBadBody("{}", httpClient1) - _ <- checkBadBody("{unparseable", httpClient1) - - // without credentials - greeterClient2 <- F.sync(httpRpcClientDispatcher(Headers())).map(new GreeterServiceClientWrapped(_)) - _ <- F.sandboxExit(greeterClient2.alternative()).map { - case Termination(exception: IRTUnexpectedHttpStatus, _, _) => assert(exception.status == Status.Forbidden) - case o => fail(s"Expected IRTGenericFailure but got $o") + privateClient <- F.sync(httpRpcClientDispatcher(Headers(privateAuth("user1")))) + protectedClient <- F.sync(httpRpcClientDispatcher(Headers(protectedAuth("user2")))) + publicClient <- F.sync(httpRpcClientDispatcher(Headers(publicAuth("user3")))) + + // Private API test + _ <- new PrivateTestServiceWrappedClient(privateClient).test("test").map { + res => assert(res.startsWith("Private")) + } + _ <- F.sandboxExit(new PrivateTestServiceWrappedClient(protectedClient).test("test")).map { + case Termination(exception: IRTUnexpectedHttpStatus, _, _) => assert(exception.status == Status.Unauthorized) + case o => fail(s"Expected Unauthorized status but got $o") + } + _ <- F.sandboxExit(new ProtectedTestServiceWrappedClient(publicClient).test("test")).map { + case Termination(exception: IRTUnexpectedHttpStatus, _, _) => assert(exception.status == Status.Unauthorized) + case o => fail(s"Expected Unauthorized status but got $o") } - // with bad credentials - greeterClient2 <- F.sync(httpRpcClientDispatcher(Headers(Authorization(BasicCredentials("user", "badpass"))))).map(new GreeterServiceClientWrapped(_)) - _ <- F.sandboxExit(greeterClient2.alternative()).map { + // Protected API test + _ <- new ProtectedTestServiceWrappedClient(protectedClient).test("test").map { + res => assert(res.startsWith("Protected")) + } + _ <- F.sandboxExit(new ProtectedTestServiceWrappedClient(privateClient).test("test")).map { + case Termination(exception: IRTUnexpectedHttpStatus, _, _) => assert(exception.status == Status.Unauthorized) + case o => fail(s"Expected Unauthorized status but got $o") + } + _ <- F.sandboxExit(new ProtectedTestServiceWrappedClient(publicClient).test("test")).map { case Termination(exception: IRTUnexpectedHttpStatus, _, _) => assert(exception.status == Status.Unauthorized) - case o => fail(s"Expected IRTGenericFailure but got $o") + case o => fail(s"Expected Unauthorized status but got $o") + } + + // Public API test + greaterClient = new GreeterServiceClientWrapped(publicClient) + _ <- new GreeterServiceClientWrapped(protectedClient).greet("Protected", "Client").map { + res => assert(res == "Hi, Protected Client!") } + _ <- new GreeterServiceClientWrapped(privateClient).greet("Protected", "Client").map { + res => assert(res == "Hi, Protected Client!") + } + + // + _ <- greaterClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) + _ <- greaterClient.alternative().attempt.map(res => assert(res == Right("value"))) + _ <- checkBadBody("{}", publicClient) + _ <- checkBadBody("{unparseable", publicClient) } yield () } } @@ -59,34 +165,68 @@ class Http4sTransportTest extends AnyWordSpec { wsRpcClientDispatcher().use { dispatcher => for { - id1 <- ZIO.succeed(s"Basic ${Base64.getEncoder.encodeToString("user:pass".getBytes)}") - _ <- dispatcher.authorize(Map("Authorization" -> id1)) - greeterClient = new GreeterServiceClientWrapped(dispatcher) - _ <- greeterClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) - _ <- greeterClient.alternative().either.map(res => assert(res == Right("value"))) -// buzzers <- ioService.wsSessionsStorage.dispatcherForClient(id1) -// _ = assert(buzzers.nonEmpty) -// _ <- ZIO.foreach(buzzers) { -// buzzer => -// val client = new GreeterServiceClientWrapped(buzzer) -// client.greet("John", "Buzzer").map(res => assert(res == "Hi, John Buzzer!")) -// } -// _ <- dispatcher.authorize(Map("Authorization" -> s"Basic ${Base64.getEncoder.encodeToString("user:badpass".getBytes)}")) -// _ <- F.sandboxExit(greeterClient.alternative()).map { -// case Termination(_: IRTGenericFailure, _, _) => -// case o => F.fail(s"Expected IRTGenericFailure but got $o") -// } + publicHeaders <- F.pure(Map("Authorization" -> publicAuth("user").values.head.value)) + privateHeaders <- F.pure(Map("Authorization" -> privateAuth("user").values.head.value)) + protectedHeaders <- F.pure(Map("Authorization" -> protectedAuth("user").values.head.value)) + badHeaders <- F.pure(Map("Authorization" -> badAuth("user").values.head.value)) + + publicClient = new GreeterServiceClientWrapped[F](dispatcher) + privateClient = new PrivateTestServiceWrappedClient[F](dispatcher) + protectedClient = new ProtectedTestServiceWrappedClient[F](dispatcher) + + // no dispatchers yet + _ <- demo.Server.protectedWsSession.dispatcherFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) + _ <- demo.Server.privateWsSession.dispatcherFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) + _ <- demo.Server.publicWsSession.dispatcherFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) + + // public authorization + _ <- dispatcher.authorize(publicHeaders) + _ <- demo.Server.protectedWsSession.dispatcherFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) + _ <- demo.Server.privateWsSession.dispatcherFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) + publicContextBuzzer <- demo.Server.publicWsSession + .dispatcherFor(PublicContext("user"), demo.Client.codec) + .fromOption(new RuntimeException("Missing Buzzer")) + _ <- new GreeterServiceClientWrapped(publicContextBuzzer).greet("John", "Buzzer").map(res => assert(res == "Hi, John Buzzer!")) + _ <- publicClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) + _ <- publicClient.alternative().attempt.map(res => assert(res == Right("value"))) + _ <- checkAnauthorizedWsCall(privateClient.test("")) + _ <- checkAnauthorizedWsCall(protectedClient.test("")) + + // re-authorize with private + _ <- dispatcher.authorize(privateHeaders) + _ <- demo.Server.protectedWsSession.dispatcherFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) + _ <- demo.Server.privateWsSession.dispatcherFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) + _ <- demo.Server.publicWsSession.dispatcherFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) + _ <- privateClient.test("test").map(res => assert(res.startsWith("Private"))) + _ <- publicClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) + _ <- checkAnauthorizedWsCall(protectedClient.test("")) + + // re-authorize with protected + _ <- dispatcher.authorize(protectedHeaders) + _ <- demo.Server.protectedWsSession.dispatcherFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) + _ <- demo.Server.privateWsSession.dispatcherFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) + _ <- demo.Server.publicWsSession.dispatcherFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) + _ <- protectedClient.test("test").map(res => assert(res.startsWith("Protected"))) + _ <- publicClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) + _ <- checkAnauthorizedWsCall(privateClient.test("")) + + // update auth and call service + _ <- dispatcher.authorize(badHeaders) + _ <- F.sandboxExit(publicClient.alternative()).map { + case Termination(f: IRTGenericFailure, _, _) if f.getMessage.contains("""{"cause": "Unauthorized"}""") => + case o => F.fail(s"Expected IRTGenericFailure with Unauthorized message but got $o") + } } yield () } } } "support request state clean" in { - executeIO { - val rs = new WsRequestState.Default[IO]() + executeF { + val rs = new WsRequestState.Default[F]() for { - id1 <- ZIO.succeed(RpcPacketId.random()) - id2 <- ZIO.succeed(RpcPacketId.random()) + id1 <- F.pure(RpcPacketId.random()) + id2 <- F.pure(RpcPacketId.random()) _ <- rs.registerRequest(id1, None, 0.minutes) _ <- rs.registerRequest(id2, None, 5.minutes) _ <- F.attempt(rs.awaitResponse(id1, 5.seconds)).map { @@ -101,26 +241,35 @@ class Http4sTransportTest extends AnyWordSpec { } } - def withServer(f: IO[Throwable, Any]): Unit = { - executeIO { - BlazeServerBuilder[IO[Throwable, _]] + def withServer(f: F[Throwable, Any]): Unit = { + executeF { + BlazeServerBuilder[F[Throwable, _]] .bindHttp(port, host) .withHttpWebSocketApp(ws => Router("/" -> ioService.service(ws)).orNotFound) .resource .use(_ => f) - .unit + .void } } - def executeIO(io: IO[Throwable, Any]): Unit = { - IO2R.unsafeRunSync(io.unit) match { + def executeF(io: F[Throwable, Any]): Unit = { + UnsafeRun2[F].unsafeRunSync(io.void) match { case Success(()) => () case failure: Exit.Failure[?] => throw failure.trace.toThrowable } } - def checkBadBody(body: String, disp: IRTDispatcherRaw[IO]): ZIO[Any, Nothing, Unit] = { - F.sandboxExit(disp.dispatchRaw(GreeterServiceMethods.greet.id, body)).map { + def checkAnauthorizedWsCall[E, A](call: F[E, A]): F[Throwable, Unit] = { + call.sandboxExit.flatMap { + case Termination(f: IRTGenericFailure, _, _) if f.getMessage.contains("""{"cause":"Unauthorized."}""") => + F.unit + case o => + F.fail(new RuntimeException(s"Expected IRTGenericFailure with Unauthorized message but got $o")) + } + } + + def checkBadBody(body: String, disp: IRTDispatcherRaw[F]): F[Nothing, Unit] = { + disp.dispatchRaw(GreeterServiceMethods.greet.id, body).sandboxExit.map { case Error(value: IRTUnexpectedHttpStatus, _) => assert(value.status == Status.BadRequest).discard() case Error(value, _) => diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/DummyAuthorizingDispatcher.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/DummyAuthorizingDispatcher.scala deleted file mode 100644 index 91a6f5c1..00000000 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/DummyAuthorizingDispatcher.scala +++ /dev/null @@ -1,34 +0,0 @@ -package izumi.idealingua.runtime.rpc.http4s.fixtures - -import izumi.functional.bio.IO2 -import izumi.idealingua.runtime.rpc._ -import izumi.idealingua.runtime.rpc.http4s.{IRTBadCredentialsException, IRTNoCredentialsException} -import org.http4s.{BasicCredentials, Status} - -final class DummyAuthorizingDispatcher[F[+_, +_]: IO2, Ctx](proxied: IRTWrappedService[F, Ctx]) extends IRTWrappedService[F, Ctx] { - override def serviceId: IRTServiceId = proxied.serviceId - - override def allMethods: Map[IRTMethodId, IRTMethodWrapper[F, Ctx]] = proxied.allMethods.mapValues { - method => - new IRTMethodWrapper[F, Ctx] { - val R: IO2[F] = implicitly - - override val signature: IRTMethodSignature = method.signature - override val marshaller: IRTCirceMarshaller = method.marshaller - - override def invoke(ctx: Ctx, input: signature.Input): F[Nothing, signature.Output] = { - ctx match { - case DummyRequestContext(_, Some(BasicCredentials(user, pass))) => - if (user == "user" && pass == "pass") { - method.invoke(ctx, input.asInstanceOf[method.signature.Input]).map(_.asInstanceOf[signature.Output]) - } else { - R.terminate(IRTBadCredentialsException(Status.Unauthorized)) - } - - case _ => - R.terminate(IRTNoCredentialsException(Status.Forbidden)) - } - } - } - }.toMap -} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/DummyRequestContext.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/DummyRequestContext.scala deleted file mode 100644 index e5247f6b..00000000 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/DummyRequestContext.scala +++ /dev/null @@ -1,7 +0,0 @@ -package izumi.idealingua.runtime.rpc.http4s.fixtures - -import org.http4s.Credentials - -import java.net.InetAddress - -final case class DummyRequestContext(ip: Option[InetAddress], credentials: Option[Credentials]) diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/DummyServices.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/DummyServices.scala deleted file mode 100644 index a5230183..00000000 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/DummyServices.scala +++ /dev/null @@ -1,35 +0,0 @@ -package izumi.idealingua.runtime.rpc.http4s.fixtures - -import izumi.functional.bio.IO2 -import izumi.idealingua.runtime.rpc.* -import izumi.idealingua.runtime.rpc.IRTServerMultiplexor.IRTServerMultiplexorImpl -import izumi.idealingua.runtime.rpc.http4s.{IRTAuthenticator, IRTContextServices, IRTContextServicesMuxer} -import izumi.r2.idealingua.test.generated.{GreeterServiceClientWrapped, GreeterServiceServerWrapped} -import izumi.r2.idealingua.test.impls.AbstractGreeterServer - -class DummyServices[F[+_, +_]: IO2, Ctx] { - - object Server { - private val greeterService = new AbstractGreeterServer.Impl[F, Ctx] - private val greeterDispatcher = new GreeterServiceServerWrapped(greeterService) - private val dispatchers: Set[IRTWrappedService[F, Ctx]] = Set(greeterDispatcher).map(d => new DummyAuthorizingDispatcher(d)) - val multiplexor = new IRTServerMultiplexorImpl[F, Ctx](dispatchers) - - private val clients: Set[IRTWrappedClient] = Set(GreeterServiceClientWrapped) - val codec = new IRTClientMultiplexorImpl[F](clients) - } - - object Client { - private val greeterService = new AbstractGreeterServer.Impl[F, Unit] - private val greeterDispatcher = new GreeterServiceServerWrapped(greeterService) - private val dispatchers: Set[IRTWrappedService[F, Unit]] = Set(greeterDispatcher) - - private val clients: Set[IRTWrappedClient] = Set(GreeterServiceClientWrapped) - val codec: IRTClientMultiplexorImpl[F] = new IRTClientMultiplexorImpl[F](clients) - val buzzerMultiplexor: IRTContextServicesMuxer[F] = { - val contextMuxer = new IRTServerMultiplexorImpl[F, Unit](dispatchers) - val contextServices = new IRTContextServices[F, Unit](contextMuxer, IRTAuthenticator.unit, Set.empty) - new IRTContextServicesMuxer[F](Set(contextServices)) - } - } -} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/Http4sTestContext.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/Http4sTestContext.scala deleted file mode 100644 index a8269f80..00000000 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/Http4sTestContext.scala +++ /dev/null @@ -1,60 +0,0 @@ -package izumi.idealingua.runtime.rpc.http4s.fixtures - -import izumi.functional.lifecycle.Lifecycle -import izumi.fundamentals.platform.network.IzSockets -import izumi.idealingua.runtime.rpc.http4s.* -import izumi.idealingua.runtime.rpc.http4s.clients.{HttpRpcDispatcher, HttpRpcDispatcherFactory, WsRpcDispatcher, WsRpcDispatcherFactory} -import izumi.idealingua.runtime.rpc.http4s.ws.WsSessionsStorage.WsSessionsStorageImpl -import org.http4s.* -import org.http4s.headers.Authorization -import zio.interop.catz.* -import zio.{IO, ZIO} - -object Http4sTestContext { - import RT.IO2R - - // - final val addr = IzSockets.temporaryServerAddress() - final val port = addr.getPort - final val host = addr.getHostName - final val baseUri = Uri(Some(Uri.Scheme.http), Some(Uri.Authority(host = Uri.RegName(host), port = Some(port)))) - final val wsUri = Uri.unsafeFromString(s"ws://$host:$port/ws") - - // -// -// import RT.rt -// import rt.* - - final val demo = new DummyServices[IO, DummyRequestContext]() - - final val authenticator = new IRTAuthenticator[IO, DummyRequestContext] { - override def authenticate(request: IRTAuthenticator.AuthContext): IO[Throwable, Option[DummyRequestContext]] = ZIO.succeed { - val creds = request.headers.get[Authorization].map(_.credentials) - Some(DummyRequestContext(request.networkAddress, creds)) - } - } - final val storage = new WsSessionsStorageImpl[IO](RT.logger) - final val contextService = new IRTContextServices[IO, DummyRequestContext](demo.Server.multiplexor, authenticator, Set.empty) - final val contextMuxer = new IRTContextServicesMuxer[IO](Set(contextService)) - final val ioService = new HttpServer[IO]( - contextMuxer, - storage, - RT.dsl, - RT.logger, - RT.printer, - ) - - final val httpClientFactory: HttpRpcDispatcherFactory[IO] = { - new HttpRpcDispatcherFactory[IO](demo.Client.codec, RT.execCtx, RT.printer, RT.logger) - } - final def httpRpcClientDispatcher(headers: Headers): HttpRpcDispatcher.IRTDispatcherRaw[IO] = { - httpClientFactory.dispatcher(baseUri, headers) - } - - final val wsClientFactory: WsRpcDispatcherFactory[IO] = { - new WsRpcDispatcherFactory[IO](demo.Client.codec, RT.printer, RT.logger, RT.izLogger) - } - final def wsRpcClientDispatcher(): Lifecycle[IO[Throwable, _], WsRpcDispatcher.IRTDispatcherWs[IO]] = { - wsClientFactory.dispatcher(wsUri, demo.Client.buzzerMultiplexor) - } -} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/RT.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/RT.scala deleted file mode 100644 index 51a92a62..00000000 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/RT.scala +++ /dev/null @@ -1,48 +0,0 @@ -package izumi.idealingua.runtime.rpc.http4s.fixtures - -import io.circe.Printer -import izumi.functional.bio.UnsafeRun2 -import izumi.idealingua.runtime.rpc.http4s.HttpExecutionContext -import izumi.logstage.api.routing.{ConfigurableLogRouter, StaticLogRouter} -import izumi.logstage.api.{IzLogger, Log} -import logstage.LogIO -import org.http4s.dsl.Http4sDsl -import zio.IO - -import java.util.concurrent.Executors -import scala.concurrent.ExecutionContext.global - -object RT { - final val izLogger = makeLogger() - final val logger = LogIO.fromLogger[IO[Nothing, _]](makeLogger()) - final val printer: Printer = Printer.noSpaces.copy(dropNullValues = true) - - final val handler = UnsafeRun2.FailureHandler.Custom(message => izLogger.warn(s"Fiber failed: $message")) - implicit val IO2R: UnsafeRun2[zio.IO] = UnsafeRun2.createZIO( - handler = handler, - customCpuPool = Some( - zio.Executor.fromJavaExecutor( - Executors.newFixedThreadPool(2) - ) - ), - ) - final val dsl = Http4sDsl.apply[zio.IO[Throwable, _]] - final val execCtx = HttpExecutionContext(global) - - private def makeLogger(): IzLogger = { - val router = ConfigurableLogRouter( - Log.Level.Debug, - levels = Map( - "org.http4s" -> Log.Level.Warn, - "org.http4s.server.blaze" -> Log.Level.Error, - "org.http4s.blaze.channel.nio1" -> Log.Level.Crit, - "izumi.idealingua.runtime.rpc.http4s" -> Log.Level.Crit, - ), - ) - - val out = IzLogger(router) - StaticLogRouter.instance.setup(router) - out - } - -} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestContext.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestContext.scala new file mode 100644 index 00000000..8d65b104 --- /dev/null +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestContext.scala @@ -0,0 +1,15 @@ +package izumi.idealingua.runtime.rpc.http4s.fixtures + +sealed trait TestContext + +final case class PrivateContext(user: String) extends TestContext { + override def toString: String = "private" +} + +final case class ProtectedContext(user: String) extends TestContext { + override def toString: String = "protected" +} + +final case class PublicContext(user: String) extends TestContext { + override def toString: String = "public" +} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestDispatcher.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestDispatcher.scala deleted file mode 100644 index 36d6e010..00000000 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestDispatcher.scala +++ /dev/null @@ -1,18 +0,0 @@ -package izumi.idealingua.runtime.rpc.http4s.fixtures - -import java.util.concurrent.atomic.AtomicReference - -import org.http4s.headers.Authorization -import org.http4s.{BasicCredentials, Header} - -trait TestDispatcher { - val creds = new AtomicReference[Seq[Header.ToRaw]](Seq.empty) - - def setupCredentials(login: String, password: String): Unit = { - creds.set(Seq(Authorization(BasicCredentials(login, password)))) - } - - def cancelCredentials(): Unit = { - creds.set(Seq.empty) - } -} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala new file mode 100644 index 00000000..da4a83fe --- /dev/null +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala @@ -0,0 +1,92 @@ +package izumi.idealingua.runtime.rpc.http4s.fixtures + +import io.circe.Json +import izumi.functional.bio.{F, IO2} +import izumi.idealingua.runtime.rpc.* +import izumi.idealingua.runtime.rpc.http4s.IRTServicesContext.IRTServicesContextImpl +import izumi.idealingua.runtime.rpc.http4s.IRTServicesContextMultiplexor.MultiContext +import izumi.idealingua.runtime.rpc.http4s.fixtures.defs.* +import izumi.idealingua.runtime.rpc.http4s.ws.WsSessionsContext.WsSessionsContextImpl +import izumi.idealingua.runtime.rpc.http4s.ws.WsSessionsStorage.WsSessionsStorageImpl +import izumi.idealingua.runtime.rpc.http4s.ws.{WsContextExtractor, WsSessionsContext, WsSessionsStorage} +import izumi.idealingua.runtime.rpc.http4s.{IRTAuthenticator, IRTServicesContext, IRTServicesContextMultiplexor} +import izumi.r2.idealingua.test.generated.{GreeterServiceClientWrapped, GreeterServiceServerWrapped} +import izumi.r2.idealingua.test.impls.AbstractGreeterServer +import logstage.LogIO2 +import org.http4s.BasicCredentials +import org.http4s.headers.Authorization + +class TestServices[F[+_, +_]: IO2]( + logger: LogIO2[F] +) { + + object Server { + final val wsStorage: WsSessionsStorage[F] = new WsSessionsStorageImpl[F](logger) + + // PRIVATE + private val privateAuth = new IRTAuthenticator[F, PrivateContext] { + override def authenticate(authContext: IRTAuthenticator.AuthContext, body: Option[Json]): F[Nothing, Option[PrivateContext]] = F.sync { + authContext.headers.get[Authorization].map(_.credentials).collect { + case BasicCredentials(user, "private") => PrivateContext(user) + } + } + } + final val privateWsSession: WsSessionsContext[F, PrivateContext, PrivateContext] = new WsSessionsContextImpl(wsStorage, Set.empty, WsContextExtractor.id) + final val privateService: IRTWrappedService[F, PrivateContext] = new PrivateTestServiceWrappedServer(new PrivateTestServiceServer[F, PrivateContext] { + def test(ctx: PrivateContext, str: String): Just[String] = F.pure(s"Private: $str") + }) + final val privateServices: IRTServicesContext[F, PrivateContext, PrivateContext] = { + new IRTServicesContextImpl(Set(privateService), privateAuth, privateWsSession) + } + + // PROTECTED + private val protectedAuth = new IRTAuthenticator[F, ProtectedContext] { + override def authenticate(authContext: IRTAuthenticator.AuthContext, body: Option[Json]): F[Nothing, Option[ProtectedContext]] = F.sync { + authContext.headers.get[Authorization].map(_.credentials).collect { + case BasicCredentials(user, "protected") => ProtectedContext(user) + } + } + } + final val protectedWsSession: WsSessionsContext[F, ProtectedContext, ProtectedContext] = { + new WsSessionsContextImpl(wsStorage, Set.empty, WsContextExtractor.id) + } + final val protectedService: IRTWrappedService[F, ProtectedContext] = new ProtectedTestServiceWrappedServer(new ProtectedTestServiceServer[F, ProtectedContext] { + def test(ctx: ProtectedContext, str: String): Just[String] = F.pure(s"Protected: $str") + }) + final val protectedServices: IRTServicesContext[F, ProtectedContext, ProtectedContext] = { + new IRTServicesContextImpl(Set(protectedService), protectedAuth, protectedWsSession) + } + + // PUBLIC + private val publicAuth = new IRTAuthenticator[F, PublicContext] { + override def authenticate(authContext: IRTAuthenticator.AuthContext, body: Option[Json]): F[Nothing, Option[PublicContext]] = F.sync { + authContext.headers.get[Authorization].map(_.credentials).collect { + case BasicCredentials(user, _) => PublicContext(user) + } + } + } + final val publicWsSession: WsSessionsContext[F, PublicContext, PublicContext] = new WsSessionsContextImpl(wsStorage, Set.empty, WsContextExtractor.id) + final val publicService: IRTWrappedService[F, PublicContext] = new GreeterServiceServerWrapped(new AbstractGreeterServer.Impl[F, PublicContext]) + final val publicServices: IRTServicesContext[F, PublicContext, PublicContext] = { + new IRTServicesContextImpl(Set(publicService), publicAuth, publicWsSession) + } + + final val contextMuxer: IRTServicesContextMultiplexor[F] = new MultiContext[F](Set(privateServices, protectedServices, publicServices)) + } + + object Client { + private val greeterService = new AbstractGreeterServer.Impl[F, Unit] + private val greeterDispatcher = new GreeterServiceServerWrapped(greeterService) + private val dispatchers: Set[IRTWrappedService[F, Unit]] = Set(greeterDispatcher) + + private val clients: Set[IRTWrappedClient] = Set( + GreeterServiceClientWrapped, + ProtectedTestServiceWrappedClient, + PrivateTestServiceWrappedClient, + ) + val codec: IRTClientMultiplexorImpl[F] = new IRTClientMultiplexorImpl[F](clients) + val buzzerMultiplexor: IRTServicesContextMultiplexor[F] = { + new IRTServicesContextMultiplexor.Single[F, Unit](dispatchers, IRTAuthenticator.unit) + } + } +} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/defs/PrivateTestServiceServer.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/defs/PrivateTestServiceServer.scala new file mode 100644 index 00000000..b4f9d9f8 --- /dev/null +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/defs/PrivateTestServiceServer.scala @@ -0,0 +1,110 @@ +package izumi.idealingua.runtime.rpc.http4s.fixtures.defs + +import _root_.io.circe.syntax.* +import _root_.io.circe.{DecodingFailure as IRTDecodingFailure, Json as IRTJson} +import _root_.izumi.functional.bio.IO2 as IRTIO2 +import _root_.izumi.idealingua.runtime.rpc.* + +trait PrivateTestServiceServer[Or[+_, +_], C] { + type Just[+T] = Or[Nothing, T] + def test(ctx: C, str: String): Just[String] +} + +trait PrivateTestServiceClient[Or[+_, +_]] { + type Just[+T] = Or[Nothing, T] + def test(str: String): Just[String] +} + +class PrivateTestServiceWrappedClient[Or[+_, +_]: IRTIO2](_dispatcher: IRTDispatcher[Or]) extends PrivateTestServiceClient[Or] { + final val _F: IRTIO2[Or] = implicitly + import _root_.izumi.idealingua.runtime.rpc.http4s.fixtures.defs.PrivateTestService as _M + def test(str: String): Just[String] = { + _F.redeem(_dispatcher.dispatch(IRTMuxRequest(IRTReqBody(new _M.test.Input(str)), _M.test.id)))( + { + err => _F.terminate(err) + }, + { + case IRTMuxResponse(IRTResBody(v: _M.test.Output), method) if method == _M.test.id => + _F.pure(v.value) + case v => + val id = "PrivateTestService.PrivateTestServiceWrappedClient.test" + val expected = classOf[_M.test.Input].toString + _F.terminate(new IRTTypeMismatchException(s"Unexpected type in $id: $v, expected $expected got ${v.getClass}", v, None)) + }, + ) + } +} + +object PrivateTestServiceWrappedClient extends IRTWrappedClient { + val allCodecs: Map[IRTMethodId, IRTCirceMarshaller] = { + Map(PrivateTestService.test.id -> PrivateTestServiceCodecs.test) + } +} + +class PrivateTestServiceWrappedServer[Or[+_, +_]: IRTIO2, C](_service: PrivateTestServiceServer[Or, C]) extends IRTWrappedService[Or, C] { + final val _F: IRTIO2[Or] = implicitly + final val serviceId: IRTServiceId = PrivateTestService.serviceId + val allMethods: Map[IRTMethodId, IRTMethodWrapper[Or, C]] = { + Seq[IRTMethodWrapper[Or, C]](test).map(m => m.signature.id -> m).toMap + } + object test extends IRTMethodWrapper[Or, C] { + import PrivateTestService.test.* + val signature: PrivateTestService.test.type = PrivateTestService.test + val marshaller: PrivateTestServiceCodecs.test.type = PrivateTestServiceCodecs.test + def invoke(ctx: C, input: Input): Just[Output] = { + assert(ctx.asInstanceOf[_root_.scala.AnyRef] != null && input.asInstanceOf[_root_.scala.AnyRef] != null) + _F.map(_service.test(ctx, input.str))(v => new Output(v)) + } + } +} + +object PrivateTestServiceWrappedServer + +object PrivateTestService { + final val serviceId: IRTServiceId = IRTServiceId("PrivateTestService") + object test extends IRTMethodSignature { + final val id: IRTMethodId = IRTMethodId(serviceId, IRTMethodName("test")) + type Input = TestInput + type Output = TestOutput + } + final case class TestInput(str: String) extends AnyVal + object TestInput { + import _root_.io.circe.derivation.{deriveDecoder, deriveEncoder} + import _root_.io.circe.{Decoder, Encoder} + implicit val encodeTestInput: Encoder.AsObject[TestInput] = deriveEncoder[TestInput] + implicit val decodeTestInput: Decoder[TestInput] = deriveDecoder[TestInput] + } + final case class TestOutput(value: String) extends AnyVal + object TestOutput { + import _root_.io.circe.* + import _root_.io.circe.syntax.* + implicit val encodeUnwrappedTestOutput: Encoder[TestOutput] = Encoder.instance { + v => v.value.asJson + } + implicit val decodeUnwrappedTestOutput: Decoder[TestOutput] = Decoder.instance { + v => v.as[String].map(d => TestOutput(d)) + } + } +} + +object PrivateTestServiceCodecs { + object test extends IRTCirceMarshaller { + import PrivateTestService.test.* + def encodeRequest: PartialFunction[IRTReqBody, IRTJson] = { + case IRTReqBody(value: Input) => + value.asJson + } + def decodeRequest[Or[+_, +_]: IRTIO2]: PartialFunction[IRTJsonBody, Or[IRTDecodingFailure, IRTReqBody]] = { + case IRTJsonBody(m, packet) if m == id => + this.decoded[Or, IRTReqBody](packet.as[Input].map(v => IRTReqBody(v))) + } + def encodeResponse: PartialFunction[IRTResBody, IRTJson] = { + case IRTResBody(value: Output) => + value.asJson + } + def decodeResponse[Or[+_, +_]: IRTIO2]: PartialFunction[IRTJsonBody, Or[IRTDecodingFailure, IRTResBody]] = { + case IRTJsonBody(m, packet) if m == id => + decoded[Or, IRTResBody](packet.as[Output].map(v => IRTResBody(v))) + } + } +} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/defs/ProtectedTestServiceServer.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/defs/ProtectedTestServiceServer.scala new file mode 100644 index 00000000..56e3eca0 --- /dev/null +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/defs/ProtectedTestServiceServer.scala @@ -0,0 +1,110 @@ +package izumi.idealingua.runtime.rpc.http4s.fixtures.defs + +import _root_.io.circe.syntax.* +import _root_.io.circe.{DecodingFailure as IRTDecodingFailure, Json as IRTJson} +import _root_.izumi.functional.bio.IO2 as IRTIO2 +import _root_.izumi.idealingua.runtime.rpc.* + +trait ProtectedTestServiceServer[Or[+_, +_], C] { + type Just[+T] = Or[Nothing, T] + def test(ctx: C, str: String): Just[String] +} + +trait ProtectedTestServiceClient[Or[+_, +_]] { + type Just[+T] = Or[Nothing, T] + def test(str: String): Just[String] +} + +class ProtectedTestServiceWrappedClient[Or[+_, +_]: IRTIO2](_dispatcher: IRTDispatcher[Or]) extends ProtectedTestServiceClient[Or] { + final val _F: IRTIO2[Or] = implicitly + import _root_.izumi.idealingua.runtime.rpc.http4s.fixtures.defs.ProtectedTestService as _M + def test(str: String): Just[String] = { + _F.redeem(_dispatcher.dispatch(IRTMuxRequest(IRTReqBody(new _M.test.Input(str)), _M.test.id)))( + { + err => _F.terminate(err) + }, + { + case IRTMuxResponse(IRTResBody(v: _M.test.Output), method) if method == _M.test.id => + _F.pure(v.value) + case v => + val id = "ProtectedTestService.ProtectedTestServiceWrappedClient.test" + val expected = classOf[_M.test.Input].toString + _F.terminate(new IRTTypeMismatchException(s"Unexpected type in $id: $v, expected $expected got ${v.getClass}", v, None)) + }, + ) + } +} + +object ProtectedTestServiceWrappedClient extends IRTWrappedClient { + val allCodecs: Map[IRTMethodId, IRTCirceMarshaller] = { + Map(ProtectedTestService.test.id -> ProtectedTestServiceCodecs.test) + } +} + +class ProtectedTestServiceWrappedServer[Or[+_, +_]: IRTIO2, C](_service: ProtectedTestServiceServer[Or, C]) extends IRTWrappedService[Or, C] { + final val _F: IRTIO2[Or] = implicitly + final val serviceId: IRTServiceId = ProtectedTestService.serviceId + val allMethods: Map[IRTMethodId, IRTMethodWrapper[Or, C]] = { + Seq[IRTMethodWrapper[Or, C]](test).map(m => m.signature.id -> m).toMap + } + object test extends IRTMethodWrapper[Or, C] { + import ProtectedTestService.test.* + val signature: ProtectedTestService.test.type = ProtectedTestService.test + val marshaller: ProtectedTestServiceCodecs.test.type = ProtectedTestServiceCodecs.test + def invoke(ctx: C, input: Input): Just[Output] = { + assert(ctx.asInstanceOf[_root_.scala.AnyRef] != null && input.asInstanceOf[_root_.scala.AnyRef] != null) + _F.map(_service.test(ctx, input.str))(v => new Output(v)) + } + } +} + +object ProtectedTestServiceWrappedServer + +object ProtectedTestService { + final val serviceId: IRTServiceId = IRTServiceId("ProtectedTestService") + object test extends IRTMethodSignature { + final val id: IRTMethodId = IRTMethodId(serviceId, IRTMethodName("test")) + type Input = TestInput + type Output = TestOutput + } + final case class TestInput(str: String) extends AnyVal + object TestInput { + import _root_.io.circe.derivation.{deriveDecoder, deriveEncoder} + import _root_.io.circe.{Decoder, Encoder} + implicit val encodeTestInput: Encoder.AsObject[TestInput] = deriveEncoder[TestInput] + implicit val decodeTestInput: Decoder[TestInput] = deriveDecoder[TestInput] + } + final case class TestOutput(value: String) extends AnyVal + object TestOutput { + import _root_.io.circe.* + import _root_.io.circe.syntax.* + implicit val encodeUnwrappedTestOutput: Encoder[TestOutput] = Encoder.instance { + v => v.value.asJson + } + implicit val decodeUnwrappedTestOutput: Decoder[TestOutput] = Decoder.instance { + v => v.as[String].map(d => TestOutput(d)) + } + } +} + +object ProtectedTestServiceCodecs { + object test extends IRTCirceMarshaller { + import ProtectedTestService.test.* + def encodeRequest: PartialFunction[IRTReqBody, IRTJson] = { + case IRTReqBody(value: Input) => + value.asJson + } + def decodeRequest[Or[+_, +_]: IRTIO2]: PartialFunction[IRTJsonBody, Or[IRTDecodingFailure, IRTReqBody]] = { + case IRTJsonBody(m, packet) if m == id => + this.decoded[Or, IRTReqBody](packet.as[Input].map(v => IRTReqBody(v))) + } + def encodeResponse: PartialFunction[IRTResBody, IRTJson] = { + case IRTResBody(value: Output) => + value.asJson + } + def decodeResponse[Or[+_, +_]: IRTIO2]: PartialFunction[IRTJsonBody, Or[IRTDecodingFailure, IRTResBody]] = { + case IRTJsonBody(m, packet) if m == id => + decoded[Or, IRTResBody](packet.as[Output].map(v => IRTResBody(v))) + } + } +} diff --git a/idealingua-v1/idealingua-v1-test-defs/src/main/scala/izumi/r2/idealingua/test/impls/AbstractGreeterServer.scala b/idealingua-v1/idealingua-v1-test-defs/src/main/scala/izumi/r2/idealingua/test/impls/AbstractGreeterServer.scala index 445a818e..c88ddd98 100644 --- a/idealingua-v1/idealingua-v1-test-defs/src/main/scala/izumi/r2/idealingua/test/impls/AbstractGreeterServer.scala +++ b/idealingua-v1/idealingua-v1-test-defs/src/main/scala/izumi/r2/idealingua/test/impls/AbstractGreeterServer.scala @@ -1,27 +1,13 @@ package izumi.r2.idealingua.test.impls -import izumi.functional.bio.IO2 -import izumi.r2.idealingua.test.generated._ +import izumi.functional.bio.{F, IO2} +import izumi.r2.idealingua.test.generated.* abstract class AbstractGreeterServer[F[+_, +_]: IO2, C] extends GreeterServiceServer[F, C] { - - val R: IO2[F] = implicitly - - override def greet(ctx: C, name: String, surname: String): Just[String] = R.pure { - s"Hi, $name $surname!" - } - - override def sayhi(ctx: C): Just[String] = R.pure { - "Hi!" - } - - override def alternative(ctx: C): F[Long, String] = R.fromEither { - Right("value") - } - - override def nothing(ctx: C): F[Nothing, String] = R.pure { - "" - } + override def greet(ctx: C, name: String, surname: String): Just[String] = F.pure(s"Hi, $name $surname!") + override def sayhi(ctx: C): Just[String] = F.pure(s"Hi! With $ctx.") + override def alternative(ctx: C): F[Long, String] = F.fromEither(Right("value")) + override def nothing(ctx: C): F[Nothing, String] = F.pure("") } object AbstractGreeterServer { From 4c949e299eb803f70f2bfaea48e57ef79dbd1861 Mon Sep 17 00:00:00 2001 From: Alex Liubymov Date: Thu, 14 Dec 2023 18:28:39 +0200 Subject: [PATCH 03/24] wip --- .../runtime/rpc/http4s/HttpServer.scala | 39 ++++++---- .../rpc/http4s/IRTServicesContext.scala | 67 ----------------- .../IRTServicesContextMultiplexor.scala | 46 ------------ .../rpc/http4s/IRTServicesMultiplexor.scala | 71 +++++++++++++++++++ .../clients/WsRpcDispatcherFactory.scala | 28 +++----- .../rpc/http4s/ws/WsClientSession.scala | 19 ++--- ...sContext.scala => WsContextSessions.scala} | 42 ++++++----- .../runtime/rpc/http4s/ws/WsRpcHandler.scala | 31 ++++---- .../rpc/http4s/Http4sTransportTest.scala | 9 ++- .../rpc/http4s/fixtures/TestServices.scala | 35 +++++---- 10 files changed, 176 insertions(+), 211 deletions(-) delete mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTServicesContext.scala delete mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTServicesContextMultiplexor.scala create mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTServicesMultiplexor.scala rename idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/{WsSessionsContext.scala => WsContextSessions.scala} (57%) diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala index 5ad6e42f..053c21e2 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala @@ -10,11 +10,12 @@ import io.circe.syntax.EncoderOps import izumi.functional.bio.Exit.{Error, Interruption, Success, Termination} import izumi.functional.bio.{Exit, F, IO2, Primitives2, Temporal2, UnsafeRun2} import izumi.fundamentals.platform.language.Quirks +import izumi.fundamentals.platform.language.Quirks.Discarder import izumi.fundamentals.platform.time.IzTime import izumi.idealingua.runtime.rpc.* import izumi.idealingua.runtime.rpc.http4s.HttpServer.{ServerWsRpcHandler, WsResponseMarker} import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext -import izumi.idealingua.runtime.rpc.http4s.IRTServicesContext.{InvokeMethodFailure, InvokeMethodResult} +import izumi.idealingua.runtime.rpc.http4s.IRTServicesMultiplexor.{InvokeMethodFailure, InvokeMethodResult} import izumi.idealingua.runtime.rpc.http4s.ws.* import izumi.idealingua.runtime.rpc.http4s.ws.WsClientSession.WsClientSessionImpl import logstage.LogIO2 @@ -31,7 +32,8 @@ import java.util.concurrent.RejectedExecutionException import scala.concurrent.duration.DurationInt class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2]( - val muxer: IRTServicesContextMultiplexor[F], + val servicesMuxer: IRTServicesMultiplexor[F, ?, ?], + val wsContextsSessions: Set[WsContextSessions[F, ?, ?]], val wsSessionsStorage: WsSessionsStorage[F], dsl: Http4sDsl[F[Throwable, _]], logger: LogIO2[F], @@ -56,7 +58,17 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2]( protected def handleWsClose(session: WsClientSession[F]): F[Throwable, Unit] = { logger.debug(s"WS Session: Websocket client disconnected ${session.sessionId}.") *> - session.finish() + session.finish(onWsDisconnected) + } + + protected def onWsConnected(authContext: AuthContext): F[Throwable, Unit] = { + authContext.discard() + F.unit + } + + protected def onWsDisconnected(authContext: AuthContext): F[Throwable, Unit] = { + authContext.discard() + F.unit } protected def globalWsListener[Ctx, WsCtx]: WsSessionListener[F, Ctx, WsCtx] = new WsSessionListener[F, Ctx, WsCtx] { @@ -84,8 +96,8 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2]( for { outQueue <- Queue.unbounded[F[Throwable, _], WebSocketFrame] authContext <- F.syncThrowable(extractAuthContext(request)) - clientSession = new WsClientSessionImpl(outQueue, authContext, muxer, wsSessionsStorage, logger, printer) - _ <- clientSession.start() + clientSession = new WsClientSessionImpl(outQueue, authContext, wsContextsSessions, wsSessionsStorage, logger, printer) + _ <- clientSession.start(onWsConnected) outStream = Stream.fromQueueUnterminated(outQueue).merge(pingStream) inStream = { @@ -121,7 +133,7 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2]( } protected def wsHandler(clientSession: WsClientSession[F]): WsRpcHandler[F] = { - new ServerWsRpcHandler(clientSession, muxer, logger) + new ServerWsRpcHandler(clientSession, servicesMuxer, logger) } protected def onWsHeartbeat(requestTime: ZonedDateTime): F[Throwable, Unit] = { @@ -138,7 +150,7 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2]( (for { authContext <- F.syncThrowable(extractAuthContext(request)) parsedBody <- F.fromEither(io.circe.parser.parse(body)) - invokeRes <- muxer.invokeMethodWithAuth(methodId)(authContext, parsedBody) + invokeRes <- servicesMuxer.invokeMethodWithAuth(methodId)(authContext, parsedBody) } yield invokeRes).sandboxExit.flatMap(handleHttpResult(request, methodId)) } @@ -235,12 +247,13 @@ object HttpServer { case object WsResponseMarker class ServerWsRpcHandler[F[+_, +_]: IO2, RequestCtx, ClientId]( clientSession: WsClientSession[F], - contextServicesMuxer: IRTServicesContextMultiplexor[F], + contextServicesMuxer: IRTServicesMultiplexor[F, ?, ?], logger: LogIO2[F], ) extends WsRpcHandler[F](contextServicesMuxer, clientSession, logger) { - override protected def getAuthContext: AuthContext = clientSession.getAuthContext - - override def handlePacket(packet: RpcPacket): F[Throwable, Unit] = { + override protected def getAuthContext: AuthContext = { + clientSession.getAuthContext + } + override protected def updateAuthContext(packet: RpcPacket): F[Throwable, Unit] = { F.traverse_(packet.headers) { headersMap => val headers = Headers.apply(headersMap.toSeq.map { case (k, v) => Header.Raw(CIString(k), v) }) @@ -248,9 +261,5 @@ object HttpServer { clientSession.updateAuthContext(authContext) } } - - override protected def handleAuthRequest(packet: RpcPacket): F[Throwable, Option[RpcPacket]] = { - F.pure(Some(RpcPacket(RPCPacketKind.RpcResponse, None, None, packet.id, None, None, None))) - } } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTServicesContext.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTServicesContext.scala deleted file mode 100644 index 7ec9fd3f..00000000 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTServicesContext.scala +++ /dev/null @@ -1,67 +0,0 @@ -package izumi.idealingua.runtime.rpc.http4s - -import io.circe.Json -import izumi.functional.bio.{Exit, F, IO2} -import izumi.idealingua.runtime.rpc.* -import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext -import izumi.idealingua.runtime.rpc.http4s.IRTServicesContext.InvokeMethodResult -import izumi.idealingua.runtime.rpc.http4s.ws.* - -trait IRTServicesContext[F[_, _], RequestCtx, WsCtx] { - val services: Set[IRTWrappedService[F, RequestCtx]] - def updateWsSession(wsSessionId: WsSessionId, authContext: Option[AuthContext]): F[Throwable, Unit] - def invokeMethodWithAuth(method: IRTMethodId)(authContext: AuthContext, body: Json): F[Throwable, InvokeMethodResult] -} - -object IRTServicesContext { - final case class InvokeMethodResult(context: Any, res: Json) - - abstract class InvokeMethodFailure(message: String) extends RuntimeException(s"Method invokation failed: $message.") - object InvokeMethodFailure { - final case class ServiceNotFound(serviceId: IRTServiceId) extends InvokeMethodFailure(s"Service $serviceId not found .") - final case class MethodNotFound(methodId: IRTMethodId) extends InvokeMethodFailure(s"Method $methodId not found .") - final case class AuthFailed(context: AuthContext) extends InvokeMethodFailure(s"Authorization with $context failed.") - } - - final class IRTServicesContextImpl[F[+_, +_]: IO2, RequestCtx, WsCtx]( - val services: Set[IRTWrappedService[F, RequestCtx]], - val authenticator: IRTAuthenticator[F, RequestCtx], - val wsContext: WsSessionsContext[F, RequestCtx, WsCtx], - ) extends IRTServicesContext[F, RequestCtx, WsCtx] { - def methods: Map[IRTMethodId, IRTMethodWrapper[F, RequestCtx]] = services.flatMap(_.allMethods).toMap - - override def updateWsSession(wsSessionId: WsSessionId, authContext: Option[AuthContext]): F[Throwable, Unit] = { - F.traverse(authContext)(authenticator.authenticate(_, None)).map(_.flatten).sandboxExit.flatMap { - case Exit.Success(ctx) => wsContext.updateSession(wsSessionId, ctx) - case _ => wsContext.updateSession(wsSessionId, None) - } - } - - override def invokeMethodWithAuth(method: IRTMethodId)(authContext: AuthContext, body: Json): F[Throwable, InvokeMethodResult] = { - for { - wrappedMethod <- F.fromOption(InvokeMethodFailure.MethodNotFound(method))(methods.get(method)) - requestCtx <- authenticator.authenticate(authContext, Some(body)).fromOption(InvokeMethodFailure.AuthFailed(authContext)) - res <- invoke(wrappedMethod)(requestCtx, body) - } yield InvokeMethodResult(requestCtx, res) - } - - @inline private[this] def invoke[C](method: IRTMethodWrapper[F, C])(context: C, parsedBody: Json): F[Throwable, Json] = { - val methodId = method.signature.id - for { - decodeAction <- F.syncThrowable(method.marshaller.decodeRequest[F].apply(IRTJsonBody(methodId, parsedBody))) - safeDecoded <- decodeAction.sandbox.catchAll { - case Exit.Interruption(decodingFailure, _, trace) => - F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON ${parsedBody.toString()} $trace", Some(decodingFailure))) - case Exit.Termination(_, exceptions, trace) => - F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON ${parsedBody.toString()} $trace", exceptions.headOption)) - case Exit.Error(decodingFailure, trace) => - F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON ${parsedBody.toString()} $trace", Some(decodingFailure))) - } - casted = safeDecoded.value.asInstanceOf[method.signature.Input] - resultAction <- F.syncThrowable(method.invoke(context, casted)) - safeResult <- resultAction - encoded <- F.syncThrowable(method.marshaller.encodeResponse.apply(IRTResBody(safeResult))) - } yield encoded - } - } -} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTServicesContextMultiplexor.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTServicesContextMultiplexor.scala deleted file mode 100644 index 8eb8e562..00000000 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTServicesContextMultiplexor.scala +++ /dev/null @@ -1,46 +0,0 @@ -package izumi.idealingua.runtime.rpc.http4s - -import io.circe.Json -import izumi.functional.bio.{F, IO2} -import izumi.idealingua.runtime.rpc.* -import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext -import izumi.idealingua.runtime.rpc.http4s.IRTServicesContext.{IRTServicesContextImpl, InvokeMethodFailure, InvokeMethodResult} -import izumi.idealingua.runtime.rpc.http4s.ws.{WsSessionId, WsSessionsContext} - -trait IRTServicesContextMultiplexor[F[+_, +_]] { - def updateWsSession(wsSessionId: WsSessionId, authContext: Option[AuthContext]): F[Throwable, Unit] - def invokeMethodWithAuth(method: IRTMethodId)(authContext: AuthContext, body: Json): F[Throwable, InvokeMethodResult] -} - -object IRTServicesContextMultiplexor { - class Single[F[+_, +_]: IO2, RequestCtx]( - val services: Set[IRTWrappedService[F, RequestCtx]], - val authenticator: IRTAuthenticator[F, RequestCtx], - ) extends IRTServicesContextMultiplexor[F] { - private val inner: IRTServicesContext[F, RequestCtx, Unit] = new IRTServicesContextImpl(services, authenticator, WsSessionsContext.empty) - override def updateWsSession(wsSessionId: WsSessionId, authContext: Option[AuthContext]): F[Throwable, Unit] = { - F.unit - } - override def invokeMethodWithAuth(method: IRTMethodId)(authContext: AuthContext, body: Json): F[Throwable, InvokeMethodResult] = { - inner.invokeMethodWithAuth(method)(authContext, body) - } - } - - class MultiContext[F[+_, +_]: IO2]( - servicesContexts: Set[IRTServicesContext[F, ?, ?]] - ) extends IRTServicesContextMultiplexor[F] { - - private val services: Map[IRTServiceId, IRTServicesContext[F, ?, ?]] = { - servicesContexts.flatMap(c => c.services.map(_.serviceId -> c)).toMap - } - - override def updateWsSession(wsSessionId: WsSessionId, authContext: Option[AuthContext]): F[Throwable, Unit] = { - F.traverse_(servicesContexts)(_.updateWsSession(wsSessionId, authContext)) - } - - def invokeMethodWithAuth(method: IRTMethodId)(authContext: AuthContext, body: Json): F[Throwable, InvokeMethodResult] = { - F.fromOption(InvokeMethodFailure.ServiceNotFound(method.service))(services.get(method.service)) - .flatMap(_.invokeMethodWithAuth(method)(authContext, body)) - } - } -} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTServicesMultiplexor.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTServicesMultiplexor.scala new file mode 100644 index 00000000..9822f5dc --- /dev/null +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTServicesMultiplexor.scala @@ -0,0 +1,71 @@ +package izumi.idealingua.runtime.rpc.http4s + +import io.circe.Json +import izumi.functional.bio.{Exit, F, IO2} +import izumi.idealingua.runtime.rpc.* +import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext +import izumi.idealingua.runtime.rpc.http4s.IRTServicesMultiplexor.InvokeMethodResult + +trait IRTServicesMultiplexor[F[_, _], RequestCtx, WsCtx] { + def invokeMethodWithAuth(method: IRTMethodId)(authContext: AuthContext, body: Json): F[Throwable, InvokeMethodResult] +} + +object IRTServicesMultiplexor { + final case class InvokeMethodResult(context: Any, res: Json) + abstract class InvokeMethodFailure(message: String) extends RuntimeException(s"Method invokation failed: $message.") + object InvokeMethodFailure { + final case class ServiceNotFound(serviceId: IRTServiceId) extends InvokeMethodFailure(s"Service $serviceId not found .") + final case class MethodNotFound(methodId: IRTMethodId) extends InvokeMethodFailure(s"Method $methodId not found .") + final case class AuthFailed(context: AuthContext) extends InvokeMethodFailure(s"Authorization with $context failed.") + } + + trait MultiContext[F[_, _]] extends IRTServicesMultiplexor[F, Unit, Unit] + object MultiContext { + class Impl[F[+_, +_]: IO2](servicesContexts: Set[IRTServicesMultiplexor.SingleContext[F, ?, ?]]) extends IRTServicesMultiplexor.MultiContext[F] { + private val services: Map[IRTServiceId, IRTServicesMultiplexor[F, ?, ?]] = { + servicesContexts.flatMap(c => c.services.map(_.serviceId -> c)).toMap + } + def invokeMethodWithAuth(method: IRTMethodId)(authContext: AuthContext, body: Json): F[Throwable, InvokeMethodResult] = { + F.fromOption(InvokeMethodFailure.ServiceNotFound(method.service))(services.get(method.service)) + .flatMap(_.invokeMethodWithAuth(method)(authContext, body)) + } + } + } + + trait SingleContext[F[_, _], RequestCtx, WsCtx] extends IRTServicesMultiplexor[F, RequestCtx, WsCtx] { + val services: Set[IRTWrappedService[F, RequestCtx]] + } + object SingleContext { + class Impl[F[+_, +_]: IO2, RequestCtx, WsCtx]( + val services: Set[IRTWrappedService[F, RequestCtx]], + val authenticator: IRTAuthenticator[F, RequestCtx], + ) extends SingleContext[F, RequestCtx, WsCtx] { + def methods: Map[IRTMethodId, IRTMethodWrapper[F, RequestCtx]] = services.flatMap(_.allMethods).toMap + + override def invokeMethodWithAuth(method: IRTMethodId)(authContext: AuthContext, body: Json): F[Throwable, InvokeMethodResult] = { + for { + wrappedMethod <- F.fromOption(InvokeMethodFailure.MethodNotFound(method))(methods.get(method)) + requestCtx <- authenticator.authenticate(authContext, Some(body)).fromOption(InvokeMethodFailure.AuthFailed(authContext)) + res <- invoke(wrappedMethod)(requestCtx, body) + } yield InvokeMethodResult(requestCtx, res) + } + + @inline private[this] def invoke[C](method: IRTMethodWrapper[F, C])(context: C, parsedBody: Json): F[Throwable, Json] = { + val methodId = method.signature.id + for { + request <- F.syncThrowable(method.marshaller.decodeRequest[F].apply(IRTJsonBody(methodId, parsedBody))).flatten.sandbox.catchAll { + case Exit.Interruption(decodingFailure, _, trace) => + F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON ${parsedBody.toString()} $trace", Some(decodingFailure))) + case Exit.Termination(_, exceptions, trace) => + F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON ${parsedBody.toString()} $trace", exceptions.headOption)) + case Exit.Error(decodingFailure, trace) => + F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON ${parsedBody.toString()} $trace", Some(decodingFailure))) + } + methodInput = request.value.asInstanceOf[method.signature.Input] + methodOutput <- F.syncThrowable(method.invoke(context, methodInput)).flatten + response <- F.syncThrowable(method.marshaller.encodeResponse.apply(IRTResBody(methodOutput))) + } yield response + } + } + } +} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala index b1fd7043..853fa3e9 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala @@ -7,7 +7,7 @@ import izumi.functional.lifecycle.Lifecycle import izumi.fundamentals.platform.language.Quirks.Discarder import izumi.idealingua.runtime.rpc.* import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext -import izumi.idealingua.runtime.rpc.http4s.IRTServicesContextMultiplexor +import izumi.idealingua.runtime.rpc.http4s.IRTServicesMultiplexor import izumi.idealingua.runtime.rpc.http4s.clients.WsRpcDispatcher.IRTDispatcherWs import izumi.idealingua.runtime.rpc.http4s.clients.WsRpcDispatcherFactory.{ClientWsRpcHandler, WsRpcClientConnection, fromNettyFuture} import izumi.idealingua.runtime.rpc.http4s.ws.{RawResponse, WsRequestState, WsRpcHandler} @@ -31,7 +31,7 @@ class WsRpcDispatcherFactory[F[+_, +_]: Async2: Temporal2: Primitives2: UnsafeRu def connect( uri: Uri, - muxer: IRTServicesContextMultiplexor[F], + muxer: IRTServicesMultiplexor[F, ?, ?], ): Lifecycle[F[Throwable, _], WsRpcClientConnection[F]] = { for { client <- WsRpcDispatcherFactory.asyncHttpClient[F] @@ -48,9 +48,9 @@ class WsRpcDispatcherFactory[F[+_, +_]: Async2: Temporal2: Primitives2: UnsafeRu } } - def dispatcher[ServerContext]( + def dispatcher( uri: Uri, - muxer: IRTServicesContextMultiplexor[F], + muxer: IRTServicesMultiplexor[F, ?, ?], tweakRequest: RpcPacket => RpcPacket = identity, timeout: FiniteDuration = 30.seconds, ): Lifecycle[F[Throwable, _], IRTDispatcherWs[F]] = { @@ -64,7 +64,7 @@ class WsRpcDispatcherFactory[F[+_, +_]: Async2: Temporal2: Primitives2: UnsafeRu } protected def wsHandler[ServerContext]( - muxer: IRTServicesContextMultiplexor[F], + muxer: IRTServicesMultiplexor[F, ?, ?], requestState: WsRequestState[F], logger: LogIO2[F], ): WsRpcHandler[F] = { @@ -72,7 +72,7 @@ class WsRpcDispatcherFactory[F[+_, +_]: Async2: Temporal2: Primitives2: UnsafeRu } protected def createListener( - muxer: IRTServicesContextMultiplexor[F], + muxer: IRTServicesMultiplexor[F, ?, ?], requestState: WsRequestState[F], logger: LogIO2[F], ): WebSocketListener = new WebSocketListener() { @@ -153,18 +153,13 @@ object WsRpcDispatcherFactory { } class ClientWsRpcHandler[F[+_, +_]: IO2]( - muxer: IRTServicesContextMultiplexor[F], + muxer: IRTServicesMultiplexor[F, ?, ?], requestState: WsRequestState[F], logger: LogIO2[F], ) extends WsRpcHandler[F](muxer, requestState, logger) { - override protected def handlePacket(packet: RpcPacket): F[Throwable, Unit] = { + override protected def updateAuthContext(packet: RpcPacket): F[Throwable, Unit] = { F.unit } - - override protected def handleAuthRequest(packet: RpcPacket): F[Throwable, Option[RpcPacket]] = { - F.pure(None) - } - override protected def getAuthContext: AuthContext = { AuthContext(Headers.empty, None) } @@ -204,13 +199,6 @@ object WsRpcDispatcherFactory { } } - trait WsRpcContextProvider[Ctx] { - def toContext(packet: RpcPacket): Ctx - } - object WsRpcContextProvider { - def unit: WsRpcContextProvider[Unit] = _ => () - } - private def fromNettyFuture[F[+_, +_]: Async2, A](mkNettyFuture: => io.netty.util.concurrent.Future[A]): F[Throwable, A] = { F.syncThrowable(mkNettyFuture).flatMap { nettyFuture => diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala index 29d61d06..294d15a8 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala @@ -7,7 +7,6 @@ import izumi.functional.bio.{F, IO2, Primitives2, Temporal2} import izumi.fundamentals.platform.time.IzTime import izumi.fundamentals.platform.uuid.UUIDGen import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext -import izumi.idealingua.runtime.rpc.http4s.IRTServicesContextMultiplexor import izumi.idealingua.runtime.rpc.http4s.ws.WsRpcHandler.WsResponder import izumi.idealingua.runtime.rpc.{IRTMethodId, RpcPacket, RpcPacketId} import logstage.LogIO2 @@ -27,8 +26,8 @@ trait WsClientSession[F[+_, +_]] extends WsResponder[F] { def updateAuthContext(newContext: AuthContext): F[Throwable, Unit] - def start(): F[Throwable, Unit] - def finish(): F[Throwable, Unit] + def start(onStart: AuthContext => F[Throwable, Unit]): F[Throwable, Unit] + def finish(onFinish: AuthContext => F[Throwable, Unit]): F[Throwable, Unit] } object WsClientSession { @@ -36,7 +35,7 @@ object WsClientSession { class WsClientSessionImpl[F[+_, +_]: IO2: Temporal2: Primitives2]( outQueue: Queue[F[Throwable, _], WebSocketFrame], initialContext: AuthContext, - muxer: IRTServicesContextMultiplexor[F], + wsSessionsContext: Set[WsContextSessions[F, ?, ?]], wsSessionStorage: WsSessionsStorage[F], logger: LogIO2[F], printer: Printer, @@ -62,7 +61,7 @@ object WsClientSession { } (oldContext, updatedContext) = contexts _ <- F.when(oldContext != updatedContext) { - muxer.updateWsSession(sessionId, Some(updatedContext)) + F.traverse_(wsSessionsContext)(_.updateSession(sessionId, Some(updatedContext))) } } yield () } @@ -87,16 +86,18 @@ object WsClientSession { requestState.responseWithData(id, data) } - override def finish(): F[Throwable, Unit] = { + override def finish(onFinish: AuthContext => F[Throwable, Unit]): F[Throwable, Unit] = { F.fromEither(WebSocketFrame.Close(1000)).flatMap(outQueue.offer(_)) *> requestState.clear() *> wsSessionStorage.deleteSession(sessionId) *> - muxer.updateWsSession(sessionId, None) + F.traverse_(wsSessionsContext)(_.updateSession(sessionId, None)) *> + onFinish(getAuthContext) } - override def start(): F[Throwable, Unit] = { + override def start(onStart: AuthContext => F[Throwable, Unit]): F[Throwable, Unit] = { wsSessionStorage.addSession(this) *> - muxer.updateWsSession(sessionId, Some(getAuthContext)) + F.traverse_(wsSessionsContext)(_.updateSession(sessionId, Some(getAuthContext))) *> + onStart(getAuthContext) } override def toString: String = s"[$sessionId, ${duration().toSeconds}s]" diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionsContext.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala similarity index 57% rename from idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionsContext.scala rename to idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala index fc6a2377..27bb9551 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionsContext.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala @@ -1,39 +1,43 @@ package izumi.idealingua.runtime.rpc.http4s.ws -import izumi.functional.bio.{F, IO2} +import izumi.functional.bio.{Exit, F, IO2} +import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator +import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext import izumi.idealingua.runtime.rpc.{IRTClientMultiplexor, IRTDispatcher} import java.util.concurrent.ConcurrentHashMap import scala.concurrent.duration.{DurationInt, FiniteDuration} -trait WsSessionsContext[F[+_, +_], RequestCtx, WsCtx] { - def updateSession(wsSessionId: WsSessionId, requestContext: Option[RequestCtx]): F[Throwable, Unit] - def dispatcherFor( - ctx: WsCtx, - codec: IRTClientMultiplexor[F], - timeout: FiniteDuration = 20.seconds, - ): F[Throwable, Option[IRTDispatcher[F]]] +trait WsContextSessions[F[+_, +_], RequestCtx, WsCtx] { + def updateSession(wsSessionId: WsSessionId, authContext: Option[AuthContext]): F[Throwable, Unit] + def updateSessionWith(wsSessionId: WsSessionId, requestContext: Option[RequestCtx]): F[Throwable, Unit] + def dispatcherFor(ctx: WsCtx, codec: IRTClientMultiplexor[F], timeout: FiniteDuration = 20.seconds): F[Throwable, Option[IRTDispatcher[F]]] } -object WsSessionsContext { - def empty[F[+_, +_]: IO2, RequestCtx]: WsSessionsContext[F, RequestCtx, Unit] = new WsSessionsContext[F, RequestCtx, Unit] { - override def updateSession(wsSessionId: WsSessionId, requestContext: Option[RequestCtx]): F[Throwable, Unit] = { - F.unit - } - override def dispatcherFor(ctx: Unit, codec: IRTClientMultiplexor[F], timeout: FiniteDuration): F[Throwable, Option[IRTDispatcher[F]]] = { - F.pure(None) - } +object WsContextSessions { + def empty[F[+_, +_]: IO2, RequestCtx]: WsContextSessions[F, RequestCtx, Unit] = new WsContextSessions[F, RequestCtx, Unit] { + override def updateSession(wsSessionId: WsSessionId, authContext: Option[AuthContext]): F[Throwable, Unit] = F.unit + override def updateSessionWith(wsSessionId: WsSessionId, requestContext: Option[RequestCtx]): F[Throwable, Unit] = F.unit + override def dispatcherFor(ctx: Unit, codec: IRTClientMultiplexor[F], timeout: FiniteDuration): F[Throwable, Option[IRTDispatcher[F]]] = F.pure(None) } - class WsSessionsContextImpl[F[+_, +_]: IO2, RequestCtx, WsCtx]( + class WsContextSessionsImpl[F[+_, +_]: IO2, RequestCtx, WsCtx]( + authenticator: IRTAuthenticator[F, RequestCtx], wsSessionsStorage: WsSessionsStorage[F], wsSessionListeners: Set[WsSessionListener[F, RequestCtx, WsCtx]], wsContextExtractor: WsContextExtractor[RequestCtx, WsCtx], - ) extends WsSessionsContext[F, RequestCtx, WsCtx] { + ) extends WsContextSessions[F, RequestCtx, WsCtx] { private val sessionToId = new ConcurrentHashMap[WsSessionId, WsCtx]() private val idToSession = new ConcurrentHashMap[WsCtx, WsSessionId]() - override def updateSession(wsSessionId: WsSessionId, requestContext: Option[RequestCtx]): F[Throwable, Unit] = { + override def updateSession(wsSessionId: WsSessionId, authContext: Option[AuthContext]): F[Throwable, Unit] = { + F.traverse(authContext)(authenticator.authenticate(_, None)).map(_.flatten).sandboxExit.flatMap { + case Exit.Success(ctx) => updateSessionWith(wsSessionId, ctx) + case _: Exit.Failure[_] => updateSessionWith(wsSessionId, None) + } + } + + override def updateSessionWith(wsSessionId: WsSessionId, requestContext: Option[RequestCtx]): F[Throwable, Unit] = { updateCtx(wsSessionId, requestContext).flatMap { case (Some(ctx), Some(previous), Some(updated)) if previous != updated => F.traverse_(wsSessionListeners)(_.onSessionUpdated(wsSessionId, ctx, previous, updated)) diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala index d182af21..47623575 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala @@ -6,34 +6,24 @@ import izumi.functional.bio.{Exit, F, IO2} import izumi.fundamentals.platform.language.Quirks.Discarder import izumi.idealingua.runtime.rpc.* import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext -import izumi.idealingua.runtime.rpc.http4s.IRTServicesContext.{InvokeMethodFailure, InvokeMethodResult} -import izumi.idealingua.runtime.rpc.http4s.IRTServicesContextMultiplexor +import izumi.idealingua.runtime.rpc.http4s.IRTServicesMultiplexor +import izumi.idealingua.runtime.rpc.http4s.IRTServicesMultiplexor.{InvokeMethodFailure, InvokeMethodResult} import izumi.idealingua.runtime.rpc.http4s.ws.WsRpcHandler.WsResponder import logstage.LogIO2 abstract class WsRpcHandler[F[+_, +_]: IO2]( - muxer: IRTServicesContextMultiplexor[F], + muxer: IRTServicesMultiplexor[F, ?, ?], responder: WsResponder[F], logger: LogIO2[F], ) { - protected def handlePacket(packet: RpcPacket): F[Throwable, Unit] - - protected def handleAuthRequest(packet: RpcPacket): F[Throwable, Option[RpcPacket]] - + protected def updateAuthContext(packet: RpcPacket): F[Throwable, Unit] protected def getAuthContext: AuthContext - protected def handleAuthResponse(ref: RpcPacketId, packet: RpcPacket): F[Throwable, Option[RpcPacket]] = { - packet.discard() - responder.responseWith(ref, RawResponse.EmptyRawResponse()).as(None) - } - - def processRpcMessage( - message: String - ): F[Throwable, Option[RpcPacket]] = { + def processRpcMessage(message: String): F[Throwable, Option[RpcPacket]] = { for { packet <- F.fromEither(io.circe.parser.decode[RpcPacket](message)) - _ <- handlePacket(packet) + _ <- updateAuthContext(packet) response <- packet match { // auth case RpcPacket(RPCPacketKind.RpcRequest, None, _, _, _, _, _) => @@ -114,6 +104,15 @@ abstract class WsRpcHandler[F[+_, +_]: IO2]( logger.error(s"WS request interrupted: $exception $allExceptions $trace").as(Some(onFail(exception.getMessage))) } } + + protected def handleAuthRequest(packet: RpcPacket): F[Throwable, Option[RpcPacket]] = { + F.pure(Some(RpcPacket(RPCPacketKind.RpcResponse, None, None, packet.id, None, None, None))) + } + + protected def handleAuthResponse(ref: RpcPacketId, packet: RpcPacket): F[Throwable, Option[RpcPacket]] = { + packet.discard() + responder.responseWith(ref, RawResponse.EmptyRawResponse()).as(None) + } } object WsRpcHandler { diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala index 263e0b51..4d299eb8 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala @@ -60,7 +60,14 @@ object Http4sTransportTest { final val demo = new TestServices[F](logger) - final val ioService = new HttpServer[F](demo.Server.contextMuxer, demo.Server.wsStorage, dsl, logger, printer) + final val ioService = new HttpServer[F]( + servicesMuxer = demo.Server.contextMuxer, + wsContextsSessions = demo.Server.wsContextsSessions, + wsSessionsStorage = demo.Server.wsStorage, + dsl = dsl, + logger = logger, + printer = printer, + ) def badAuth(user: String): Header.ToRaw = Authorization(BasicCredentials(user, "badpass")) def publicAuth(user: String): Header.ToRaw = Authorization(BasicCredentials(user, "public")) diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala index da4a83fe..064a901e 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala @@ -3,13 +3,11 @@ package izumi.idealingua.runtime.rpc.http4s.fixtures import io.circe.Json import izumi.functional.bio.{F, IO2} import izumi.idealingua.runtime.rpc.* -import izumi.idealingua.runtime.rpc.http4s.IRTServicesContext.IRTServicesContextImpl -import izumi.idealingua.runtime.rpc.http4s.IRTServicesContextMultiplexor.MultiContext import izumi.idealingua.runtime.rpc.http4s.fixtures.defs.* -import izumi.idealingua.runtime.rpc.http4s.ws.WsSessionsContext.WsSessionsContextImpl +import izumi.idealingua.runtime.rpc.http4s.ws.WsContextSessions.WsContextSessionsImpl import izumi.idealingua.runtime.rpc.http4s.ws.WsSessionsStorage.WsSessionsStorageImpl -import izumi.idealingua.runtime.rpc.http4s.ws.{WsContextExtractor, WsSessionsContext, WsSessionsStorage} -import izumi.idealingua.runtime.rpc.http4s.{IRTAuthenticator, IRTServicesContext, IRTServicesContextMultiplexor} +import izumi.idealingua.runtime.rpc.http4s.ws.{WsContextExtractor, WsContextSessions, WsSessionsStorage} +import izumi.idealingua.runtime.rpc.http4s.{IRTAuthenticator, IRTServicesMultiplexor} import izumi.r2.idealingua.test.generated.{GreeterServiceClientWrapped, GreeterServiceServerWrapped} import izumi.r2.idealingua.test.impls.AbstractGreeterServer import logstage.LogIO2 @@ -31,12 +29,12 @@ class TestServices[F[+_, +_]: IO2]( } } } - final val privateWsSession: WsSessionsContext[F, PrivateContext, PrivateContext] = new WsSessionsContextImpl(wsStorage, Set.empty, WsContextExtractor.id) + final val privateWsSession: WsContextSessions[F, PrivateContext, PrivateContext] = new WsContextSessionsImpl(privateAuth, wsStorage, Set.empty, WsContextExtractor.id) final val privateService: IRTWrappedService[F, PrivateContext] = new PrivateTestServiceWrappedServer(new PrivateTestServiceServer[F, PrivateContext] { def test(ctx: PrivateContext, str: String): Just[String] = F.pure(s"Private: $str") }) - final val privateServices: IRTServicesContext[F, PrivateContext, PrivateContext] = { - new IRTServicesContextImpl(Set(privateService), privateAuth, privateWsSession) + final val privateServices: IRTServicesMultiplexor.SingleContext[F, PrivateContext, PrivateContext] = { + new IRTServicesMultiplexor.SingleContext.Impl(Set(privateService), privateAuth) } // PROTECTED @@ -47,14 +45,14 @@ class TestServices[F[+_, +_]: IO2]( } } } - final val protectedWsSession: WsSessionsContext[F, ProtectedContext, ProtectedContext] = { - new WsSessionsContextImpl(wsStorage, Set.empty, WsContextExtractor.id) + final val protectedWsSession: WsContextSessions[F, ProtectedContext, ProtectedContext] = { + new WsContextSessionsImpl(protectedAuth, wsStorage, Set.empty, WsContextExtractor.id) } final val protectedService: IRTWrappedService[F, ProtectedContext] = new ProtectedTestServiceWrappedServer(new ProtectedTestServiceServer[F, ProtectedContext] { def test(ctx: ProtectedContext, str: String): Just[String] = F.pure(s"Protected: $str") }) - final val protectedServices: IRTServicesContext[F, ProtectedContext, ProtectedContext] = { - new IRTServicesContextImpl(Set(protectedService), protectedAuth, protectedWsSession) + final val protectedServices: IRTServicesMultiplexor.SingleContext[F, ProtectedContext, ProtectedContext] = { + new IRTServicesMultiplexor.SingleContext.Impl(Set(protectedService), protectedAuth) } // PUBLIC @@ -65,13 +63,14 @@ class TestServices[F[+_, +_]: IO2]( } } } - final val publicWsSession: WsSessionsContext[F, PublicContext, PublicContext] = new WsSessionsContextImpl(wsStorage, Set.empty, WsContextExtractor.id) + final val publicWsSession: WsContextSessions[F, PublicContext, PublicContext] = new WsContextSessionsImpl(publicAuth, wsStorage, Set.empty, WsContextExtractor.id) final val publicService: IRTWrappedService[F, PublicContext] = new GreeterServiceServerWrapped(new AbstractGreeterServer.Impl[F, PublicContext]) - final val publicServices: IRTServicesContext[F, PublicContext, PublicContext] = { - new IRTServicesContextImpl(Set(publicService), publicAuth, publicWsSession) + final val publicServices: IRTServicesMultiplexor.SingleContext[F, PublicContext, PublicContext] = { + new IRTServicesMultiplexor.SingleContext.Impl(Set(publicService), publicAuth) } - final val contextMuxer: IRTServicesContextMultiplexor[F] = new MultiContext[F](Set(privateServices, protectedServices, publicServices)) + final val contextMuxer: IRTServicesMultiplexor[F, Unit, Unit] = new IRTServicesMultiplexor.MultiContext.Impl(Set(privateServices, protectedServices, publicServices)) + final val wsContextsSessions: Set[WsContextSessions[F, ?, ?]] = Set(privateWsSession, protectedWsSession, publicWsSession) } object Client { @@ -85,8 +84,8 @@ class TestServices[F[+_, +_]: IO2]( PrivateTestServiceWrappedClient, ) val codec: IRTClientMultiplexorImpl[F] = new IRTClientMultiplexorImpl[F](clients) - val buzzerMultiplexor: IRTServicesContextMultiplexor[F] = { - new IRTServicesContextMultiplexor.Single[F, Unit](dispatchers, IRTAuthenticator.unit) + val buzzerMultiplexor: IRTServicesMultiplexor[F, Unit, Unit] = { + new IRTServicesMultiplexor.SingleContext.Impl(dispatchers, IRTAuthenticator.unit) } } } From 5605cbd7bdac1e36ebea0fdb4710959df7766875 Mon Sep 17 00:00:00 2001 From: Alex Liubymov Date: Fri, 15 Dec 2023 13:40:31 +0200 Subject: [PATCH 04/24] reuse server muxer --- .../runtime/rpc/http4s/HttpServer.scala | 85 +++++++------------ .../runtime/rpc/http4s/IRTAuthenticator.scala | 9 +- .../rpc/http4s/IRTContextServices.scala | 20 +++++ .../rpc/http4s/IRTHttpFailureException.scala | 3 +- .../rpc/http4s/IRTServicesMultiplexor.scala | 71 ---------------- .../clients/WsRpcDispatcherFactory.scala | 68 ++++++++------- .../http4s/context/HttpContextExtractor.scala | 22 +++++ .../http4s/context/WsContextExtractor.scala | 29 +++++++ .../rpc/http4s/context/WsIdExtractor.scala | 9 ++ .../rpc/http4s/ws/WsClientSession.scala | 48 ++++++----- .../rpc/http4s/ws/WsContextExtractor.scala | 11 --- .../rpc/http4s/ws/WsContextSessions.scala | 37 ++++---- .../runtime/rpc/http4s/ws/WsRpcHandler.scala | 45 +++++----- .../rpc/http4s/ws/WsSessionsStorage.scala | 28 +++--- .../rpc/http4s/Http4sTransportTest.scala | 19 +++-- .../rpc/http4s/fixtures/TestServices.scala | 62 +++++++------- .../runtime/rpc/IRTServerMultiplexor.scala | 70 +++++++++------ .../runtime/rpc/IRTTransportException.scala | 16 ++-- .../test/GreeterRunnerExample.scala | 4 +- 19 files changed, 329 insertions(+), 327 deletions(-) create mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala delete mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTServicesMultiplexor.scala create mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/HttpContextExtractor.scala create mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/WsContextExtractor.scala create mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/WsIdExtractor.scala delete mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextExtractor.scala diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala index 053c21e2..6fa920b6 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala @@ -5,8 +5,8 @@ import cats.effect.Async import cats.effect.std.Queue import fs2.Stream import io.circe -import io.circe.Printer import io.circe.syntax.EncoderOps +import io.circe.{Json, Printer} import izumi.functional.bio.Exit.{Error, Interruption, Success, Termination} import izumi.functional.bio.{Exit, F, IO2, Primitives2, Temporal2, UnsafeRun2} import izumi.fundamentals.platform.language.Quirks @@ -14,27 +14,25 @@ import izumi.fundamentals.platform.language.Quirks.Discarder import izumi.fundamentals.platform.time.IzTime import izumi.idealingua.runtime.rpc.* import izumi.idealingua.runtime.rpc.http4s.HttpServer.{ServerWsRpcHandler, WsResponseMarker} -import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext -import izumi.idealingua.runtime.rpc.http4s.IRTServicesMultiplexor.{InvokeMethodFailure, InvokeMethodResult} +import izumi.idealingua.runtime.rpc.http4s.context.{HttpContextExtractor, WsContextExtractor} import izumi.idealingua.runtime.rpc.http4s.ws.* import izumi.idealingua.runtime.rpc.http4s.ws.WsClientSession.WsClientSessionImpl import logstage.LogIO2 import org.http4s.* import org.http4s.dsl.Http4sDsl -import org.http4s.headers.`X-Forwarded-For` import org.http4s.server.websocket.WebSocketBuilder2 import org.http4s.websocket.WebSocketFrame -import org.typelevel.ci.CIString import org.typelevel.vault.Key import java.time.ZonedDateTime import java.util.concurrent.RejectedExecutionException import scala.concurrent.duration.DurationInt -class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2]( - val servicesMuxer: IRTServicesMultiplexor[F, ?, ?], - val wsContextsSessions: Set[WsContextSessions[F, ?, ?]], - val wsSessionsStorage: WsSessionsStorage[F], +class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2, AuthCtx]( + val contextServices: Set[IRTContextServices[F, AuthCtx, ?, ?]], + val httpContextExtractor: HttpContextExtractor[AuthCtx], + val wsContextExtractor: WsContextExtractor[AuthCtx], + val wsSessionsStorage: WsSessionsStorage[F, AuthCtx], dsl: Http4sDsl[F[Throwable, _]], logger: LogIO2[F], printer: Printer, @@ -42,6 +40,9 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2]( ) { import dsl.* + private val muxer: IRTServerMultiplexor[F, AuthCtx] = IRTServerMultiplexor.combine(contextServices.map(_.authorizedMuxer)) + private val wsContextsSessions: Set[WsContextSessions[F, AuthCtx, ?]] = contextServices.map(_.authorizedWsSessions) + // WS Response attribute key, to differ from usual HTTP responses private val wsAttributeKey = UnsafeRun2[F].unsafeRun(Key.newKey[F[Throwable, _], WsResponseMarker.type]) @@ -56,17 +57,17 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2]( case request @ POST -> Root / service / method => request.decode[String](processHttpRequest(request, service, method)) } - protected def handleWsClose(session: WsClientSession[F]): F[Throwable, Unit] = { + protected def handleWsClose(session: WsClientSession[F, AuthCtx]): F[Throwable, Unit] = { logger.debug(s"WS Session: Websocket client disconnected ${session.sessionId}.") *> session.finish(onWsDisconnected) } - protected def onWsConnected(authContext: AuthContext): F[Throwable, Unit] = { + protected def onWsConnected(authContext: AuthCtx): F[Throwable, Unit] = { authContext.discard() F.unit } - protected def onWsDisconnected(authContext: AuthContext): F[Throwable, Unit] = { + protected def onWsDisconnected(authContext: AuthCtx): F[Throwable, Unit] = { authContext.discard() F.unit } @@ -95,8 +96,8 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2]( } for { outQueue <- Queue.unbounded[F[Throwable, _], WebSocketFrame] - authContext <- F.syncThrowable(extractAuthContext(request)) - clientSession = new WsClientSessionImpl(outQueue, authContext, wsContextsSessions, wsSessionsStorage, logger, printer) + authContext <- F.syncThrowable(httpContextExtractor.extract(request)) + clientSession = new WsClientSessionImpl(outQueue, authContext, wsContextsSessions, wsSessionsStorage, wsContextExtractor, logger, printer) _ <- clientSession.start(onWsConnected) outStream = Stream.fromQueueUnterminated(outQueue).merge(pingStream) @@ -116,7 +117,7 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2]( } protected def processWsRequest( - clientSession: WsClientSession[F], + clientSession: WsClientSession[F, AuthCtx], requestTime: ZonedDateTime, )(frame: WebSocketFrame ): F[Throwable, Option[String]] = { @@ -132,8 +133,8 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2]( }).map(_.map(p => printer.print(p.asJson))) } - protected def wsHandler(clientSession: WsClientSession[F]): WsRpcHandler[F] = { - new ServerWsRpcHandler(clientSession, servicesMuxer, logger) + protected def wsHandler(clientSession: WsClientSession[F, AuthCtx]): WsRpcHandler[F, AuthCtx] = { + new ServerWsRpcHandler(clientSession, muxer, wsContextExtractor, logger) } protected def onWsHeartbeat(requestTime: ZonedDateTime): F[Throwable, Unit] = { @@ -148,29 +149,24 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2]( ): F[Throwable, Response[F[Throwable, _]]] = { val methodId = IRTMethodId(IRTServiceId(serviceName), IRTMethodName(methodName)) (for { - authContext <- F.syncThrowable(extractAuthContext(request)) + authContext <- F.syncThrowable(httpContextExtractor.extract(request)) parsedBody <- F.fromEither(io.circe.parser.parse(body)) - invokeRes <- servicesMuxer.invokeMethodWithAuth(methodId)(authContext, parsedBody) + invokeRes <- muxer.invokeMethod(methodId)(authContext, parsedBody) } yield invokeRes).sandboxExit.flatMap(handleHttpResult(request, methodId)) } protected def handleHttpResult( request: Request[F[Throwable, _]], method: IRTMethodId, - )(result: Exit[Throwable, InvokeMethodResult] + )(result: Exit[Throwable, Json] ): F[Throwable, Response[F[Throwable, _]]] = { result match { - case Success(InvokeMethodResult(_, res)) => + case Success(res) => Ok(printer.print(res)) - case Error(err: InvokeMethodFailure.ServiceNotFound, _) => - logger.warn(s"No service handler for $method: $err") *> NotFound() - - case Error(err: InvokeMethodFailure.MethodNotFound, _) => - logger.warn(s"No method handler for $method: $err") *> NotFound() - - case Error(err: InvokeMethodFailure.AuthFailed, _) => - logger.warn(s"Auth failed for $method: $err") *> F.pure(Response(Status.Unauthorized)) + case Error(err: IRTMissingHandlerException, _) => + logger.warn(s"No service and method handler for $method: $err") *> + NotFound() case Error(error: circe.Error, trace) => logger.info(s"Parsing failure while handling $method: $error $trace") *> @@ -210,15 +206,6 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2]( } } - protected def extractAuthContext(request: Request[F[Throwable, _]]): AuthContext = { - val networkAddress = request.headers - .get[`X-Forwarded-For`] - .flatMap(_.values.head.map(_.toInetAddress)) - .orElse(request.remote.map(_.host.toInetAddress)) - val headers = request.headers - AuthContext(headers, networkAddress) - } - protected def loggingMiddle(service: HttpRoutes[F[Throwable, _]]): HttpRoutes[F[Throwable, _]] = { cats.data.Kleisli { (req: Request[F[Throwable, _]]) => @@ -245,21 +232,13 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2]( object HttpServer { case object WsResponseMarker - class ServerWsRpcHandler[F[+_, +_]: IO2, RequestCtx, ClientId]( - clientSession: WsClientSession[F], - contextServicesMuxer: IRTServicesMultiplexor[F, ?, ?], + class ServerWsRpcHandler[F[+_, +_]: IO2, AuthCtx]( + clientSession: WsClientSession[F, AuthCtx], + muxer: IRTServerMultiplexor[F, AuthCtx], + wsContextExtractor: WsContextExtractor[AuthCtx], logger: LogIO2[F], - ) extends WsRpcHandler[F](contextServicesMuxer, clientSession, logger) { - override protected def getAuthContext: AuthContext = { - clientSession.getAuthContext - } - override protected def updateAuthContext(packet: RpcPacket): F[Throwable, Unit] = { - F.traverse_(packet.headers) { - headersMap => - val headers = Headers.apply(headersMap.toSeq.map { case (k, v) => Header.Raw(CIString(k), v) }) - val authContext = AuthContext(headers, None) - clientSession.updateAuthContext(authContext) - } - } + ) extends WsRpcHandler[F, AuthCtx](muxer, clientSession, logger) { + override protected def getRequestCtx: AuthCtx = clientSession.getRequestCtx + override protected def updateRequestCtx(packet: RpcPacket): F[Throwable, Unit] = clientSession.updateRequestCtx(wsContextExtractor.extract(packet)) } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTAuthenticator.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTAuthenticator.scala index 7e825a46..912462eb 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTAuthenticator.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTAuthenticator.scala @@ -2,18 +2,17 @@ package izumi.idealingua.runtime.rpc.http4s import io.circe.Json import izumi.functional.bio.{Applicative2, F} -import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext import org.http4s.Headers import java.net.InetAddress -abstract class IRTAuthenticator[F[_, _], RequestCtx] { - def authenticate(authContext: AuthContext, body: Option[Json]): F[Nothing, Option[RequestCtx]] +abstract class IRTAuthenticator[F[_, _], AuthCtx, RequestCtx] { + def authenticate(authContext: AuthCtx, body: Option[Json]): F[Nothing, Option[RequestCtx]] } object IRTAuthenticator { - def unit[F[+_, +_]: Applicative2]: IRTAuthenticator[F, Unit] = new IRTAuthenticator[F, Unit] { - override def authenticate(authContext: AuthContext, body: Option[Json]): F[Nothing, Option[Unit]] = F.pure(Some(())) + def unit[F[+_, +_]: Applicative2, C]: IRTAuthenticator[F, C, Unit] = new IRTAuthenticator[F, C, Unit] { + override def authenticate(authContext: C, body: Option[Json]): F[Nothing, Option[Unit]] = F.pure(Some(())) } final case class AuthContext(headers: Headers, networkAddress: Option[InetAddress]) } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala new file mode 100644 index 00000000..192a0951 --- /dev/null +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala @@ -0,0 +1,20 @@ +package izumi.idealingua.runtime.rpc.http4s + +import izumi.functional.bio.{Error2, Monad2} +import izumi.idealingua.runtime.rpc.IRTServerMultiplexor +import izumi.idealingua.runtime.rpc.http4s.ws.WsContextSessions + +final case class IRTContextServices[F[+_, +_], AuthCtx, RequestCtx, WsCtx]( + authenticator: IRTAuthenticator[F, AuthCtx, RequestCtx], + serverMuxer: IRTServerMultiplexor[F, RequestCtx], + wsSessions: WsContextSessions[F, RequestCtx, WsCtx], +) { + def authorizedMuxer(implicit E: Error2[F]): IRTServerMultiplexor[F, AuthCtx] = serverMuxer.contramap { + case (authCtx, body) => + authenticator.authenticate(authCtx, Some(body)) + } + def authorizedWsSessions(implicit M: Monad2[F]): WsContextSessions[F, AuthCtx, WsCtx] = wsSessions.contramap { + authCtx => + authenticator.authenticate(authCtx, None) + } +} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTHttpFailureException.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTHttpFailureException.scala index f650abaa..b8cfd158 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTHttpFailureException.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTHttpFailureException.scala @@ -7,8 +7,7 @@ abstract class IRTHttpFailureException( message: String, val status: Status, cause: Option[Throwable] = None, -) extends RuntimeException(message, cause.orNull) - with IRTTransportException +) extends IRTTransportException(message, cause) case class IRTUnexpectedHttpStatus(override val status: Status) extends IRTHttpFailureException(s"Unexpected http status: $status", status) case class IRTNoCredentialsException(override val status: Status) extends IRTHttpFailureException("No valid credentials", status) diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTServicesMultiplexor.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTServicesMultiplexor.scala deleted file mode 100644 index 9822f5dc..00000000 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTServicesMultiplexor.scala +++ /dev/null @@ -1,71 +0,0 @@ -package izumi.idealingua.runtime.rpc.http4s - -import io.circe.Json -import izumi.functional.bio.{Exit, F, IO2} -import izumi.idealingua.runtime.rpc.* -import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext -import izumi.idealingua.runtime.rpc.http4s.IRTServicesMultiplexor.InvokeMethodResult - -trait IRTServicesMultiplexor[F[_, _], RequestCtx, WsCtx] { - def invokeMethodWithAuth(method: IRTMethodId)(authContext: AuthContext, body: Json): F[Throwable, InvokeMethodResult] -} - -object IRTServicesMultiplexor { - final case class InvokeMethodResult(context: Any, res: Json) - abstract class InvokeMethodFailure(message: String) extends RuntimeException(s"Method invokation failed: $message.") - object InvokeMethodFailure { - final case class ServiceNotFound(serviceId: IRTServiceId) extends InvokeMethodFailure(s"Service $serviceId not found .") - final case class MethodNotFound(methodId: IRTMethodId) extends InvokeMethodFailure(s"Method $methodId not found .") - final case class AuthFailed(context: AuthContext) extends InvokeMethodFailure(s"Authorization with $context failed.") - } - - trait MultiContext[F[_, _]] extends IRTServicesMultiplexor[F, Unit, Unit] - object MultiContext { - class Impl[F[+_, +_]: IO2](servicesContexts: Set[IRTServicesMultiplexor.SingleContext[F, ?, ?]]) extends IRTServicesMultiplexor.MultiContext[F] { - private val services: Map[IRTServiceId, IRTServicesMultiplexor[F, ?, ?]] = { - servicesContexts.flatMap(c => c.services.map(_.serviceId -> c)).toMap - } - def invokeMethodWithAuth(method: IRTMethodId)(authContext: AuthContext, body: Json): F[Throwable, InvokeMethodResult] = { - F.fromOption(InvokeMethodFailure.ServiceNotFound(method.service))(services.get(method.service)) - .flatMap(_.invokeMethodWithAuth(method)(authContext, body)) - } - } - } - - trait SingleContext[F[_, _], RequestCtx, WsCtx] extends IRTServicesMultiplexor[F, RequestCtx, WsCtx] { - val services: Set[IRTWrappedService[F, RequestCtx]] - } - object SingleContext { - class Impl[F[+_, +_]: IO2, RequestCtx, WsCtx]( - val services: Set[IRTWrappedService[F, RequestCtx]], - val authenticator: IRTAuthenticator[F, RequestCtx], - ) extends SingleContext[F, RequestCtx, WsCtx] { - def methods: Map[IRTMethodId, IRTMethodWrapper[F, RequestCtx]] = services.flatMap(_.allMethods).toMap - - override def invokeMethodWithAuth(method: IRTMethodId)(authContext: AuthContext, body: Json): F[Throwable, InvokeMethodResult] = { - for { - wrappedMethod <- F.fromOption(InvokeMethodFailure.MethodNotFound(method))(methods.get(method)) - requestCtx <- authenticator.authenticate(authContext, Some(body)).fromOption(InvokeMethodFailure.AuthFailed(authContext)) - res <- invoke(wrappedMethod)(requestCtx, body) - } yield InvokeMethodResult(requestCtx, res) - } - - @inline private[this] def invoke[C](method: IRTMethodWrapper[F, C])(context: C, parsedBody: Json): F[Throwable, Json] = { - val methodId = method.signature.id - for { - request <- F.syncThrowable(method.marshaller.decodeRequest[F].apply(IRTJsonBody(methodId, parsedBody))).flatten.sandbox.catchAll { - case Exit.Interruption(decodingFailure, _, trace) => - F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON ${parsedBody.toString()} $trace", Some(decodingFailure))) - case Exit.Termination(_, exceptions, trace) => - F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON ${parsedBody.toString()} $trace", exceptions.headOption)) - case Exit.Error(decodingFailure, trace) => - F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON ${parsedBody.toString()} $trace", Some(decodingFailure))) - } - methodInput = request.value.asInstanceOf[method.signature.Input] - methodOutput <- F.syncThrowable(method.invoke(context, methodInput)).flatten - response <- F.syncThrowable(method.marshaller.encodeResponse.apply(IRTResBody(methodOutput))) - } yield response - } - } - } -} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala index 853fa3e9..b3a75e1f 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala @@ -6,17 +6,16 @@ import izumi.functional.bio.{Async2, Exit, F, IO2, Primitives2, Temporal2, Unsaf import izumi.functional.lifecycle.Lifecycle import izumi.fundamentals.platform.language.Quirks.Discarder import izumi.idealingua.runtime.rpc.* -import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext -import izumi.idealingua.runtime.rpc.http4s.IRTServicesMultiplexor import izumi.idealingua.runtime.rpc.http4s.clients.WsRpcDispatcher.IRTDispatcherWs import izumi.idealingua.runtime.rpc.http4s.clients.WsRpcDispatcherFactory.{ClientWsRpcHandler, WsRpcClientConnection, fromNettyFuture} +import izumi.idealingua.runtime.rpc.http4s.context.WsContextExtractor import izumi.idealingua.runtime.rpc.http4s.ws.{RawResponse, WsRequestState, WsRpcHandler} import izumi.logstage.api.IzLogger import logstage.LogIO2 import org.asynchttpclient.netty.ws.NettyWebSocket import org.asynchttpclient.ws.{WebSocket, WebSocketListener, WebSocketUpgradeHandler} import org.asynchttpclient.{DefaultAsyncHttpClient, DefaultAsyncHttpClientConfig} -import org.http4s.{Headers, Uri} +import org.http4s.Uri import java.util.concurrent.atomic.AtomicReference import scala.concurrent.duration.{DurationInt, FiniteDuration} @@ -29,32 +28,34 @@ class WsRpcDispatcherFactory[F[+_, +_]: Async2: Temporal2: Primitives2: UnsafeRu izLogger: IzLogger, ) { - def connect( + def connect[ServerContext]( uri: Uri, - muxer: IRTServicesMultiplexor[F, ?, ?], + serverMuxer: IRTServerMultiplexor[F, ServerContext], + wsContextExtractor: WsContextExtractor[ServerContext], ): Lifecycle[F[Throwable, _], WsRpcClientConnection[F]] = { for { - client <- WsRpcDispatcherFactory.asyncHttpClient[F] - requestState <- Lifecycle.liftF(F.syncThrowable(WsRequestState.create[F])) - listener <- Lifecycle.liftF(F.syncThrowable(createListener(muxer, requestState, dispatcherLogger(uri, logger)))) - handler <- Lifecycle.liftF(F.syncThrowable(new WebSocketUpgradeHandler(List(listener).asJava))) + client <- WsRpcDispatcherFactory.asyncHttpClient[F] + wsRequestState <- Lifecycle.liftF(F.syncThrowable(WsRequestState.create[F])) + listener <- Lifecycle.liftF(F.syncThrowable(createListener(serverMuxer, wsRequestState, wsContextExtractor, dispatcherLogger(uri, logger)))) + handler <- Lifecycle.liftF(F.syncThrowable(new WebSocketUpgradeHandler(List(listener).asJava))) nettyWebSocket <- Lifecycle.make( F.fromFutureJava(client.prepareGet(uri.toString()).execute(handler).toCompletableFuture) )(nettyWebSocket => fromNettyFuture(nettyWebSocket.sendCloseFrame()).void) // fill promises before closing WS connection, potentially giving a chance to send out an error response before closing - _ <- Lifecycle.make(F.unit)(_ => requestState.clear()) + _ <- Lifecycle.make(F.unit)(_ => wsRequestState.clear()) } yield { - new WsRpcClientConnection.Netty(nettyWebSocket, requestState, printer) + new WsRpcClientConnection.Netty(nettyWebSocket, wsRequestState, printer) } } - def dispatcher( + def dispatcher[ServerContext]( uri: Uri, - muxer: IRTServicesMultiplexor[F, ?, ?], + serverMuxer: IRTServerMultiplexor[F, ServerContext], + wsContextExtractor: WsContextExtractor[ServerContext], tweakRequest: RpcPacket => RpcPacket = identity, timeout: FiniteDuration = 30.seconds, ): Lifecycle[F[Throwable, _], IRTDispatcherWs[F]] = { - connect(uri, muxer).map { + connect(uri, serverMuxer, wsContextExtractor).map { new WsRpcDispatcher(_, timeout, codec, dispatcherLogger(uri, logger)) { override protected def buildRequest(rpcPacketId: RpcPacketId, method: IRTMethodId, body: Json): RpcPacket = { tweakRequest(super.buildRequest(rpcPacketId, method, body)) @@ -64,19 +65,21 @@ class WsRpcDispatcherFactory[F[+_, +_]: Async2: Temporal2: Primitives2: UnsafeRu } protected def wsHandler[ServerContext]( - muxer: IRTServicesMultiplexor[F, ?, ?], - requestState: WsRequestState[F], + serverMuxer: IRTServerMultiplexor[F, ServerContext], + wsRequestState: WsRequestState[F], + wsContextExtractor: WsContextExtractor[ServerContext], logger: LogIO2[F], - ): WsRpcHandler[F] = { - new ClientWsRpcHandler(muxer, requestState, logger) + ): WsRpcHandler[F, ServerContext] = { + new ClientWsRpcHandler(serverMuxer, wsRequestState, wsContextExtractor, logger) } - protected def createListener( - muxer: IRTServicesMultiplexor[F, ?, ?], - requestState: WsRequestState[F], + protected def createListener[ServerContext]( + serverMuxer: IRTServerMultiplexor[F, ServerContext], + wsRequestState: WsRequestState[F], + wsContextExtractor: WsContextExtractor[ServerContext], logger: LogIO2[F], ): WebSocketListener = new WebSocketListener() { - private val handler = wsHandler(muxer, requestState, logger) + private val handler = wsHandler(serverMuxer, wsRequestState, wsContextExtractor, logger) private val socketRef = new AtomicReference[Option[WebSocket]](None) override def onOpen(websocket: WebSocket): Unit = { @@ -152,16 +155,23 @@ object WsRpcDispatcherFactory { }) } - class ClientWsRpcHandler[F[+_, +_]: IO2]( - muxer: IRTServicesMultiplexor[F, ?, ?], + class ClientWsRpcHandler[F[+_, +_]: IO2, RequestCtx]( + muxer: IRTServerMultiplexor[F, RequestCtx], requestState: WsRequestState[F], + wsContextExtractor: WsContextExtractor[RequestCtx], logger: LogIO2[F], - ) extends WsRpcHandler[F](muxer, requestState, logger) { - override protected def updateAuthContext(packet: RpcPacket): F[Throwable, Unit] = { - F.unit + ) extends WsRpcHandler[F, RequestCtx](muxer, requestState, logger) { + private val requestCtxRef: AtomicReference[Option[RequestCtx]] = new AtomicReference(None) + override protected def updateRequestCtx(packet: RpcPacket): F[Throwable, Unit] = F.sync { + val updated = wsContextExtractor.extract(packet) + requestCtxRef.updateAndGet { + case None => Some(updated) + case Some(previous) => Some(wsContextExtractor.merge(previous, updated)) + } + () } - override protected def getAuthContext: AuthContext = { - AuthContext(Headers.empty, None) + override protected def getRequestCtx: RequestCtx = { + requestCtxRef.get().getOrElse(throw new IRTUnathorizedRequestContextException("Missing WS request context.")) } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/HttpContextExtractor.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/HttpContextExtractor.scala new file mode 100644 index 00000000..bf3373f8 --- /dev/null +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/HttpContextExtractor.scala @@ -0,0 +1,22 @@ +package izumi.idealingua.runtime.rpc.http4s.context + +import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext +import org.http4s.Request +import org.http4s.headers.`X-Forwarded-For` + +trait HttpContextExtractor[RequestCtx] { + def extract[F[_, _]](request: Request[F[Throwable, _]]): RequestCtx +} + +object HttpContextExtractor { + def authContext: HttpContextExtractor[AuthContext] = new HttpContextExtractor[AuthContext] { + override def extract[F[_, _]](request: Request[F[Throwable, _]]): AuthContext = { + val networkAddress = request.headers + .get[`X-Forwarded-For`] + .flatMap(_.values.head.map(_.toInetAddress)) + .orElse(request.remote.map(_.host.toInetAddress)) + val headers = request.headers + AuthContext(headers, networkAddress) + } + } +} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/WsContextExtractor.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/WsContextExtractor.scala new file mode 100644 index 00000000..abca5c4f --- /dev/null +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/WsContextExtractor.scala @@ -0,0 +1,29 @@ +package izumi.idealingua.runtime.rpc.http4s.context + +import izumi.idealingua.runtime.rpc.RpcPacket +import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext +import org.http4s.{Header, Headers} +import org.typelevel.ci.CIString + +trait WsContextExtractor[RequestCtx] { + def extract(packet: RpcPacket): RequestCtx + def merge(previous: RequestCtx, updated: RequestCtx): RequestCtx +} + +object WsContextExtractor { + def unit: WsContextExtractor[Unit] = new WsContextExtractor[Unit] { + override def extract(packet: RpcPacket): Unit = () + override def merge(previous: Unit, updated: Unit): Unit = () + } + def authContext: WsContextExtractor[AuthContext] = new WsContextExtractor[AuthContext] { + override def extract(packet: RpcPacket): AuthContext = { + val headersMap = packet.headers.getOrElse(Map.empty) + val headers = Headers.apply(headersMap.toSeq.map { case (k, v) => Header.Raw(CIString(k), v) }) + AuthContext(headers, None) + } + + override def merge(previous: AuthContext, updated: AuthContext): AuthContext = { + AuthContext(previous.headers ++ updated.headers, updated.networkAddress.orElse(previous.networkAddress)) + } + } +} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/WsIdExtractor.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/WsIdExtractor.scala new file mode 100644 index 00000000..30c3aaa3 --- /dev/null +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/WsIdExtractor.scala @@ -0,0 +1,9 @@ +package izumi.idealingua.runtime.rpc.http4s.context + +trait WsIdExtractor[RequestCtx, WsCtx] { + def extract(ctx: RequestCtx): Option[WsCtx] +} + +object WsIdExtractor { + def id[C]: WsIdExtractor[C, C] = c => Some(c) +} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala index 294d15a8..613a024c 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala @@ -6,7 +6,7 @@ import io.circe.{Json, Printer} import izumi.functional.bio.{F, IO2, Primitives2, Temporal2} import izumi.fundamentals.platform.time.IzTime import izumi.fundamentals.platform.uuid.UUIDGen -import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext +import izumi.idealingua.runtime.rpc.http4s.context.WsContextExtractor import izumi.idealingua.runtime.rpc.http4s.ws.WsRpcHandler.WsResponder import izumi.idealingua.runtime.rpc.{IRTMethodId, RpcPacket, RpcPacketId} import logstage.LogIO2 @@ -18,43 +18,45 @@ import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicReference import scala.concurrent.duration.* -trait WsClientSession[F[+_, +_]] extends WsResponder[F] { +trait WsClientSession[F[+_, +_], RequestCtx] extends WsResponder[F] { def sessionId: WsSessionId - def getAuthContext: AuthContext + def getRequestCtx: RequestCtx def requestAndAwaitResponse(method: IRTMethodId, data: Json, timeout: FiniteDuration): F[Throwable, Option[RawResponse]] - def updateAuthContext(newContext: AuthContext): F[Throwable, Unit] + def updateRequestCtx(newContext: RequestCtx): F[Throwable, Unit] - def start(onStart: AuthContext => F[Throwable, Unit]): F[Throwable, Unit] - def finish(onFinish: AuthContext => F[Throwable, Unit]): F[Throwable, Unit] + def start(onStart: RequestCtx => F[Throwable, Unit]): F[Throwable, Unit] + def finish(onFinish: RequestCtx => F[Throwable, Unit]): F[Throwable, Unit] } object WsClientSession { - class WsClientSessionImpl[F[+_, +_]: IO2: Temporal2: Primitives2]( + class WsClientSessionImpl[F[+_, +_]: IO2: Temporal2: Primitives2, RequestCtx]( outQueue: Queue[F[Throwable, _], WebSocketFrame], - initialContext: AuthContext, - wsSessionsContext: Set[WsContextSessions[F, ?, ?]], - wsSessionStorage: WsSessionsStorage[F], + initialContext: RequestCtx, + wsSessionsContext: Set[WsContextSessions[F, RequestCtx, ?]], + wsSessionStorage: WsSessionsStorage[F, RequestCtx], + wsContextExtractor: WsContextExtractor[RequestCtx], logger: LogIO2[F], printer: Printer, - ) extends WsClientSession[F] { - private val authContextRef = new AtomicReference[AuthContext](initialContext) + ) extends WsClientSession[F, RequestCtx] { + private val requestCtxRef = new AtomicReference[RequestCtx](initialContext) private val openingTime: ZonedDateTime = IzTime.utcNow private val requestState: WsRequestState[F] = WsRequestState.create[F] override val sessionId: WsSessionId = WsSessionId(UUIDGen.getTimeUUID()) - override def getAuthContext: AuthContext = authContextRef.get() + override def getRequestCtx: RequestCtx = requestCtxRef.get() - override def updateAuthContext(newContext: AuthContext): F[Throwable, Unit] = { + override def updateRequestCtx(newContext: RequestCtx): F[Throwable, Unit] = { for { contexts <- F.sync { - authContextRef.synchronized { - val oldContext = authContextRef.get() - val updatedContext = authContextRef.updateAndGet { - old => AuthContext(old.headers ++ newContext.headers, old.networkAddress.orElse(newContext.networkAddress)) + requestCtxRef.synchronized { + val oldContext = requestCtxRef.get() + val updatedContext = requestCtxRef.updateAndGet { + old => + wsContextExtractor.merge(old, newContext) } oldContext -> updatedContext } @@ -86,18 +88,18 @@ object WsClientSession { requestState.responseWithData(id, data) } - override def finish(onFinish: AuthContext => F[Throwable, Unit]): F[Throwable, Unit] = { + override def finish(onFinish: RequestCtx => F[Throwable, Unit]): F[Throwable, Unit] = { F.fromEither(WebSocketFrame.Close(1000)).flatMap(outQueue.offer(_)) *> requestState.clear() *> wsSessionStorage.deleteSession(sessionId) *> F.traverse_(wsSessionsContext)(_.updateSession(sessionId, None)) *> - onFinish(getAuthContext) + onFinish(getRequestCtx) } - override def start(onStart: AuthContext => F[Throwable, Unit]): F[Throwable, Unit] = { + override def start(onStart: RequestCtx => F[Throwable, Unit]): F[Throwable, Unit] = { wsSessionStorage.addSession(this) *> - F.traverse_(wsSessionsContext)(_.updateSession(sessionId, Some(getAuthContext))) *> - onStart(getAuthContext) + F.traverse_(wsSessionsContext)(_.updateSession(sessionId, Some(getRequestCtx))) *> + onStart(getRequestCtx) } override def toString: String = s"[$sessionId, ${duration().toSeconds}s]" diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextExtractor.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextExtractor.scala deleted file mode 100644 index 8cb91fcd..00000000 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextExtractor.scala +++ /dev/null @@ -1,11 +0,0 @@ -package izumi.idealingua.runtime.rpc.http4s.ws - -trait WsContextExtractor[RequestCtx, WsCtx] { - def extract(ctx: RequestCtx): Option[WsCtx] -} - -object WsContextExtractor { - def id[Ctx]: WsContextExtractor[Ctx, Ctx] = new WsContextExtractor[Ctx, Ctx] { - override def extract(ctx: Ctx): Option[Ctx] = Some(ctx) - } -} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala index 27bb9551..1183003d 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala @@ -1,43 +1,42 @@ package izumi.idealingua.runtime.rpc.http4s.ws -import izumi.functional.bio.{Exit, F, IO2} -import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator -import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext +import izumi.functional.bio.{F, IO2, Monad2} +import izumi.idealingua.runtime.rpc.http4s.context.WsIdExtractor import izumi.idealingua.runtime.rpc.{IRTClientMultiplexor, IRTDispatcher} import java.util.concurrent.ConcurrentHashMap import scala.concurrent.duration.{DurationInt, FiniteDuration} trait WsContextSessions[F[+_, +_], RequestCtx, WsCtx] { - def updateSession(wsSessionId: WsSessionId, authContext: Option[AuthContext]): F[Throwable, Unit] - def updateSessionWith(wsSessionId: WsSessionId, requestContext: Option[RequestCtx]): F[Throwable, Unit] + self => + def updateSession(wsSessionId: WsSessionId, requestContext: Option[RequestCtx]): F[Throwable, Unit] def dispatcherFor(ctx: WsCtx, codec: IRTClientMultiplexor[F], timeout: FiniteDuration = 20.seconds): F[Throwable, Option[IRTDispatcher[F]]] + + final def contramap[C](updateCtx: C => F[Throwable, Option[RequestCtx]])(implicit M: Monad2[F]): WsContextSessions[F, C, WsCtx] = new WsContextSessions[F, C, WsCtx] { + override def updateSession(wsSessionId: WsSessionId, requestContext: Option[C]): F[Throwable, Unit] = { + F.traverse(requestContext)(updateCtx).flatMap(mbCtx => self.updateSession(wsSessionId, mbCtx.flatten)) + } + override def dispatcherFor(ctx: WsCtx, codec: IRTClientMultiplexor[F], timeout: FiniteDuration): F[Throwable, Option[IRTDispatcher[F]]] = { + self.dispatcherFor(ctx, codec, timeout) + } + } } object WsContextSessions { def empty[F[+_, +_]: IO2, RequestCtx]: WsContextSessions[F, RequestCtx, Unit] = new WsContextSessions[F, RequestCtx, Unit] { - override def updateSession(wsSessionId: WsSessionId, authContext: Option[AuthContext]): F[Throwable, Unit] = F.unit - override def updateSessionWith(wsSessionId: WsSessionId, requestContext: Option[RequestCtx]): F[Throwable, Unit] = F.unit + override def updateSession(wsSessionId: WsSessionId, requestContext: Option[RequestCtx]): F[Throwable, Unit] = F.unit override def dispatcherFor(ctx: Unit, codec: IRTClientMultiplexor[F], timeout: FiniteDuration): F[Throwable, Option[IRTDispatcher[F]]] = F.pure(None) } class WsContextSessionsImpl[F[+_, +_]: IO2, RequestCtx, WsCtx]( - authenticator: IRTAuthenticator[F, RequestCtx], - wsSessionsStorage: WsSessionsStorage[F], + wsSessionsStorage: WsSessionsStorage[F, ?], wsSessionListeners: Set[WsSessionListener[F, RequestCtx, WsCtx]], - wsContextExtractor: WsContextExtractor[RequestCtx, WsCtx], + wsIdExtractor: WsIdExtractor[RequestCtx, WsCtx], ) extends WsContextSessions[F, RequestCtx, WsCtx] { private val sessionToId = new ConcurrentHashMap[WsSessionId, WsCtx]() private val idToSession = new ConcurrentHashMap[WsCtx, WsSessionId]() - override def updateSession(wsSessionId: WsSessionId, authContext: Option[AuthContext]): F[Throwable, Unit] = { - F.traverse(authContext)(authenticator.authenticate(_, None)).map(_.flatten).sandboxExit.flatMap { - case Exit.Success(ctx) => updateSessionWith(wsSessionId, ctx) - case _: Exit.Failure[_] => updateSessionWith(wsSessionId, None) - } - } - - override def updateSessionWith(wsSessionId: WsSessionId, requestContext: Option[RequestCtx]): F[Throwable, Unit] = { + override def updateSession(wsSessionId: WsSessionId, requestContext: Option[RequestCtx]): F[Throwable, Unit] = { updateCtx(wsSessionId, requestContext).flatMap { case (Some(ctx), Some(previous), Some(updated)) if previous != updated => F.traverse_(wsSessionListeners)(_.onSessionUpdated(wsSessionId, ctx, previous, updated)) @@ -64,7 +63,7 @@ object WsContextSessions { ): F[Nothing, (Option[RequestCtx], Option[WsCtx], Option[WsCtx])] = F.sync { synchronized { val previous = Option(sessionToId.get(wsSessionId)) - val updated = requestContext.flatMap(wsContextExtractor.extract) + val updated = requestContext.flatMap(wsIdExtractor.extract) (updated, previous) match { case (Some(upd), _) => sessionToId.put(wsSessionId, upd) diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala index 47623575..21553300 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala @@ -5,25 +5,22 @@ import izumi.functional.bio.Exit.Success import izumi.functional.bio.{Exit, F, IO2} import izumi.fundamentals.platform.language.Quirks.Discarder import izumi.idealingua.runtime.rpc.* -import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext -import izumi.idealingua.runtime.rpc.http4s.IRTServicesMultiplexor -import izumi.idealingua.runtime.rpc.http4s.IRTServicesMultiplexor.{InvokeMethodFailure, InvokeMethodResult} import izumi.idealingua.runtime.rpc.http4s.ws.WsRpcHandler.WsResponder import logstage.LogIO2 -abstract class WsRpcHandler[F[+_, +_]: IO2]( - muxer: IRTServicesMultiplexor[F, ?, ?], +abstract class WsRpcHandler[F[+_, +_]: IO2, RequestCtx]( + muxer: IRTServerMultiplexor[F, RequestCtx], responder: WsResponder[F], logger: LogIO2[F], ) { - protected def updateAuthContext(packet: RpcPacket): F[Throwable, Unit] - protected def getAuthContext: AuthContext + protected def updateRequestCtx(packet: RpcPacket): F[Throwable, Unit] + protected def getRequestCtx: RequestCtx def processRpcMessage(message: String): F[Throwable, Option[RpcPacket]] = { for { packet <- F.fromEither(io.circe.parser.decode[RpcPacket](message)) - _ <- updateAuthContext(packet) + _ <- updateRequestCtx(packet) response <- packet match { // auth case RpcPacket(RPCPacketKind.RpcRequest, None, _, _, _, _, _) => @@ -80,29 +77,25 @@ abstract class WsRpcHandler[F[+_, +_]: IO2]( )(onSuccess: Json => RpcPacket, onFail: String => RpcPacket, ): F[Throwable, Option[RpcPacket]] = { - muxer - .invokeMethodWithAuth(methodId)(getAuthContext, data).sandboxExit.flatMap { - case Success(InvokeMethodResult(_, res)) => - F.pure(Some(onSuccess(res))) + muxer.invokeMethod(methodId)(getRequestCtx, data).sandboxExit.flatMap { + case Success(res) => + F.pure(Some(onSuccess(res))) - case Exit.Error(_: InvokeMethodFailure.ServiceNotFound, _) => - logger.error(s"WS request errored: No service handler for $methodId.").as(Some(onFail("Service not found."))) + case Exit.Error(_: IRTMissingHandlerException, _) => + logger.error(s"WS request errored: No service handler for $methodId.").as(Some(onFail("Service an method not found."))) - case Exit.Error(_: InvokeMethodFailure.MethodNotFound, _) => - logger.error(s"WS request errored: No method handler for $methodId.").as(Some(onFail("Method not found."))) + case Exit.Error(err: IRTUnathorizedRequestContextException, _) => + logger.warn(s"WS request errored: unauthorized - ${err.getMessage -> "message"}.").as(Some(onFail("Unauthorized."))) - case Exit.Error(err: InvokeMethodFailure.AuthFailed, _) => - logger.warn(s"WS request errored: unauthorized - ${err.getMessage -> "message"}.").as(Some(onFail("Unauthorized."))) + case Exit.Termination(exception, allExceptions, trace) => + logger.error(s"WS request terminated: $exception, $allExceptions, $trace").as(Some(onFail(exception.getMessage))) - case Exit.Termination(exception, allExceptions, trace) => - logger.error(s"WS request terminated: $exception, $allExceptions, $trace").as(Some(onFail(exception.getMessage))) + case Exit.Error(exception, trace) => + logger.error(s"WS request failed: $exception $trace").as(Some(onFail(exception.getMessage))) - case Exit.Error(exception, trace) => - logger.error(s"WS request failed: $exception $trace").as(Some(onFail(exception.getMessage))) - - case Exit.Interruption(exception, allExceptions, trace) => - logger.error(s"WS request interrupted: $exception $allExceptions $trace").as(Some(onFail(exception.getMessage))) - } + case Exit.Interruption(exception, allExceptions, trace) => + logger.error(s"WS request interrupted: $exception $allExceptions $trace").as(Some(onFail(exception.getMessage))) + } } protected def handleAuthRequest(packet: RpcPacket): F[Throwable, Option[RpcPacket]] = { diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionsStorage.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionsStorage.scala index bfa98997..061bf00c 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionsStorage.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionsStorage.scala @@ -8,11 +8,11 @@ import java.util.concurrent.{ConcurrentHashMap, TimeoutException} import scala.concurrent.duration.* import scala.jdk.CollectionConverters.* -trait WsSessionsStorage[F[+_, +_]] { - def addSession(session: WsClientSession[F]): F[Throwable, WsClientSession[F]] - def deleteSession(sessionId: WsSessionId): F[Throwable, Option[WsClientSession[F]]] - def allSessions(): F[Throwable, Seq[WsClientSession[F]]] - def getSession(sessionId: WsSessionId): F[Throwable, Option[WsClientSession[F]]] +trait WsSessionsStorage[F[+_, +_], RequestCtx] { + def addSession(session: WsClientSession[F, RequestCtx]): F[Throwable, WsClientSession[F, RequestCtx]] + def deleteSession(sessionId: WsSessionId): F[Throwable, Option[WsClientSession[F, RequestCtx]]] + def allSessions(): F[Throwable, Seq[WsClientSession[F, RequestCtx]]] + def getSession(sessionId: WsSessionId): F[Throwable, Option[WsClientSession[F, RequestCtx]]] def dispatcherForSession( sessionId: WsSessionId, @@ -23,28 +23,28 @@ trait WsSessionsStorage[F[+_, +_]] { object WsSessionsStorage { - class WsSessionsStorageImpl[F[+_, +_]: IO2](logger: LogIO2[F]) extends WsSessionsStorage[F] { - protected val sessions = new ConcurrentHashMap[WsSessionId, WsClientSession[F]]() + class WsSessionsStorageImpl[F[+_, +_]: IO2, RequestCtx](logger: LogIO2[F]) extends WsSessionsStorage[F, RequestCtx] { + protected val sessions = new ConcurrentHashMap[WsSessionId, WsClientSession[F, RequestCtx]]() - override def addSession(session: WsClientSession[F]): F[Throwable, WsClientSession[F]] = { + override def addSession(session: WsClientSession[F, RequestCtx]): F[Throwable, WsClientSession[F, RequestCtx]] = { for { _ <- logger.debug(s"Adding a client with session - ${session.sessionId}") _ <- F.sync(sessions.put(session.sessionId, session)) } yield session } - override def deleteSession(sessionId: WsSessionId): F[Throwable, Option[WsClientSession[F]]] = { + override def deleteSession(sessionId: WsSessionId): F[Throwable, Option[WsClientSession[F, RequestCtx]]] = { for { _ <- logger.debug(s"Deleting a client with session - $sessionId") res <- F.sync(Option(sessions.remove(sessionId))) } yield res } - override def getSession(sessionId: WsSessionId): F[Throwable, Option[WsClientSession[F]]] = { + override def getSession(sessionId: WsSessionId): F[Throwable, Option[WsClientSession[F, RequestCtx]]] = { F.sync(Option(sessions.get(sessionId))) } - override def allSessions(): F[Throwable, Seq[WsClientSession[F]]] = F.sync { + override def allSessions(): F[Throwable, Seq[WsClientSession[F, RequestCtx]]] = F.sync { sessions.values().asScala.toSeq } @@ -52,13 +52,13 @@ object WsSessionsStorage { sessionId: WsSessionId, codec: IRTClientMultiplexor[F], timeout: FiniteDuration, - ): F[Throwable, Option[WsClientDispatcher[F]]] = F.sync { + ): F[Throwable, Option[WsClientDispatcher[F, RequestCtx]]] = F.sync { Option(sessions.get(sessionId)).map(new WsClientDispatcher(_, codec, logger, timeout)) } } - class WsClientDispatcher[F[+_, +_]: IO2]( - session: WsClientSession[F], + class WsClientDispatcher[F[+_, +_]: IO2, RequestCtx]( + session: WsClientSession[F, RequestCtx], codec: IRTClientMultiplexor[F], logger: LogIO2[F], timeout: FiniteDuration, diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala index 4d299eb8..5ebbe7ff 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala @@ -10,8 +10,10 @@ import izumi.fundamentals.platform.language.Quirks.* import izumi.fundamentals.platform.network.IzSockets import izumi.idealingua.runtime.rpc.* import izumi.idealingua.runtime.rpc.http4s.Http4sTransportTest.{Ctx, IO2R} +import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext import izumi.idealingua.runtime.rpc.http4s.clients.HttpRpcDispatcher.IRTDispatcherRaw import izumi.idealingua.runtime.rpc.http4s.clients.{HttpRpcDispatcher, HttpRpcDispatcherFactory, WsRpcDispatcher, WsRpcDispatcherFactory} +import izumi.idealingua.runtime.rpc.http4s.context.{HttpContextExtractor, WsContextExtractor} import izumi.idealingua.runtime.rpc.http4s.fixtures.TestServices import izumi.idealingua.runtime.rpc.http4s.fixtures.defs.{PrivateTestServiceWrappedClient, ProtectedTestServiceWrappedClient} import izumi.idealingua.runtime.rpc.http4s.ws.{RawResponse, WsRequestState} @@ -60,13 +62,14 @@ object Http4sTransportTest { final val demo = new TestServices[F](logger) - final val ioService = new HttpServer[F]( - servicesMuxer = demo.Server.contextMuxer, - wsContextsSessions = demo.Server.wsContextsSessions, - wsSessionsStorage = demo.Server.wsStorage, - dsl = dsl, - logger = logger, - printer = printer, + final val ioService = new HttpServer[F, AuthContext]( + contextServices = demo.Server.contextServices, + httpContextExtractor = HttpContextExtractor.authContext, + wsContextExtractor = WsContextExtractor.authContext, + wsSessionsStorage = demo.Server.wsStorage, + dsl = dsl, + logger = logger, + printer = printer, ) def badAuth(user: String): Header.ToRaw = Authorization(BasicCredentials(user, "badpass")) @@ -85,7 +88,7 @@ object Http4sTransportTest { new WsRpcDispatcherFactory[F](demo.Client.codec, printer, logger, izLogger) } def wsRpcClientDispatcher(): Lifecycle[F[Throwable, _], WsRpcDispatcher.IRTDispatcherWs[F]] = { - wsClientFactory.dispatcher(wsUri, demo.Client.buzzerMultiplexor) + wsClientFactory.dispatcher(wsUri, demo.Client.buzzerMultiplexor, WsContextExtractor.unit) } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala index 064a901e..29843ff5 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala @@ -3,11 +3,14 @@ package izumi.idealingua.runtime.rpc.http4s.fixtures import io.circe.Json import izumi.functional.bio.{F, IO2} import izumi.idealingua.runtime.rpc.* +import izumi.idealingua.runtime.rpc.IRTServerMultiplexor.IRTServerMultiplexorImpl +import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext +import izumi.idealingua.runtime.rpc.http4s.context.WsIdExtractor import izumi.idealingua.runtime.rpc.http4s.fixtures.defs.* import izumi.idealingua.runtime.rpc.http4s.ws.WsContextSessions.WsContextSessionsImpl import izumi.idealingua.runtime.rpc.http4s.ws.WsSessionsStorage.WsSessionsStorageImpl -import izumi.idealingua.runtime.rpc.http4s.ws.{WsContextExtractor, WsContextSessions, WsSessionsStorage} -import izumi.idealingua.runtime.rpc.http4s.{IRTAuthenticator, IRTServicesMultiplexor} +import izumi.idealingua.runtime.rpc.http4s.ws.{WsContextSessions, WsSessionsStorage} +import izumi.idealingua.runtime.rpc.http4s.{IRTAuthenticator, IRTContextServices} import izumi.r2.idealingua.test.generated.{GreeterServiceClientWrapped, GreeterServiceServerWrapped} import izumi.r2.idealingua.test.impls.AbstractGreeterServer import logstage.LogIO2 @@ -19,58 +22,61 @@ class TestServices[F[+_, +_]: IO2]( ) { object Server { - final val wsStorage: WsSessionsStorage[F] = new WsSessionsStorageImpl[F](logger) + final val wsStorage: WsSessionsStorage[F, AuthContext] = new WsSessionsStorageImpl[F, AuthContext](logger) // PRIVATE - private val privateAuth = new IRTAuthenticator[F, PrivateContext] { - override def authenticate(authContext: IRTAuthenticator.AuthContext, body: Option[Json]): F[Nothing, Option[PrivateContext]] = F.sync { + private val privateAuth = new IRTAuthenticator[F, AuthContext, PrivateContext] { + override def authenticate(authContext: AuthContext, body: Option[Json]): F[Nothing, Option[PrivateContext]] = F.sync { authContext.headers.get[Authorization].map(_.credentials).collect { case BasicCredentials(user, "private") => PrivateContext(user) } } } - final val privateWsSession: WsContextSessions[F, PrivateContext, PrivateContext] = new WsContextSessionsImpl(privateAuth, wsStorage, Set.empty, WsContextExtractor.id) + final val privateWsSession: WsContextSessions[F, PrivateContext, PrivateContext] = new WsContextSessionsImpl(wsStorage, Set.empty, WsIdExtractor.id) final val privateService: IRTWrappedService[F, PrivateContext] = new PrivateTestServiceWrappedServer(new PrivateTestServiceServer[F, PrivateContext] { def test(ctx: PrivateContext, str: String): Just[String] = F.pure(s"Private: $str") }) - final val privateServices: IRTServicesMultiplexor.SingleContext[F, PrivateContext, PrivateContext] = { - new IRTServicesMultiplexor.SingleContext.Impl(Set(privateService), privateAuth) - } + final val privateServices: IRTContextServices[F, AuthContext, PrivateContext, PrivateContext] = IRTContextServices( + authenticator = privateAuth, + serverMuxer = new IRTServerMultiplexorImpl(Set(privateService)), + wsSessions = privateWsSession, + ) // PROTECTED - private val protectedAuth = new IRTAuthenticator[F, ProtectedContext] { - override def authenticate(authContext: IRTAuthenticator.AuthContext, body: Option[Json]): F[Nothing, Option[ProtectedContext]] = F.sync { + private val protectedAuth = new IRTAuthenticator[F, AuthContext, ProtectedContext] { + override def authenticate(authContext: AuthContext, body: Option[Json]): F[Nothing, Option[ProtectedContext]] = F.sync { authContext.headers.get[Authorization].map(_.credentials).collect { case BasicCredentials(user, "protected") => ProtectedContext(user) } } } - final val protectedWsSession: WsContextSessions[F, ProtectedContext, ProtectedContext] = { - new WsContextSessionsImpl(protectedAuth, wsStorage, Set.empty, WsContextExtractor.id) - } + final val protectedWsSession: WsContextSessions[F, ProtectedContext, ProtectedContext] = new WsContextSessionsImpl(wsStorage, Set.empty, WsIdExtractor.id) final val protectedService: IRTWrappedService[F, ProtectedContext] = new ProtectedTestServiceWrappedServer(new ProtectedTestServiceServer[F, ProtectedContext] { def test(ctx: ProtectedContext, str: String): Just[String] = F.pure(s"Protected: $str") }) - final val protectedServices: IRTServicesMultiplexor.SingleContext[F, ProtectedContext, ProtectedContext] = { - new IRTServicesMultiplexor.SingleContext.Impl(Set(protectedService), protectedAuth) - } + final val protectedServices: IRTContextServices[F, AuthContext, ProtectedContext, ProtectedContext] = IRTContextServices( + authenticator = protectedAuth, + serverMuxer = new IRTServerMultiplexorImpl(Set(protectedService)), + wsSessions = protectedWsSession, + ) // PUBLIC - private val publicAuth = new IRTAuthenticator[F, PublicContext] { - override def authenticate(authContext: IRTAuthenticator.AuthContext, body: Option[Json]): F[Nothing, Option[PublicContext]] = F.sync { + private val publicAuth = new IRTAuthenticator[F, AuthContext, PublicContext] { + override def authenticate(authContext: AuthContext, body: Option[Json]): F[Nothing, Option[PublicContext]] = F.sync { authContext.headers.get[Authorization].map(_.credentials).collect { case BasicCredentials(user, _) => PublicContext(user) } } } - final val publicWsSession: WsContextSessions[F, PublicContext, PublicContext] = new WsContextSessionsImpl(publicAuth, wsStorage, Set.empty, WsContextExtractor.id) + final val publicWsSession: WsContextSessions[F, PublicContext, PublicContext] = new WsContextSessionsImpl(wsStorage, Set.empty, WsIdExtractor.id) final val publicService: IRTWrappedService[F, PublicContext] = new GreeterServiceServerWrapped(new AbstractGreeterServer.Impl[F, PublicContext]) - final val publicServices: IRTServicesMultiplexor.SingleContext[F, PublicContext, PublicContext] = { - new IRTServicesMultiplexor.SingleContext.Impl(Set(publicService), publicAuth) - } + final val publicServices: IRTContextServices[F, AuthContext, PublicContext, PublicContext] = IRTContextServices( + authenticator = publicAuth, + serverMuxer = new IRTServerMultiplexorImpl(Set(publicService)), + wsSessions = publicWsSession, + ) - final val contextMuxer: IRTServicesMultiplexor[F, Unit, Unit] = new IRTServicesMultiplexor.MultiContext.Impl(Set(privateServices, protectedServices, publicServices)) - final val wsContextsSessions: Set[WsContextSessions[F, ?, ?]] = Set(privateWsSession, protectedWsSession, publicWsSession) + final val contextServices: Set[IRTContextServices[F, AuthContext, ?, ?]] = Set(privateServices, protectedServices, publicServices) } object Client { @@ -83,9 +89,7 @@ class TestServices[F[+_, +_]: IO2]( ProtectedTestServiceWrappedClient, PrivateTestServiceWrappedClient, ) - val codec: IRTClientMultiplexorImpl[F] = new IRTClientMultiplexorImpl[F](clients) - val buzzerMultiplexor: IRTServicesMultiplexor[F, Unit, Unit] = { - new IRTServicesMultiplexor.SingleContext.Impl(dispatchers, IRTAuthenticator.unit) - } + val codec: IRTClientMultiplexorImpl[F] = new IRTClientMultiplexorImpl[F](clients) + val buzzerMultiplexor: IRTServerMultiplexor[F, Unit] = new IRTServerMultiplexorImpl(dispatchers) } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala index 44c642b5..1c00c3e6 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala @@ -1,48 +1,64 @@ package izumi.idealingua.runtime.rpc import io.circe.Json -import izumi.functional.bio.{Exit, F, IO2} +import izumi.functional.bio.{Error2, Exit, F, IO2} trait IRTServerMultiplexor[F[+_, +_], C] { - def services: Set[IRTWrappedService[F, C]] - def doInvoke(parsedBody: Json, context: C, toInvoke: IRTMethodId): F[Throwable, Option[Json]] + self => + def allMethods: Set[IRTMethodId] + + def invokeMethod(method: IRTMethodId)(context: C, parsedBody: Json): F[Throwable, Json] + + final def contramap[C2](updateContext: (C2, Json) => F[Throwable, Option[C]])(implicit M: Error2[F]): IRTServerMultiplexor[F, C2] = new IRTServerMultiplexor[F, C2] { + override def allMethods: Set[IRTMethodId] = self.allMethods + override def invokeMethod(method: IRTMethodId)(context: C2, parsedBody: Json): F[Throwable, Json] = { + updateContext(context, parsedBody) + .fromOption(new IRTUnathorizedRequestContextException(s"Unauthorized $method call. Context: $context.")) + .flatMap(self.invokeMethod(method)(_, parsedBody)) + } + } } object IRTServerMultiplexor { + + def combine[F[+_, +_]: Error2, C](multiplexors: Iterable[IRTServerMultiplexor[F, C]]): IRTServerMultiplexor[F, C] = new IRTServerMultiplexor[F, C] { + private val all: Map[IRTMethodId, IRTMethodId => (C, Json) => F[Throwable, Json]] = { + multiplexors.toList.flatMap { + muxer => muxer.allMethods.map(method => method -> muxer.invokeMethod) + }.toMap + } + override def allMethods: Set[IRTMethodId] = all.keySet + override def invokeMethod(method: IRTMethodId)(context: C, parsedBody: Json): F[Throwable, Json] = { + F.fromOption(new IRTMissingHandlerException(s"Method $method not found.", parsedBody))(all.get(method)) + .flatMap(invoke => invoke.apply(method).apply(context, parsedBody)) + } + } + class IRTServerMultiplexorImpl[F[+_, +_]: IO2, C]( - val services: Set[IRTWrappedService[F, C]] + services: Set[IRTWrappedService[F, C]] ) extends IRTServerMultiplexor[F, C] { - private val serviceToWrapped: Map[IRTServiceId, IRTWrappedService[F, C]] = { - services.map(s => s.serviceId -> s).toMap - } + private val methodToWrapped: Map[IRTMethodId, IRTMethodWrapper[F, C]] = services.flatMap(_.allMethods).toMap + + override def allMethods: Set[IRTMethodId] = methodToWrapped.keySet - def doInvoke(parsedBody: Json, context: C, toInvoke: IRTMethodId): F[Throwable, Option[Json]] = { - (for { - service <- serviceToWrapped.get(toInvoke.service) - method <- service.allMethods.get(toInvoke) - } yield method) match { - case Some(value) => - invoke(context, toInvoke, value, parsedBody).map(Some.apply) - case None => - F.pure(None) - } + override def invokeMethod(method: IRTMethodId)(context: C, parsedBody: Json): F[Throwable, Json] = { + F.fromOption(new IRTMissingHandlerException(s"Method $method not found.", parsedBody))(methodToWrapped.get(method)) + .flatMap(invoke(_)(context, parsedBody)) } - @inline private[this] def invoke(context: C, toInvoke: IRTMethodId, method: IRTMethodWrapper[F, C], parsedBody: Json): F[Throwable, Json] = { + @inline private[this] def invoke(method: IRTMethodWrapper[F, C])(context: C, parsedBody: Json): F[Throwable, Json] = { + val methodId = method.signature.id for { - decodeAction <- F.syncThrowable(method.marshaller.decodeRequest[F].apply(IRTJsonBody(toInvoke, parsedBody))) - safeDecoded <- decodeAction.sandbox.catchAll { + requestBody <- F.syncThrowable(method.marshaller.decodeRequest[F].apply(IRTJsonBody(methodId, parsedBody))).flatten.sandbox.catchAll { case Exit.Interruption(decodingFailure, _, trace) => - F.fail(new IRTDecodingException(s"$toInvoke: Failed to decode JSON ${parsedBody.toString()} $trace", Some(decodingFailure))) + F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON ${parsedBody.toString()} $trace", Some(decodingFailure))) case Exit.Termination(_, exceptions, trace) => - F.fail(new IRTDecodingException(s"$toInvoke: Failed to decode JSON ${parsedBody.toString()} $trace", exceptions.headOption)) + F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON ${parsedBody.toString()} $trace", exceptions.headOption)) case Exit.Error(decodingFailure, trace) => - F.fail(new IRTDecodingException(s"$toInvoke: Failed to decode JSON ${parsedBody.toString()} $trace", Some(decodingFailure))) + F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON ${parsedBody.toString()} $trace", Some(decodingFailure))) } - casted = safeDecoded.value.asInstanceOf[method.signature.Input] - resultAction <- F.syncThrowable(method.invoke(context, casted)) - safeResult <- resultAction - encoded <- F.syncThrowable(method.marshaller.encodeResponse.apply(IRTResBody(safeResult))) + result <- F.syncThrowable(method.invoke(context, requestBody.value.asInstanceOf[method.signature.Input])).flatten + encoded <- F.syncThrowable(method.marshaller.encodeResponse.apply(IRTResBody(result))) } yield encoded } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTTransportException.scala b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTTransportException.scala index a1725e49..5596a0be 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTTransportException.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTTransportException.scala @@ -1,17 +1,17 @@ package izumi.idealingua.runtime.rpc -trait IRTTransportException +abstract class IRTTransportException(message: String, cause: Option[Throwable]) extends RuntimeException(message, cause.orNull) -class IRTUnparseableDataException(message: String, cause: Option[Throwable] = None) extends RuntimeException(message, cause.orNull) with IRTTransportException +class IRTUnparseableDataException(message: String, cause: Option[Throwable] = None) extends IRTTransportException(message, cause) -class IRTDecodingException(message: String, cause: Option[Throwable] = None) extends RuntimeException(message, cause.orNull) with IRTTransportException +class IRTDecodingException(message: String, cause: Option[Throwable] = None) extends IRTTransportException(message, cause) -class IRTTypeMismatchException(message: String, val v: Any, cause: Option[Throwable] = None) extends RuntimeException(message, cause.orNull) with IRTTransportException +class IRTTypeMismatchException(message: String, val v: Any, cause: Option[Throwable] = None) extends IRTTransportException(message, cause) -class IRTMissingHandlerException(message: String, val v: Any, cause: Option[Throwable] = None) extends RuntimeException(message, cause.orNull) with IRTTransportException +class IRTMissingHandlerException(message: String, val v: Any, cause: Option[Throwable] = None) extends IRTTransportException(message, cause) -class IRTLimitReachedException(message: String, cause: Option[Throwable] = None) extends RuntimeException(message, cause.orNull) with IRTTransportException +class IRTLimitReachedException(message: String, cause: Option[Throwable] = None) extends IRTTransportException(message, cause) -class IRTUnathorizedRequestContextException(message: String, cause: Option[Throwable] = None) extends RuntimeException(message, cause.orNull) with IRTTransportException +class IRTUnathorizedRequestContextException(message: String, cause: Option[Throwable] = None) extends IRTTransportException(message, cause) -class IRTGenericFailure(message: String, cause: Option[Throwable] = None) extends RuntimeException(message, cause.orNull) with IRTTransportException +class IRTGenericFailure(message: String, cause: Option[Throwable] = None) extends IRTTransportException(message, cause) diff --git a/idealingua-v1/idealingua-v1-test-defs/src/main/scala/izumi/r2/idealingua/test/GreeterRunnerExample.scala b/idealingua-v1/idealingua-v1-test-defs/src/main/scala/izumi/r2/idealingua/test/GreeterRunnerExample.scala index 26ba7cf5..81b4e319 100644 --- a/idealingua-v1/idealingua-v1-test-defs/src/main/scala/izumi/r2/idealingua/test/GreeterRunnerExample.scala +++ b/idealingua-v1/idealingua-v1-test-defs/src/main/scala/izumi/r2/idealingua/test/GreeterRunnerExample.scala @@ -18,8 +18,8 @@ object GreeterRunnerExample { val json2 = req2.asJson println(json2) - val invoked1 = multiplexor.doInvoke(json1, (), greeter.greet.signature.id) - val invoked2 = multiplexor.doInvoke(json1, (), greeter.alternative.signature.id) + val invoked1 = multiplexor.invokeMethod(greeter.greet.signature.id)((), json1) + val invoked2 = multiplexor.invokeMethod(greeter.alternative.signature.id)((), json1) implicit val unsafe: Unsafe = Unsafe.unsafe(identity) println(zio.Runtime.default.unsafe.run(invoked1).getOrThrowFiberFailure()) From 46346bc428b3dcabb478ac46f3aec4f828e69223 Mon Sep 17 00:00:00 2001 From: Alex Liubymov Date: Fri, 15 Dec 2023 15:07:55 +0200 Subject: [PATCH 05/24] test ws listeners --- .../runtime/rpc/http4s/HttpServer.scala | 34 ++++------ .../rpc/http4s/ws/WsContextSessions.scala | 15 +++-- .../runtime/rpc/http4s/ws/WsRpcHandler.scala | 35 +++++++--- .../rpc/http4s/Http4sTransportTest.scala | 66 ++++++++++++++----- .../http4s/fixtures/LoggingWsListener.scala | 24 +++++++ .../rpc/http4s/fixtures/TestContext.scala | 6 +- .../rpc/http4s/fixtures/TestServices.scala | 41 +++++++++--- .../runtime/rpc/IRTServerMultiplexor.scala | 6 +- .../idealingua/runtime/rpc/packets.scala | 5 +- 9 files changed, 158 insertions(+), 74 deletions(-) create mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/LoggingWsListener.scala diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala index 6fa920b6..b8ac7670 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala @@ -72,18 +72,6 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2, AuthCtx]( F.unit } - protected def globalWsListener[Ctx, WsCtx]: WsSessionListener[F, Ctx, WsCtx] = new WsSessionListener[F, Ctx, WsCtx] { - override def onSessionOpened(sessionId: WsSessionId, reqCtx: Ctx, wsCtx: WsCtx): F[Throwable, Unit] = { - logger.debug(s"WS Session: $sessionId opened $wsCtx on $reqCtx.") - } - override def onSessionUpdated(sessionId: WsSessionId, reqCtx: Ctx, prevStx: WsCtx, newCtx: WsCtx): F[Throwable, Unit] = { - logger.debug(s"WS Session: $sessionId updated $newCtx from $prevStx on $reqCtx.") - } - override def onSessionClosed(sessionId: WsSessionId, wsCtx: WsCtx): F[Throwable, Unit] = { - logger.debug(s"WS Session: $sessionId closed $wsCtx .") - } - } - protected def setupWs( request: Request[F[Throwable, _]], ws: WebSocketBuilder2[F[Throwable, _]], @@ -150,7 +138,7 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2, AuthCtx]( val methodId = IRTMethodId(IRTServiceId(serviceName), IRTMethodName(methodName)) (for { authContext <- F.syncThrowable(httpContextExtractor.extract(request)) - parsedBody <- F.fromEither(io.circe.parser.parse(body)) + parsedBody <- F.fromEither(io.circe.parser.parse(body)).leftMap(err => new IRTDecodingException(s"Can not parse JSON body '$body'.", Some(err))) invokeRes <- muxer.invokeMethod(methodId)(authContext, parsedBody) } yield invokeRes).sandboxExit.flatMap(handleHttpResult(request, methodId)) } @@ -165,43 +153,43 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2, AuthCtx]( Ok(printer.print(res)) case Error(err: IRTMissingHandlerException, _) => - logger.warn(s"No service and method handler for $method: $err") *> + logger.warn(s"HTTP Request execution failed - no method handler for $method: $err") *> NotFound() case Error(error: circe.Error, trace) => - logger.info(s"Parsing failure while handling $method: $error $trace") *> + logger.warn(s"HTTP Request execution failed - parsing failure while handling $method:\n${error.getMessage -> "error"}\n$trace") *> BadRequest() case Error(error: IRTDecodingException, trace) => - logger.info(s"Parsing failure while handling $method: $error $trace") *> + logger.warn(s"HTTP Request execution failed - parsing failure while handling $method:\n$error\n$trace") *> BadRequest() case Error(error: IRTLimitReachedException, trace) => - logger.debug(s"$Request failed because of request limit reached $method: $error $trace") *> + logger.debug(s"HTTP Request failed - request limit reached $method:\n$error\n$trace") *> TooManyRequests() case Error(error: IRTUnathorizedRequestContextException, trace) => - logger.debug(s"$Request failed because of unexpected request context reached $method: $error $trace") *> + logger.debug(s"HTTP Request failed - unauthorized $method call:\n$error\n$trace") *> F.pure(Response(status = Status.Unauthorized)) case Error(error, trace) => - logger.info(s"Unexpected failure while handling $method: $error $trace") *> + logger.warn(s"HTTP Request unexpectedly failed while handling $method:\n$error\n$trace") *> InternalServerError() case Termination(_, (cause: IRTHttpFailureException) :: _, trace) => - logger.debug(s"Request rejected, $method, $request, $cause, $trace") *> + logger.error(s"HTTP Request rejected - $method, $request:\n$cause\n$trace") *> F.pure(Response(status = cause.status)) case Termination(_, (cause: RejectedExecutionException) :: _, trace) => - logger.warn(s"Not enough capacity to handle $method: $cause $trace") *> + logger.warn(s"HTTP Request rejected - Not enough capacity to handle $method:\n$cause\n$trace") *> TooManyRequests() case Termination(cause, _, trace) => - logger.error(s"Execution failed, termination, $method, $request, $cause, $trace") *> + logger.error(s"HTTP Request execution failed, termination, $method, $request:\n$cause\n$trace") *> InternalServerError() case Interruption(cause, _, trace) => - logger.info(s"Unexpected interruption while handling $method: $cause $trace") *> + logger.error(s"HTTP Request unexpectedly interrupted while handling $method:\n$cause\n$trace") *> InternalServerError() } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala index 1183003d..6daa0e51 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala @@ -30,27 +30,29 @@ object WsContextSessions { class WsContextSessionsImpl[F[+_, +_]: IO2, RequestCtx, WsCtx]( wsSessionsStorage: WsSessionsStorage[F, ?], + globalWsListeners: Set[WsSessionListener[F, Any, Any]], wsSessionListeners: Set[WsSessionListener[F, RequestCtx, WsCtx]], wsIdExtractor: WsIdExtractor[RequestCtx, WsCtx], ) extends WsContextSessions[F, RequestCtx, WsCtx] { - private val sessionToId = new ConcurrentHashMap[WsSessionId, WsCtx]() - private val idToSession = new ConcurrentHashMap[WsCtx, WsSessionId]() + private[this] val allListeners = globalWsListeners ++ wsSessionListeners + private[this] val sessionToId = new ConcurrentHashMap[WsSessionId, WsCtx]() + private[this] val idToSession = new ConcurrentHashMap[WsCtx, WsSessionId]() override def updateSession(wsSessionId: WsSessionId, requestContext: Option[RequestCtx]): F[Throwable, Unit] = { updateCtx(wsSessionId, requestContext).flatMap { case (Some(ctx), Some(previous), Some(updated)) if previous != updated => - F.traverse_(wsSessionListeners)(_.onSessionUpdated(wsSessionId, ctx, previous, updated)) + F.traverse_(allListeners)(_.onSessionUpdated(wsSessionId, ctx, previous, updated)) case (Some(ctx), None, Some(updated)) => - F.traverse_(wsSessionListeners)(_.onSessionOpened(wsSessionId, ctx, updated)) + F.traverse_(allListeners)(_.onSessionOpened(wsSessionId, ctx, updated)) case (_, Some(prev), None) => - F.traverse_(wsSessionListeners)(_.onSessionClosed(wsSessionId, prev)) + F.traverse_(allListeners)(_.onSessionClosed(wsSessionId, prev)) case _ => F.unit } } override def dispatcherFor(ctx: WsCtx, codec: IRTClientMultiplexor[F], timeout: FiniteDuration): F[Throwable, Option[IRTDispatcher[F]]] = { - F.sync(Option(idToSession.get(ctx))).flatMap { + F.sync(synchronized(Option(idToSession.get(ctx)))).flatMap { F.traverse(_) { wsSessionsStorage.dispatcherForSession(_, codec, timeout) } @@ -66,6 +68,7 @@ object WsContextSessions { val updated = requestContext.flatMap(wsIdExtractor.extract) (updated, previous) match { case (Some(upd), _) => + previous.map(idToSession.remove) sessionToId.put(wsSessionId, upd) idToSession.put(upd, wsSessionId) () diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala index 21553300..b9593cd5 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala @@ -19,8 +19,10 @@ abstract class WsRpcHandler[F[+_, +_]: IO2, RequestCtx]( def processRpcMessage(message: String): F[Throwable, Option[RpcPacket]] = { for { - packet <- F.fromEither(io.circe.parser.decode[RpcPacket](message)) - _ <- updateRequestCtx(packet) + packet <- F + .fromEither(io.circe.parser.decode[RpcPacket](message)) + .leftMap(err => new IRTDecodingException(s"Can not decode Rpc Packet '$message'.\nError: $err.")) + _ <- updateRequestCtx(packet) response <- packet match { // auth case RpcPacket(RPCPacketKind.RpcRequest, None, _, _, _, _, _) => @@ -81,20 +83,35 @@ abstract class WsRpcHandler[F[+_, +_]: IO2, RequestCtx]( case Success(res) => F.pure(Some(onSuccess(res))) - case Exit.Error(_: IRTMissingHandlerException, _) => - logger.error(s"WS request errored: No service handler for $methodId.").as(Some(onFail("Service an method not found."))) + case Exit.Error(error: IRTMissingHandlerException, trace) => + logger + .error(s"WS Request failed - no method handler for $methodId:\n$error\n$trace") + .as(Some(onFail("Not Found."))) - case Exit.Error(err: IRTUnathorizedRequestContextException, _) => - logger.warn(s"WS request errored: unauthorized - ${err.getMessage -> "message"}.").as(Some(onFail("Unauthorized."))) + case Exit.Error(error: IRTUnathorizedRequestContextException, trace) => + logger + .warn(s"WS Request failed - unauthorized $methodId call:\n$error\n$trace") + .as(Some(onFail("Unauthorized."))) + + case Exit.Error(error: IRTDecodingException, trace) => + logger + .warn(s"WS Request failed - decoding failed:\n$error\n$trace") + .as(Some(onFail("BadRequest."))) case Exit.Termination(exception, allExceptions, trace) => - logger.error(s"WS request terminated: $exception, $allExceptions, $trace").as(Some(onFail(exception.getMessage))) + logger + .error(s"WS Request terminated:\n$exception\n$allExceptions\n$trace") + .as(Some(onFail(exception.getMessage))) case Exit.Error(exception, trace) => - logger.error(s"WS request failed: $exception $trace").as(Some(onFail(exception.getMessage))) + logger + .error(s"WS Request unexpectedly failed:\n$exception\n$trace") + .as(Some(onFail(exception.getMessage))) case Exit.Interruption(exception, allExceptions, trace) => - logger.error(s"WS request interrupted: $exception $allExceptions $trace").as(Some(onFail(exception.getMessage))) + logger + .error(s"WS Request unexpectedly interrupted:\n$exception\n$allExceptions\n$trace") + .as(Some(onFail(exception.getMessage))) } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala index 5ebbe7ff..56092e0c 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala @@ -20,7 +20,7 @@ import izumi.idealingua.runtime.rpc.http4s.ws.{RawResponse, WsRequestState} import izumi.logstage.api.routing.{ConfigurableLogRouter, StaticLogRouter} import izumi.logstage.api.{IzLogger, Log} import izumi.r2.idealingua.test.generated.{GreeterServiceClientWrapped, GreeterServiceMethods} -import logstage.LogIO +import logstage.LogIO2 import org.http4s.* import org.http4s.blaze.server.* import org.http4s.dsl.Http4sDsl @@ -48,8 +48,8 @@ object Http4sTransportTest { ), ) final class Ctx[F[+_, +_]: Async2: Temporal2: Primitives2: UnsafeRun2](implicit asyncThrowable: Async[F[Throwable, _]]) { - private val logger = LogIO.fromLogger[F[Nothing, _]](makeLogger()) - private val printer: Printer = Printer.noSpaces.copy(dropNullValues = true) + private val logger: LogIO2[F] = LogIO2.fromLogger(izLogger) + private val printer: Printer = Printer.noSpaces.copy(dropNullValues = true) final val dsl = Http4sDsl.apply[F[Throwable, _]] final val execCtx = HttpExecutionContext(global) @@ -72,7 +72,7 @@ object Http4sTransportTest { printer = printer, ) - def badAuth(user: String): Header.ToRaw = Authorization(BasicCredentials(user, "badpass")) + def badAuth(): Header.ToRaw = Authorization(Credentials.Token(AuthScheme.Bearer, "token")) def publicAuth(user: String): Header.ToRaw = Authorization(BasicCredentials(user, "public")) def protectedAuth(user: String): Header.ToRaw = Authorization(BasicCredentials(user, "protected")) def privateAuth(user: String): Header.ToRaw = Authorization(BasicCredentials(user, "private")) @@ -96,10 +96,10 @@ object Http4sTransportTest { val router = ConfigurableLogRouter( Log.Level.Debug, levels = Map( - "io.netty" -> Log.Level.Info, - "org.http4s.blaze.channel.nio1" -> Log.Level.Info, - "org.http4s" -> Log.Level.Info, - "org.asynchttpclient" -> Log.Level.Info, + "io.netty" -> Log.Level.Error, + "org.http4s.blaze.channel.nio1" -> Log.Level.Error, + "org.http4s" -> Log.Level.Error, + "org.asynchttpclient" -> Log.Level.Error, ), ) @@ -175,10 +175,11 @@ abstract class Http4sTransportTestBase[F[+_, +_]: Async2: Primitives2: Temporal2 wsRpcClientDispatcher().use { dispatcher => for { - publicHeaders <- F.pure(Map("Authorization" -> publicAuth("user").values.head.value)) - privateHeaders <- F.pure(Map("Authorization" -> privateAuth("user").values.head.value)) - protectedHeaders <- F.pure(Map("Authorization" -> protectedAuth("user").values.head.value)) - badHeaders <- F.pure(Map("Authorization" -> badAuth("user").values.head.value)) + publicHeaders <- F.pure(Map("Authorization" -> publicAuth("user").values.head.value)) + privateHeaders <- F.pure(Map("Authorization" -> privateAuth("user").values.head.value)) + protectedHeaders <- F.pure(Map("Authorization" -> protectedAuth("user").values.head.value)) + protectedHeaders2 <- F.pure(Map("Authorization" -> protectedAuth("John").values.head.value)) + badHeaders <- F.pure(Map("Authorization" -> badAuth().values.head.value)) publicClient = new GreeterServiceClientWrapped[F](dispatcher) privateClient = new PrivateTestServiceWrappedClient[F](dispatcher) @@ -188,11 +189,21 @@ abstract class Http4sTransportTestBase[F[+_, +_]: Async2: Primitives2: Temporal2 _ <- demo.Server.protectedWsSession.dispatcherFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) _ <- demo.Server.privateWsSession.dispatcherFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) _ <- demo.Server.publicWsSession.dispatcherFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) + // all listeners are empty + _ = assert(demo.Server.protectedWsListener.connected.isEmpty) + _ = assert(demo.Server.privateWsListener.connected.isEmpty) + _ = assert(demo.Server.publicWsListener.connected.isEmpty) // public authorization _ <- dispatcher.authorize(publicHeaders) + // protected and private listeners are empty + _ = assert(demo.Server.protectedWsListener.connected.isEmpty) + _ = assert(demo.Server.privateWsListener.connected.isEmpty) + _ = assert(demo.Server.publicWsListener.connected.contains(PublicContext("user"))) + // protected and private sessions are empty _ <- demo.Server.protectedWsSession.dispatcherFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) _ <- demo.Server.privateWsSession.dispatcherFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) + // public dispatcher works as expected publicContextBuzzer <- demo.Server.publicWsSession .dispatcherFor(PublicContext("user"), demo.Client.codec) .fromOption(new RuntimeException("Missing Buzzer")) @@ -204,6 +215,11 @@ abstract class Http4sTransportTestBase[F[+_, +_]: Async2: Primitives2: Temporal2 // re-authorize with private _ <- dispatcher.authorize(privateHeaders) + // protected listener is empty + _ = assert(demo.Server.protectedWsListener.connected.isEmpty) + _ = assert(demo.Server.privateWsListener.connected.contains(PrivateContext("user"))) + _ = assert(demo.Server.publicWsListener.connected.contains(PublicContext("user"))) + // protected sessions is empty _ <- demo.Server.protectedWsSession.dispatcherFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) _ <- demo.Server.privateWsSession.dispatcherFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) _ <- demo.Server.publicWsSession.dispatcherFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) @@ -213,6 +229,11 @@ abstract class Http4sTransportTestBase[F[+_, +_]: Async2: Primitives2: Temporal2 // re-authorize with protected _ <- dispatcher.authorize(protectedHeaders) + // private listener is empty + _ = assert(demo.Server.protectedWsListener.connected.contains(ProtectedContext("user"))) + _ = assert(demo.Server.privateWsListener.connected.isEmpty) + _ = assert(demo.Server.publicWsListener.connected.contains(PublicContext("user"))) + // private sessions is empty _ <- demo.Server.protectedWsSession.dispatcherFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) _ <- demo.Server.privateWsSession.dispatcherFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) _ <- demo.Server.publicWsSession.dispatcherFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) @@ -220,12 +241,23 @@ abstract class Http4sTransportTestBase[F[+_, +_]: Async2: Primitives2: Temporal2 _ <- publicClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) _ <- checkAnauthorizedWsCall(privateClient.test("")) - // update auth and call service + // auth session context update + _ <- dispatcher.authorize(protectedHeaders2) + // session and listeners notified + _ = assert(demo.Server.protectedWsListener.connected.contains(ProtectedContext("John"))) + _ = assert(demo.Server.protectedWsListener.connected.size == 1) + _ = assert(demo.Server.publicWsListener.connected.contains(PublicContext("John"))) + _ = assert(demo.Server.publicWsListener.connected.size == 1) + _ = assert(demo.Server.privateWsListener.connected.isEmpty) + _ <- demo.Server.privateWsSession.dispatcherFor(PrivateContext("John"), demo.Client.codec).map(b => assert(b.isEmpty)) + _ <- demo.Server.publicWsSession.dispatcherFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) + _ <- demo.Server.publicWsSession.dispatcherFor(PublicContext("John"), demo.Client.codec).map(b => assert(b.nonEmpty)) + _ <- demo.Server.protectedWsSession.dispatcherFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) + _ <- demo.Server.protectedWsSession.dispatcherFor(ProtectedContext("John"), demo.Client.codec).map(b => assert(b.nonEmpty)) + + // bad authorization _ <- dispatcher.authorize(badHeaders) - _ <- F.sandboxExit(publicClient.alternative()).map { - case Termination(f: IRTGenericFailure, _, _) if f.getMessage.contains("""{"cause": "Unauthorized"}""") => - case o => F.fail(s"Expected IRTGenericFailure with Unauthorized message but got $o") - } + _ <- checkAnauthorizedWsCall(publicClient.alternative()) } yield () } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/LoggingWsListener.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/LoggingWsListener.scala new file mode 100644 index 00000000..f49a7ba2 --- /dev/null +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/LoggingWsListener.scala @@ -0,0 +1,24 @@ +package izumi.idealingua.runtime.rpc.http4s.fixtures + +import izumi.functional.bio.{F, IO2} +import izumi.idealingua.runtime.rpc.http4s.ws.{WsSessionId, WsSessionListener} + +import scala.collection.mutable + +final class LoggingWsListener[F[+_, +_]: IO2, RequestCtx, WsCtx] extends WsSessionListener[F, RequestCtx, WsCtx] { + private val connections = mutable.Set.empty[WsCtx] + def connected: Set[WsCtx] = connections.toSet + + override def onSessionOpened(sessionId: WsSessionId, reqCtx: RequestCtx, wsCtx: WsCtx): F[Throwable, Unit] = F.sync { + connections.add(wsCtx) + } + + override def onSessionUpdated(sessionId: WsSessionId, reqCtx: RequestCtx, prevStx: WsCtx, newCtx: WsCtx): F[Throwable, Unit] = F.sync { + connections.remove(prevStx) + connections.add(newCtx) + } + + override def onSessionClosed(sessionId: WsSessionId, wsCtx: WsCtx): F[Throwable, Unit] = F.sync { + connections.remove(wsCtx) + } +} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestContext.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestContext.scala index 8d65b104..a2e6f7ed 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestContext.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestContext.scala @@ -3,13 +3,13 @@ package izumi.idealingua.runtime.rpc.http4s.fixtures sealed trait TestContext final case class PrivateContext(user: String) extends TestContext { - override def toString: String = "private" + override def toString: String = s"private: $user" } final case class ProtectedContext(user: String) extends TestContext { - override def toString: String = "protected" + override def toString: String = s"protected: $user" } final case class PublicContext(user: String) extends TestContext { - override def toString: String = "public" + override def toString: String = s"public: $user" } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala index 29843ff5..b91dd15e 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala @@ -9,7 +9,7 @@ import izumi.idealingua.runtime.rpc.http4s.context.WsIdExtractor import izumi.idealingua.runtime.rpc.http4s.fixtures.defs.* import izumi.idealingua.runtime.rpc.http4s.ws.WsContextSessions.WsContextSessionsImpl import izumi.idealingua.runtime.rpc.http4s.ws.WsSessionsStorage.WsSessionsStorageImpl -import izumi.idealingua.runtime.rpc.http4s.ws.{WsContextSessions, WsSessionsStorage} +import izumi.idealingua.runtime.rpc.http4s.ws.{WsContextSessions, WsSessionId, WsSessionListener, WsSessionsStorage} import izumi.idealingua.runtime.rpc.http4s.{IRTAuthenticator, IRTContextServices} import izumi.r2.idealingua.test.generated.{GreeterServiceClientWrapped, GreeterServiceServerWrapped} import izumi.r2.idealingua.test.impls.AbstractGreeterServer @@ -23,7 +23,19 @@ class TestServices[F[+_, +_]: IO2]( object Server { final val wsStorage: WsSessionsStorage[F, AuthContext] = new WsSessionsStorageImpl[F, AuthContext](logger) - + final val globalWsListeners = Set( + new WsSessionListener[F, Any, Any] { + override def onSessionOpened(sessionId: WsSessionId, reqCtx: Any, wsCtx: Any): F[Throwable, Unit] = { + logger.debug(s"WS Session: $sessionId opened $wsCtx on $reqCtx.") + } + override def onSessionUpdated(sessionId: WsSessionId, reqCtx: Any, prevStx: Any, newCtx: Any): F[Throwable, Unit] = { + logger.debug(s"WS Session: $sessionId updated $newCtx from $prevStx on $reqCtx.") + } + override def onSessionClosed(sessionId: WsSessionId, wsCtx: Any): F[Throwable, Unit] = { + logger.debug(s"WS Session: $sessionId closed $wsCtx .") + } + } + ) // PRIVATE private val privateAuth = new IRTAuthenticator[F, AuthContext, PrivateContext] { override def authenticate(authContext: AuthContext, body: Option[Json]): F[Nothing, Option[PrivateContext]] = F.sync { @@ -32,13 +44,16 @@ class TestServices[F[+_, +_]: IO2]( } } } - final val privateWsSession: WsContextSessions[F, PrivateContext, PrivateContext] = new WsContextSessionsImpl(wsStorage, Set.empty, WsIdExtractor.id) + final val privateWsListener: LoggingWsListener[F, PrivateContext, PrivateContext] = new LoggingWsListener[F, PrivateContext, PrivateContext] + final val privateWsSession: WsContextSessions[F, PrivateContext, PrivateContext] = { + new WsContextSessionsImpl(wsStorage, globalWsListeners, Set(privateWsListener), WsIdExtractor.id[PrivateContext]) + } final val privateService: IRTWrappedService[F, PrivateContext] = new PrivateTestServiceWrappedServer(new PrivateTestServiceServer[F, PrivateContext] { def test(ctx: PrivateContext, str: String): Just[String] = F.pure(s"Private: $str") }) final val privateServices: IRTContextServices[F, AuthContext, PrivateContext, PrivateContext] = IRTContextServices( authenticator = privateAuth, - serverMuxer = new IRTServerMultiplexorImpl(Set(privateService)), + serverMuxer = new IRTServerMultiplexorImpl(Set(privateService)), wsSessions = privateWsSession, ) @@ -50,29 +65,35 @@ class TestServices[F[+_, +_]: IO2]( } } } - final val protectedWsSession: WsContextSessions[F, ProtectedContext, ProtectedContext] = new WsContextSessionsImpl(wsStorage, Set.empty, WsIdExtractor.id) + final val protectedWsListener: LoggingWsListener[F, ProtectedContext, ProtectedContext] = new LoggingWsListener[F, ProtectedContext, ProtectedContext] + final val protectedWsSession: WsContextSessions[F, ProtectedContext, ProtectedContext] = { + new WsContextSessionsImpl(wsStorage, globalWsListeners, Set(protectedWsListener), WsIdExtractor.id) + } final val protectedService: IRTWrappedService[F, ProtectedContext] = new ProtectedTestServiceWrappedServer(new ProtectedTestServiceServer[F, ProtectedContext] { def test(ctx: ProtectedContext, str: String): Just[String] = F.pure(s"Protected: $str") }) final val protectedServices: IRTContextServices[F, AuthContext, ProtectedContext, ProtectedContext] = IRTContextServices( authenticator = protectedAuth, - serverMuxer = new IRTServerMultiplexorImpl(Set(protectedService)), + serverMuxer = new IRTServerMultiplexorImpl(Set(protectedService)), wsSessions = protectedWsSession, ) // PUBLIC - private val publicAuth = new IRTAuthenticator[F, AuthContext, PublicContext] { + final val publicAuth = new IRTAuthenticator[F, AuthContext, PublicContext] { override def authenticate(authContext: AuthContext, body: Option[Json]): F[Nothing, Option[PublicContext]] = F.sync { authContext.headers.get[Authorization].map(_.credentials).collect { case BasicCredentials(user, _) => PublicContext(user) } } } - final val publicWsSession: WsContextSessions[F, PublicContext, PublicContext] = new WsContextSessionsImpl(wsStorage, Set.empty, WsIdExtractor.id) - final val publicService: IRTWrappedService[F, PublicContext] = new GreeterServiceServerWrapped(new AbstractGreeterServer.Impl[F, PublicContext]) + final val publicWsListener: LoggingWsListener[F, PublicContext, PublicContext] = new LoggingWsListener[F, PublicContext, PublicContext] + final val publicWsSession: WsContextSessions[F, PublicContext, PublicContext] = { + new WsContextSessionsImpl(wsStorage, globalWsListeners, Set(publicWsListener), WsIdExtractor.id) + } + final val publicService: IRTWrappedService[F, PublicContext] = new GreeterServiceServerWrapped(new AbstractGreeterServer.Impl[F, PublicContext]) final val publicServices: IRTContextServices[F, AuthContext, PublicContext, PublicContext] = IRTContextServices( authenticator = publicAuth, - serverMuxer = new IRTServerMultiplexorImpl(Set(publicService)), + serverMuxer = new IRTServerMultiplexorImpl(Set(publicService)), wsSessions = publicWsSession, ) diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala index 1c00c3e6..a04bb62c 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala @@ -51,11 +51,11 @@ object IRTServerMultiplexor { for { requestBody <- F.syncThrowable(method.marshaller.decodeRequest[F].apply(IRTJsonBody(methodId, parsedBody))).flatten.sandbox.catchAll { case Exit.Interruption(decodingFailure, _, trace) => - F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON ${parsedBody.toString()} $trace", Some(decodingFailure))) + F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON '${parsedBody.noSpaces}'.\nTrace: $trace", Some(decodingFailure))) case Exit.Termination(_, exceptions, trace) => - F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON ${parsedBody.toString()} $trace", exceptions.headOption)) + F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON '${parsedBody.noSpaces}'.\nTrace: $trace", exceptions.headOption)) case Exit.Error(decodingFailure, trace) => - F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON ${parsedBody.toString()} $trace", Some(decodingFailure))) + F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON '${parsedBody.noSpaces}'.\nTrace: $trace", Some(decodingFailure))) } result <- F.syncThrowable(method.invoke(context, requestBody.value.asInstanceOf[method.signature.Input])).flatten encoded <- F.syncThrowable(method.marshaller.encodeResponse.apply(IRTResBody(result))) diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/packets.scala b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/packets.scala index 386af425..7784827c 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/packets.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/packets.scala @@ -78,7 +78,6 @@ object RpcPacketId { def random(): RpcPacketId = RpcPacketId(UUIDGen.getTimeUUID().toString) implicit def dec0: Decoder[RpcPacketId] = Decoder.decodeString.map(RpcPacketId.apply) - implicit def enc0: Encoder[RpcPacketId] = Encoder.encodeString.contramap(_.v) } @@ -118,7 +117,7 @@ object RpcPacket { } def rpcFail(ref: Option[RpcPacketId], cause: String): RpcPacket = { - RpcPacket(RPCPacketKind.RpcFail, Some(Map("cause" -> cause).asJson), None, ref, None, None, None) + RpcPacket(RPCPacketKind.RpcFail, Some(Json.obj("cause" -> Json.fromString(cause))), None, ref, None, None, None) } def buzzerRequest(id: RpcPacketId, method: IRTMethodId, data: Json): RpcPacket = { @@ -130,6 +129,6 @@ object RpcPacket { } def buzzerFail(ref: Option[RpcPacketId], cause: String): RpcPacket = { - RpcPacket(RPCPacketKind.BuzzFailure, Some(Map("cause" -> cause).asJson), None, ref, None, None, None) + RpcPacket(RPCPacketKind.BuzzFailure, Some(Json.obj("cause" -> Json.fromString(cause))), None, ref, None, None, None) } } From 232a126d3fc995f57369edd37da26c36af2855b1 Mon Sep 17 00:00:00 2001 From: Alex Liubymov Date: Fri, 15 Dec 2023 16:40:58 +0200 Subject: [PATCH 06/24] better server muxer - create large methods map instead of recursive search; add middlewares --- .../runtime/rpc/http4s/HttpServer.scala | 30 ++++---- .../rpc/http4s/IRTContextServices.scala | 25 ++++-- .../rpc/http4s/Http4sTransportTest.scala | 63 +++++++-------- .../rpc/http4s/fixtures/TestContext.scala | 4 +- .../rpc/http4s/fixtures/TestServices.scala | 26 +++++-- .../runtime/rpc/IRTServerMethod.scala | 42 ++++++++++ .../runtime/rpc/IRTServerMiddleware.scala | 8 ++ .../runtime/rpc/IRTServerMultiplexor.scala | 76 +++++++------------ .../test/GreeterRunnerExample.scala | 4 +- 9 files changed, 166 insertions(+), 112 deletions(-) create mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMethod.scala create mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMiddleware.scala diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala index b8ac7670..b0a14937 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala @@ -57,21 +57,6 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2, AuthCtx]( case request @ POST -> Root / service / method => request.decode[String](processHttpRequest(request, service, method)) } - protected def handleWsClose(session: WsClientSession[F, AuthCtx]): F[Throwable, Unit] = { - logger.debug(s"WS Session: Websocket client disconnected ${session.sessionId}.") *> - session.finish(onWsDisconnected) - } - - protected def onWsConnected(authContext: AuthCtx): F[Throwable, Unit] = { - authContext.discard() - F.unit - } - - protected def onWsDisconnected(authContext: AuthCtx): F[Throwable, Unit] = { - authContext.discard() - F.unit - } - protected def setupWs( request: Request[F[Throwable, _]], ws: WebSocketBuilder2[F[Throwable, _]], @@ -125,10 +110,25 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2, AuthCtx]( new ServerWsRpcHandler(clientSession, muxer, wsContextExtractor, logger) } + protected def handleWsClose(session: WsClientSession[F, AuthCtx]): F[Throwable, Unit] = { + logger.debug(s"WS Session: Websocket client disconnected ${session.sessionId}.") *> + session.finish(onWsDisconnected) + } + + protected def onWsConnected(authContext: AuthCtx): F[Throwable, Unit] = { + authContext.discard() + F.unit + } + protected def onWsHeartbeat(requestTime: ZonedDateTime): F[Throwable, Unit] = { logger.debug(s"WS Session: pong frame at $requestTime") } + protected def onWsDisconnected(authContext: AuthCtx): F[Throwable, Unit] = { + authContext.discard() + F.unit + } + protected def processHttpRequest( request: Request[F[Throwable, _]], serviceName: String, diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala index 192a0951..969a825e 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala @@ -1,20 +1,29 @@ package izumi.idealingua.runtime.rpc.http4s -import izumi.functional.bio.{Error2, Monad2} -import izumi.idealingua.runtime.rpc.IRTServerMultiplexor +import izumi.functional.bio.{IO2, Monad2} import izumi.idealingua.runtime.rpc.http4s.ws.WsContextSessions +import izumi.idealingua.runtime.rpc.{IRTServerMiddleware, IRTServerMultiplexor} final case class IRTContextServices[F[+_, +_], AuthCtx, RequestCtx, WsCtx]( authenticator: IRTAuthenticator[F, AuthCtx, RequestCtx], serverMuxer: IRTServerMultiplexor[F, RequestCtx], + middlewares: Set[IRTServerMiddleware[F, RequestCtx]], wsSessions: WsContextSessions[F, RequestCtx, WsCtx], ) { - def authorizedMuxer(implicit E: Error2[F]): IRTServerMultiplexor[F, AuthCtx] = serverMuxer.contramap { - case (authCtx, body) => - authenticator.authenticate(authCtx, Some(body)) + def authorizedMuxer(implicit io2: IO2[F]): IRTServerMultiplexor[F, AuthCtx] = { + val withMiddlewares: IRTServerMultiplexor[F, RequestCtx] = middlewares.toList.sortBy(_.priority).foldLeft(serverMuxer) { + case (muxer, middleware) => muxer.wrap(middleware) + } + val authorized: IRTServerMultiplexor[F, AuthCtx] = withMiddlewares.contramap { + case (authCtx, body) => authenticator.authenticate(authCtx, Some(body)) + } + authorized } - def authorizedWsSessions(implicit M: Monad2[F]): WsContextSessions[F, AuthCtx, WsCtx] = wsSessions.contramap { - authCtx => - authenticator.authenticate(authCtx, None) + def authorizedWsSessions(implicit M: Monad2[F]): WsContextSessions[F, AuthCtx, WsCtx] = { + val authorized: WsContextSessions[F, AuthCtx, WsCtx] = wsSessions.contramap { + authCtx => + authenticator.authenticate(authCtx, None) + } + authorized } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala index 56092e0c..97481766 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala @@ -125,45 +125,37 @@ abstract class Http4sTransportTestBase[F[+_, +_]: Async2: Primitives2: Temporal2 privateClient <- F.sync(httpRpcClientDispatcher(Headers(privateAuth("user1")))) protectedClient <- F.sync(httpRpcClientDispatcher(Headers(protectedAuth("user2")))) publicClient <- F.sync(httpRpcClientDispatcher(Headers(publicAuth("user3")))) + publicOrcClient <- F.sync(httpRpcClientDispatcher(Headers(publicAuth("orc")))) // Private API test _ <- new PrivateTestServiceWrappedClient(privateClient).test("test").map { res => assert(res.startsWith("Private")) } - _ <- F.sandboxExit(new PrivateTestServiceWrappedClient(protectedClient).test("test")).map { - case Termination(exception: IRTUnexpectedHttpStatus, _, _) => assert(exception.status == Status.Unauthorized) - case o => fail(s"Expected Unauthorized status but got $o") - } - _ <- F.sandboxExit(new ProtectedTestServiceWrappedClient(publicClient).test("test")).map { - case Termination(exception: IRTUnexpectedHttpStatus, _, _) => assert(exception.status == Status.Unauthorized) - case o => fail(s"Expected Unauthorized status but got $o") - } + _ <- checkUnauthorizedHttpCall(new PrivateTestServiceWrappedClient(protectedClient).test("test")) + _ <- checkUnauthorizedHttpCall(new ProtectedTestServiceWrappedClient(publicClient).test("test")) // Protected API test _ <- new ProtectedTestServiceWrappedClient(protectedClient).test("test").map { res => assert(res.startsWith("Protected")) } - _ <- F.sandboxExit(new ProtectedTestServiceWrappedClient(privateClient).test("test")).map { - case Termination(exception: IRTUnexpectedHttpStatus, _, _) => assert(exception.status == Status.Unauthorized) - case o => fail(s"Expected Unauthorized status but got $o") - } - _ <- F.sandboxExit(new ProtectedTestServiceWrappedClient(publicClient).test("test")).map { - case Termination(exception: IRTUnexpectedHttpStatus, _, _) => assert(exception.status == Status.Unauthorized) - case o => fail(s"Expected Unauthorized status but got $o") - } + _ <- checkUnauthorizedHttpCall(new ProtectedTestServiceWrappedClient(privateClient).test("test")) + _ <- checkUnauthorizedHttpCall(new ProtectedTestServiceWrappedClient(publicClient).test("test")) // Public API test + _ <- new GreeterServiceClientWrapped(protectedClient) + .greet("Protected", "Client") + .map(res => assert(res == "Hi, Protected Client!")) + _ <- new GreeterServiceClientWrapped(privateClient) + .greet("Protected", "Client") + .map(res => assert(res == "Hi, Protected Client!")) greaterClient = new GreeterServiceClientWrapped(publicClient) - _ <- new GreeterServiceClientWrapped(protectedClient).greet("Protected", "Client").map { - res => assert(res == "Hi, Protected Client!") - } - _ <- new GreeterServiceClientWrapped(privateClient).greet("Protected", "Client").map { - res => assert(res == "Hi, Protected Client!") - } + _ <- greaterClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) + _ <- greaterClient.alternative().attempt.map(res => assert(res == Right("value"))) - // - _ <- greaterClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) - _ <- greaterClient.alternative().attempt.map(res => assert(res == Right("value"))) + // middleware test + _ <- checkUnauthorizedHttpCall(new GreeterServiceClientWrapped(publicOrcClient).greet("Orc", "Smith")) + + // bad body test _ <- checkBadBody("{}", publicClient) _ <- checkBadBody("{unparseable", publicClient) } yield () @@ -210,8 +202,8 @@ abstract class Http4sTransportTestBase[F[+_, +_]: Async2: Primitives2: Temporal2 _ <- new GreeterServiceClientWrapped(publicContextBuzzer).greet("John", "Buzzer").map(res => assert(res == "Hi, John Buzzer!")) _ <- publicClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) _ <- publicClient.alternative().attempt.map(res => assert(res == Right("value"))) - _ <- checkAnauthorizedWsCall(privateClient.test("")) - _ <- checkAnauthorizedWsCall(protectedClient.test("")) + _ <- checkUnauthorizedWsCall(privateClient.test("")) + _ <- checkUnauthorizedWsCall(protectedClient.test("")) // re-authorize with private _ <- dispatcher.authorize(privateHeaders) @@ -225,7 +217,7 @@ abstract class Http4sTransportTestBase[F[+_, +_]: Async2: Primitives2: Temporal2 _ <- demo.Server.publicWsSession.dispatcherFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) _ <- privateClient.test("test").map(res => assert(res.startsWith("Private"))) _ <- publicClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) - _ <- checkAnauthorizedWsCall(protectedClient.test("")) + _ <- checkUnauthorizedWsCall(protectedClient.test("")) // re-authorize with protected _ <- dispatcher.authorize(protectedHeaders) @@ -239,7 +231,7 @@ abstract class Http4sTransportTestBase[F[+_, +_]: Async2: Primitives2: Temporal2 _ <- demo.Server.publicWsSession.dispatcherFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) _ <- protectedClient.test("test").map(res => assert(res.startsWith("Protected"))) _ <- publicClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) - _ <- checkAnauthorizedWsCall(privateClient.test("")) + _ <- checkUnauthorizedWsCall(privateClient.test("")) // auth session context update _ <- dispatcher.authorize(protectedHeaders2) @@ -257,7 +249,7 @@ abstract class Http4sTransportTestBase[F[+_, +_]: Async2: Primitives2: Temporal2 // bad authorization _ <- dispatcher.authorize(badHeaders) - _ <- checkAnauthorizedWsCall(publicClient.alternative()) + _ <- checkUnauthorizedWsCall(publicClient.alternative()) } yield () } } @@ -301,7 +293,16 @@ abstract class Http4sTransportTestBase[F[+_, +_]: Async2: Primitives2: Temporal2 } } - def checkAnauthorizedWsCall[E, A](call: F[E, A]): F[Throwable, Unit] = { + def checkUnauthorizedHttpCall[E, A](call: F[E, A]): F[Throwable, Unit] = { + call.sandboxExit.flatMap { + case Termination(exception: IRTUnexpectedHttpStatus, _, _) => + F.sync(assert(exception.status == Status.Unauthorized)).void + case o => + F.fail(new RuntimeException(s"Expected Unauthorized status but got $o")) + } + } + + def checkUnauthorizedWsCall[E, A](call: F[E, A]): F[Throwable, Unit] = { call.sandboxExit.flatMap { case Termination(f: IRTGenericFailure, _, _) if f.getMessage.contains("""{"cause":"Unauthorized."}""") => F.unit diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestContext.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestContext.scala index a2e6f7ed..84317709 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestContext.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestContext.scala @@ -1,6 +1,8 @@ package izumi.idealingua.runtime.rpc.http4s.fixtures -sealed trait TestContext +sealed trait TestContext { + def user: String +} final case class PrivateContext(user: String) extends TestContext { override def toString: String = s"private: $user" diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala index b91dd15e..5d04504d 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala @@ -3,7 +3,6 @@ package izumi.idealingua.runtime.rpc.http4s.fixtures import io.circe.Json import izumi.functional.bio.{F, IO2} import izumi.idealingua.runtime.rpc.* -import izumi.idealingua.runtime.rpc.IRTServerMultiplexor.IRTServerMultiplexorImpl import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext import izumi.idealingua.runtime.rpc.http4s.context.WsIdExtractor import izumi.idealingua.runtime.rpc.http4s.fixtures.defs.* @@ -22,6 +21,16 @@ class TestServices[F[+_, +_]: IO2]( ) { object Server { + def userBlacklistMiddleware[C <: TestContext]( + rejectedNames: Set[String] + ): IRTServerMiddleware[F, C] = new IRTServerMiddleware[F, C] { + override def priority: Int = 0 + override def prepare(methodId: IRTMethodId)(context: C, parsedBody: Json): F[Throwable, Unit] = { + F.when(rejectedNames.contains(context.user)) { + F.fail(new IRTUnathorizedRequestContextException(s"Rejected for users: $rejectedNames.")) + } + } + } final val wsStorage: WsSessionsStorage[F, AuthContext] = new WsSessionsStorageImpl[F, AuthContext](logger) final val globalWsListeners = Set( new WsSessionListener[F, Any, Any] { @@ -37,7 +46,7 @@ class TestServices[F[+_, +_]: IO2]( } ) // PRIVATE - private val privateAuth = new IRTAuthenticator[F, AuthContext, PrivateContext] { + final val privateAuth = new IRTAuthenticator[F, AuthContext, PrivateContext] { override def authenticate(authContext: AuthContext, body: Option[Json]): F[Nothing, Option[PrivateContext]] = F.sync { authContext.headers.get[Authorization].map(_.credentials).collect { case BasicCredentials(user, "private") => PrivateContext(user) @@ -53,12 +62,13 @@ class TestServices[F[+_, +_]: IO2]( }) final val privateServices: IRTContextServices[F, AuthContext, PrivateContext, PrivateContext] = IRTContextServices( authenticator = privateAuth, - serverMuxer = new IRTServerMultiplexorImpl(Set(privateService)), + serverMuxer = new IRTServerMultiplexor.FromServices(Set(privateService)), + middlewares = Set.empty, wsSessions = privateWsSession, ) // PROTECTED - private val protectedAuth = new IRTAuthenticator[F, AuthContext, ProtectedContext] { + final val protectedAuth = new IRTAuthenticator[F, AuthContext, ProtectedContext] { override def authenticate(authContext: AuthContext, body: Option[Json]): F[Nothing, Option[ProtectedContext]] = F.sync { authContext.headers.get[Authorization].map(_.credentials).collect { case BasicCredentials(user, "protected") => ProtectedContext(user) @@ -74,7 +84,8 @@ class TestServices[F[+_, +_]: IO2]( }) final val protectedServices: IRTContextServices[F, AuthContext, ProtectedContext, ProtectedContext] = IRTContextServices( authenticator = protectedAuth, - serverMuxer = new IRTServerMultiplexorImpl(Set(protectedService)), + serverMuxer = new IRTServerMultiplexor.FromServices(Set(protectedService)), + middlewares = Set.empty, wsSessions = protectedWsSession, ) @@ -93,7 +104,8 @@ class TestServices[F[+_, +_]: IO2]( final val publicService: IRTWrappedService[F, PublicContext] = new GreeterServiceServerWrapped(new AbstractGreeterServer.Impl[F, PublicContext]) final val publicServices: IRTContextServices[F, AuthContext, PublicContext, PublicContext] = IRTContextServices( authenticator = publicAuth, - serverMuxer = new IRTServerMultiplexorImpl(Set(publicService)), + serverMuxer = new IRTServerMultiplexor.FromServices(Set(publicService)), + middlewares = Set(userBlacklistMiddleware(Set("orc"))), wsSessions = publicWsSession, ) @@ -111,6 +123,6 @@ class TestServices[F[+_, +_]: IO2]( PrivateTestServiceWrappedClient, ) val codec: IRTClientMultiplexorImpl[F] = new IRTClientMultiplexorImpl[F](clients) - val buzzerMultiplexor: IRTServerMultiplexor[F, Unit] = new IRTServerMultiplexorImpl(dispatchers) + val buzzerMultiplexor: IRTServerMultiplexor[F, Unit] = new IRTServerMultiplexor.FromServices(dispatchers) } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMethod.scala b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMethod.scala new file mode 100644 index 00000000..cc846b89 --- /dev/null +++ b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMethod.scala @@ -0,0 +1,42 @@ +package izumi.idealingua.runtime.rpc + +import io.circe.Json +import izumi.functional.bio.{Error2, Exit, F, IO2} + +trait IRTServerMethod[F[+_, +_], C] { + self => + def methodId: IRTMethodId + def invoke(context: C, parsedBody: Json): F[Throwable, Json] + + final def contramap[C2](updateContext: (C2, Json) => F[Throwable, Option[C]])(implicit E: Error2[F]): IRTServerMethod[F, C2] = new IRTServerMethod[F, C2] { + override def methodId: IRTMethodId = self.methodId + override def invoke(context: C2, parsedBody: Json): F[Throwable, Json] = { + updateContext(context, parsedBody) + .fromOption(new IRTUnathorizedRequestContextException(s"Unauthorized $methodId call. Context: $context.")) + .flatMap(self.invoke(_, parsedBody)) + } + } +} + +object IRTServerMethod { + def apply[F[+_, +_]: IO2, C](method: IRTMethodWrapper[F, C]): IRTServerMethod[F, C] = FromWrapper.apply(method) + + final case class FromWrapper[F[+_, +_]: IO2, C](method: IRTMethodWrapper[F, C]) extends IRTServerMethod[F, C] { + override def methodId: IRTMethodId = method.signature.id + @inline override def invoke(context: C, parsedBody: Json): F[Throwable, Json] = { + val methodId = method.signature.id + for { + requestBody <- F.syncThrowable(method.marshaller.decodeRequest[F].apply(IRTJsonBody(methodId, parsedBody))).flatten.sandbox.catchAll { + case Exit.Interruption(decodingFailure, _, trace) => + F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON '${parsedBody.noSpaces}'.\nTrace: $trace", Some(decodingFailure))) + case Exit.Termination(_, exceptions, trace) => + F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON '${parsedBody.noSpaces}'.\nTrace: $trace", exceptions.headOption)) + case Exit.Error(decodingFailure, trace) => + F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON '${parsedBody.noSpaces}'.\nTrace: $trace", Some(decodingFailure))) + } + result <- F.syncThrowable(method.invoke(context, requestBody.value.asInstanceOf[method.signature.Input])).flatten + encoded <- F.syncThrowable(method.marshaller.encodeResponse.apply(IRTResBody(result))) + } yield encoded + } + } +} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMiddleware.scala b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMiddleware.scala new file mode 100644 index 00000000..f4a7aa10 --- /dev/null +++ b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMiddleware.scala @@ -0,0 +1,8 @@ +package izumi.idealingua.runtime.rpc + +import io.circe.Json + +trait IRTServerMiddleware[F[_, _], C] { + def priority: Int + def prepare(methodId: IRTMethodId)(context: C, parsedBody: Json): F[Throwable, Unit] +} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala index a04bb62c..0458381d 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala @@ -1,65 +1,45 @@ package izumi.idealingua.runtime.rpc import io.circe.Json -import izumi.functional.bio.{Error2, Exit, F, IO2} +import izumi.functional.bio.{Error2, F, IO2} trait IRTServerMultiplexor[F[+_, +_], C] { self => - def allMethods: Set[IRTMethodId] + def methods: Map[IRTMethodId, IRTServerMethod[F, C]] - def invokeMethod(method: IRTMethodId)(context: C, parsedBody: Json): F[Throwable, Json] + def invokeMethod(method: IRTMethodId)(context: C, parsedBody: Json)(implicit E: Error2[F]): F[Throwable, Json] = { + F.fromOption(new IRTMissingHandlerException(s"Method $method not found.", parsedBody))(methods.get(method)) + .flatMap(_.invoke(context, parsedBody)) + } + + final def contramap[C2]( + updateContext: (C2, Json) => F[Throwable, Option[C]] + )(implicit io2: IO2[F] + ): IRTServerMultiplexor[F, C2] = { + val mappedMethods = self.methods.view.mapValues(_.contramap(updateContext)).toMap + new IRTServerMultiplexor.FromMethods(mappedMethods) + } - final def contramap[C2](updateContext: (C2, Json) => F[Throwable, Option[C]])(implicit M: Error2[F]): IRTServerMultiplexor[F, C2] = new IRTServerMultiplexor[F, C2] { - override def allMethods: Set[IRTMethodId] = self.allMethods - override def invokeMethod(method: IRTMethodId)(context: C2, parsedBody: Json): F[Throwable, Json] = { - updateContext(context, parsedBody) - .fromOption(new IRTUnathorizedRequestContextException(s"Unauthorized $method call. Context: $context.")) - .flatMap(self.invokeMethod(method)(_, parsedBody)) + final def wrap(middleware: IRTServerMiddleware[F, C])(implicit io2: IO2[F]): IRTServerMultiplexor[F, C] = { + val wrappedMethods = self.methods.map { + case (methodId, method) => + val wrappedMethod: IRTServerMethod[F, C] = method.contramap[C] { + case (ctx, body) => + middleware.prepare(methodId)(ctx, body).as(Some(ctx)) + } + methodId -> wrappedMethod } + new IRTServerMultiplexor.FromMethods(wrappedMethods) } } object IRTServerMultiplexor { - - def combine[F[+_, +_]: Error2, C](multiplexors: Iterable[IRTServerMultiplexor[F, C]]): IRTServerMultiplexor[F, C] = new IRTServerMultiplexor[F, C] { - private val all: Map[IRTMethodId, IRTMethodId => (C, Json) => F[Throwable, Json]] = { - multiplexors.toList.flatMap { - muxer => muxer.allMethods.map(method => method -> muxer.invokeMethod) - }.toMap - } - override def allMethods: Set[IRTMethodId] = all.keySet - override def invokeMethod(method: IRTMethodId)(context: C, parsedBody: Json): F[Throwable, Json] = { - F.fromOption(new IRTMissingHandlerException(s"Method $method not found.", parsedBody))(all.get(method)) - .flatMap(invoke => invoke.apply(method).apply(context, parsedBody)) - } + def combine[F[+_, +_], C](multiplexors: Iterable[IRTServerMultiplexor[F, C]]): IRTServerMultiplexor[F, C] = { + new FromMethods(multiplexors.flatMap(_.methods).toMap) } - class IRTServerMultiplexorImpl[F[+_, +_]: IO2, C]( - services: Set[IRTWrappedService[F, C]] - ) extends IRTServerMultiplexor[F, C] { - private val methodToWrapped: Map[IRTMethodId, IRTMethodWrapper[F, C]] = services.flatMap(_.allMethods).toMap + class FromMethods[F[+_, +_], C](val methods: Map[IRTMethodId, IRTServerMethod[F, C]]) extends IRTServerMultiplexor[F, C] - override def allMethods: Set[IRTMethodId] = methodToWrapped.keySet - - override def invokeMethod(method: IRTMethodId)(context: C, parsedBody: Json): F[Throwable, Json] = { - F.fromOption(new IRTMissingHandlerException(s"Method $method not found.", parsedBody))(methodToWrapped.get(method)) - .flatMap(invoke(_)(context, parsedBody)) - } - - @inline private[this] def invoke(method: IRTMethodWrapper[F, C])(context: C, parsedBody: Json): F[Throwable, Json] = { - val methodId = method.signature.id - for { - requestBody <- F.syncThrowable(method.marshaller.decodeRequest[F].apply(IRTJsonBody(methodId, parsedBody))).flatten.sandbox.catchAll { - case Exit.Interruption(decodingFailure, _, trace) => - F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON '${parsedBody.noSpaces}'.\nTrace: $trace", Some(decodingFailure))) - case Exit.Termination(_, exceptions, trace) => - F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON '${parsedBody.noSpaces}'.\nTrace: $trace", exceptions.headOption)) - case Exit.Error(decodingFailure, trace) => - F.fail(new IRTDecodingException(s"$methodId: Failed to decode JSON '${parsedBody.noSpaces}'.\nTrace: $trace", Some(decodingFailure))) - } - result <- F.syncThrowable(method.invoke(context, requestBody.value.asInstanceOf[method.signature.Input])).flatten - encoded <- F.syncThrowable(method.marshaller.encodeResponse.apply(IRTResBody(result))) - } yield encoded - } - } + class FromServices[F[+_, +_]: IO2, C](val services: Set[IRTWrappedService[F, C]]) + extends FromMethods[F, C](services.flatMap(_.allMethods.view.mapValues(m => IRTServerMethod(m))).toMap) } diff --git a/idealingua-v1/idealingua-v1-test-defs/src/main/scala/izumi/r2/idealingua/test/GreeterRunnerExample.scala b/idealingua-v1/idealingua-v1-test-defs/src/main/scala/izumi/r2/idealingua/test/GreeterRunnerExample.scala index 81b4e319..1218933b 100644 --- a/idealingua-v1/idealingua-v1-test-defs/src/main/scala/izumi/r2/idealingua/test/GreeterRunnerExample.scala +++ b/idealingua-v1/idealingua-v1-test-defs/src/main/scala/izumi/r2/idealingua/test/GreeterRunnerExample.scala @@ -1,14 +1,14 @@ package izumi.r2.idealingua.test import _root_.io.circe.syntax.* -import izumi.idealingua.runtime.rpc.IRTServerMultiplexor.IRTServerMultiplexorImpl +import izumi.idealingua.runtime.rpc.IRTServerMultiplexor import izumi.r2.idealingua.test.generated.GreeterServiceServerWrapped import zio.* object GreeterRunnerExample { def main(args: Array[String]): Unit = { val greeter = new GreeterServiceServerWrapped[IO, Unit](new impls.AbstractGreeterServer.Impl[IO, Unit]()) - val multiplexor = new IRTServerMultiplexorImpl[IO, Unit](Set(greeter)) + val multiplexor = new IRTServerMultiplexor.FromServices[IO, Unit](Set(greeter)) val req1 = new greeter.greet.signature.Input("John", "Doe") val json1 = req1.asJson From 048953dd46f11961eefeb97c9b80e9079039abca Mon Sep 17 00:00:00 2001 From: Alex Liubymov Date: Fri, 15 Dec 2023 16:57:30 +0200 Subject: [PATCH 07/24] fix 2.12 and 3.3.1 builds --- build.sbt | 320 +++++++++--------- .../rpc/http4s/Http4sTransportTest.scala | 3 +- .../http4s/fixtures/LoggingWsListener.scala | 6 +- .../rpc/http4s/fixtures/TestServices.scala | 3 +- .../runtime/rpc/IRTServerMultiplexor.scala | 4 +- .../generated}/PrivateTestServiceServer.scala | 29 +- .../ProtectedTestServiceServer.scala | 29 +- project/plugins.sbt | 11 + 8 files changed, 211 insertions(+), 194 deletions(-) rename idealingua-v1/{idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/defs => idealingua-v1-test-defs/src/main/scala/izumi/r2/idealingua/test/generated}/PrivateTestServiceServer.scala (80%) rename idealingua-v1/{idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/defs => idealingua-v1-test-defs/src/main/scala/izumi/r2/idealingua/test/generated}/ProtectedTestServiceServer.scala (80%) diff --git a/build.sbt b/build.sbt index f1aa5ef0..b8b2a667 100644 --- a/build.sbt +++ b/build.sbt @@ -3,6 +3,8 @@ // ALL CHANGES WILL BE LOST +import sbtcrossproject.CrossPlugin.autoImport.{crossProject, CrossType} + enablePlugins(SbtgenVerificationPlugin) @@ -11,13 +13,13 @@ ThisBuild / libraryDependencySchemes += "io.circe" %% "circe-core" % VersionSche ThisBuild / libraryDependencySchemes += "io.circe" %% "circe-core_sjs1" % VersionScheme.Always -lazy val `idealingua-v1-model` = project.in(file("idealingua-v1/idealingua-v1-model")) +lazy val `idealingua-v1-model` = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).in(file("idealingua-v1/idealingua-v1-model")) .settings( libraryDependencies ++= Seq( - "org.scalatest" %% "scalatest" % V.scalatest % Test, - "io.7mind.izumi" %% "fundamentals-collections" % Izumi.version, - "io.7mind.izumi" %% "fundamentals-platform" % Izumi.version, - "io.7mind.izumi" %% "fundamentals-functional" % Izumi.version + "org.scalatest" %%% "scalatest" % V.scalatest % Test, + "io.7mind.izumi" %%% "fundamentals-collections" % Izumi.version, + "io.7mind.izumi" %%% "fundamentals-platform" % Izumi.version, + "io.7mind.izumi" %%% "fundamentals-functional" % Izumi.version ), libraryDependencies ++= { if (scalaVersion.value.startsWith("2.")) Seq( compilerPlugin("org.typelevel" % "kind-projector" % V.kind_projector cross CrossVersion.full), @@ -25,21 +27,7 @@ lazy val `idealingua-v1-model` = project.in(file("idealingua-v1/idealingua-v1-mo ) else Seq.empty } ) .settings( - crossScalaVersions := Seq( - "3.3.1", - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -151,37 +139,44 @@ lazy val `idealingua-v1-model` = project.in(file("idealingua-v1/idealingua-v1-mo } }, scalacOptions -= "-Wconf:any:error" ) + .jvmSettings( + crossScalaVersions := Seq( + "3.3.1", + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head + ) + .jsSettings( + crossScalaVersions := Seq( + "3.3.1", + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, + coverageEnabled := false, + scalaJSLinkerConfig := { scalaJSLinkerConfig.value.withModuleKind(ModuleKind.CommonJSModule) } + ) .enablePlugins(IzumiPlugin) +lazy val `idealingua-v1-modelJVM` = `idealingua-v1-model`.jvm +lazy val `idealingua-v1-modelJS` = `idealingua-v1-model`.js -lazy val `idealingua-v1-core` = project.in(file("idealingua-v1/idealingua-v1-core")) +lazy val `idealingua-v1-core` = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).in(file("idealingua-v1/idealingua-v1-core")) .dependsOn( `idealingua-v1-model` % "test->compile;compile->compile" ) .settings( libraryDependencies ++= Seq( - "org.scalatest" %% "scalatest" % V.scalatest % Test, - "com.lihaoyi" %% "fastparse" % V.fastparse, - "io.7mind.izumi" %% "fundamentals-reflection" % Izumi.version + "org.scalatest" %%% "scalatest" % V.scalatest % Test, + "com.lihaoyi" %%% "fastparse" % V.fastparse, + "io.7mind.izumi" %%% "fundamentals-reflection" % Izumi.version ), libraryDependencies ++= { if (scalaVersion.value.startsWith("2.")) Seq( compilerPlugin("org.typelevel" % "kind-projector" % V.kind_projector cross CrossVersion.full) ) else Seq.empty } ) .settings( - crossScalaVersions := Seq( - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -293,53 +288,57 @@ lazy val `idealingua-v1-core` = project.in(file("idealingua-v1/idealingua-v1-cor } }, scalacOptions -= "-Wconf:any:error" ) + .jvmSettings( + crossScalaVersions := Seq( + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head + ) + .jsSettings( + crossScalaVersions := Seq( + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, + coverageEnabled := false, + scalaJSLinkerConfig := { scalaJSLinkerConfig.value.withModuleKind(ModuleKind.CommonJSModule) } + ) .enablePlugins(IzumiPlugin) +lazy val `idealingua-v1-coreJVM` = `idealingua-v1-core`.jvm +lazy val `idealingua-v1-coreJS` = `idealingua-v1-core`.js -lazy val `idealingua-v1-runtime-rpc-scala` = project.in(file("idealingua-v1/idealingua-v1-runtime-rpc-scala")) +lazy val `idealingua-v1-runtime-rpc-scala` = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).in(file("idealingua-v1/idealingua-v1-runtime-rpc-scala")) .settings( libraryDependencies ++= Seq( - "org.scalatest" %% "scalatest" % V.scalatest % Test, - "io.7mind.izumi" %% "fundamentals-bio" % Izumi.version, - "io.7mind.izumi" %% "fundamentals-platform" % Izumi.version, - "org.typelevel" %% "cats-core" % Izumi.Deps.fundamentals_bioJVM.org_typelevel_cats_core_version, - "org.typelevel" %% "cats-effect" % Izumi.Deps.fundamentals_bioJVM.org_typelevel_cats_effect_version, - "io.circe" %% "circe-parser" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, - "io.circe" %% "circe-literal" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, - "dev.zio" %% "zio" % Izumi.Deps.fundamentals_bioJVM.dev_zio_zio_version % Test, - "dev.zio" %% "zio-interop-cats" % Izumi.Deps.fundamentals_bioJVM.dev_zio_zio_interop_cats_version % Test, - "dev.zio" %% "izumi-reflect" % Izumi.Deps.fundamentals_bioJVM.dev_zio_izumi_reflect_version % Test + "org.scalatest" %%% "scalatest" % V.scalatest % Test, + "io.7mind.izumi" %%% "fundamentals-bio" % Izumi.version, + "io.7mind.izumi" %%% "fundamentals-platform" % Izumi.version, + "org.typelevel" %%% "cats-core" % Izumi.Deps.fundamentals_bioJVM.org_typelevel_cats_core_version, + "org.typelevel" %%% "cats-effect" % Izumi.Deps.fundamentals_bioJVM.org_typelevel_cats_effect_version, + "io.circe" %%% "circe-parser" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, + "io.circe" %%% "circe-literal" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, + "dev.zio" %%% "zio" % Izumi.Deps.fundamentals_bioJVM.dev_zio_zio_version % Test, + "dev.zio" %%% "zio-interop-cats" % Izumi.Deps.fundamentals_bioJVM.dev_zio_zio_interop_cats_version % Test, + "dev.zio" %%% "izumi-reflect" % Izumi.Deps.fundamentals_bioJVM.dev_zio_izumi_reflect_version % Test ), libraryDependencies ++= { if (scalaVersion.value.startsWith("2.")) Seq( compilerPlugin("org.typelevel" % "kind-projector" % V.kind_projector cross CrossVersion.full), "org.scala-lang" % "scala-reflect" % scalaVersion.value % Provided, - "io.circe" %% "circe-generic-extras" % V.circe_generic_extras, - "io.circe" %% "circe-derivation" % V.circe_derivation + "io.circe" %%% "circe-generic-extras" % V.circe_generic_extras, + "io.circe" %%% "circe-derivation" % V.circe_derivation ) else Seq.empty }, libraryDependencies ++= { val version = scalaVersion.value if (version.startsWith("0.") || version.startsWith("3.")) { Seq( - "io.circe" %% "circe-generic" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version + "io.circe" %%% "circe-generic" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version ) } else Seq.empty } ) .settings( - crossScalaVersions := Seq( - "3.3.1", - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -451,11 +450,37 @@ lazy val `idealingua-v1-runtime-rpc-scala` = project.in(file("idealingua-v1/idea } }, scalacOptions -= "-Wconf:any:error" ) + .jvmSettings( + crossScalaVersions := Seq( + "3.3.1", + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head + ) + .jsSettings( + crossScalaVersions := Seq( + "3.3.1", + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, + coverageEnabled := false, + scalaJSLinkerConfig := { scalaJSLinkerConfig.value.withModuleKind(ModuleKind.CommonJSModule) } + ) .enablePlugins(IzumiPlugin) +lazy val `idealingua-v1-runtime-rpc-scalaJVM` = `idealingua-v1-runtime-rpc-scala`.jvm +lazy val `idealingua-v1-runtime-rpc-scalaJS` = `idealingua-v1-runtime-rpc-scala`.js + .settings( + libraryDependencies ++= Seq( + "org.typelevel" %% "jawn-parser" % Izumi.Deps.fundamentals_json_circeJVM.org_typelevel_jawn_parser_version, + "io.github.cquiroz" %%% "scala-java-time" % V.scala_java_time % Test + ) + ) lazy val `idealingua-v1-runtime-rpc-http4s` = project.in(file("idealingua-v1/idealingua-v1-runtime-rpc-http4s")) .dependsOn( - `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile", + `idealingua-v1-runtime-rpc-scalaJVM` % "test->compile;compile->compile", `idealingua-v1-test-defs` % "test->compile" ) .settings( @@ -481,14 +506,6 @@ lazy val `idealingua-v1-runtime-rpc-http4s` = project.in(file("idealingua-v1/ide ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -602,54 +619,36 @@ lazy val `idealingua-v1-runtime-rpc-http4s` = project.in(file("idealingua-v1/ide ) .enablePlugins(IzumiPlugin) -lazy val `idealingua-v1-transpilers` = project.in(file("idealingua-v1/idealingua-v1-transpilers")) +lazy val `idealingua-v1-transpilers` = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).in(file("idealingua-v1/idealingua-v1-transpilers")) .dependsOn( `idealingua-v1-core` % "test->compile;compile->compile", - `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile", - `idealingua-v1-test-defs` % "test->compile", - `idealingua-v1-runtime-rpc-typescript` % "test->compile", - `idealingua-v1-runtime-rpc-go` % "test->compile", - `idealingua-v1-runtime-rpc-csharp` % "test->compile" + `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile" ) .settings( libraryDependencies ++= Seq( - "org.scalatest" %% "scalatest" % V.scalatest % Test, - "org.scala-lang.modules" %% "scala-xml" % V.scala_xml, - "org.scalameta" %% "scalameta" % V.scalameta, - "io.7mind.izumi" %% "fundamentals-bio" % Izumi.version, - "io.circe" %% "circe-parser" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, - "io.circe" %% "circe-literal" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version + "org.scalatest" %%% "scalatest" % V.scalatest % Test, + "org.scala-lang.modules" %%% "scala-xml" % V.scala_xml, + "org.scalameta" %%% "scalameta" % V.scalameta, + "io.7mind.izumi" %%% "fundamentals-bio" % Izumi.version, + "io.circe" %%% "circe-parser" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, + "io.circe" %%% "circe-literal" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version ), libraryDependencies ++= { if (scalaVersion.value.startsWith("2.")) Seq( compilerPlugin("org.typelevel" % "kind-projector" % V.kind_projector cross CrossVersion.full), - "io.circe" %% "circe-generic-extras" % V.circe_generic_extras, - "io.circe" %% "circe-derivation" % V.circe_derivation + "io.circe" %%% "circe-generic-extras" % V.circe_generic_extras, + "io.circe" %%% "circe-derivation" % V.circe_derivation ) else Seq.empty }, libraryDependencies ++= { val version = scalaVersion.value if (version.startsWith("0.") || version.startsWith("3.")) { Seq( - "io.circe" %% "circe-generic" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version + "io.circe" %%% "circe-generic" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version ) } else Seq.empty } ) .settings( - crossScalaVersions := Seq( - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, - Test / fork := true, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -761,11 +760,41 @@ lazy val `idealingua-v1-transpilers` = project.in(file("idealingua-v1/idealingua } }, scalacOptions -= "-Wconf:any:error" ) + .jvmSettings( + crossScalaVersions := Seq( + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, + Test / fork := true + ) + .jsSettings( + crossScalaVersions := Seq( + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, + coverageEnabled := false, + scalaJSLinkerConfig := { scalaJSLinkerConfig.value.withModuleKind(ModuleKind.CommonJSModule) } + ) .enablePlugins(IzumiPlugin) +lazy val `idealingua-v1-transpilersJVM` = `idealingua-v1-transpilers`.jvm + .dependsOn( + `idealingua-v1-test-defs` % "test->compile", + `idealingua-v1-runtime-rpc-typescript` % "test->compile", + `idealingua-v1-runtime-rpc-go` % "test->compile", + `idealingua-v1-runtime-rpc-csharp` % "test->compile" + ) +lazy val `idealingua-v1-transpilersJS` = `idealingua-v1-transpilers`.js + .settings( + libraryDependencies ++= Seq( + "org.typelevel" %% "jawn-parser" % Izumi.Deps.fundamentals_json_circeJVM.org_typelevel_jawn_parser_version + ) + ) lazy val `idealingua-v1-test-defs` = project.in(file("idealingua-v1/idealingua-v1-test-defs")) .dependsOn( - `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile" + `idealingua-v1-runtime-rpc-scalaJVM` % "test->compile;compile->compile" ) .settings( libraryDependencies ++= Seq( @@ -786,14 +815,6 @@ lazy val `idealingua-v1-test-defs` = project.in(file("idealingua-v1/idealingua-v ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -924,14 +945,6 @@ lazy val `idealingua-v1-runtime-rpc-typescript` = project.in(file("idealingua-v1 ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -1062,14 +1075,6 @@ lazy val `idealingua-v1-runtime-rpc-go` = project.in(file("idealingua-v1/idealin ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -1200,14 +1205,6 @@ lazy val `idealingua-v1-runtime-rpc-csharp` = project.in(file("idealingua-v1/ide ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -1323,8 +1320,8 @@ lazy val `idealingua-v1-runtime-rpc-csharp` = project.in(file("idealingua-v1/ide lazy val `idealingua-v1-compiler` = project.in(file("idealingua-v1/idealingua-v1-compiler")) .dependsOn( - `idealingua-v1-transpilers` % "test->compile;compile->compile", - `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile", + `idealingua-v1-transpilersJVM` % "test->compile;compile->compile", + `idealingua-v1-runtime-rpc-scalaJVM` % "test->compile;compile->compile", `idealingua-v1-runtime-rpc-typescript` % "test->compile;compile->compile", `idealingua-v1-runtime-rpc-go` % "test->compile;compile->compile", `idealingua-v1-runtime-rpc-csharp` % "test->compile;compile->compile", @@ -1346,14 +1343,6 @@ lazy val `idealingua-v1-compiler` = project.in(file("idealingua-v1/idealingua-v1 ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -1474,11 +1463,15 @@ lazy val `idealingua` = (project in file(".agg/idealingua-v1-idealingua")) ) .enablePlugins(IzumiPlugin) .aggregate( - `idealingua-v1-model`, - `idealingua-v1-core`, - `idealingua-v1-runtime-rpc-scala`, + `idealingua-v1-modelJVM`, + `idealingua-v1-modelJS`, + `idealingua-v1-coreJVM`, + `idealingua-v1-coreJS`, + `idealingua-v1-runtime-rpc-scalaJVM`, + `idealingua-v1-runtime-rpc-scalaJS`, `idealingua-v1-runtime-rpc-http4s`, - `idealingua-v1-transpilers`, + `idealingua-v1-transpilersJVM`, + `idealingua-v1-transpilersJS`, `idealingua-v1-test-defs`, `idealingua-v1-runtime-rpc-typescript`, `idealingua-v1-runtime-rpc-go`, @@ -1492,11 +1485,11 @@ lazy val `idealingua-jvm` = (project in file(".agg/idealingua-v1-idealingua-jvm" crossScalaVersions := Nil ) .aggregate( - `idealingua-v1-model`, - `idealingua-v1-core`, - `idealingua-v1-runtime-rpc-scala`, + `idealingua-v1-modelJVM`, + `idealingua-v1-coreJVM`, + `idealingua-v1-runtime-rpc-scalaJVM`, `idealingua-v1-runtime-rpc-http4s`, - `idealingua-v1-transpilers`, + `idealingua-v1-transpilersJVM`, `idealingua-v1-test-defs`, `idealingua-v1-runtime-rpc-typescript`, `idealingua-v1-runtime-rpc-go`, @@ -1504,6 +1497,18 @@ lazy val `idealingua-jvm` = (project in file(".agg/idealingua-v1-idealingua-jvm" `idealingua-v1-compiler` ) +lazy val `idealingua-js` = (project in file(".agg/idealingua-v1-idealingua-js")) + .settings( + publish / skip := true, + crossScalaVersions := Nil + ) + .aggregate( + `idealingua-v1-modelJS`, + `idealingua-v1-coreJS`, + `idealingua-v1-runtime-rpc-scalaJS`, + `idealingua-v1-transpilersJS` + ) + lazy val `idealingua-v1-jvm` = (project in file(".agg/.agg-jvm")) .settings( publish / skip := true, @@ -1513,6 +1518,15 @@ lazy val `idealingua-v1-jvm` = (project in file(".agg/.agg-jvm")) `idealingua-jvm` ) +lazy val `idealingua-v1-js` = (project in file(".agg/.agg-js")) + .settings( + publish / skip := true, + crossScalaVersions := Nil + ) + .aggregate( + `idealingua-js` + ) + lazy val `idealingua-v1` = (project in file(".")) .settings( publish / skip := true, diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala index 97481766..a6b1d88a 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala @@ -15,11 +15,10 @@ import izumi.idealingua.runtime.rpc.http4s.clients.HttpRpcDispatcher.IRTDispatch import izumi.idealingua.runtime.rpc.http4s.clients.{HttpRpcDispatcher, HttpRpcDispatcherFactory, WsRpcDispatcher, WsRpcDispatcherFactory} import izumi.idealingua.runtime.rpc.http4s.context.{HttpContextExtractor, WsContextExtractor} import izumi.idealingua.runtime.rpc.http4s.fixtures.TestServices -import izumi.idealingua.runtime.rpc.http4s.fixtures.defs.{PrivateTestServiceWrappedClient, ProtectedTestServiceWrappedClient} import izumi.idealingua.runtime.rpc.http4s.ws.{RawResponse, WsRequestState} import izumi.logstage.api.routing.{ConfigurableLogRouter, StaticLogRouter} import izumi.logstage.api.{IzLogger, Log} -import izumi.r2.idealingua.test.generated.{GreeterServiceClientWrapped, GreeterServiceMethods} +import izumi.r2.idealingua.test.generated.{GreeterServiceClientWrapped, GreeterServiceMethods, PrivateTestServiceWrappedClient, ProtectedTestServiceWrappedClient} import logstage.LogIO2 import org.http4s.* import org.http4s.blaze.server.* diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/LoggingWsListener.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/LoggingWsListener.scala index f49a7ba2..15f9067e 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/LoggingWsListener.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/LoggingWsListener.scala @@ -11,14 +11,14 @@ final class LoggingWsListener[F[+_, +_]: IO2, RequestCtx, WsCtx] extends WsSessi override def onSessionOpened(sessionId: WsSessionId, reqCtx: RequestCtx, wsCtx: WsCtx): F[Throwable, Unit] = F.sync { connections.add(wsCtx) - } + }.void override def onSessionUpdated(sessionId: WsSessionId, reqCtx: RequestCtx, prevStx: WsCtx, newCtx: WsCtx): F[Throwable, Unit] = F.sync { connections.remove(prevStx) connections.add(newCtx) - } + }.void override def onSessionClosed(sessionId: WsSessionId, wsCtx: WsCtx): F[Throwable, Unit] = F.sync { connections.remove(wsCtx) - } + }.void } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala index 5d04504d..632bfd45 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala @@ -5,12 +5,11 @@ import izumi.functional.bio.{F, IO2} import izumi.idealingua.runtime.rpc.* import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext import izumi.idealingua.runtime.rpc.http4s.context.WsIdExtractor -import izumi.idealingua.runtime.rpc.http4s.fixtures.defs.* import izumi.idealingua.runtime.rpc.http4s.ws.WsContextSessions.WsContextSessionsImpl import izumi.idealingua.runtime.rpc.http4s.ws.WsSessionsStorage.WsSessionsStorageImpl import izumi.idealingua.runtime.rpc.http4s.ws.{WsContextSessions, WsSessionId, WsSessionListener, WsSessionsStorage} import izumi.idealingua.runtime.rpc.http4s.{IRTAuthenticator, IRTContextServices} -import izumi.r2.idealingua.test.generated.{GreeterServiceClientWrapped, GreeterServiceServerWrapped} +import izumi.r2.idealingua.test.generated.* import izumi.r2.idealingua.test.impls.AbstractGreeterServer import logstage.LogIO2 import org.http4s.BasicCredentials diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala index 0458381d..343b9181 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala @@ -16,7 +16,7 @@ trait IRTServerMultiplexor[F[+_, +_], C] { updateContext: (C2, Json) => F[Throwable, Option[C]] )(implicit io2: IO2[F] ): IRTServerMultiplexor[F, C2] = { - val mappedMethods = self.methods.view.mapValues(_.contramap(updateContext)).toMap + val mappedMethods = self.methods.map { case (k, v) => k -> v.contramap(updateContext) } new IRTServerMultiplexor.FromMethods(mappedMethods) } @@ -41,5 +41,5 @@ object IRTServerMultiplexor { class FromMethods[F[+_, +_], C](val methods: Map[IRTMethodId, IRTServerMethod[F, C]]) extends IRTServerMultiplexor[F, C] class FromServices[F[+_, +_]: IO2, C](val services: Set[IRTWrappedService[F, C]]) - extends FromMethods[F, C](services.flatMap(_.allMethods.view.mapValues(m => IRTServerMethod(m))).toMap) + extends FromMethods[F, C](services.flatMap(_.allMethods.map { case (k, v) => k -> IRTServerMethod(v) }).toMap) } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/defs/PrivateTestServiceServer.scala b/idealingua-v1/idealingua-v1-test-defs/src/main/scala/izumi/r2/idealingua/test/generated/PrivateTestServiceServer.scala similarity index 80% rename from idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/defs/PrivateTestServiceServer.scala rename to idealingua-v1/idealingua-v1-test-defs/src/main/scala/izumi/r2/idealingua/test/generated/PrivateTestServiceServer.scala index b4f9d9f8..7c6cec8c 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/defs/PrivateTestServiceServer.scala +++ b/idealingua-v1/idealingua-v1-test-defs/src/main/scala/izumi/r2/idealingua/test/generated/PrivateTestServiceServer.scala @@ -1,9 +1,10 @@ -package izumi.idealingua.runtime.rpc.http4s.fixtures.defs +package izumi.r2.idealingua.test.generated -import _root_.io.circe.syntax.* -import _root_.io.circe.{DecodingFailure as IRTDecodingFailure, Json as IRTJson} -import _root_.izumi.functional.bio.IO2 as IRTIO2 -import _root_.izumi.idealingua.runtime.rpc.* +import io.circe.* +import io.circe.generic.semiauto.* +import io.circe.syntax.* +import izumi.functional.bio.IO2 as IRTIO2 +import izumi.idealingua.runtime.rpc.* trait PrivateTestServiceServer[Or[+_, +_], C] { type Just[+T] = Or[Nothing, T] @@ -17,7 +18,7 @@ trait PrivateTestServiceClient[Or[+_, +_]] { class PrivateTestServiceWrappedClient[Or[+_, +_]: IRTIO2](_dispatcher: IRTDispatcher[Or]) extends PrivateTestServiceClient[Or] { final val _F: IRTIO2[Or] = implicitly - import _root_.izumi.idealingua.runtime.rpc.http4s.fixtures.defs.PrivateTestService as _M + import izumi.r2.idealingua.test.generated.PrivateTestService as _M def test(str: String): Just[String] = { _F.redeem(_dispatcher.dispatch(IRTMuxRequest(IRTReqBody(new _M.test.Input(str)), _M.test.id)))( { @@ -67,17 +68,13 @@ object PrivateTestService { type Input = TestInput type Output = TestOutput } - final case class TestInput(str: String) extends AnyVal + final case class TestInput(str: String) object TestInput { - import _root_.io.circe.derivation.{deriveDecoder, deriveEncoder} - import _root_.io.circe.{Decoder, Encoder} implicit val encodeTestInput: Encoder.AsObject[TestInput] = deriveEncoder[TestInput] implicit val decodeTestInput: Decoder[TestInput] = deriveDecoder[TestInput] } - final case class TestOutput(value: String) extends AnyVal + final case class TestOutput(value: String) object TestOutput { - import _root_.io.circe.* - import _root_.io.circe.syntax.* implicit val encodeUnwrappedTestOutput: Encoder[TestOutput] = Encoder.instance { v => v.value.asJson } @@ -90,19 +87,19 @@ object PrivateTestService { object PrivateTestServiceCodecs { object test extends IRTCirceMarshaller { import PrivateTestService.test.* - def encodeRequest: PartialFunction[IRTReqBody, IRTJson] = { + def encodeRequest: PartialFunction[IRTReqBody, Json] = { case IRTReqBody(value: Input) => value.asJson } - def decodeRequest[Or[+_, +_]: IRTIO2]: PartialFunction[IRTJsonBody, Or[IRTDecodingFailure, IRTReqBody]] = { + def decodeRequest[Or[+_, +_]: IRTIO2]: PartialFunction[IRTJsonBody, Or[DecodingFailure, IRTReqBody]] = { case IRTJsonBody(m, packet) if m == id => this.decoded[Or, IRTReqBody](packet.as[Input].map(v => IRTReqBody(v))) } - def encodeResponse: PartialFunction[IRTResBody, IRTJson] = { + def encodeResponse: PartialFunction[IRTResBody, Json] = { case IRTResBody(value: Output) => value.asJson } - def decodeResponse[Or[+_, +_]: IRTIO2]: PartialFunction[IRTJsonBody, Or[IRTDecodingFailure, IRTResBody]] = { + def decodeResponse[Or[+_, +_]: IRTIO2]: PartialFunction[IRTJsonBody, Or[DecodingFailure, IRTResBody]] = { case IRTJsonBody(m, packet) if m == id => decoded[Or, IRTResBody](packet.as[Output].map(v => IRTResBody(v))) } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/defs/ProtectedTestServiceServer.scala b/idealingua-v1/idealingua-v1-test-defs/src/main/scala/izumi/r2/idealingua/test/generated/ProtectedTestServiceServer.scala similarity index 80% rename from idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/defs/ProtectedTestServiceServer.scala rename to idealingua-v1/idealingua-v1-test-defs/src/main/scala/izumi/r2/idealingua/test/generated/ProtectedTestServiceServer.scala index 56e3eca0..fed8f8e5 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/defs/ProtectedTestServiceServer.scala +++ b/idealingua-v1/idealingua-v1-test-defs/src/main/scala/izumi/r2/idealingua/test/generated/ProtectedTestServiceServer.scala @@ -1,9 +1,10 @@ -package izumi.idealingua.runtime.rpc.http4s.fixtures.defs +package izumi.r2.idealingua.test.generated -import _root_.io.circe.syntax.* -import _root_.io.circe.{DecodingFailure as IRTDecodingFailure, Json as IRTJson} -import _root_.izumi.functional.bio.IO2 as IRTIO2 -import _root_.izumi.idealingua.runtime.rpc.* +import io.circe.* +import io.circe.generic.semiauto.* +import io.circe.syntax.* +import izumi.functional.bio.IO2 as IRTIO2 +import izumi.idealingua.runtime.rpc.* trait ProtectedTestServiceServer[Or[+_, +_], C] { type Just[+T] = Or[Nothing, T] @@ -17,7 +18,7 @@ trait ProtectedTestServiceClient[Or[+_, +_]] { class ProtectedTestServiceWrappedClient[Or[+_, +_]: IRTIO2](_dispatcher: IRTDispatcher[Or]) extends ProtectedTestServiceClient[Or] { final val _F: IRTIO2[Or] = implicitly - import _root_.izumi.idealingua.runtime.rpc.http4s.fixtures.defs.ProtectedTestService as _M + import izumi.r2.idealingua.test.generated.ProtectedTestService as _M def test(str: String): Just[String] = { _F.redeem(_dispatcher.dispatch(IRTMuxRequest(IRTReqBody(new _M.test.Input(str)), _M.test.id)))( { @@ -67,17 +68,13 @@ object ProtectedTestService { type Input = TestInput type Output = TestOutput } - final case class TestInput(str: String) extends AnyVal + final case class TestInput(str: String) object TestInput { - import _root_.io.circe.derivation.{deriveDecoder, deriveEncoder} - import _root_.io.circe.{Decoder, Encoder} implicit val encodeTestInput: Encoder.AsObject[TestInput] = deriveEncoder[TestInput] implicit val decodeTestInput: Decoder[TestInput] = deriveDecoder[TestInput] } - final case class TestOutput(value: String) extends AnyVal + final case class TestOutput(value: String) object TestOutput { - import _root_.io.circe.* - import _root_.io.circe.syntax.* implicit val encodeUnwrappedTestOutput: Encoder[TestOutput] = Encoder.instance { v => v.value.asJson } @@ -90,19 +87,19 @@ object ProtectedTestService { object ProtectedTestServiceCodecs { object test extends IRTCirceMarshaller { import ProtectedTestService.test.* - def encodeRequest: PartialFunction[IRTReqBody, IRTJson] = { + def encodeRequest: PartialFunction[IRTReqBody, Json] = { case IRTReqBody(value: Input) => value.asJson } - def decodeRequest[Or[+_, +_]: IRTIO2]: PartialFunction[IRTJsonBody, Or[IRTDecodingFailure, IRTReqBody]] = { + def decodeRequest[Or[+_, +_]: IRTIO2]: PartialFunction[IRTJsonBody, Or[DecodingFailure, IRTReqBody]] = { case IRTJsonBody(m, packet) if m == id => this.decoded[Or, IRTReqBody](packet.as[Input].map(v => IRTReqBody(v))) } - def encodeResponse: PartialFunction[IRTResBody, IRTJson] = { + def encodeResponse: PartialFunction[IRTResBody, Json] = { case IRTResBody(value: Output) => value.asJson } - def decodeResponse[Or[+_, +_]: IRTIO2]: PartialFunction[IRTJsonBody, Or[IRTDecodingFailure, IRTResBody]] = { + def decodeResponse[Or[+_, +_]: IRTIO2]: PartialFunction[IRTJsonBody, Or[DecodingFailure, IRTResBody]] = { case IRTJsonBody(m, packet) if m == id => decoded[Or, IRTResBody](packet.as[Output].map(v => IRTResBody(v))) } diff --git a/project/plugins.sbt b/project/plugins.sbt index a0d6b66c..578656af 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,6 +1,17 @@ // DO NOT EDIT THIS FILE // IT IS AUTOGENERATED BY `sbtgen.sc` SCRIPT // ALL CHANGES WILL BE LOST +// https://www.scala-js.org/ +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.13.0") + +// https://github.com/portable-scala/sbt-crossproject +addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.1") + +// https://scalacenter.github.io/scalajs-bundler/ +addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % "0.21.1") + +// https://github.com/scala-js/jsdependencies +addSbtPlugin("org.scala-js" % "sbt-jsdependencies" % "1.0.2") //////////////////////////////////////////////////////////////////////////////// From d4a3c70ec6098939d92bb9058c51b7c3b0be12df Mon Sep 17 00:00:00 2001 From: Alex Liubymov Date: Fri, 15 Dec 2023 16:58:16 +0200 Subject: [PATCH 08/24] remove HttpRequestContext --- .../idealingua/runtime/rpc/http4s/HttpRequestContext.scala | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpRequestContext.scala diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpRequestContext.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpRequestContext.scala deleted file mode 100644 index d2187792..00000000 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpRequestContext.scala +++ /dev/null @@ -1,6 +0,0 @@ -package izumi.idealingua.runtime.rpc.http4s - -import org.http4s.Request - -// we can't make it a case class, see https://github.com/scala/bug/issues/11239 -class HttpRequestContext[F[_]](val request: Request[F], val context: Any) From 1aabc95f3b2cb3200f1f52f453533def0df12ee6 Mon Sep 17 00:00:00 2001 From: Alex Liubymov Date: Fri, 15 Dec 2023 19:35:55 +0200 Subject: [PATCH 09/24] Allow IRTContextServices extension, more types in tests (2.13 build typer stack overflow ._.) --- build.sbt | 320 +++++++++--------- .../runtime/rpc/http4s/HttpServer.scala | 2 +- .../rpc/http4s/IRTContextServices.scala | 30 +- .../rpc/http4s/Http4sTransportTest.scala | 106 +++--- .../rpc/http4s/fixtures/TestServices.scala | 125 ++++--- project/plugins.sbt | 11 - 6 files changed, 314 insertions(+), 280 deletions(-) diff --git a/build.sbt b/build.sbt index b8b2a667..f1aa5ef0 100644 --- a/build.sbt +++ b/build.sbt @@ -3,8 +3,6 @@ // ALL CHANGES WILL BE LOST -import sbtcrossproject.CrossPlugin.autoImport.{crossProject, CrossType} - enablePlugins(SbtgenVerificationPlugin) @@ -13,13 +11,13 @@ ThisBuild / libraryDependencySchemes += "io.circe" %% "circe-core" % VersionSche ThisBuild / libraryDependencySchemes += "io.circe" %% "circe-core_sjs1" % VersionScheme.Always -lazy val `idealingua-v1-model` = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).in(file("idealingua-v1/idealingua-v1-model")) +lazy val `idealingua-v1-model` = project.in(file("idealingua-v1/idealingua-v1-model")) .settings( libraryDependencies ++= Seq( - "org.scalatest" %%% "scalatest" % V.scalatest % Test, - "io.7mind.izumi" %%% "fundamentals-collections" % Izumi.version, - "io.7mind.izumi" %%% "fundamentals-platform" % Izumi.version, - "io.7mind.izumi" %%% "fundamentals-functional" % Izumi.version + "org.scalatest" %% "scalatest" % V.scalatest % Test, + "io.7mind.izumi" %% "fundamentals-collections" % Izumi.version, + "io.7mind.izumi" %% "fundamentals-platform" % Izumi.version, + "io.7mind.izumi" %% "fundamentals-functional" % Izumi.version ), libraryDependencies ++= { if (scalaVersion.value.startsWith("2.")) Seq( compilerPlugin("org.typelevel" % "kind-projector" % V.kind_projector cross CrossVersion.full), @@ -27,7 +25,21 @@ lazy val `idealingua-v1-model` = crossProject(JVMPlatform, JSPlatform).crossType ) else Seq.empty } ) .settings( + crossScalaVersions := Seq( + "3.3.1", + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -139,44 +151,37 @@ lazy val `idealingua-v1-model` = crossProject(JVMPlatform, JSPlatform).crossType } }, scalacOptions -= "-Wconf:any:error" ) - .jvmSettings( - crossScalaVersions := Seq( - "3.3.1", - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head - ) - .jsSettings( - crossScalaVersions := Seq( - "3.3.1", - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, - coverageEnabled := false, - scalaJSLinkerConfig := { scalaJSLinkerConfig.value.withModuleKind(ModuleKind.CommonJSModule) } - ) .enablePlugins(IzumiPlugin) -lazy val `idealingua-v1-modelJVM` = `idealingua-v1-model`.jvm -lazy val `idealingua-v1-modelJS` = `idealingua-v1-model`.js -lazy val `idealingua-v1-core` = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).in(file("idealingua-v1/idealingua-v1-core")) +lazy val `idealingua-v1-core` = project.in(file("idealingua-v1/idealingua-v1-core")) .dependsOn( `idealingua-v1-model` % "test->compile;compile->compile" ) .settings( libraryDependencies ++= Seq( - "org.scalatest" %%% "scalatest" % V.scalatest % Test, - "com.lihaoyi" %%% "fastparse" % V.fastparse, - "io.7mind.izumi" %%% "fundamentals-reflection" % Izumi.version + "org.scalatest" %% "scalatest" % V.scalatest % Test, + "com.lihaoyi" %% "fastparse" % V.fastparse, + "io.7mind.izumi" %% "fundamentals-reflection" % Izumi.version ), libraryDependencies ++= { if (scalaVersion.value.startsWith("2.")) Seq( compilerPlugin("org.typelevel" % "kind-projector" % V.kind_projector cross CrossVersion.full) ) else Seq.empty } ) .settings( + crossScalaVersions := Seq( + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -288,57 +293,53 @@ lazy val `idealingua-v1-core` = crossProject(JVMPlatform, JSPlatform).crossType( } }, scalacOptions -= "-Wconf:any:error" ) - .jvmSettings( - crossScalaVersions := Seq( - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head - ) - .jsSettings( - crossScalaVersions := Seq( - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, - coverageEnabled := false, - scalaJSLinkerConfig := { scalaJSLinkerConfig.value.withModuleKind(ModuleKind.CommonJSModule) } - ) .enablePlugins(IzumiPlugin) -lazy val `idealingua-v1-coreJVM` = `idealingua-v1-core`.jvm -lazy val `idealingua-v1-coreJS` = `idealingua-v1-core`.js -lazy val `idealingua-v1-runtime-rpc-scala` = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).in(file("idealingua-v1/idealingua-v1-runtime-rpc-scala")) +lazy val `idealingua-v1-runtime-rpc-scala` = project.in(file("idealingua-v1/idealingua-v1-runtime-rpc-scala")) .settings( libraryDependencies ++= Seq( - "org.scalatest" %%% "scalatest" % V.scalatest % Test, - "io.7mind.izumi" %%% "fundamentals-bio" % Izumi.version, - "io.7mind.izumi" %%% "fundamentals-platform" % Izumi.version, - "org.typelevel" %%% "cats-core" % Izumi.Deps.fundamentals_bioJVM.org_typelevel_cats_core_version, - "org.typelevel" %%% "cats-effect" % Izumi.Deps.fundamentals_bioJVM.org_typelevel_cats_effect_version, - "io.circe" %%% "circe-parser" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, - "io.circe" %%% "circe-literal" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, - "dev.zio" %%% "zio" % Izumi.Deps.fundamentals_bioJVM.dev_zio_zio_version % Test, - "dev.zio" %%% "zio-interop-cats" % Izumi.Deps.fundamentals_bioJVM.dev_zio_zio_interop_cats_version % Test, - "dev.zio" %%% "izumi-reflect" % Izumi.Deps.fundamentals_bioJVM.dev_zio_izumi_reflect_version % Test + "org.scalatest" %% "scalatest" % V.scalatest % Test, + "io.7mind.izumi" %% "fundamentals-bio" % Izumi.version, + "io.7mind.izumi" %% "fundamentals-platform" % Izumi.version, + "org.typelevel" %% "cats-core" % Izumi.Deps.fundamentals_bioJVM.org_typelevel_cats_core_version, + "org.typelevel" %% "cats-effect" % Izumi.Deps.fundamentals_bioJVM.org_typelevel_cats_effect_version, + "io.circe" %% "circe-parser" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, + "io.circe" %% "circe-literal" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, + "dev.zio" %% "zio" % Izumi.Deps.fundamentals_bioJVM.dev_zio_zio_version % Test, + "dev.zio" %% "zio-interop-cats" % Izumi.Deps.fundamentals_bioJVM.dev_zio_zio_interop_cats_version % Test, + "dev.zio" %% "izumi-reflect" % Izumi.Deps.fundamentals_bioJVM.dev_zio_izumi_reflect_version % Test ), libraryDependencies ++= { if (scalaVersion.value.startsWith("2.")) Seq( compilerPlugin("org.typelevel" % "kind-projector" % V.kind_projector cross CrossVersion.full), "org.scala-lang" % "scala-reflect" % scalaVersion.value % Provided, - "io.circe" %%% "circe-generic-extras" % V.circe_generic_extras, - "io.circe" %%% "circe-derivation" % V.circe_derivation + "io.circe" %% "circe-generic-extras" % V.circe_generic_extras, + "io.circe" %% "circe-derivation" % V.circe_derivation ) else Seq.empty }, libraryDependencies ++= { val version = scalaVersion.value if (version.startsWith("0.") || version.startsWith("3.")) { Seq( - "io.circe" %%% "circe-generic" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version + "io.circe" %% "circe-generic" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version ) } else Seq.empty } ) .settings( + crossScalaVersions := Seq( + "3.3.1", + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -450,37 +451,11 @@ lazy val `idealingua-v1-runtime-rpc-scala` = crossProject(JVMPlatform, JSPlatfor } }, scalacOptions -= "-Wconf:any:error" ) - .jvmSettings( - crossScalaVersions := Seq( - "3.3.1", - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head - ) - .jsSettings( - crossScalaVersions := Seq( - "3.3.1", - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, - coverageEnabled := false, - scalaJSLinkerConfig := { scalaJSLinkerConfig.value.withModuleKind(ModuleKind.CommonJSModule) } - ) .enablePlugins(IzumiPlugin) -lazy val `idealingua-v1-runtime-rpc-scalaJVM` = `idealingua-v1-runtime-rpc-scala`.jvm -lazy val `idealingua-v1-runtime-rpc-scalaJS` = `idealingua-v1-runtime-rpc-scala`.js - .settings( - libraryDependencies ++= Seq( - "org.typelevel" %% "jawn-parser" % Izumi.Deps.fundamentals_json_circeJVM.org_typelevel_jawn_parser_version, - "io.github.cquiroz" %%% "scala-java-time" % V.scala_java_time % Test - ) - ) lazy val `idealingua-v1-runtime-rpc-http4s` = project.in(file("idealingua-v1/idealingua-v1-runtime-rpc-http4s")) .dependsOn( - `idealingua-v1-runtime-rpc-scalaJVM` % "test->compile;compile->compile", + `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile", `idealingua-v1-test-defs` % "test->compile" ) .settings( @@ -506,6 +481,14 @@ lazy val `idealingua-v1-runtime-rpc-http4s` = project.in(file("idealingua-v1/ide ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -619,36 +602,54 @@ lazy val `idealingua-v1-runtime-rpc-http4s` = project.in(file("idealingua-v1/ide ) .enablePlugins(IzumiPlugin) -lazy val `idealingua-v1-transpilers` = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).in(file("idealingua-v1/idealingua-v1-transpilers")) +lazy val `idealingua-v1-transpilers` = project.in(file("idealingua-v1/idealingua-v1-transpilers")) .dependsOn( `idealingua-v1-core` % "test->compile;compile->compile", - `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile" + `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile", + `idealingua-v1-test-defs` % "test->compile", + `idealingua-v1-runtime-rpc-typescript` % "test->compile", + `idealingua-v1-runtime-rpc-go` % "test->compile", + `idealingua-v1-runtime-rpc-csharp` % "test->compile" ) .settings( libraryDependencies ++= Seq( - "org.scalatest" %%% "scalatest" % V.scalatest % Test, - "org.scala-lang.modules" %%% "scala-xml" % V.scala_xml, - "org.scalameta" %%% "scalameta" % V.scalameta, - "io.7mind.izumi" %%% "fundamentals-bio" % Izumi.version, - "io.circe" %%% "circe-parser" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, - "io.circe" %%% "circe-literal" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version + "org.scalatest" %% "scalatest" % V.scalatest % Test, + "org.scala-lang.modules" %% "scala-xml" % V.scala_xml, + "org.scalameta" %% "scalameta" % V.scalameta, + "io.7mind.izumi" %% "fundamentals-bio" % Izumi.version, + "io.circe" %% "circe-parser" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, + "io.circe" %% "circe-literal" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version ), libraryDependencies ++= { if (scalaVersion.value.startsWith("2.")) Seq( compilerPlugin("org.typelevel" % "kind-projector" % V.kind_projector cross CrossVersion.full), - "io.circe" %%% "circe-generic-extras" % V.circe_generic_extras, - "io.circe" %%% "circe-derivation" % V.circe_derivation + "io.circe" %% "circe-generic-extras" % V.circe_generic_extras, + "io.circe" %% "circe-derivation" % V.circe_derivation ) else Seq.empty }, libraryDependencies ++= { val version = scalaVersion.value if (version.startsWith("0.") || version.startsWith("3.")) { Seq( - "io.circe" %%% "circe-generic" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version + "io.circe" %% "circe-generic" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version ) } else Seq.empty } ) .settings( + crossScalaVersions := Seq( + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, + Test / fork := true, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -760,41 +761,11 @@ lazy val `idealingua-v1-transpilers` = crossProject(JVMPlatform, JSPlatform).cro } }, scalacOptions -= "-Wconf:any:error" ) - .jvmSettings( - crossScalaVersions := Seq( - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, - Test / fork := true - ) - .jsSettings( - crossScalaVersions := Seq( - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, - coverageEnabled := false, - scalaJSLinkerConfig := { scalaJSLinkerConfig.value.withModuleKind(ModuleKind.CommonJSModule) } - ) .enablePlugins(IzumiPlugin) -lazy val `idealingua-v1-transpilersJVM` = `idealingua-v1-transpilers`.jvm - .dependsOn( - `idealingua-v1-test-defs` % "test->compile", - `idealingua-v1-runtime-rpc-typescript` % "test->compile", - `idealingua-v1-runtime-rpc-go` % "test->compile", - `idealingua-v1-runtime-rpc-csharp` % "test->compile" - ) -lazy val `idealingua-v1-transpilersJS` = `idealingua-v1-transpilers`.js - .settings( - libraryDependencies ++= Seq( - "org.typelevel" %% "jawn-parser" % Izumi.Deps.fundamentals_json_circeJVM.org_typelevel_jawn_parser_version - ) - ) lazy val `idealingua-v1-test-defs` = project.in(file("idealingua-v1/idealingua-v1-test-defs")) .dependsOn( - `idealingua-v1-runtime-rpc-scalaJVM` % "test->compile;compile->compile" + `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile" ) .settings( libraryDependencies ++= Seq( @@ -815,6 +786,14 @@ lazy val `idealingua-v1-test-defs` = project.in(file("idealingua-v1/idealingua-v ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -945,6 +924,14 @@ lazy val `idealingua-v1-runtime-rpc-typescript` = project.in(file("idealingua-v1 ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -1075,6 +1062,14 @@ lazy val `idealingua-v1-runtime-rpc-go` = project.in(file("idealingua-v1/idealin ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -1205,6 +1200,14 @@ lazy val `idealingua-v1-runtime-rpc-csharp` = project.in(file("idealingua-v1/ide ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -1320,8 +1323,8 @@ lazy val `idealingua-v1-runtime-rpc-csharp` = project.in(file("idealingua-v1/ide lazy val `idealingua-v1-compiler` = project.in(file("idealingua-v1/idealingua-v1-compiler")) .dependsOn( - `idealingua-v1-transpilersJVM` % "test->compile;compile->compile", - `idealingua-v1-runtime-rpc-scalaJVM` % "test->compile;compile->compile", + `idealingua-v1-transpilers` % "test->compile;compile->compile", + `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile", `idealingua-v1-runtime-rpc-typescript` % "test->compile;compile->compile", `idealingua-v1-runtime-rpc-go` % "test->compile;compile->compile", `idealingua-v1-runtime-rpc-csharp` % "test->compile;compile->compile", @@ -1343,6 +1346,14 @@ lazy val `idealingua-v1-compiler` = project.in(file("idealingua-v1/idealingua-v1 ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -1463,15 +1474,11 @@ lazy val `idealingua` = (project in file(".agg/idealingua-v1-idealingua")) ) .enablePlugins(IzumiPlugin) .aggregate( - `idealingua-v1-modelJVM`, - `idealingua-v1-modelJS`, - `idealingua-v1-coreJVM`, - `idealingua-v1-coreJS`, - `idealingua-v1-runtime-rpc-scalaJVM`, - `idealingua-v1-runtime-rpc-scalaJS`, + `idealingua-v1-model`, + `idealingua-v1-core`, + `idealingua-v1-runtime-rpc-scala`, `idealingua-v1-runtime-rpc-http4s`, - `idealingua-v1-transpilersJVM`, - `idealingua-v1-transpilersJS`, + `idealingua-v1-transpilers`, `idealingua-v1-test-defs`, `idealingua-v1-runtime-rpc-typescript`, `idealingua-v1-runtime-rpc-go`, @@ -1485,11 +1492,11 @@ lazy val `idealingua-jvm` = (project in file(".agg/idealingua-v1-idealingua-jvm" crossScalaVersions := Nil ) .aggregate( - `idealingua-v1-modelJVM`, - `idealingua-v1-coreJVM`, - `idealingua-v1-runtime-rpc-scalaJVM`, + `idealingua-v1-model`, + `idealingua-v1-core`, + `idealingua-v1-runtime-rpc-scala`, `idealingua-v1-runtime-rpc-http4s`, - `idealingua-v1-transpilersJVM`, + `idealingua-v1-transpilers`, `idealingua-v1-test-defs`, `idealingua-v1-runtime-rpc-typescript`, `idealingua-v1-runtime-rpc-go`, @@ -1497,18 +1504,6 @@ lazy val `idealingua-jvm` = (project in file(".agg/idealingua-v1-idealingua-jvm" `idealingua-v1-compiler` ) -lazy val `idealingua-js` = (project in file(".agg/idealingua-v1-idealingua-js")) - .settings( - publish / skip := true, - crossScalaVersions := Nil - ) - .aggregate( - `idealingua-v1-modelJS`, - `idealingua-v1-coreJS`, - `idealingua-v1-runtime-rpc-scalaJS`, - `idealingua-v1-transpilersJS` - ) - lazy val `idealingua-v1-jvm` = (project in file(".agg/.agg-jvm")) .settings( publish / skip := true, @@ -1518,15 +1513,6 @@ lazy val `idealingua-v1-jvm` = (project in file(".agg/.agg-jvm")) `idealingua-jvm` ) -lazy val `idealingua-v1-js` = (project in file(".agg/.agg-js")) - .settings( - publish / skip := true, - crossScalaVersions := Nil - ) - .aggregate( - `idealingua-js` - ) - lazy val `idealingua-v1` = (project in file(".")) .settings( publish / skip := true, diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala index b0a14937..3b0b0154 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala @@ -29,7 +29,7 @@ import java.util.concurrent.RejectedExecutionException import scala.concurrent.duration.DurationInt class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2, AuthCtx]( - val contextServices: Set[IRTContextServices[F, AuthCtx, ?, ?]], + val contextServices: Set[IRTContextServices.AnyContext[F, AuthCtx]], val httpContextExtractor: HttpContextExtractor[AuthCtx], val wsContextExtractor: WsContextExtractor[AuthCtx], val wsSessionsStorage: WsSessionsStorage[F, AuthCtx], diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala index 969a825e..f2c61b6f 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala @@ -4,12 +4,12 @@ import izumi.functional.bio.{IO2, Monad2} import izumi.idealingua.runtime.rpc.http4s.ws.WsContextSessions import izumi.idealingua.runtime.rpc.{IRTServerMiddleware, IRTServerMultiplexor} -final case class IRTContextServices[F[+_, +_], AuthCtx, RequestCtx, WsCtx]( - authenticator: IRTAuthenticator[F, AuthCtx, RequestCtx], - serverMuxer: IRTServerMultiplexor[F, RequestCtx], - middlewares: Set[IRTServerMiddleware[F, RequestCtx]], - wsSessions: WsContextSessions[F, RequestCtx, WsCtx], -) { +trait IRTContextServices[F[+_, +_], AuthCtx, RequestCtx, WsCtx] { + def authenticator: IRTAuthenticator[F, AuthCtx, RequestCtx] + def serverMuxer: IRTServerMultiplexor[F, RequestCtx] + def middlewares: Set[IRTServerMiddleware[F, RequestCtx]] + def wsSessions: WsContextSessions[F, RequestCtx, WsCtx] + def authorizedMuxer(implicit io2: IO2[F]): IRTServerMultiplexor[F, AuthCtx] = { val withMiddlewares: IRTServerMultiplexor[F, RequestCtx] = middlewares.toList.sortBy(_.priority).foldLeft(serverMuxer) { case (muxer, middleware) => muxer.wrap(middleware) @@ -27,3 +27,21 @@ final case class IRTContextServices[F[+_, +_], AuthCtx, RequestCtx, WsCtx]( authorized } } + +object IRTContextServices { + type AnyContext[F[+_, +_], AuthCtx] = IRTContextServices[F, AuthCtx, ?, ?] + + def apply[F[+_, +_], AuthCtx, RequestCtx, WsCtx]( + authenticator: IRTAuthenticator[F, AuthCtx, RequestCtx], + serverMuxer: IRTServerMultiplexor[F, RequestCtx], + middlewares: Set[IRTServerMiddleware[F, RequestCtx]], + wsSessions: WsContextSessions[F, RequestCtx, WsCtx], + ): Default[F, AuthCtx, RequestCtx, WsCtx] = Default(authenticator, serverMuxer, middlewares, wsSessions) + + final case class Default[F[+_, +_], AuthCtx, RequestCtx, WsCtx]( + authenticator: IRTAuthenticator[F, AuthCtx, RequestCtx], + serverMuxer: IRTServerMultiplexor[F, RequestCtx], + middlewares: Set[IRTServerMiddleware[F, RequestCtx]], + wsSessions: WsContextSessions[F, RequestCtx, WsCtx], + ) extends IRTContextServices[F, AuthCtx, RequestCtx, WsCtx] +} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala index a6b1d88a..d8b0ac74 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala @@ -2,11 +2,11 @@ package izumi.idealingua.runtime.rpc.http4s import cats.effect.Async import io.circe.{Json, Printer} -import izumi.functional.bio.Exit.{Error, Interruption, Success, Termination} +import izumi.functional.bio.Exit.{Error, Success, Termination} import izumi.functional.bio.UnsafeRun2.FailureHandler +import izumi.functional.bio.impl.{AsyncZio, PrimitivesZio} import izumi.functional.bio.{Async2, Exit, F, Primitives2, Temporal2, UnsafeRun2} import izumi.functional.lifecycle.Lifecycle -import izumi.fundamentals.platform.language.Quirks.* import izumi.fundamentals.platform.network.IzSockets import izumi.idealingua.runtime.rpc.* import izumi.idealingua.runtime.rpc.http4s.Http4sTransportTest.{Ctx, IO2R} @@ -26,19 +26,25 @@ import org.http4s.dsl.Http4sDsl import org.http4s.headers.Authorization import org.http4s.server.Router import org.scalatest.wordspec.AnyWordSpec -import zio.IO -import zio.interop.catz.* +import java.net.InetSocketAddress import java.util.concurrent.Executors import scala.concurrent.ExecutionContext.global import scala.concurrent.duration.DurationInt -final class Http4sTransportTest extends Http4sTransportTestBase[IO] +final class Http4sTransportTest + extends Http4sTransportTestBase[zio.IO]()( + async2 = AsyncZio, + primitives2 = PrimitivesZio, + temporal2 = AsyncZio, + unsafeRun2 = IO2R, + asyncThrowable = zio.interop.catz.asyncInstance, + ) object Http4sTransportTest { final val izLogger: IzLogger = makeLogger() final val handler: FailureHandler.Custom = UnsafeRun2.FailureHandler.Custom(message => izLogger.trace(s"Fiber failed: $message")) - implicit val IO2R: UnsafeRun2[zio.IO] = UnsafeRun2.createZIO( + final val IO2R: UnsafeRun2[zio.IO] = UnsafeRun2.createZIO( handler = handler, customCpuPool = Some( zio.Executor.fromJavaExecutor( @@ -46,22 +52,23 @@ object Http4sTransportTest { ) ), ) + final class Ctx[F[+_, +_]: Async2: Temporal2: Primitives2: UnsafeRun2](implicit asyncThrowable: Async[F[Throwable, _]]) { private val logger: LogIO2[F] = LogIO2.fromLogger(izLogger) private val printer: Printer = Printer.noSpaces.copy(dropNullValues = true) - final val dsl = Http4sDsl.apply[F[Throwable, _]] - final val execCtx = HttpExecutionContext(global) + val dsl: Http4sDsl[F[Throwable, _]] = Http4sDsl.apply[F[Throwable, _]] + val execCtx: HttpExecutionContext = HttpExecutionContext(global) - final val addr = IzSockets.temporaryServerAddress() - final val port = addr.getPort - final val host = addr.getHostName - final val baseUri = Uri(Some(Uri.Scheme.http), Some(Uri.Authority(host = Uri.RegName(host), port = Some(port)))) - final val wsUri = Uri.unsafeFromString(s"ws://$host:$port/ws") + val addr: InetSocketAddress = IzSockets.temporaryServerAddress() + val port: Int = addr.getPort + val host: String = addr.getHostName + val baseUri: Uri = Uri(Some(Uri.Scheme.http), Some(Uri.Authority(host = Uri.RegName(host), port = Some(port)))) + val wsUri: Uri = Uri.unsafeFromString(s"ws://$host:$port/ws") - final val demo = new TestServices[F](logger) + val demo: TestServices[F] = new TestServices[F](logger) - final val ioService = new HttpServer[F, AuthContext]( + val ioService: HttpServer[F, AuthContext] = new HttpServer[F, AuthContext]( contextServices = demo.Server.contextServices, httpContextExtractor = HttpContextExtractor.authContext, wsContextExtractor = WsContextExtractor.authContext, @@ -108,8 +115,13 @@ object Http4sTransportTest { } } -abstract class Http4sTransportTestBase[F[+_, +_]: Async2: Primitives2: Temporal2: UnsafeRun2]( - implicit asyncThrowable: Async[F[Throwable, _]] +abstract class Http4sTransportTestBase[F[+_, +_]]( + implicit + async2: Async2[F], + primitives2: Primitives2[F], + temporal2: Temporal2[F], + unsafeRun2: UnsafeRun2[F], + asyncThrowable: Async[F[Throwable, _]], ) extends AnyWordSpec { private val ctx = new Ctx[F] @@ -127,26 +139,22 @@ abstract class Http4sTransportTestBase[F[+_, +_]: Async2: Primitives2: Temporal2 publicOrcClient <- F.sync(httpRpcClientDispatcher(Headers(publicAuth("orc")))) // Private API test - _ <- new PrivateTestServiceWrappedClient(privateClient).test("test").map { - res => assert(res.startsWith("Private")) - } + _ <- new PrivateTestServiceWrappedClient(privateClient) + .test("test").map(res => assert(res.startsWith("Private"))) _ <- checkUnauthorizedHttpCall(new PrivateTestServiceWrappedClient(protectedClient).test("test")) _ <- checkUnauthorizedHttpCall(new ProtectedTestServiceWrappedClient(publicClient).test("test")) // Protected API test - _ <- new ProtectedTestServiceWrappedClient(protectedClient).test("test").map { - res => assert(res.startsWith("Protected")) - } + _ <- new ProtectedTestServiceWrappedClient(protectedClient) + .test("test").map(res => assert(res.startsWith("Protected"))) _ <- checkUnauthorizedHttpCall(new ProtectedTestServiceWrappedClient(privateClient).test("test")) _ <- checkUnauthorizedHttpCall(new ProtectedTestServiceWrappedClient(publicClient).test("test")) // Public API test _ <- new GreeterServiceClientWrapped(protectedClient) - .greet("Protected", "Client") - .map(res => assert(res == "Hi, Protected Client!")) + .greet("Protected", "Client").map(res => assert(res == "Hi, Protected Client!")) _ <- new GreeterServiceClientWrapped(privateClient) - .greet("Protected", "Client") - .map(res => assert(res == "Hi, Protected Client!")) + .greet("Protected", "Client").map(res => assert(res == "Hi, Protected Client!")) greaterClient = new GreeterServiceClientWrapped(publicClient) _ <- greaterClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) _ <- greaterClient.alternative().attempt.map(res => assert(res == Right("value"))) @@ -274,54 +282,42 @@ abstract class Http4sTransportTestBase[F[+_, +_]: Async2: Primitives2: Temporal2 } } - def withServer(f: F[Throwable, Any]): Unit = { + def withServer(f: F[Throwable, Unit]): Unit = { executeF { BlazeServerBuilder[F[Throwable, _]] .bindHttp(port, host) .withHttpWebSocketApp(ws => Router("/" -> ioService.service(ws)).orNotFound) .resource .use(_ => f) - .void } } - def executeF(io: F[Throwable, Any]): Unit = { - UnsafeRun2[F].unsafeRunSync(io.void) match { + def executeF(io: F[Throwable, Unit]): Unit = { + UnsafeRun2[F].unsafeRunSync(io) match { case Success(()) => () case failure: Exit.Failure[?] => throw failure.trace.toThrowable } } def checkUnauthorizedHttpCall[E, A](call: F[E, A]): F[Throwable, Unit] = { - call.sandboxExit.flatMap { - case Termination(exception: IRTUnexpectedHttpStatus, _, _) => - F.sync(assert(exception.status == Status.Unauthorized)).void - case o => - F.fail(new RuntimeException(s"Expected Unauthorized status but got $o")) - } + call.sandboxExit.map { + case Termination(exception: IRTUnexpectedHttpStatus, _, _) => assert(exception.status == Status.Unauthorized) + case o => fail(s"Expected Unauthorized status but got $o") + }.void } def checkUnauthorizedWsCall[E, A](call: F[E, A]): F[Throwable, Unit] = { - call.sandboxExit.flatMap { - case Termination(f: IRTGenericFailure, _, _) if f.getMessage.contains("""{"cause":"Unauthorized."}""") => - F.unit - case o => - F.fail(new RuntimeException(s"Expected IRTGenericFailure with Unauthorized message but got $o")) - } + call.sandboxExit.map { + case Termination(f: IRTGenericFailure, _, _) => assert(f.getMessage.contains("""{"cause":"Unauthorized."}""")) + case o => fail(s"Expected IRTGenericFailure with Unauthorized message but got $o") + }.void } def checkBadBody(body: String, disp: IRTDispatcherRaw[F]): F[Nothing, Unit] = { - disp.dispatchRaw(GreeterServiceMethods.greet.id, body).sandboxExit.map { - case Error(value: IRTUnexpectedHttpStatus, _) => - assert(value.status == Status.BadRequest).discard() - case Error(value, _) => - fail(s"Unexpected error: $value") - case Success(value) => - fail(s"Unexpected success: $value") - case Termination(exception, _, _) => - fail("Unexpected failure", exception) - case Interruption(value, _, _) => - fail(s"Interrupted: $value") - } + disp + .dispatchRaw(GreeterServiceMethods.greet.id, body).sandboxExit.map { + case Error(value: IRTUnexpectedHttpStatus, _) => assert(value.status == Status.BadRequest) + case o => fail(s"Expected IRTUnexpectedHttpStatus with BadRequest but got $o") + }.void } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala index 632bfd45..f4beff85 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala @@ -52,19 +52,32 @@ class TestServices[F[+_, +_]: IO2]( } } } - final val privateWsListener: LoggingWsListener[F, PrivateContext, PrivateContext] = new LoggingWsListener[F, PrivateContext, PrivateContext] + final val privateWsListener: LoggingWsListener[F, PrivateContext, PrivateContext] = { + new LoggingWsListener[F, PrivateContext, PrivateContext] + } final val privateWsSession: WsContextSessions[F, PrivateContext, PrivateContext] = { - new WsContextSessionsImpl(wsStorage, globalWsListeners, Set(privateWsListener), WsIdExtractor.id[PrivateContext]) - } - final val privateService: IRTWrappedService[F, PrivateContext] = new PrivateTestServiceWrappedServer(new PrivateTestServiceServer[F, PrivateContext] { - def test(ctx: PrivateContext, str: String): Just[String] = F.pure(s"Private: $str") - }) - final val privateServices: IRTContextServices[F, AuthContext, PrivateContext, PrivateContext] = IRTContextServices( - authenticator = privateAuth, - serverMuxer = new IRTServerMultiplexor.FromServices(Set(privateService)), - middlewares = Set.empty, - wsSessions = privateWsSession, - ) + new WsContextSessionsImpl( + wsSessionsStorage = wsStorage, + globalWsListeners = globalWsListeners, + wsSessionListeners = Set(privateWsListener), + wsIdExtractor = WsIdExtractor.id[PrivateContext], + ) + } + final val privateService: IRTWrappedService[F, PrivateContext] = { + new PrivateTestServiceWrappedServer[F, PrivateContext]( + new PrivateTestServiceServer[F, PrivateContext] { + def test(ctx: PrivateContext, str: String): Just[String] = F.pure(s"Private: $str") + } + ) + } + final val privateServices: IRTContextServices[F, AuthContext, PrivateContext, PrivateContext] = { + IRTContextServices[F, AuthContext, PrivateContext, PrivateContext]( + authenticator = privateAuth, + serverMuxer = new IRTServerMultiplexor.FromServices(Set(privateService)), + middlewares = Set.empty, + wsSessions = privateWsSession, + ) + } // PROTECTED final val protectedAuth = new IRTAuthenticator[F, AuthContext, ProtectedContext] { @@ -74,19 +87,32 @@ class TestServices[F[+_, +_]: IO2]( } } } - final val protectedWsListener: LoggingWsListener[F, ProtectedContext, ProtectedContext] = new LoggingWsListener[F, ProtectedContext, ProtectedContext] + final val protectedWsListener: LoggingWsListener[F, ProtectedContext, ProtectedContext] = { + new LoggingWsListener[F, ProtectedContext, ProtectedContext] + } final val protectedWsSession: WsContextSessions[F, ProtectedContext, ProtectedContext] = { - new WsContextSessionsImpl(wsStorage, globalWsListeners, Set(protectedWsListener), WsIdExtractor.id) - } - final val protectedService: IRTWrappedService[F, ProtectedContext] = new ProtectedTestServiceWrappedServer(new ProtectedTestServiceServer[F, ProtectedContext] { - def test(ctx: ProtectedContext, str: String): Just[String] = F.pure(s"Protected: $str") - }) - final val protectedServices: IRTContextServices[F, AuthContext, ProtectedContext, ProtectedContext] = IRTContextServices( - authenticator = protectedAuth, - serverMuxer = new IRTServerMultiplexor.FromServices(Set(protectedService)), - middlewares = Set.empty, - wsSessions = protectedWsSession, - ) + new WsContextSessionsImpl[F, ProtectedContext, ProtectedContext]( + wsSessionsStorage = wsStorage, + globalWsListeners = globalWsListeners, + wsSessionListeners = Set(protectedWsListener), + wsIdExtractor = WsIdExtractor.id, + ) + } + final val protectedService: IRTWrappedService[F, ProtectedContext] = { + new ProtectedTestServiceWrappedServer[F, ProtectedContext]( + new ProtectedTestServiceServer[F, ProtectedContext] { + def test(ctx: ProtectedContext, str: String): Just[String] = F.pure(s"Protected: $str") + } + ) + } + final val protectedServices: IRTContextServices[F, AuthContext, ProtectedContext, ProtectedContext] = { + IRTContextServices[F, AuthContext, ProtectedContext, ProtectedContext]( + authenticator = protectedAuth, + serverMuxer = new IRTServerMultiplexor.FromServices(Set(protectedService)), + middlewares = Set.empty, + wsSessions = protectedWsSession, + ) + } // PUBLIC final val publicAuth = new IRTAuthenticator[F, AuthContext, PublicContext] { @@ -96,32 +122,51 @@ class TestServices[F[+_, +_]: IO2]( } } } - final val publicWsListener: LoggingWsListener[F, PublicContext, PublicContext] = new LoggingWsListener[F, PublicContext, PublicContext] + final val publicWsListener: LoggingWsListener[F, PublicContext, PublicContext] = { + new LoggingWsListener[F, PublicContext, PublicContext] + } final val publicWsSession: WsContextSessions[F, PublicContext, PublicContext] = { - new WsContextSessionsImpl(wsStorage, globalWsListeners, Set(publicWsListener), WsIdExtractor.id) - } - final val publicService: IRTWrappedService[F, PublicContext] = new GreeterServiceServerWrapped(new AbstractGreeterServer.Impl[F, PublicContext]) - final val publicServices: IRTContextServices[F, AuthContext, PublicContext, PublicContext] = IRTContextServices( - authenticator = publicAuth, - serverMuxer = new IRTServerMultiplexor.FromServices(Set(publicService)), - middlewares = Set(userBlacklistMiddleware(Set("orc"))), - wsSessions = publicWsSession, - ) + new WsContextSessionsImpl( + wsSessionsStorage = wsStorage, + globalWsListeners = globalWsListeners, + wsSessionListeners = Set(publicWsListener), + wsIdExtractor = WsIdExtractor.id, + ) + } + final val publicService: IRTWrappedService[F, PublicContext] = { + new GreeterServiceServerWrapped[F, PublicContext]( + new AbstractGreeterServer.Impl[F, PublicContext] + ) + } + final val publicServices: IRTContextServices[F, AuthContext, PublicContext, PublicContext] = { + IRTContextServices[F, AuthContext, PublicContext, PublicContext]( + authenticator = publicAuth, + serverMuxer = new IRTServerMultiplexor.FromServices(Set(publicService)), + middlewares = Set(userBlacklistMiddleware(Set("orc"))), + wsSessions = publicWsSession, + ) + } - final val contextServices: Set[IRTContextServices[F, AuthContext, ?, ?]] = Set(privateServices, protectedServices, publicServices) + final val contextServices: Set[IRTContextServices.AnyContext[F, AuthContext]] = { + Set[IRTContextServices.AnyContext[F, AuthContext]]( + privateServices, + protectedServices, + publicServices, + ) + } } object Client { - private val greeterService = new AbstractGreeterServer.Impl[F, Unit] - private val greeterDispatcher = new GreeterServiceServerWrapped(greeterService) - private val dispatchers: Set[IRTWrappedService[F, Unit]] = Set(greeterDispatcher) + private val greeterService: AbstractGreeterServer[F, Unit] = new AbstractGreeterServer.Impl[F, Unit] + private val greeterDispatcher: GreeterServiceServerWrapped[F, Unit] = new GreeterServiceServerWrapped[F, Unit](greeterService) + private val dispatchers: Set[IRTWrappedService[F, Unit]] = Set[IRTWrappedService[F, Unit]](greeterDispatcher) - private val clients: Set[IRTWrappedClient] = Set( + private val clients: Set[IRTWrappedClient] = Set[IRTWrappedClient]( GreeterServiceClientWrapped, ProtectedTestServiceWrappedClient, PrivateTestServiceWrappedClient, ) val codec: IRTClientMultiplexorImpl[F] = new IRTClientMultiplexorImpl[F](clients) - val buzzerMultiplexor: IRTServerMultiplexor[F, Unit] = new IRTServerMultiplexor.FromServices(dispatchers) + val buzzerMultiplexor: IRTServerMultiplexor[F, Unit] = new IRTServerMultiplexor.FromServices[F, Unit](dispatchers) } } diff --git a/project/plugins.sbt b/project/plugins.sbt index 578656af..a0d6b66c 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,17 +1,6 @@ // DO NOT EDIT THIS FILE // IT IS AUTOGENERATED BY `sbtgen.sc` SCRIPT // ALL CHANGES WILL BE LOST -// https://www.scala-js.org/ -addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.13.0") - -// https://github.com/portable-scala/sbt-crossproject -addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.1") - -// https://scalacenter.github.io/scalajs-bundler/ -addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % "0.21.1") - -// https://github.com/scala-js/jsdependencies -addSbtPlugin("org.scala-js" % "sbt-jsdependencies" % "1.0.2") //////////////////////////////////////////////////////////////////////////////// From e99e2f67b927d90440b7dd87c23d8f0fdae7c530 Mon Sep 17 00:00:00 2001 From: Alex Liubymov Date: Fri, 15 Dec 2023 19:38:46 +0200 Subject: [PATCH 10/24] sbtgen --- build.sbt | 320 +++++++++++++++++++++++--------------------- project/plugins.sbt | 11 ++ 2 files changed, 178 insertions(+), 153 deletions(-) diff --git a/build.sbt b/build.sbt index f1aa5ef0..b8b2a667 100644 --- a/build.sbt +++ b/build.sbt @@ -3,6 +3,8 @@ // ALL CHANGES WILL BE LOST +import sbtcrossproject.CrossPlugin.autoImport.{crossProject, CrossType} + enablePlugins(SbtgenVerificationPlugin) @@ -11,13 +13,13 @@ ThisBuild / libraryDependencySchemes += "io.circe" %% "circe-core" % VersionSche ThisBuild / libraryDependencySchemes += "io.circe" %% "circe-core_sjs1" % VersionScheme.Always -lazy val `idealingua-v1-model` = project.in(file("idealingua-v1/idealingua-v1-model")) +lazy val `idealingua-v1-model` = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).in(file("idealingua-v1/idealingua-v1-model")) .settings( libraryDependencies ++= Seq( - "org.scalatest" %% "scalatest" % V.scalatest % Test, - "io.7mind.izumi" %% "fundamentals-collections" % Izumi.version, - "io.7mind.izumi" %% "fundamentals-platform" % Izumi.version, - "io.7mind.izumi" %% "fundamentals-functional" % Izumi.version + "org.scalatest" %%% "scalatest" % V.scalatest % Test, + "io.7mind.izumi" %%% "fundamentals-collections" % Izumi.version, + "io.7mind.izumi" %%% "fundamentals-platform" % Izumi.version, + "io.7mind.izumi" %%% "fundamentals-functional" % Izumi.version ), libraryDependencies ++= { if (scalaVersion.value.startsWith("2.")) Seq( compilerPlugin("org.typelevel" % "kind-projector" % V.kind_projector cross CrossVersion.full), @@ -25,21 +27,7 @@ lazy val `idealingua-v1-model` = project.in(file("idealingua-v1/idealingua-v1-mo ) else Seq.empty } ) .settings( - crossScalaVersions := Seq( - "3.3.1", - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -151,37 +139,44 @@ lazy val `idealingua-v1-model` = project.in(file("idealingua-v1/idealingua-v1-mo } }, scalacOptions -= "-Wconf:any:error" ) + .jvmSettings( + crossScalaVersions := Seq( + "3.3.1", + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head + ) + .jsSettings( + crossScalaVersions := Seq( + "3.3.1", + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, + coverageEnabled := false, + scalaJSLinkerConfig := { scalaJSLinkerConfig.value.withModuleKind(ModuleKind.CommonJSModule) } + ) .enablePlugins(IzumiPlugin) +lazy val `idealingua-v1-modelJVM` = `idealingua-v1-model`.jvm +lazy val `idealingua-v1-modelJS` = `idealingua-v1-model`.js -lazy val `idealingua-v1-core` = project.in(file("idealingua-v1/idealingua-v1-core")) +lazy val `idealingua-v1-core` = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).in(file("idealingua-v1/idealingua-v1-core")) .dependsOn( `idealingua-v1-model` % "test->compile;compile->compile" ) .settings( libraryDependencies ++= Seq( - "org.scalatest" %% "scalatest" % V.scalatest % Test, - "com.lihaoyi" %% "fastparse" % V.fastparse, - "io.7mind.izumi" %% "fundamentals-reflection" % Izumi.version + "org.scalatest" %%% "scalatest" % V.scalatest % Test, + "com.lihaoyi" %%% "fastparse" % V.fastparse, + "io.7mind.izumi" %%% "fundamentals-reflection" % Izumi.version ), libraryDependencies ++= { if (scalaVersion.value.startsWith("2.")) Seq( compilerPlugin("org.typelevel" % "kind-projector" % V.kind_projector cross CrossVersion.full) ) else Seq.empty } ) .settings( - crossScalaVersions := Seq( - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -293,53 +288,57 @@ lazy val `idealingua-v1-core` = project.in(file("idealingua-v1/idealingua-v1-cor } }, scalacOptions -= "-Wconf:any:error" ) + .jvmSettings( + crossScalaVersions := Seq( + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head + ) + .jsSettings( + crossScalaVersions := Seq( + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, + coverageEnabled := false, + scalaJSLinkerConfig := { scalaJSLinkerConfig.value.withModuleKind(ModuleKind.CommonJSModule) } + ) .enablePlugins(IzumiPlugin) +lazy val `idealingua-v1-coreJVM` = `idealingua-v1-core`.jvm +lazy val `idealingua-v1-coreJS` = `idealingua-v1-core`.js -lazy val `idealingua-v1-runtime-rpc-scala` = project.in(file("idealingua-v1/idealingua-v1-runtime-rpc-scala")) +lazy val `idealingua-v1-runtime-rpc-scala` = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).in(file("idealingua-v1/idealingua-v1-runtime-rpc-scala")) .settings( libraryDependencies ++= Seq( - "org.scalatest" %% "scalatest" % V.scalatest % Test, - "io.7mind.izumi" %% "fundamentals-bio" % Izumi.version, - "io.7mind.izumi" %% "fundamentals-platform" % Izumi.version, - "org.typelevel" %% "cats-core" % Izumi.Deps.fundamentals_bioJVM.org_typelevel_cats_core_version, - "org.typelevel" %% "cats-effect" % Izumi.Deps.fundamentals_bioJVM.org_typelevel_cats_effect_version, - "io.circe" %% "circe-parser" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, - "io.circe" %% "circe-literal" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, - "dev.zio" %% "zio" % Izumi.Deps.fundamentals_bioJVM.dev_zio_zio_version % Test, - "dev.zio" %% "zio-interop-cats" % Izumi.Deps.fundamentals_bioJVM.dev_zio_zio_interop_cats_version % Test, - "dev.zio" %% "izumi-reflect" % Izumi.Deps.fundamentals_bioJVM.dev_zio_izumi_reflect_version % Test + "org.scalatest" %%% "scalatest" % V.scalatest % Test, + "io.7mind.izumi" %%% "fundamentals-bio" % Izumi.version, + "io.7mind.izumi" %%% "fundamentals-platform" % Izumi.version, + "org.typelevel" %%% "cats-core" % Izumi.Deps.fundamentals_bioJVM.org_typelevel_cats_core_version, + "org.typelevel" %%% "cats-effect" % Izumi.Deps.fundamentals_bioJVM.org_typelevel_cats_effect_version, + "io.circe" %%% "circe-parser" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, + "io.circe" %%% "circe-literal" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, + "dev.zio" %%% "zio" % Izumi.Deps.fundamentals_bioJVM.dev_zio_zio_version % Test, + "dev.zio" %%% "zio-interop-cats" % Izumi.Deps.fundamentals_bioJVM.dev_zio_zio_interop_cats_version % Test, + "dev.zio" %%% "izumi-reflect" % Izumi.Deps.fundamentals_bioJVM.dev_zio_izumi_reflect_version % Test ), libraryDependencies ++= { if (scalaVersion.value.startsWith("2.")) Seq( compilerPlugin("org.typelevel" % "kind-projector" % V.kind_projector cross CrossVersion.full), "org.scala-lang" % "scala-reflect" % scalaVersion.value % Provided, - "io.circe" %% "circe-generic-extras" % V.circe_generic_extras, - "io.circe" %% "circe-derivation" % V.circe_derivation + "io.circe" %%% "circe-generic-extras" % V.circe_generic_extras, + "io.circe" %%% "circe-derivation" % V.circe_derivation ) else Seq.empty }, libraryDependencies ++= { val version = scalaVersion.value if (version.startsWith("0.") || version.startsWith("3.")) { Seq( - "io.circe" %% "circe-generic" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version + "io.circe" %%% "circe-generic" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version ) } else Seq.empty } ) .settings( - crossScalaVersions := Seq( - "3.3.1", - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -451,11 +450,37 @@ lazy val `idealingua-v1-runtime-rpc-scala` = project.in(file("idealingua-v1/idea } }, scalacOptions -= "-Wconf:any:error" ) + .jvmSettings( + crossScalaVersions := Seq( + "3.3.1", + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head + ) + .jsSettings( + crossScalaVersions := Seq( + "3.3.1", + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, + coverageEnabled := false, + scalaJSLinkerConfig := { scalaJSLinkerConfig.value.withModuleKind(ModuleKind.CommonJSModule) } + ) .enablePlugins(IzumiPlugin) +lazy val `idealingua-v1-runtime-rpc-scalaJVM` = `idealingua-v1-runtime-rpc-scala`.jvm +lazy val `idealingua-v1-runtime-rpc-scalaJS` = `idealingua-v1-runtime-rpc-scala`.js + .settings( + libraryDependencies ++= Seq( + "org.typelevel" %% "jawn-parser" % Izumi.Deps.fundamentals_json_circeJVM.org_typelevel_jawn_parser_version, + "io.github.cquiroz" %%% "scala-java-time" % V.scala_java_time % Test + ) + ) lazy val `idealingua-v1-runtime-rpc-http4s` = project.in(file("idealingua-v1/idealingua-v1-runtime-rpc-http4s")) .dependsOn( - `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile", + `idealingua-v1-runtime-rpc-scalaJVM` % "test->compile;compile->compile", `idealingua-v1-test-defs` % "test->compile" ) .settings( @@ -481,14 +506,6 @@ lazy val `idealingua-v1-runtime-rpc-http4s` = project.in(file("idealingua-v1/ide ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -602,54 +619,36 @@ lazy val `idealingua-v1-runtime-rpc-http4s` = project.in(file("idealingua-v1/ide ) .enablePlugins(IzumiPlugin) -lazy val `idealingua-v1-transpilers` = project.in(file("idealingua-v1/idealingua-v1-transpilers")) +lazy val `idealingua-v1-transpilers` = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).in(file("idealingua-v1/idealingua-v1-transpilers")) .dependsOn( `idealingua-v1-core` % "test->compile;compile->compile", - `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile", - `idealingua-v1-test-defs` % "test->compile", - `idealingua-v1-runtime-rpc-typescript` % "test->compile", - `idealingua-v1-runtime-rpc-go` % "test->compile", - `idealingua-v1-runtime-rpc-csharp` % "test->compile" + `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile" ) .settings( libraryDependencies ++= Seq( - "org.scalatest" %% "scalatest" % V.scalatest % Test, - "org.scala-lang.modules" %% "scala-xml" % V.scala_xml, - "org.scalameta" %% "scalameta" % V.scalameta, - "io.7mind.izumi" %% "fundamentals-bio" % Izumi.version, - "io.circe" %% "circe-parser" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, - "io.circe" %% "circe-literal" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version + "org.scalatest" %%% "scalatest" % V.scalatest % Test, + "org.scala-lang.modules" %%% "scala-xml" % V.scala_xml, + "org.scalameta" %%% "scalameta" % V.scalameta, + "io.7mind.izumi" %%% "fundamentals-bio" % Izumi.version, + "io.circe" %%% "circe-parser" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, + "io.circe" %%% "circe-literal" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version ), libraryDependencies ++= { if (scalaVersion.value.startsWith("2.")) Seq( compilerPlugin("org.typelevel" % "kind-projector" % V.kind_projector cross CrossVersion.full), - "io.circe" %% "circe-generic-extras" % V.circe_generic_extras, - "io.circe" %% "circe-derivation" % V.circe_derivation + "io.circe" %%% "circe-generic-extras" % V.circe_generic_extras, + "io.circe" %%% "circe-derivation" % V.circe_derivation ) else Seq.empty }, libraryDependencies ++= { val version = scalaVersion.value if (version.startsWith("0.") || version.startsWith("3.")) { Seq( - "io.circe" %% "circe-generic" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version + "io.circe" %%% "circe-generic" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version ) } else Seq.empty } ) .settings( - crossScalaVersions := Seq( - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, - Test / fork := true, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -761,11 +760,41 @@ lazy val `idealingua-v1-transpilers` = project.in(file("idealingua-v1/idealingua } }, scalacOptions -= "-Wconf:any:error" ) + .jvmSettings( + crossScalaVersions := Seq( + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, + Test / fork := true + ) + .jsSettings( + crossScalaVersions := Seq( + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, + coverageEnabled := false, + scalaJSLinkerConfig := { scalaJSLinkerConfig.value.withModuleKind(ModuleKind.CommonJSModule) } + ) .enablePlugins(IzumiPlugin) +lazy val `idealingua-v1-transpilersJVM` = `idealingua-v1-transpilers`.jvm + .dependsOn( + `idealingua-v1-test-defs` % "test->compile", + `idealingua-v1-runtime-rpc-typescript` % "test->compile", + `idealingua-v1-runtime-rpc-go` % "test->compile", + `idealingua-v1-runtime-rpc-csharp` % "test->compile" + ) +lazy val `idealingua-v1-transpilersJS` = `idealingua-v1-transpilers`.js + .settings( + libraryDependencies ++= Seq( + "org.typelevel" %% "jawn-parser" % Izumi.Deps.fundamentals_json_circeJVM.org_typelevel_jawn_parser_version + ) + ) lazy val `idealingua-v1-test-defs` = project.in(file("idealingua-v1/idealingua-v1-test-defs")) .dependsOn( - `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile" + `idealingua-v1-runtime-rpc-scalaJVM` % "test->compile;compile->compile" ) .settings( libraryDependencies ++= Seq( @@ -786,14 +815,6 @@ lazy val `idealingua-v1-test-defs` = project.in(file("idealingua-v1/idealingua-v ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -924,14 +945,6 @@ lazy val `idealingua-v1-runtime-rpc-typescript` = project.in(file("idealingua-v1 ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -1062,14 +1075,6 @@ lazy val `idealingua-v1-runtime-rpc-go` = project.in(file("idealingua-v1/idealin ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -1200,14 +1205,6 @@ lazy val `idealingua-v1-runtime-rpc-csharp` = project.in(file("idealingua-v1/ide ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -1323,8 +1320,8 @@ lazy val `idealingua-v1-runtime-rpc-csharp` = project.in(file("idealingua-v1/ide lazy val `idealingua-v1-compiler` = project.in(file("idealingua-v1/idealingua-v1-compiler")) .dependsOn( - `idealingua-v1-transpilers` % "test->compile;compile->compile", - `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile", + `idealingua-v1-transpilersJVM` % "test->compile;compile->compile", + `idealingua-v1-runtime-rpc-scalaJVM` % "test->compile;compile->compile", `idealingua-v1-runtime-rpc-typescript` % "test->compile;compile->compile", `idealingua-v1-runtime-rpc-go` % "test->compile;compile->compile", `idealingua-v1-runtime-rpc-csharp` % "test->compile;compile->compile", @@ -1346,14 +1343,6 @@ lazy val `idealingua-v1-compiler` = project.in(file("idealingua-v1/idealingua-v1 ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -1474,11 +1463,15 @@ lazy val `idealingua` = (project in file(".agg/idealingua-v1-idealingua")) ) .enablePlugins(IzumiPlugin) .aggregate( - `idealingua-v1-model`, - `idealingua-v1-core`, - `idealingua-v1-runtime-rpc-scala`, + `idealingua-v1-modelJVM`, + `idealingua-v1-modelJS`, + `idealingua-v1-coreJVM`, + `idealingua-v1-coreJS`, + `idealingua-v1-runtime-rpc-scalaJVM`, + `idealingua-v1-runtime-rpc-scalaJS`, `idealingua-v1-runtime-rpc-http4s`, - `idealingua-v1-transpilers`, + `idealingua-v1-transpilersJVM`, + `idealingua-v1-transpilersJS`, `idealingua-v1-test-defs`, `idealingua-v1-runtime-rpc-typescript`, `idealingua-v1-runtime-rpc-go`, @@ -1492,11 +1485,11 @@ lazy val `idealingua-jvm` = (project in file(".agg/idealingua-v1-idealingua-jvm" crossScalaVersions := Nil ) .aggregate( - `idealingua-v1-model`, - `idealingua-v1-core`, - `idealingua-v1-runtime-rpc-scala`, + `idealingua-v1-modelJVM`, + `idealingua-v1-coreJVM`, + `idealingua-v1-runtime-rpc-scalaJVM`, `idealingua-v1-runtime-rpc-http4s`, - `idealingua-v1-transpilers`, + `idealingua-v1-transpilersJVM`, `idealingua-v1-test-defs`, `idealingua-v1-runtime-rpc-typescript`, `idealingua-v1-runtime-rpc-go`, @@ -1504,6 +1497,18 @@ lazy val `idealingua-jvm` = (project in file(".agg/idealingua-v1-idealingua-jvm" `idealingua-v1-compiler` ) +lazy val `idealingua-js` = (project in file(".agg/idealingua-v1-idealingua-js")) + .settings( + publish / skip := true, + crossScalaVersions := Nil + ) + .aggregate( + `idealingua-v1-modelJS`, + `idealingua-v1-coreJS`, + `idealingua-v1-runtime-rpc-scalaJS`, + `idealingua-v1-transpilersJS` + ) + lazy val `idealingua-v1-jvm` = (project in file(".agg/.agg-jvm")) .settings( publish / skip := true, @@ -1513,6 +1518,15 @@ lazy val `idealingua-v1-jvm` = (project in file(".agg/.agg-jvm")) `idealingua-jvm` ) +lazy val `idealingua-v1-js` = (project in file(".agg/.agg-js")) + .settings( + publish / skip := true, + crossScalaVersions := Nil + ) + .aggregate( + `idealingua-js` + ) + lazy val `idealingua-v1` = (project in file(".")) .settings( publish / skip := true, diff --git a/project/plugins.sbt b/project/plugins.sbt index a0d6b66c..578656af 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,6 +1,17 @@ // DO NOT EDIT THIS FILE // IT IS AUTOGENERATED BY `sbtgen.sc` SCRIPT // ALL CHANGES WILL BE LOST +// https://www.scala-js.org/ +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.13.0") + +// https://github.com/portable-scala/sbt-crossproject +addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.1") + +// https://scalacenter.github.io/scalajs-bundler/ +addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % "0.21.1") + +// https://github.com/scala-js/jsdependencies +addSbtPlugin("org.scala-js" % "sbt-jsdependencies" % "1.0.2") //////////////////////////////////////////////////////////////////////////////// From 506e69eb2dd12b3bfd85ff59b15a45ab972b1e2d Mon Sep 17 00:00:00 2001 From: Alex Liubymov Date: Fri, 15 Dec 2023 20:23:43 +0200 Subject: [PATCH 11/24] -Xss1m --- .jvmopts | 1 + 1 file changed, 1 insertion(+) diff --git a/.jvmopts b/.jvmopts index cdfceeb7..184027a5 100644 --- a/.jvmopts +++ b/.jvmopts @@ -1,4 +1,5 @@ -Xmx4G +-Xss1m -XX:ReservedCodeCacheSize=256m -XX:MaxMetaspaceSize=3G From ec711033c6944bddeced077cd0f0027a76d982a2 Mon Sep 17 00:00:00 2001 From: Alex Liubymov Date: Fri, 15 Dec 2023 20:29:54 +0200 Subject: [PATCH 12/24] -Xss2m --- .jvmopts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.jvmopts b/.jvmopts index 184027a5..80e9449d 100644 --- a/.jvmopts +++ b/.jvmopts @@ -1,5 +1,5 @@ -Xmx4G --Xss1m +-Xss2m -XX:ReservedCodeCacheSize=256m -XX:MaxMetaspaceSize=3G From b674b2a434b7a34d4e29cc1f15443660e27ff053 Mon Sep 17 00:00:00 2001 From: Alex Liubymov Date: Fri, 15 Dec 2023 21:13:17 +0200 Subject: [PATCH 13/24] Wrapping midleware instead of tap action. --- .../runtime/rpc/http4s/fixtures/TestServices.scala | 9 +++++---- .../izumi/idealingua/runtime/rpc/IRTServerMethod.scala | 9 +++++++++ .../idealingua/runtime/rpc/IRTServerMiddleware.scala | 7 ++++++- .../idealingua/runtime/rpc/IRTServerMultiplexor.scala | 9 +++++---- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala index f4beff85..cfaced6d 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala @@ -24,10 +24,11 @@ class TestServices[F[+_, +_]: IO2]( rejectedNames: Set[String] ): IRTServerMiddleware[F, C] = new IRTServerMiddleware[F, C] { override def priority: Int = 0 - override def prepare(methodId: IRTMethodId)(context: C, parsedBody: Json): F[Throwable, Unit] = { - F.when(rejectedNames.contains(context.user)) { - F.fail(new IRTUnathorizedRequestContextException(s"Rejected for users: $rejectedNames.")) - } + override def apply(methodId: IRTMethodId)(context: C, parsedBody: Json)(next: => F[Throwable, Json]): F[Throwable, Json] = { + F.ifThenElse(rejectedNames.contains(context.user))( + F.fail(new IRTUnathorizedRequestContextException(s"Rejected for users: $rejectedNames.")), + next, + ) } } final val wsStorage: WsSessionsStorage[F, AuthContext] = new WsSessionsStorageImpl[F, AuthContext](logger) diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMethod.scala b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMethod.scala index cc846b89..6169b25e 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMethod.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMethod.scala @@ -8,6 +8,7 @@ trait IRTServerMethod[F[+_, +_], C] { def methodId: IRTMethodId def invoke(context: C, parsedBody: Json): F[Throwable, Json] + /** Contramap eval on context C2 -> C. If context is missing IRTUnathorizedRequestContextException will raise. */ final def contramap[C2](updateContext: (C2, Json) => F[Throwable, Option[C]])(implicit E: Error2[F]): IRTServerMethod[F, C2] = new IRTServerMethod[F, C2] { override def methodId: IRTMethodId = self.methodId override def invoke(context: C2, parsedBody: Json): F[Throwable, Json] = { @@ -16,6 +17,14 @@ trait IRTServerMethod[F[+_, +_], C] { .flatMap(self.invoke(_, parsedBody)) } } + + /** Wrap invocation with function '(Context, Body)(Method.Invoke) => Result' . */ + final def wrap(middleware: (C, Json) => F[Throwable, Json] => F[Throwable, Json]): IRTServerMethod[F, C] = new IRTServerMethod[F, C] { + override def methodId: IRTMethodId = self.methodId + override def invoke(context: C, parsedBody: Json): F[Throwable, Json] = { + middleware(context, parsedBody)(self.invoke(context, parsedBody)) + } + } } object IRTServerMethod { diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMiddleware.scala b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMiddleware.scala index f4a7aa10..b22bd29e 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMiddleware.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMiddleware.scala @@ -4,5 +4,10 @@ import io.circe.Json trait IRTServerMiddleware[F[_, _], C] { def priority: Int - def prepare(methodId: IRTMethodId)(context: C, parsedBody: Json): F[Throwable, Unit] + def apply( + methodId: IRTMethodId + )(context: C, + parsedBody: Json, + )(next: => F[Throwable, Json] + ): F[Throwable, Json] } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala index 343b9181..7774c702 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala @@ -12,6 +12,7 @@ trait IRTServerMultiplexor[F[+_, +_], C] { .flatMap(_.invoke(context, parsedBody)) } + /** Contramap eval on context C2 -> C. If context is missing IRTUnathorizedRequestContextException will raise. */ final def contramap[C2]( updateContext: (C2, Json) => F[Throwable, Option[C]] )(implicit io2: IO2[F] @@ -19,13 +20,13 @@ trait IRTServerMultiplexor[F[+_, +_], C] { val mappedMethods = self.methods.map { case (k, v) => k -> v.contramap(updateContext) } new IRTServerMultiplexor.FromMethods(mappedMethods) } - - final def wrap(middleware: IRTServerMiddleware[F, C])(implicit io2: IO2[F]): IRTServerMultiplexor[F, C] = { + /** Wrap invocation with function '(Context, Body)(Method.Invoke) => Result' . */ + final def wrap(middleware: IRTServerMiddleware[F, C]): IRTServerMultiplexor[F, C] = { val wrappedMethods = self.methods.map { case (methodId, method) => - val wrappedMethod: IRTServerMethod[F, C] = method.contramap[C] { + val wrappedMethod: IRTServerMethod[F, C] = method.wrap { case (ctx, body) => - middleware.prepare(methodId)(ctx, body).as(Some(ctx)) + next => middleware(method.methodId)(ctx, body)(next) } methodId -> wrappedMethod } From 1836bb0caed8cdfbdb7a6bb7b63934ea83d07881 Mon Sep 17 00:00:00 2001 From: Alex Liubymov Date: Sat, 16 Dec 2023 15:45:32 +0200 Subject: [PATCH 14/24] Support WS client authorization with HTTP request. --- .../clients/WsRpcDispatcherFactory.scala | 46 ++++++++++++++++--- .../rpc/http4s/Http4sTransportTest.scala | 30 +++++++++++- 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala index b3a75e1f..c9d2b4f9 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala @@ -32,14 +32,20 @@ class WsRpcDispatcherFactory[F[+_, +_]: Async2: Temporal2: Primitives2: UnsafeRu uri: Uri, serverMuxer: IRTServerMultiplexor[F, ServerContext], wsContextExtractor: WsContextExtractor[ServerContext], + headers: Map[String, String] = Map.empty, ): Lifecycle[F[Throwable, _], WsRpcClientConnection[F]] = { for { - client <- WsRpcDispatcherFactory.asyncHttpClient[F] + client <- createAsyncHttpClient() wsRequestState <- Lifecycle.liftF(F.syncThrowable(WsRequestState.create[F])) listener <- Lifecycle.liftF(F.syncThrowable(createListener(serverMuxer, wsRequestState, wsContextExtractor, dispatcherLogger(uri, logger)))) handler <- Lifecycle.liftF(F.syncThrowable(new WebSocketUpgradeHandler(List(listener).asJava))) nettyWebSocket <- Lifecycle.make( - F.fromFutureJava(client.prepareGet(uri.toString()).execute(handler).toCompletableFuture) + F.fromFutureJava { + client + .prepareGet(uri.toString()) + .setSingleHeaders(headers.asJava) + .execute(handler).toCompletableFuture + } )(nettyWebSocket => fromNettyFuture(nettyWebSocket.sendCloseFrame()).void) // fill promises before closing WS connection, potentially giving a chance to send out an error response before closing _ <- Lifecycle.make(F.unit)(_ => wsRequestState.clear()) @@ -48,14 +54,23 @@ class WsRpcDispatcherFactory[F[+_, +_]: Async2: Temporal2: Primitives2: UnsafeRu } } + def connectSimple( + uri: Uri, + serverMuxer: IRTServerMultiplexor[F, Unit], + headers: Map[String, String] = Map.empty, + ): Lifecycle[F[Throwable, _], WsRpcClientConnection[F]] = { + connect(uri, serverMuxer, WsContextExtractor.unit, headers) + } + def dispatcher[ServerContext]( uri: Uri, serverMuxer: IRTServerMultiplexor[F, ServerContext], wsContextExtractor: WsContextExtractor[ServerContext], + headers: Map[String, String] = Map.empty, tweakRequest: RpcPacket => RpcPacket = identity, timeout: FiniteDuration = 30.seconds, ): Lifecycle[F[Throwable, _], IRTDispatcherWs[F]] = { - connect(uri, serverMuxer, wsContextExtractor).map { + connect(uri, serverMuxer, wsContextExtractor, headers).map { new WsRpcDispatcher(_, timeout, codec, dispatcherLogger(uri, logger)) { override protected def buildRequest(rpcPacketId: RpcPacketId, method: IRTMethodId, body: Json): RpcPacket = { tweakRequest(super.buildRequest(rpcPacketId, method, body)) @@ -64,6 +79,16 @@ class WsRpcDispatcherFactory[F[+_, +_]: Async2: Temporal2: Primitives2: UnsafeRu } } + def dispatcherSimple( + uri: Uri, + serverMuxer: IRTServerMultiplexor[F, Unit], + headers: Map[String, String] = Map.empty, + tweakRequest: RpcPacket => RpcPacket = identity, + timeout: FiniteDuration = 30.seconds, + ): Lifecycle[F[Throwable, _], IRTDispatcherWs[F]] = { + dispatcher(uri, serverMuxer, WsContextExtractor.unit, headers, tweakRequest, timeout) + } + protected def wsHandler[ServerContext]( serverMuxer: IRTServerMultiplexor[F, ServerContext], wsRequestState: WsRequestState[F], @@ -134,10 +159,8 @@ class WsRpcDispatcherFactory[F[+_, +_]: Async2: Temporal2: Primitives2: UnsafeRu Some(RpcPacket.rpcCritical(message, None)) } } -} -object WsRpcDispatcherFactory { - def asyncHttpClient[F[+_, +_]: IO2]: Lifecycle[F[Throwable, _], DefaultAsyncHttpClient] = { + protected def createAsyncHttpClient(): Lifecycle[F[Throwable, _], DefaultAsyncHttpClient] = { Lifecycle.fromAutoCloseable(F.syncThrowable { new DefaultAsyncHttpClient( new DefaultAsyncHttpClientConfig.Builder() @@ -154,6 +177,9 @@ object WsRpcDispatcherFactory { ) }) } +} + +object WsRpcDispatcherFactory { class ClientWsRpcHandler[F[+_, +_]: IO2, RequestCtx]( muxer: IRTServerMultiplexor[F, RequestCtx], @@ -171,7 +197,13 @@ object WsRpcDispatcherFactory { () } override protected def getRequestCtx: RequestCtx = { - requestCtxRef.get().getOrElse(throw new IRTUnathorizedRequestContextException("Missing WS request context.")) + requestCtxRef.get().getOrElse { + throw new IRTUnathorizedRequestContextException( + """Impossible - missing WS request context. + |Request context should be set with `updateRequestCtx`, before this method is called. (Ref: WsRpcHandler.scala:25) + |Please, report as a bug.""".stripMargin + ) + } } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala index d8b0ac74..225803b7 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala @@ -93,8 +93,8 @@ object Http4sTransportTest { val wsClientFactory: WsRpcDispatcherFactory[F] = { new WsRpcDispatcherFactory[F](demo.Client.codec, printer, logger, izLogger) } - def wsRpcClientDispatcher(): Lifecycle[F[Throwable, _], WsRpcDispatcher.IRTDispatcherWs[F]] = { - wsClientFactory.dispatcher(wsUri, demo.Client.buzzerMultiplexor, WsContextExtractor.unit) + def wsRpcClientDispatcher(headers: Map[String, String] = Map.empty): Lifecycle[F[Throwable, _], WsRpcDispatcher.IRTDispatcherWs[F]] = { + wsClientFactory.dispatcherSimple(wsUri, demo.Client.buzzerMultiplexor, headers) } } @@ -262,6 +262,32 @@ abstract class Http4sTransportTestBase[F[+_, +_]]( } } + "support websockets request auth" in { + withServer { + for { + privateHeaders <- F.pure(Map("Authorization" -> privateAuth("user").values.head.value)) + _ <- wsRpcClientDispatcher(privateHeaders).use { + dispatcher => + val publicClient = new GreeterServiceClientWrapped[F](dispatcher) + val privateClient = new PrivateTestServiceWrappedClient[F](dispatcher) + val protectedClient = new ProtectedTestServiceWrappedClient[F](dispatcher) + for { + _ <- demo.Server.protectedWsSession.dispatcherFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) + _ <- demo.Server.privateWsSession.dispatcherFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) + _ <- demo.Server.publicWsSession.dispatcherFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) + _ = assert(demo.Server.protectedWsListener.connected.isEmpty) + _ = assert(demo.Server.privateWsListener.connected.size == 1) + _ = assert(demo.Server.publicWsListener.connected.size == 1) + + _ <- privateClient.test("test").map(res => assert(res.startsWith("Private"))) + _ <- publicClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) + _ <- checkUnauthorizedWsCall(protectedClient.test("")) + } yield () + } + } yield () + } + } + "support request state clean" in { executeF { val rs = new WsRequestState.Default[F]() From 1ba9d92ebe4a54171500c7269725749a49fc0418 Mon Sep 17 00:00:00 2001 From: Alex Liubymov Date: Tue, 19 Dec 2023 18:55:10 +0200 Subject: [PATCH 15/24] Add missing methods. --- build.sbt | 320 +++++++++--------- .../runtime/rpc/http4s/HttpServer.scala | 13 +- .../clients/WsRpcDispatcherFactory.scala | 18 +- .../rpc/http4s/context/WsIdExtractor.scala | 4 +- .../rpc/http4s/ws/WsClientSession.scala | 19 +- .../rpc/http4s/ws/WsContextSessions.scala | 32 +- .../runtime/rpc/http4s/ws/WsRpcHandler.scala | 19 +- .../rpc/http4s/ws/WsSessionListener.scala | 4 +- project/plugins.sbt | 11 - 9 files changed, 210 insertions(+), 230 deletions(-) diff --git a/build.sbt b/build.sbt index b8b2a667..f1aa5ef0 100644 --- a/build.sbt +++ b/build.sbt @@ -3,8 +3,6 @@ // ALL CHANGES WILL BE LOST -import sbtcrossproject.CrossPlugin.autoImport.{crossProject, CrossType} - enablePlugins(SbtgenVerificationPlugin) @@ -13,13 +11,13 @@ ThisBuild / libraryDependencySchemes += "io.circe" %% "circe-core" % VersionSche ThisBuild / libraryDependencySchemes += "io.circe" %% "circe-core_sjs1" % VersionScheme.Always -lazy val `idealingua-v1-model` = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).in(file("idealingua-v1/idealingua-v1-model")) +lazy val `idealingua-v1-model` = project.in(file("idealingua-v1/idealingua-v1-model")) .settings( libraryDependencies ++= Seq( - "org.scalatest" %%% "scalatest" % V.scalatest % Test, - "io.7mind.izumi" %%% "fundamentals-collections" % Izumi.version, - "io.7mind.izumi" %%% "fundamentals-platform" % Izumi.version, - "io.7mind.izumi" %%% "fundamentals-functional" % Izumi.version + "org.scalatest" %% "scalatest" % V.scalatest % Test, + "io.7mind.izumi" %% "fundamentals-collections" % Izumi.version, + "io.7mind.izumi" %% "fundamentals-platform" % Izumi.version, + "io.7mind.izumi" %% "fundamentals-functional" % Izumi.version ), libraryDependencies ++= { if (scalaVersion.value.startsWith("2.")) Seq( compilerPlugin("org.typelevel" % "kind-projector" % V.kind_projector cross CrossVersion.full), @@ -27,7 +25,21 @@ lazy val `idealingua-v1-model` = crossProject(JVMPlatform, JSPlatform).crossType ) else Seq.empty } ) .settings( + crossScalaVersions := Seq( + "3.3.1", + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -139,44 +151,37 @@ lazy val `idealingua-v1-model` = crossProject(JVMPlatform, JSPlatform).crossType } }, scalacOptions -= "-Wconf:any:error" ) - .jvmSettings( - crossScalaVersions := Seq( - "3.3.1", - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head - ) - .jsSettings( - crossScalaVersions := Seq( - "3.3.1", - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, - coverageEnabled := false, - scalaJSLinkerConfig := { scalaJSLinkerConfig.value.withModuleKind(ModuleKind.CommonJSModule) } - ) .enablePlugins(IzumiPlugin) -lazy val `idealingua-v1-modelJVM` = `idealingua-v1-model`.jvm -lazy val `idealingua-v1-modelJS` = `idealingua-v1-model`.js -lazy val `idealingua-v1-core` = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).in(file("idealingua-v1/idealingua-v1-core")) +lazy val `idealingua-v1-core` = project.in(file("idealingua-v1/idealingua-v1-core")) .dependsOn( `idealingua-v1-model` % "test->compile;compile->compile" ) .settings( libraryDependencies ++= Seq( - "org.scalatest" %%% "scalatest" % V.scalatest % Test, - "com.lihaoyi" %%% "fastparse" % V.fastparse, - "io.7mind.izumi" %%% "fundamentals-reflection" % Izumi.version + "org.scalatest" %% "scalatest" % V.scalatest % Test, + "com.lihaoyi" %% "fastparse" % V.fastparse, + "io.7mind.izumi" %% "fundamentals-reflection" % Izumi.version ), libraryDependencies ++= { if (scalaVersion.value.startsWith("2.")) Seq( compilerPlugin("org.typelevel" % "kind-projector" % V.kind_projector cross CrossVersion.full) ) else Seq.empty } ) .settings( + crossScalaVersions := Seq( + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -288,57 +293,53 @@ lazy val `idealingua-v1-core` = crossProject(JVMPlatform, JSPlatform).crossType( } }, scalacOptions -= "-Wconf:any:error" ) - .jvmSettings( - crossScalaVersions := Seq( - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head - ) - .jsSettings( - crossScalaVersions := Seq( - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, - coverageEnabled := false, - scalaJSLinkerConfig := { scalaJSLinkerConfig.value.withModuleKind(ModuleKind.CommonJSModule) } - ) .enablePlugins(IzumiPlugin) -lazy val `idealingua-v1-coreJVM` = `idealingua-v1-core`.jvm -lazy val `idealingua-v1-coreJS` = `idealingua-v1-core`.js -lazy val `idealingua-v1-runtime-rpc-scala` = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).in(file("idealingua-v1/idealingua-v1-runtime-rpc-scala")) +lazy val `idealingua-v1-runtime-rpc-scala` = project.in(file("idealingua-v1/idealingua-v1-runtime-rpc-scala")) .settings( libraryDependencies ++= Seq( - "org.scalatest" %%% "scalatest" % V.scalatest % Test, - "io.7mind.izumi" %%% "fundamentals-bio" % Izumi.version, - "io.7mind.izumi" %%% "fundamentals-platform" % Izumi.version, - "org.typelevel" %%% "cats-core" % Izumi.Deps.fundamentals_bioJVM.org_typelevel_cats_core_version, - "org.typelevel" %%% "cats-effect" % Izumi.Deps.fundamentals_bioJVM.org_typelevel_cats_effect_version, - "io.circe" %%% "circe-parser" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, - "io.circe" %%% "circe-literal" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, - "dev.zio" %%% "zio" % Izumi.Deps.fundamentals_bioJVM.dev_zio_zio_version % Test, - "dev.zio" %%% "zio-interop-cats" % Izumi.Deps.fundamentals_bioJVM.dev_zio_zio_interop_cats_version % Test, - "dev.zio" %%% "izumi-reflect" % Izumi.Deps.fundamentals_bioJVM.dev_zio_izumi_reflect_version % Test + "org.scalatest" %% "scalatest" % V.scalatest % Test, + "io.7mind.izumi" %% "fundamentals-bio" % Izumi.version, + "io.7mind.izumi" %% "fundamentals-platform" % Izumi.version, + "org.typelevel" %% "cats-core" % Izumi.Deps.fundamentals_bioJVM.org_typelevel_cats_core_version, + "org.typelevel" %% "cats-effect" % Izumi.Deps.fundamentals_bioJVM.org_typelevel_cats_effect_version, + "io.circe" %% "circe-parser" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, + "io.circe" %% "circe-literal" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, + "dev.zio" %% "zio" % Izumi.Deps.fundamentals_bioJVM.dev_zio_zio_version % Test, + "dev.zio" %% "zio-interop-cats" % Izumi.Deps.fundamentals_bioJVM.dev_zio_zio_interop_cats_version % Test, + "dev.zio" %% "izumi-reflect" % Izumi.Deps.fundamentals_bioJVM.dev_zio_izumi_reflect_version % Test ), libraryDependencies ++= { if (scalaVersion.value.startsWith("2.")) Seq( compilerPlugin("org.typelevel" % "kind-projector" % V.kind_projector cross CrossVersion.full), "org.scala-lang" % "scala-reflect" % scalaVersion.value % Provided, - "io.circe" %%% "circe-generic-extras" % V.circe_generic_extras, - "io.circe" %%% "circe-derivation" % V.circe_derivation + "io.circe" %% "circe-generic-extras" % V.circe_generic_extras, + "io.circe" %% "circe-derivation" % V.circe_derivation ) else Seq.empty }, libraryDependencies ++= { val version = scalaVersion.value if (version.startsWith("0.") || version.startsWith("3.")) { Seq( - "io.circe" %%% "circe-generic" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version + "io.circe" %% "circe-generic" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version ) } else Seq.empty } ) .settings( + crossScalaVersions := Seq( + "3.3.1", + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -450,37 +451,11 @@ lazy val `idealingua-v1-runtime-rpc-scala` = crossProject(JVMPlatform, JSPlatfor } }, scalacOptions -= "-Wconf:any:error" ) - .jvmSettings( - crossScalaVersions := Seq( - "3.3.1", - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head - ) - .jsSettings( - crossScalaVersions := Seq( - "3.3.1", - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, - coverageEnabled := false, - scalaJSLinkerConfig := { scalaJSLinkerConfig.value.withModuleKind(ModuleKind.CommonJSModule) } - ) .enablePlugins(IzumiPlugin) -lazy val `idealingua-v1-runtime-rpc-scalaJVM` = `idealingua-v1-runtime-rpc-scala`.jvm -lazy val `idealingua-v1-runtime-rpc-scalaJS` = `idealingua-v1-runtime-rpc-scala`.js - .settings( - libraryDependencies ++= Seq( - "org.typelevel" %% "jawn-parser" % Izumi.Deps.fundamentals_json_circeJVM.org_typelevel_jawn_parser_version, - "io.github.cquiroz" %%% "scala-java-time" % V.scala_java_time % Test - ) - ) lazy val `idealingua-v1-runtime-rpc-http4s` = project.in(file("idealingua-v1/idealingua-v1-runtime-rpc-http4s")) .dependsOn( - `idealingua-v1-runtime-rpc-scalaJVM` % "test->compile;compile->compile", + `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile", `idealingua-v1-test-defs` % "test->compile" ) .settings( @@ -506,6 +481,14 @@ lazy val `idealingua-v1-runtime-rpc-http4s` = project.in(file("idealingua-v1/ide ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -619,36 +602,54 @@ lazy val `idealingua-v1-runtime-rpc-http4s` = project.in(file("idealingua-v1/ide ) .enablePlugins(IzumiPlugin) -lazy val `idealingua-v1-transpilers` = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).in(file("idealingua-v1/idealingua-v1-transpilers")) +lazy val `idealingua-v1-transpilers` = project.in(file("idealingua-v1/idealingua-v1-transpilers")) .dependsOn( `idealingua-v1-core` % "test->compile;compile->compile", - `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile" + `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile", + `idealingua-v1-test-defs` % "test->compile", + `idealingua-v1-runtime-rpc-typescript` % "test->compile", + `idealingua-v1-runtime-rpc-go` % "test->compile", + `idealingua-v1-runtime-rpc-csharp` % "test->compile" ) .settings( libraryDependencies ++= Seq( - "org.scalatest" %%% "scalatest" % V.scalatest % Test, - "org.scala-lang.modules" %%% "scala-xml" % V.scala_xml, - "org.scalameta" %%% "scalameta" % V.scalameta, - "io.7mind.izumi" %%% "fundamentals-bio" % Izumi.version, - "io.circe" %%% "circe-parser" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, - "io.circe" %%% "circe-literal" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version + "org.scalatest" %% "scalatest" % V.scalatest % Test, + "org.scala-lang.modules" %% "scala-xml" % V.scala_xml, + "org.scalameta" %% "scalameta" % V.scalameta, + "io.7mind.izumi" %% "fundamentals-bio" % Izumi.version, + "io.circe" %% "circe-parser" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, + "io.circe" %% "circe-literal" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version ), libraryDependencies ++= { if (scalaVersion.value.startsWith("2.")) Seq( compilerPlugin("org.typelevel" % "kind-projector" % V.kind_projector cross CrossVersion.full), - "io.circe" %%% "circe-generic-extras" % V.circe_generic_extras, - "io.circe" %%% "circe-derivation" % V.circe_derivation + "io.circe" %% "circe-generic-extras" % V.circe_generic_extras, + "io.circe" %% "circe-derivation" % V.circe_derivation ) else Seq.empty }, libraryDependencies ++= { val version = scalaVersion.value if (version.startsWith("0.") || version.startsWith("3.")) { Seq( - "io.circe" %%% "circe-generic" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version + "io.circe" %% "circe-generic" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version ) } else Seq.empty } ) .settings( + crossScalaVersions := Seq( + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, + Test / fork := true, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -760,41 +761,11 @@ lazy val `idealingua-v1-transpilers` = crossProject(JVMPlatform, JSPlatform).cro } }, scalacOptions -= "-Wconf:any:error" ) - .jvmSettings( - crossScalaVersions := Seq( - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, - Test / fork := true - ) - .jsSettings( - crossScalaVersions := Seq( - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, - coverageEnabled := false, - scalaJSLinkerConfig := { scalaJSLinkerConfig.value.withModuleKind(ModuleKind.CommonJSModule) } - ) .enablePlugins(IzumiPlugin) -lazy val `idealingua-v1-transpilersJVM` = `idealingua-v1-transpilers`.jvm - .dependsOn( - `idealingua-v1-test-defs` % "test->compile", - `idealingua-v1-runtime-rpc-typescript` % "test->compile", - `idealingua-v1-runtime-rpc-go` % "test->compile", - `idealingua-v1-runtime-rpc-csharp` % "test->compile" - ) -lazy val `idealingua-v1-transpilersJS` = `idealingua-v1-transpilers`.js - .settings( - libraryDependencies ++= Seq( - "org.typelevel" %% "jawn-parser" % Izumi.Deps.fundamentals_json_circeJVM.org_typelevel_jawn_parser_version - ) - ) lazy val `idealingua-v1-test-defs` = project.in(file("idealingua-v1/idealingua-v1-test-defs")) .dependsOn( - `idealingua-v1-runtime-rpc-scalaJVM` % "test->compile;compile->compile" + `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile" ) .settings( libraryDependencies ++= Seq( @@ -815,6 +786,14 @@ lazy val `idealingua-v1-test-defs` = project.in(file("idealingua-v1/idealingua-v ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -945,6 +924,14 @@ lazy val `idealingua-v1-runtime-rpc-typescript` = project.in(file("idealingua-v1 ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -1075,6 +1062,14 @@ lazy val `idealingua-v1-runtime-rpc-go` = project.in(file("idealingua-v1/idealin ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -1205,6 +1200,14 @@ lazy val `idealingua-v1-runtime-rpc-csharp` = project.in(file("idealingua-v1/ide ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -1320,8 +1323,8 @@ lazy val `idealingua-v1-runtime-rpc-csharp` = project.in(file("idealingua-v1/ide lazy val `idealingua-v1-compiler` = project.in(file("idealingua-v1/idealingua-v1-compiler")) .dependsOn( - `idealingua-v1-transpilersJVM` % "test->compile;compile->compile", - `idealingua-v1-runtime-rpc-scalaJVM` % "test->compile;compile->compile", + `idealingua-v1-transpilers` % "test->compile;compile->compile", + `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile", `idealingua-v1-runtime-rpc-typescript` % "test->compile;compile->compile", `idealingua-v1-runtime-rpc-go` % "test->compile;compile->compile", `idealingua-v1-runtime-rpc-csharp` % "test->compile;compile->compile", @@ -1343,6 +1346,14 @@ lazy val `idealingua-v1-compiler` = project.in(file("idealingua-v1/idealingua-v1 ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", + Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , + Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, + Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , + Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , + Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) + .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, + Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -1463,15 +1474,11 @@ lazy val `idealingua` = (project in file(".agg/idealingua-v1-idealingua")) ) .enablePlugins(IzumiPlugin) .aggregate( - `idealingua-v1-modelJVM`, - `idealingua-v1-modelJS`, - `idealingua-v1-coreJVM`, - `idealingua-v1-coreJS`, - `idealingua-v1-runtime-rpc-scalaJVM`, - `idealingua-v1-runtime-rpc-scalaJS`, + `idealingua-v1-model`, + `idealingua-v1-core`, + `idealingua-v1-runtime-rpc-scala`, `idealingua-v1-runtime-rpc-http4s`, - `idealingua-v1-transpilersJVM`, - `idealingua-v1-transpilersJS`, + `idealingua-v1-transpilers`, `idealingua-v1-test-defs`, `idealingua-v1-runtime-rpc-typescript`, `idealingua-v1-runtime-rpc-go`, @@ -1485,11 +1492,11 @@ lazy val `idealingua-jvm` = (project in file(".agg/idealingua-v1-idealingua-jvm" crossScalaVersions := Nil ) .aggregate( - `idealingua-v1-modelJVM`, - `idealingua-v1-coreJVM`, - `idealingua-v1-runtime-rpc-scalaJVM`, + `idealingua-v1-model`, + `idealingua-v1-core`, + `idealingua-v1-runtime-rpc-scala`, `idealingua-v1-runtime-rpc-http4s`, - `idealingua-v1-transpilersJVM`, + `idealingua-v1-transpilers`, `idealingua-v1-test-defs`, `idealingua-v1-runtime-rpc-typescript`, `idealingua-v1-runtime-rpc-go`, @@ -1497,18 +1504,6 @@ lazy val `idealingua-jvm` = (project in file(".agg/idealingua-v1-idealingua-jvm" `idealingua-v1-compiler` ) -lazy val `idealingua-js` = (project in file(".agg/idealingua-v1-idealingua-js")) - .settings( - publish / skip := true, - crossScalaVersions := Nil - ) - .aggregate( - `idealingua-v1-modelJS`, - `idealingua-v1-coreJS`, - `idealingua-v1-runtime-rpc-scalaJS`, - `idealingua-v1-transpilersJS` - ) - lazy val `idealingua-v1-jvm` = (project in file(".agg/.agg-jvm")) .settings( publish / skip := true, @@ -1518,15 +1513,6 @@ lazy val `idealingua-v1-jvm` = (project in file(".agg/.agg-jvm")) `idealingua-jvm` ) -lazy val `idealingua-v1-js` = (project in file(".agg/.agg-js")) - .settings( - publish / skip := true, - crossScalaVersions := Nil - ) - .aggregate( - `idealingua-js` - ) - lazy val `idealingua-v1` = (project in file(".")) .settings( publish / skip := true, diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala index 3b0b0154..8236e183 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala @@ -40,8 +40,8 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2, AuthCtx]( ) { import dsl.* - private val muxer: IRTServerMultiplexor[F, AuthCtx] = IRTServerMultiplexor.combine(contextServices.map(_.authorizedMuxer)) - private val wsContextsSessions: Set[WsContextSessions[F, AuthCtx, ?]] = contextServices.map(_.authorizedWsSessions) + protected val serverMuxer: IRTServerMultiplexor[F, AuthCtx] = IRTServerMultiplexor.combine(contextServices.map(_.authorizedMuxer)) + protected val wsContextsSessions: Set[WsContextSessions.AnyContext[F, AuthCtx]] = contextServices.map(_.authorizedWsSessions) // WS Response attribute key, to differ from usual HTTP responses private val wsAttributeKey = UnsafeRun2[F].unsafeRun(Key.newKey[F[Throwable, _], WsResponseMarker.type]) @@ -107,7 +107,7 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2, AuthCtx]( } protected def wsHandler(clientSession: WsClientSession[F, AuthCtx]): WsRpcHandler[F, AuthCtx] = { - new ServerWsRpcHandler(clientSession, muxer, wsContextExtractor, logger) + new ServerWsRpcHandler(clientSession, serverMuxer, wsContextExtractor, logger) } protected def handleWsClose(session: WsClientSession[F, AuthCtx]): F[Throwable, Unit] = { @@ -139,7 +139,7 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2, AuthCtx]( (for { authContext <- F.syncThrowable(httpContextExtractor.extract(request)) parsedBody <- F.fromEither(io.circe.parser.parse(body)).leftMap(err => new IRTDecodingException(s"Can not parse JSON body '$body'.", Some(err))) - invokeRes <- muxer.invokeMethod(methodId)(authContext, parsedBody) + invokeRes <- serverMuxer.invokeMethod(methodId)(authContext, parsedBody) } yield invokeRes).sandboxExit.flatMap(handleHttpResult(request, methodId)) } @@ -226,7 +226,8 @@ object HttpServer { wsContextExtractor: WsContextExtractor[AuthCtx], logger: LogIO2[F], ) extends WsRpcHandler[F, AuthCtx](muxer, clientSession, logger) { - override protected def getRequestCtx: AuthCtx = clientSession.getRequestCtx - override protected def updateRequestCtx(packet: RpcPacket): F[Throwable, Unit] = clientSession.updateRequestCtx(wsContextExtractor.extract(packet)) + override protected def updateRequestCtx(packet: RpcPacket): F[Throwable, AuthCtx] = { + clientSession.updateRequestCtx(wsContextExtractor.extract(packet)) + } } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala index c9d2b4f9..6b879d01 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala @@ -187,22 +187,12 @@ object WsRpcDispatcherFactory { wsContextExtractor: WsContextExtractor[RequestCtx], logger: LogIO2[F], ) extends WsRpcHandler[F, RequestCtx](muxer, requestState, logger) { - private val requestCtxRef: AtomicReference[Option[RequestCtx]] = new AtomicReference(None) - override protected def updateRequestCtx(packet: RpcPacket): F[Throwable, Unit] = F.sync { + private val requestCtxRef: AtomicReference[RequestCtx] = new AtomicReference() + override protected def updateRequestCtx(packet: RpcPacket): F[Throwable, RequestCtx] = F.sync { val updated = wsContextExtractor.extract(packet) requestCtxRef.updateAndGet { - case None => Some(updated) - case Some(previous) => Some(wsContextExtractor.merge(previous, updated)) - } - () - } - override protected def getRequestCtx: RequestCtx = { - requestCtxRef.get().getOrElse { - throw new IRTUnathorizedRequestContextException( - """Impossible - missing WS request context. - |Request context should be set with `updateRequestCtx`, before this method is called. (Ref: WsRpcHandler.scala:25) - |Please, report as a bug.""".stripMargin - ) + case null => updated + case previous => wsContextExtractor.merge(previous, updated) } } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/WsIdExtractor.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/WsIdExtractor.scala index 30c3aaa3..a2634b40 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/WsIdExtractor.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/WsIdExtractor.scala @@ -1,9 +1,9 @@ package izumi.idealingua.runtime.rpc.http4s.context trait WsIdExtractor[RequestCtx, WsCtx] { - def extract(ctx: RequestCtx): Option[WsCtx] + def extract(ctx: RequestCtx, previous: Option[WsCtx]): Option[WsCtx] } object WsIdExtractor { - def id[C]: WsIdExtractor[C, C] = c => Some(c) + def id[C]: WsIdExtractor[C, C] = (c, _) => Some(c) } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala index 613a024c..c67721b1 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala @@ -20,11 +20,10 @@ import scala.concurrent.duration.* trait WsClientSession[F[+_, +_], RequestCtx] extends WsResponder[F] { def sessionId: WsSessionId - def getRequestCtx: RequestCtx def requestAndAwaitResponse(method: IRTMethodId, data: Json, timeout: FiniteDuration): F[Throwable, Option[RawResponse]] - def updateRequestCtx(newContext: RequestCtx): F[Throwable, Unit] + def updateRequestCtx(newContext: RequestCtx): F[Throwable, RequestCtx] def start(onStart: RequestCtx => F[Throwable, Unit]): F[Throwable, Unit] def finish(onFinish: RequestCtx => F[Throwable, Unit]): F[Throwable, Unit] @@ -35,7 +34,7 @@ object WsClientSession { class WsClientSessionImpl[F[+_, +_]: IO2: Temporal2: Primitives2, RequestCtx]( outQueue: Queue[F[Throwable, _], WebSocketFrame], initialContext: RequestCtx, - wsSessionsContext: Set[WsContextSessions[F, RequestCtx, ?]], + wsSessionsContext: Set[WsContextSessions.AnyContext[F, RequestCtx]], wsSessionStorage: WsSessionsStorage[F, RequestCtx], wsContextExtractor: WsContextExtractor[RequestCtx], logger: LogIO2[F], @@ -47,9 +46,7 @@ object WsClientSession { override val sessionId: WsSessionId = WsSessionId(UUIDGen.getTimeUUID()) - override def getRequestCtx: RequestCtx = requestCtxRef.get() - - override def updateRequestCtx(newContext: RequestCtx): F[Throwable, Unit] = { + override def updateRequestCtx(newContext: RequestCtx): F[Throwable, RequestCtx] = { for { contexts <- F.sync { requestCtxRef.synchronized { @@ -65,7 +62,7 @@ object WsClientSession { _ <- F.when(oldContext != updatedContext) { F.traverse_(wsSessionsContext)(_.updateSession(sessionId, Some(updatedContext))) } - } yield () + } yield updatedContext } def requestAndAwaitResponse(method: IRTMethodId, data: Json, timeout: FiniteDuration): F[Throwable, Option[RawResponse]] = { @@ -89,17 +86,19 @@ object WsClientSession { } override def finish(onFinish: RequestCtx => F[Throwable, Unit]): F[Throwable, Unit] = { + val requestCtx = requestCtxRef.get() F.fromEither(WebSocketFrame.Close(1000)).flatMap(outQueue.offer(_)) *> requestState.clear() *> wsSessionStorage.deleteSession(sessionId) *> F.traverse_(wsSessionsContext)(_.updateSession(sessionId, None)) *> - onFinish(getRequestCtx) + onFinish(requestCtx) } override def start(onStart: RequestCtx => F[Throwable, Unit]): F[Throwable, Unit] = { + val requestCtx = requestCtxRef.get() wsSessionStorage.addSession(this) *> - F.traverse_(wsSessionsContext)(_.updateSession(sessionId, Some(getRequestCtx))) *> - onStart(getRequestCtx) + F.traverse_(wsSessionsContext)(_.updateSession(sessionId, Some(requestCtx))) *> + onStart(requestCtx) } override def toString: String = s"[$sessionId, ${duration().toSeconds}s]" diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala index 6daa0e51..b0a16f4d 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala @@ -11,8 +11,9 @@ trait WsContextSessions[F[+_, +_], RequestCtx, WsCtx] { self => def updateSession(wsSessionId: WsSessionId, requestContext: Option[RequestCtx]): F[Throwable, Unit] def dispatcherFor(ctx: WsCtx, codec: IRTClientMultiplexor[F], timeout: FiniteDuration = 20.seconds): F[Throwable, Option[IRTDispatcher[F]]] - + def getSession(ctx: WsCtx): F[Throwable, Option[WsClientSession[F, ?]]] final def contramap[C](updateCtx: C => F[Throwable, Option[RequestCtx]])(implicit M: Monad2[F]): WsContextSessions[F, C, WsCtx] = new WsContextSessions[F, C, WsCtx] { + override def getSession(ctx: WsCtx): F[Throwable, Option[WsClientSession[F, ?]]] = self.getSession(ctx) override def updateSession(wsSessionId: WsSessionId, requestContext: Option[C]): F[Throwable, Unit] = { F.traverse(requestContext)(updateCtx).flatMap(mbCtx => self.updateSession(wsSessionId, mbCtx.flatten)) } @@ -23,34 +24,45 @@ trait WsContextSessions[F[+_, +_], RequestCtx, WsCtx] { } object WsContextSessions { + type AnyContext[F[+_, +_], RequestCtx] = WsContextSessions[F, RequestCtx, ?] + def empty[F[+_, +_]: IO2, RequestCtx]: WsContextSessions[F, RequestCtx, Unit] = new WsContextSessions[F, RequestCtx, Unit] { override def updateSession(wsSessionId: WsSessionId, requestContext: Option[RequestCtx]): F[Throwable, Unit] = F.unit override def dispatcherFor(ctx: Unit, codec: IRTClientMultiplexor[F], timeout: FiniteDuration): F[Throwable, Option[IRTDispatcher[F]]] = F.pure(None) + override def getSession(ctx: Unit): F[Throwable, Option[WsClientSession[F, ?]]] = F.pure(None) } class WsContextSessionsImpl[F[+_, +_]: IO2, RequestCtx, WsCtx]( wsSessionsStorage: WsSessionsStorage[F, ?], - globalWsListeners: Set[WsSessionListener[F, Any, Any]], + globalWsListeners: Set[WsSessionListener.Global[F]], wsSessionListeners: Set[WsSessionListener[F, RequestCtx, WsCtx]], wsIdExtractor: WsIdExtractor[RequestCtx, WsCtx], ) extends WsContextSessions[F, RequestCtx, WsCtx] { - private[this] val allListeners = globalWsListeners ++ wsSessionListeners - private[this] val sessionToId = new ConcurrentHashMap[WsSessionId, WsCtx]() - private[this] val idToSession = new ConcurrentHashMap[WsCtx, WsSessionId]() + private[this] val sessionToId = new ConcurrentHashMap[WsSessionId, WsCtx]() + private[this] val idToSession = new ConcurrentHashMap[WsCtx, WsSessionId]() override def updateSession(wsSessionId: WsSessionId, requestContext: Option[RequestCtx]): F[Throwable, Unit] = { updateCtx(wsSessionId, requestContext).flatMap { case (Some(ctx), Some(previous), Some(updated)) if previous != updated => - F.traverse_(allListeners)(_.onSessionUpdated(wsSessionId, ctx, previous, updated)) + F.traverse_(wsSessionListeners)(_.onSessionUpdated(wsSessionId, ctx, previous, updated)) *> + F.traverse_(globalWsListeners)(_.onSessionUpdated(wsSessionId, ctx, previous, updated)) case (Some(ctx), None, Some(updated)) => - F.traverse_(allListeners)(_.onSessionOpened(wsSessionId, ctx, updated)) + F.traverse_(wsSessionListeners)(_.onSessionOpened(wsSessionId, ctx, updated)) *> + F.traverse_(globalWsListeners)(_.onSessionOpened(wsSessionId, ctx, updated)) case (_, Some(prev), None) => - F.traverse_(allListeners)(_.onSessionClosed(wsSessionId, prev)) + F.traverse_(wsSessionListeners)(_.onSessionClosed(wsSessionId, prev)) *> + F.traverse_(globalWsListeners)(_.onSessionClosed(wsSessionId, prev)) case _ => F.unit } } + override def getSession(ctx: WsCtx): F[Throwable, Option[WsClientSession[F, ?]]] = { + F.sync(synchronized(Option(idToSession.get(ctx)))) + .flatMap(F.traverse(_)(wsSessionsStorage.getSession)) + .map(_.flatten) + } + override def dispatcherFor(ctx: WsCtx, codec: IRTClientMultiplexor[F], timeout: FiniteDuration): F[Throwable, Option[IRTDispatcher[F]]] = { F.sync(synchronized(Option(idToSession.get(ctx)))).flatMap { F.traverse(_) { @@ -59,13 +71,13 @@ object WsContextSessions { }.map(_.flatten) } - private def updateCtx( + @inline private final def updateCtx( wsSessionId: WsSessionId, requestContext: Option[RequestCtx], ): F[Nothing, (Option[RequestCtx], Option[WsCtx], Option[WsCtx])] = F.sync { synchronized { val previous = Option(sessionToId.get(wsSessionId)) - val updated = requestContext.flatMap(wsIdExtractor.extract) + val updated = requestContext.flatMap(wsIdExtractor.extract(_, previous)) (updated, previous) match { case (Some(upd), _) => previous.map(idToSession.remove) diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala index b9593cd5..d90e2706 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala @@ -14,26 +14,25 @@ abstract class WsRpcHandler[F[+_, +_]: IO2, RequestCtx]( logger: LogIO2[F], ) { - protected def updateRequestCtx(packet: RpcPacket): F[Throwable, Unit] - protected def getRequestCtx: RequestCtx + protected def updateRequestCtx(packet: RpcPacket): F[Throwable, RequestCtx] def processRpcMessage(message: String): F[Throwable, Option[RpcPacket]] = { for { packet <- F .fromEither(io.circe.parser.decode[RpcPacket](message)) .leftMap(err => new IRTDecodingException(s"Can not decode Rpc Packet '$message'.\nError: $err.")) - _ <- updateRequestCtx(packet) + requestCtx <- updateRequestCtx(packet) response <- packet match { // auth case RpcPacket(RPCPacketKind.RpcRequest, None, _, _, _, _, _) => - handleAuthRequest(packet) + handleAuthRequest(requestCtx, packet) case RpcPacket(RPCPacketKind.RpcResponse, None, _, Some(ref), _, _, _) => handleAuthResponse(ref, packet) // rpc case RpcPacket(RPCPacketKind.RpcRequest, Some(data), Some(id), _, Some(service), Some(method), _) => - handleWsRequest(data, IRTMethodId(IRTServiceId(service), IRTMethodName(method)))( + handleWsRequest(IRTMethodId(IRTServiceId(service), IRTMethodName(method)), requestCtx, data)( onSuccess = RpcPacket.rpcResponse(id, _), onFail = RpcPacket.rpcFail(Some(id), _), ) @@ -46,7 +45,7 @@ abstract class WsRpcHandler[F[+_, +_]: IO2, RequestCtx]( // buzzer case RpcPacket(RPCPacketKind.BuzzRequest, Some(data), Some(id), _, Some(service), Some(method), _) => - handleWsRequest(data, IRTMethodId(IRTServiceId(service), IRTMethodName(method)))( + handleWsRequest(IRTMethodId(IRTServiceId(service), IRTMethodName(method)), requestCtx, data)( onSuccess = RpcPacket.buzzerResponse(id, _), onFail = RpcPacket.buzzerFail(Some(id), _), ) @@ -74,12 +73,13 @@ abstract class WsRpcHandler[F[+_, +_]: IO2, RequestCtx]( } protected def handleWsRequest( - data: Json, methodId: IRTMethodId, + requestCtx: RequestCtx, + data: Json, )(onSuccess: Json => RpcPacket, onFail: String => RpcPacket, ): F[Throwable, Option[RpcPacket]] = { - muxer.invokeMethod(methodId)(getRequestCtx, data).sandboxExit.flatMap { + muxer.invokeMethod(methodId)(requestCtx, data).sandboxExit.flatMap { case Success(res) => F.pure(Some(onSuccess(res))) @@ -115,7 +115,8 @@ abstract class WsRpcHandler[F[+_, +_]: IO2, RequestCtx]( } } - protected def handleAuthRequest(packet: RpcPacket): F[Throwable, Option[RpcPacket]] = { + protected def handleAuthRequest(requestCtx: RequestCtx, packet: RpcPacket): F[Throwable, Option[RpcPacket]] = { + requestCtx.discard() F.pure(Some(RpcPacket(RPCPacketKind.RpcResponse, None, None, packet.id, None, None, None))) } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionListener.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionListener.scala index 5a66d144..ae227e68 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionListener.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionListener.scala @@ -2,13 +2,15 @@ package izumi.idealingua.runtime.rpc.http4s.ws import izumi.functional.bio.{Applicative2, F} -trait WsSessionListener[F[_, _], RequestCtx, WsCtx] { +trait WsSessionListener[F[_, _], -RequestCtx, -WsCtx] { def onSessionOpened(sessionId: WsSessionId, reqCtx: RequestCtx, wsCtx: WsCtx): F[Throwable, Unit] def onSessionUpdated(sessionId: WsSessionId, reqCtx: RequestCtx, prevStx: WsCtx, newCtx: WsCtx): F[Throwable, Unit] def onSessionClosed(sessionId: WsSessionId, wsCtx: WsCtx): F[Throwable, Unit] } object WsSessionListener { + type Global[F[_, _]] = WsSessionListener[F, Any, Any] + def empty[F[+_, +_]: Applicative2, RequestCtx, WsCtx]: WsSessionListener[F, RequestCtx, WsCtx] = new WsSessionListener[F, RequestCtx, WsCtx] { override def onSessionOpened(sessionId: WsSessionId, reqCtx: RequestCtx, wsCtx: WsCtx): F[Throwable, Unit] = F.unit override def onSessionUpdated(sessionId: WsSessionId, reqCtx: RequestCtx, prevStx: WsCtx, newCtx: WsCtx): F[Throwable, Unit] = F.unit diff --git a/project/plugins.sbt b/project/plugins.sbt index 578656af..a0d6b66c 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,17 +1,6 @@ // DO NOT EDIT THIS FILE // IT IS AUTOGENERATED BY `sbtgen.sc` SCRIPT // ALL CHANGES WILL BE LOST -// https://www.scala-js.org/ -addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.13.0") - -// https://github.com/portable-scala/sbt-crossproject -addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.1") - -// https://scalacenter.github.io/scalajs-bundler/ -addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % "0.21.1") - -// https://github.com/scala-js/jsdependencies -addSbtPlugin("org.scala-js" % "sbt-jsdependencies" % "1.0.2") //////////////////////////////////////////////////////////////////////////////// From 8389590377fdb77e1dcfea706d5289d1d37cf0aa Mon Sep 17 00:00:00 2001 From: Alex Liubymov Date: Wed, 20 Dec 2023 18:00:05 +0200 Subject: [PATCH 16/24] Support multiple sessions on a single context. Fix cross builds. --- build.sbt | 320 +++++++++--------- .../rpc/http4s/context/WsIdExtractor.scala | 3 +- .../rpc/http4s/ws/WsClientSession.scala | 28 +- .../rpc/http4s/ws/WsContextSessions.scala | 87 ++--- .../rpc/http4s/ws/WsContextStorage.scala | 106 ++++++ .../runtime/rpc/http4s/ws/WsRpcHandler.scala | 3 + .../rpc/http4s/ws/WsSessionsStorage.scala | 24 +- .../rpc/http4s/Http4sTransportTest.scala | 106 +++--- .../http4s/fixtures/LoggingWsListener.scala | 13 +- .../rpc/http4s/fixtures/TestServices.scala | 26 +- project/plugins.sbt | 11 + 11 files changed, 423 insertions(+), 304 deletions(-) create mode 100644 idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextStorage.scala diff --git a/build.sbt b/build.sbt index f1aa5ef0..b8b2a667 100644 --- a/build.sbt +++ b/build.sbt @@ -3,6 +3,8 @@ // ALL CHANGES WILL BE LOST +import sbtcrossproject.CrossPlugin.autoImport.{crossProject, CrossType} + enablePlugins(SbtgenVerificationPlugin) @@ -11,13 +13,13 @@ ThisBuild / libraryDependencySchemes += "io.circe" %% "circe-core" % VersionSche ThisBuild / libraryDependencySchemes += "io.circe" %% "circe-core_sjs1" % VersionScheme.Always -lazy val `idealingua-v1-model` = project.in(file("idealingua-v1/idealingua-v1-model")) +lazy val `idealingua-v1-model` = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).in(file("idealingua-v1/idealingua-v1-model")) .settings( libraryDependencies ++= Seq( - "org.scalatest" %% "scalatest" % V.scalatest % Test, - "io.7mind.izumi" %% "fundamentals-collections" % Izumi.version, - "io.7mind.izumi" %% "fundamentals-platform" % Izumi.version, - "io.7mind.izumi" %% "fundamentals-functional" % Izumi.version + "org.scalatest" %%% "scalatest" % V.scalatest % Test, + "io.7mind.izumi" %%% "fundamentals-collections" % Izumi.version, + "io.7mind.izumi" %%% "fundamentals-platform" % Izumi.version, + "io.7mind.izumi" %%% "fundamentals-functional" % Izumi.version ), libraryDependencies ++= { if (scalaVersion.value.startsWith("2.")) Seq( compilerPlugin("org.typelevel" % "kind-projector" % V.kind_projector cross CrossVersion.full), @@ -25,21 +27,7 @@ lazy val `idealingua-v1-model` = project.in(file("idealingua-v1/idealingua-v1-mo ) else Seq.empty } ) .settings( - crossScalaVersions := Seq( - "3.3.1", - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -151,37 +139,44 @@ lazy val `idealingua-v1-model` = project.in(file("idealingua-v1/idealingua-v1-mo } }, scalacOptions -= "-Wconf:any:error" ) + .jvmSettings( + crossScalaVersions := Seq( + "3.3.1", + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head + ) + .jsSettings( + crossScalaVersions := Seq( + "3.3.1", + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, + coverageEnabled := false, + scalaJSLinkerConfig := { scalaJSLinkerConfig.value.withModuleKind(ModuleKind.CommonJSModule) } + ) .enablePlugins(IzumiPlugin) +lazy val `idealingua-v1-modelJVM` = `idealingua-v1-model`.jvm +lazy val `idealingua-v1-modelJS` = `idealingua-v1-model`.js -lazy val `idealingua-v1-core` = project.in(file("idealingua-v1/idealingua-v1-core")) +lazy val `idealingua-v1-core` = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).in(file("idealingua-v1/idealingua-v1-core")) .dependsOn( `idealingua-v1-model` % "test->compile;compile->compile" ) .settings( libraryDependencies ++= Seq( - "org.scalatest" %% "scalatest" % V.scalatest % Test, - "com.lihaoyi" %% "fastparse" % V.fastparse, - "io.7mind.izumi" %% "fundamentals-reflection" % Izumi.version + "org.scalatest" %%% "scalatest" % V.scalatest % Test, + "com.lihaoyi" %%% "fastparse" % V.fastparse, + "io.7mind.izumi" %%% "fundamentals-reflection" % Izumi.version ), libraryDependencies ++= { if (scalaVersion.value.startsWith("2.")) Seq( compilerPlugin("org.typelevel" % "kind-projector" % V.kind_projector cross CrossVersion.full) ) else Seq.empty } ) .settings( - crossScalaVersions := Seq( - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -293,53 +288,57 @@ lazy val `idealingua-v1-core` = project.in(file("idealingua-v1/idealingua-v1-cor } }, scalacOptions -= "-Wconf:any:error" ) + .jvmSettings( + crossScalaVersions := Seq( + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head + ) + .jsSettings( + crossScalaVersions := Seq( + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, + coverageEnabled := false, + scalaJSLinkerConfig := { scalaJSLinkerConfig.value.withModuleKind(ModuleKind.CommonJSModule) } + ) .enablePlugins(IzumiPlugin) +lazy val `idealingua-v1-coreJVM` = `idealingua-v1-core`.jvm +lazy val `idealingua-v1-coreJS` = `idealingua-v1-core`.js -lazy val `idealingua-v1-runtime-rpc-scala` = project.in(file("idealingua-v1/idealingua-v1-runtime-rpc-scala")) +lazy val `idealingua-v1-runtime-rpc-scala` = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).in(file("idealingua-v1/idealingua-v1-runtime-rpc-scala")) .settings( libraryDependencies ++= Seq( - "org.scalatest" %% "scalatest" % V.scalatest % Test, - "io.7mind.izumi" %% "fundamentals-bio" % Izumi.version, - "io.7mind.izumi" %% "fundamentals-platform" % Izumi.version, - "org.typelevel" %% "cats-core" % Izumi.Deps.fundamentals_bioJVM.org_typelevel_cats_core_version, - "org.typelevel" %% "cats-effect" % Izumi.Deps.fundamentals_bioJVM.org_typelevel_cats_effect_version, - "io.circe" %% "circe-parser" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, - "io.circe" %% "circe-literal" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, - "dev.zio" %% "zio" % Izumi.Deps.fundamentals_bioJVM.dev_zio_zio_version % Test, - "dev.zio" %% "zio-interop-cats" % Izumi.Deps.fundamentals_bioJVM.dev_zio_zio_interop_cats_version % Test, - "dev.zio" %% "izumi-reflect" % Izumi.Deps.fundamentals_bioJVM.dev_zio_izumi_reflect_version % Test + "org.scalatest" %%% "scalatest" % V.scalatest % Test, + "io.7mind.izumi" %%% "fundamentals-bio" % Izumi.version, + "io.7mind.izumi" %%% "fundamentals-platform" % Izumi.version, + "org.typelevel" %%% "cats-core" % Izumi.Deps.fundamentals_bioJVM.org_typelevel_cats_core_version, + "org.typelevel" %%% "cats-effect" % Izumi.Deps.fundamentals_bioJVM.org_typelevel_cats_effect_version, + "io.circe" %%% "circe-parser" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, + "io.circe" %%% "circe-literal" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, + "dev.zio" %%% "zio" % Izumi.Deps.fundamentals_bioJVM.dev_zio_zio_version % Test, + "dev.zio" %%% "zio-interop-cats" % Izumi.Deps.fundamentals_bioJVM.dev_zio_zio_interop_cats_version % Test, + "dev.zio" %%% "izumi-reflect" % Izumi.Deps.fundamentals_bioJVM.dev_zio_izumi_reflect_version % Test ), libraryDependencies ++= { if (scalaVersion.value.startsWith("2.")) Seq( compilerPlugin("org.typelevel" % "kind-projector" % V.kind_projector cross CrossVersion.full), "org.scala-lang" % "scala-reflect" % scalaVersion.value % Provided, - "io.circe" %% "circe-generic-extras" % V.circe_generic_extras, - "io.circe" %% "circe-derivation" % V.circe_derivation + "io.circe" %%% "circe-generic-extras" % V.circe_generic_extras, + "io.circe" %%% "circe-derivation" % V.circe_derivation ) else Seq.empty }, libraryDependencies ++= { val version = scalaVersion.value if (version.startsWith("0.") || version.startsWith("3.")) { Seq( - "io.circe" %% "circe-generic" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version + "io.circe" %%% "circe-generic" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version ) } else Seq.empty } ) .settings( - crossScalaVersions := Seq( - "3.3.1", - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -451,11 +450,37 @@ lazy val `idealingua-v1-runtime-rpc-scala` = project.in(file("idealingua-v1/idea } }, scalacOptions -= "-Wconf:any:error" ) + .jvmSettings( + crossScalaVersions := Seq( + "3.3.1", + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head + ) + .jsSettings( + crossScalaVersions := Seq( + "3.3.1", + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, + coverageEnabled := false, + scalaJSLinkerConfig := { scalaJSLinkerConfig.value.withModuleKind(ModuleKind.CommonJSModule) } + ) .enablePlugins(IzumiPlugin) +lazy val `idealingua-v1-runtime-rpc-scalaJVM` = `idealingua-v1-runtime-rpc-scala`.jvm +lazy val `idealingua-v1-runtime-rpc-scalaJS` = `idealingua-v1-runtime-rpc-scala`.js + .settings( + libraryDependencies ++= Seq( + "org.typelevel" %% "jawn-parser" % Izumi.Deps.fundamentals_json_circeJVM.org_typelevel_jawn_parser_version, + "io.github.cquiroz" %%% "scala-java-time" % V.scala_java_time % Test + ) + ) lazy val `idealingua-v1-runtime-rpc-http4s` = project.in(file("idealingua-v1/idealingua-v1-runtime-rpc-http4s")) .dependsOn( - `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile", + `idealingua-v1-runtime-rpc-scalaJVM` % "test->compile;compile->compile", `idealingua-v1-test-defs` % "test->compile" ) .settings( @@ -481,14 +506,6 @@ lazy val `idealingua-v1-runtime-rpc-http4s` = project.in(file("idealingua-v1/ide ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -602,54 +619,36 @@ lazy val `idealingua-v1-runtime-rpc-http4s` = project.in(file("idealingua-v1/ide ) .enablePlugins(IzumiPlugin) -lazy val `idealingua-v1-transpilers` = project.in(file("idealingua-v1/idealingua-v1-transpilers")) +lazy val `idealingua-v1-transpilers` = crossProject(JVMPlatform, JSPlatform).crossType(CrossType.Pure).in(file("idealingua-v1/idealingua-v1-transpilers")) .dependsOn( `idealingua-v1-core` % "test->compile;compile->compile", - `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile", - `idealingua-v1-test-defs` % "test->compile", - `idealingua-v1-runtime-rpc-typescript` % "test->compile", - `idealingua-v1-runtime-rpc-go` % "test->compile", - `idealingua-v1-runtime-rpc-csharp` % "test->compile" + `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile" ) .settings( libraryDependencies ++= Seq( - "org.scalatest" %% "scalatest" % V.scalatest % Test, - "org.scala-lang.modules" %% "scala-xml" % V.scala_xml, - "org.scalameta" %% "scalameta" % V.scalameta, - "io.7mind.izumi" %% "fundamentals-bio" % Izumi.version, - "io.circe" %% "circe-parser" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, - "io.circe" %% "circe-literal" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version + "org.scalatest" %%% "scalatest" % V.scalatest % Test, + "org.scala-lang.modules" %%% "scala-xml" % V.scala_xml, + "org.scalameta" %%% "scalameta" % V.scalameta, + "io.7mind.izumi" %%% "fundamentals-bio" % Izumi.version, + "io.circe" %%% "circe-parser" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version, + "io.circe" %%% "circe-literal" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version ), libraryDependencies ++= { if (scalaVersion.value.startsWith("2.")) Seq( compilerPlugin("org.typelevel" % "kind-projector" % V.kind_projector cross CrossVersion.full), - "io.circe" %% "circe-generic-extras" % V.circe_generic_extras, - "io.circe" %% "circe-derivation" % V.circe_derivation + "io.circe" %%% "circe-generic-extras" % V.circe_generic_extras, + "io.circe" %%% "circe-derivation" % V.circe_derivation ) else Seq.empty }, libraryDependencies ++= { val version = scalaVersion.value if (version.startsWith("0.") || version.startsWith("3.")) { Seq( - "io.circe" %% "circe-generic" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version + "io.circe" %%% "circe-generic" % Izumi.Deps.fundamentals_json_circeJVM.io_circe_circe_core_version ) } else Seq.empty } ) .settings( - crossScalaVersions := Seq( - "2.13.12", - "2.12.18" - ), - scalaVersion := crossScalaVersions.value.head, - Test / fork := true, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -761,11 +760,41 @@ lazy val `idealingua-v1-transpilers` = project.in(file("idealingua-v1/idealingua } }, scalacOptions -= "-Wconf:any:error" ) + .jvmSettings( + crossScalaVersions := Seq( + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, + Test / fork := true + ) + .jsSettings( + crossScalaVersions := Seq( + "2.13.12", + "2.12.18" + ), + scalaVersion := crossScalaVersions.value.head, + coverageEnabled := false, + scalaJSLinkerConfig := { scalaJSLinkerConfig.value.withModuleKind(ModuleKind.CommonJSModule) } + ) .enablePlugins(IzumiPlugin) +lazy val `idealingua-v1-transpilersJVM` = `idealingua-v1-transpilers`.jvm + .dependsOn( + `idealingua-v1-test-defs` % "test->compile", + `idealingua-v1-runtime-rpc-typescript` % "test->compile", + `idealingua-v1-runtime-rpc-go` % "test->compile", + `idealingua-v1-runtime-rpc-csharp` % "test->compile" + ) +lazy val `idealingua-v1-transpilersJS` = `idealingua-v1-transpilers`.js + .settings( + libraryDependencies ++= Seq( + "org.typelevel" %% "jawn-parser" % Izumi.Deps.fundamentals_json_circeJVM.org_typelevel_jawn_parser_version + ) + ) lazy val `idealingua-v1-test-defs` = project.in(file("idealingua-v1/idealingua-v1-test-defs")) .dependsOn( - `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile" + `idealingua-v1-runtime-rpc-scalaJVM` % "test->compile;compile->compile" ) .settings( libraryDependencies ++= Seq( @@ -786,14 +815,6 @@ lazy val `idealingua-v1-test-defs` = project.in(file("idealingua-v1/idealingua-v ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -924,14 +945,6 @@ lazy val `idealingua-v1-runtime-rpc-typescript` = project.in(file("idealingua-v1 ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -1062,14 +1075,6 @@ lazy val `idealingua-v1-runtime-rpc-go` = project.in(file("idealingua-v1/idealin ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -1200,14 +1205,6 @@ lazy val `idealingua-v1-runtime-rpc-csharp` = project.in(file("idealingua-v1/ide ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -1323,8 +1320,8 @@ lazy val `idealingua-v1-runtime-rpc-csharp` = project.in(file("idealingua-v1/ide lazy val `idealingua-v1-compiler` = project.in(file("idealingua-v1/idealingua-v1-compiler")) .dependsOn( - `idealingua-v1-transpilers` % "test->compile;compile->compile", - `idealingua-v1-runtime-rpc-scala` % "test->compile;compile->compile", + `idealingua-v1-transpilersJVM` % "test->compile;compile->compile", + `idealingua-v1-runtime-rpc-scalaJVM` % "test->compile;compile->compile", `idealingua-v1-runtime-rpc-typescript` % "test->compile;compile->compile", `idealingua-v1-runtime-rpc-go` % "test->compile;compile->compile", `idealingua-v1-runtime-rpc-csharp` % "test->compile;compile->compile", @@ -1346,14 +1343,6 @@ lazy val `idealingua-v1-compiler` = project.in(file("idealingua-v1/idealingua-v1 ), scalaVersion := crossScalaVersions.value.head, organization := "io.7mind.izumi", - Compile / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/main/scala" , - Compile / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/main/scala-$v").distinct, - Compile / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/main/resources" , - Test / unmanagedSourceDirectories += baseDirectory.value / ".jvm/src/test/scala" , - Test / unmanagedSourceDirectories ++= (scalaBinaryVersion.value :: CrossVersion.partialVersion(scalaVersion.value).toList.map(_._1)) - .map(v => baseDirectory.value / s".jvm/src/test/scala-$v").distinct, - Test / unmanagedResourceDirectories += baseDirectory.value / ".jvm/src/test/resources" , scalacOptions ++= Seq( s"-Xmacro-settings:product-name=${name.value}", s"-Xmacro-settings:product-version=${version.value}", @@ -1474,11 +1463,15 @@ lazy val `idealingua` = (project in file(".agg/idealingua-v1-idealingua")) ) .enablePlugins(IzumiPlugin) .aggregate( - `idealingua-v1-model`, - `idealingua-v1-core`, - `idealingua-v1-runtime-rpc-scala`, + `idealingua-v1-modelJVM`, + `idealingua-v1-modelJS`, + `idealingua-v1-coreJVM`, + `idealingua-v1-coreJS`, + `idealingua-v1-runtime-rpc-scalaJVM`, + `idealingua-v1-runtime-rpc-scalaJS`, `idealingua-v1-runtime-rpc-http4s`, - `idealingua-v1-transpilers`, + `idealingua-v1-transpilersJVM`, + `idealingua-v1-transpilersJS`, `idealingua-v1-test-defs`, `idealingua-v1-runtime-rpc-typescript`, `idealingua-v1-runtime-rpc-go`, @@ -1492,11 +1485,11 @@ lazy val `idealingua-jvm` = (project in file(".agg/idealingua-v1-idealingua-jvm" crossScalaVersions := Nil ) .aggregate( - `idealingua-v1-model`, - `idealingua-v1-core`, - `idealingua-v1-runtime-rpc-scala`, + `idealingua-v1-modelJVM`, + `idealingua-v1-coreJVM`, + `idealingua-v1-runtime-rpc-scalaJVM`, `idealingua-v1-runtime-rpc-http4s`, - `idealingua-v1-transpilers`, + `idealingua-v1-transpilersJVM`, `idealingua-v1-test-defs`, `idealingua-v1-runtime-rpc-typescript`, `idealingua-v1-runtime-rpc-go`, @@ -1504,6 +1497,18 @@ lazy val `idealingua-jvm` = (project in file(".agg/idealingua-v1-idealingua-jvm" `idealingua-v1-compiler` ) +lazy val `idealingua-js` = (project in file(".agg/idealingua-v1-idealingua-js")) + .settings( + publish / skip := true, + crossScalaVersions := Nil + ) + .aggregate( + `idealingua-v1-modelJS`, + `idealingua-v1-coreJS`, + `idealingua-v1-runtime-rpc-scalaJS`, + `idealingua-v1-transpilersJS` + ) + lazy val `idealingua-v1-jvm` = (project in file(".agg/.agg-jvm")) .settings( publish / skip := true, @@ -1513,6 +1518,15 @@ lazy val `idealingua-v1-jvm` = (project in file(".agg/.agg-jvm")) `idealingua-jvm` ) +lazy val `idealingua-v1-js` = (project in file(".agg/.agg-js")) + .settings( + publish / skip := true, + crossScalaVersions := Nil + ) + .aggregate( + `idealingua-js` + ) + lazy val `idealingua-v1` = (project in file(".")) .settings( publish / skip := true, diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/WsIdExtractor.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/WsIdExtractor.scala index a2634b40..3245a349 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/WsIdExtractor.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/WsIdExtractor.scala @@ -5,5 +5,6 @@ trait WsIdExtractor[RequestCtx, WsCtx] { } object WsIdExtractor { - def id[C]: WsIdExtractor[C, C] = (c, _) => Some(c) + def id[C]: WsIdExtractor[C, C] = (c, _) => Some(c) + def widen[C, C0 >: C]: WsIdExtractor[C, C0] = (c, _) => Some(c) } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala index c67721b1..93aaf302 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala @@ -18,35 +18,35 @@ import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicReference import scala.concurrent.duration.* -trait WsClientSession[F[+_, +_], RequestCtx] extends WsResponder[F] { +trait WsClientSession[F[+_, +_], SessionCtx] extends WsResponder[F] { def sessionId: WsSessionId def requestAndAwaitResponse(method: IRTMethodId, data: Json, timeout: FiniteDuration): F[Throwable, Option[RawResponse]] - def updateRequestCtx(newContext: RequestCtx): F[Throwable, RequestCtx] + def updateRequestCtx(newContext: SessionCtx): F[Throwable, SessionCtx] - def start(onStart: RequestCtx => F[Throwable, Unit]): F[Throwable, Unit] - def finish(onFinish: RequestCtx => F[Throwable, Unit]): F[Throwable, Unit] + def start(onStart: SessionCtx => F[Throwable, Unit]): F[Throwable, Unit] + def finish(onFinish: SessionCtx => F[Throwable, Unit]): F[Throwable, Unit] } object WsClientSession { - class WsClientSessionImpl[F[+_, +_]: IO2: Temporal2: Primitives2, RequestCtx]( + class WsClientSessionImpl[F[+_, +_]: IO2: Temporal2: Primitives2, SessionCtx]( outQueue: Queue[F[Throwable, _], WebSocketFrame], - initialContext: RequestCtx, - wsSessionsContext: Set[WsContextSessions.AnyContext[F, RequestCtx]], - wsSessionStorage: WsSessionsStorage[F, RequestCtx], - wsContextExtractor: WsContextExtractor[RequestCtx], + initialContext: SessionCtx, + wsSessionsContext: Set[WsContextSessions.AnyContext[F, SessionCtx]], + wsSessionStorage: WsSessionsStorage[F, SessionCtx], + wsContextExtractor: WsContextExtractor[SessionCtx], logger: LogIO2[F], printer: Printer, - ) extends WsClientSession[F, RequestCtx] { - private val requestCtxRef = new AtomicReference[RequestCtx](initialContext) + ) extends WsClientSession[F, SessionCtx] { + private val requestCtxRef = new AtomicReference[SessionCtx](initialContext) private val openingTime: ZonedDateTime = IzTime.utcNow private val requestState: WsRequestState[F] = WsRequestState.create[F] override val sessionId: WsSessionId = WsSessionId(UUIDGen.getTimeUUID()) - override def updateRequestCtx(newContext: RequestCtx): F[Throwable, RequestCtx] = { + override def updateRequestCtx(newContext: SessionCtx): F[Throwable, SessionCtx] = { for { contexts <- F.sync { requestCtxRef.synchronized { @@ -85,7 +85,7 @@ object WsClientSession { requestState.responseWithData(id, data) } - override def finish(onFinish: RequestCtx => F[Throwable, Unit]): F[Throwable, Unit] = { + override def finish(onFinish: SessionCtx => F[Throwable, Unit]): F[Throwable, Unit] = { val requestCtx = requestCtxRef.get() F.fromEither(WebSocketFrame.Close(1000)).flatMap(outQueue.offer(_)) *> requestState.clear() *> @@ -94,7 +94,7 @@ object WsClientSession { onFinish(requestCtx) } - override def start(onStart: RequestCtx => F[Throwable, Unit]): F[Throwable, Unit] = { + override def start(onStart: SessionCtx => F[Throwable, Unit]): F[Throwable, Unit] = { val requestCtx = requestCtxRef.get() wsSessionStorage.addSession(this) *> F.traverse_(wsSessionsContext)(_.updateSession(sessionId, Some(requestCtx))) *> diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala index b0a16f4d..5823d26f 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala @@ -2,24 +2,16 @@ package izumi.idealingua.runtime.rpc.http4s.ws import izumi.functional.bio.{F, IO2, Monad2} import izumi.idealingua.runtime.rpc.http4s.context.WsIdExtractor -import izumi.idealingua.runtime.rpc.{IRTClientMultiplexor, IRTDispatcher} - -import java.util.concurrent.ConcurrentHashMap -import scala.concurrent.duration.{DurationInt, FiniteDuration} trait WsContextSessions[F[+_, +_], RequestCtx, WsCtx] { self => + /** Updates session context if can be updated and sends callbacks to Set[WsSessionListener] if context were changed. */ def updateSession(wsSessionId: WsSessionId, requestContext: Option[RequestCtx]): F[Throwable, Unit] - def dispatcherFor(ctx: WsCtx, codec: IRTClientMultiplexor[F], timeout: FiniteDuration = 20.seconds): F[Throwable, Option[IRTDispatcher[F]]] - def getSession(ctx: WsCtx): F[Throwable, Option[WsClientSession[F, ?]]] + /** Contramap with F over context type. Useful for authentications and context extensions. */ final def contramap[C](updateCtx: C => F[Throwable, Option[RequestCtx]])(implicit M: Monad2[F]): WsContextSessions[F, C, WsCtx] = new WsContextSessions[F, C, WsCtx] { - override def getSession(ctx: WsCtx): F[Throwable, Option[WsClientSession[F, ?]]] = self.getSession(ctx) override def updateSession(wsSessionId: WsSessionId, requestContext: Option[C]): F[Throwable, Unit] = { F.traverse(requestContext)(updateCtx).flatMap(mbCtx => self.updateSession(wsSessionId, mbCtx.flatten)) } - override def dispatcherFor(ctx: WsCtx, codec: IRTClientMultiplexor[F], timeout: FiniteDuration): F[Throwable, Option[IRTDispatcher[F]]] = { - self.dispatcherFor(ctx, codec, timeout) - } } } @@ -27,72 +19,35 @@ object WsContextSessions { type AnyContext[F[+_, +_], RequestCtx] = WsContextSessions[F, RequestCtx, ?] def empty[F[+_, +_]: IO2, RequestCtx]: WsContextSessions[F, RequestCtx, Unit] = new WsContextSessions[F, RequestCtx, Unit] { - override def updateSession(wsSessionId: WsSessionId, requestContext: Option[RequestCtx]): F[Throwable, Unit] = F.unit - override def dispatcherFor(ctx: Unit, codec: IRTClientMultiplexor[F], timeout: FiniteDuration): F[Throwable, Option[IRTDispatcher[F]]] = F.pure(None) - override def getSession(ctx: Unit): F[Throwable, Option[WsClientSession[F, ?]]] = F.pure(None) + override def updateSession(wsSessionId: WsSessionId, requestContext: Option[RequestCtx]): F[Throwable, Unit] = F.unit } class WsContextSessionsImpl[F[+_, +_]: IO2, RequestCtx, WsCtx]( - wsSessionsStorage: WsSessionsStorage[F, ?], + wsContextStorage: WsContextStorage[F, WsCtx], globalWsListeners: Set[WsSessionListener.Global[F]], wsSessionListeners: Set[WsSessionListener[F, RequestCtx, WsCtx]], wsIdExtractor: WsIdExtractor[RequestCtx, WsCtx], ) extends WsContextSessions[F, RequestCtx, WsCtx] { - private[this] val sessionToId = new ConcurrentHashMap[WsSessionId, WsCtx]() - private[this] val idToSession = new ConcurrentHashMap[WsCtx, WsSessionId]() - override def updateSession(wsSessionId: WsSessionId, requestContext: Option[RequestCtx]): F[Throwable, Unit] = { - updateCtx(wsSessionId, requestContext).flatMap { - case (Some(ctx), Some(previous), Some(updated)) if previous != updated => - F.traverse_(wsSessionListeners)(_.onSessionUpdated(wsSessionId, ctx, previous, updated)) *> - F.traverse_(globalWsListeners)(_.onSessionUpdated(wsSessionId, ctx, previous, updated)) - case (Some(ctx), None, Some(updated)) => - F.traverse_(wsSessionListeners)(_.onSessionOpened(wsSessionId, ctx, updated)) *> - F.traverse_(globalWsListeners)(_.onSessionOpened(wsSessionId, ctx, updated)) - case (_, Some(prev), None) => - F.traverse_(wsSessionListeners)(_.onSessionClosed(wsSessionId, prev)) *> - F.traverse_(globalWsListeners)(_.onSessionClosed(wsSessionId, prev)) - case _ => - F.unit - } - } - - override def getSession(ctx: WsCtx): F[Throwable, Option[WsClientSession[F, ?]]] = { - F.sync(synchronized(Option(idToSession.get(ctx)))) - .flatMap(F.traverse(_)(wsSessionsStorage.getSession)) - .map(_.flatten) - } - - override def dispatcherFor(ctx: WsCtx, codec: IRTClientMultiplexor[F], timeout: FiniteDuration): F[Throwable, Option[IRTDispatcher[F]]] = { - F.sync(synchronized(Option(idToSession.get(ctx)))).flatMap { - F.traverse(_) { - wsSessionsStorage.dispatcherForSession(_, codec, timeout) - } - }.map(_.flatten) - } - - @inline private final def updateCtx( - wsSessionId: WsSessionId, - requestContext: Option[RequestCtx], - ): F[Nothing, (Option[RequestCtx], Option[WsCtx], Option[WsCtx])] = F.sync { - synchronized { - val previous = Option(sessionToId.get(wsSessionId)) - val updated = requestContext.flatMap(wsIdExtractor.extract(_, previous)) - (updated, previous) match { - case (Some(upd), _) => - previous.map(idToSession.remove) - sessionToId.put(wsSessionId, upd) - idToSession.put(upd, wsSessionId) - () - case (None, Some(prev)) => - sessionToId.remove(wsSessionId) - idToSession.remove(prev) - () + for { + ctxUpdate <- wsContextStorage.updateCtx(wsSessionId) { + mbPrevCtx => + requestContext.flatMap(wsIdExtractor.extract(_, mbPrevCtx)) + } + _ <- (requestContext, ctxUpdate.previous, ctxUpdate.updated) match { + case (Some(ctx), Some(previous), Some(updated)) if previous != updated => + F.traverse_(wsSessionListeners)(_.onSessionUpdated(wsSessionId, ctx, previous, updated)) *> + F.traverse_(globalWsListeners)(_.onSessionUpdated(wsSessionId, ctx, previous, updated)) + case (Some(ctx), None, Some(updated)) => + F.traverse_(wsSessionListeners)(_.onSessionOpened(wsSessionId, ctx, updated)) *> + F.traverse_(globalWsListeners)(_.onSessionOpened(wsSessionId, ctx, updated)) + case (_, Some(prev), None) => + F.traverse_(wsSessionListeners)(_.onSessionClosed(wsSessionId, prev)) *> + F.traverse_(globalWsListeners)(_.onSessionClosed(wsSessionId, prev)) case _ => - () + F.unit } - (requestContext, previous, updated) - } + } yield () } } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextStorage.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextStorage.scala new file mode 100644 index 00000000..aeb1e420 --- /dev/null +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextStorage.scala @@ -0,0 +1,106 @@ +package izumi.idealingua.runtime.rpc.http4s.ws + +import izumi.functional.bio.{F, IO2} +import izumi.idealingua.runtime.rpc.http4s.ws.WsContextStorage.WsCtxUpdate +import izumi.idealingua.runtime.rpc.{IRTClientMultiplexor, IRTDispatcher} + +import java.util.concurrent.ConcurrentHashMap +import scala.concurrent.duration.{DurationInt, FiniteDuration} + +/** Sessions storage based on WS context. + * Supports [one session - one context] and [one context - many sessions mapping] + * It is possible to support [one sessions - many contexts] mapping (for generic context storages), + * but in such case we would able to choose one from many to update session context data. + */ +trait WsContextStorage[F[+_, +_], WsCtx] { + def getCtx(wsSessionId: WsSessionId): Option[WsCtx] + + /** Updates session context using [updateCtx] function (maybeOldContext => maybeNewContext) */ + def updateCtx(wsSessionId: WsSessionId)(updateCtx: Option[WsCtx] => Option[WsCtx]): F[Nothing, WsCtxUpdate[WsCtx]] + + def getSessions(ctx: WsCtx): F[Throwable, List[WsClientSession[F, ?]]] + def dispatchersFor(ctx: WsCtx, codec: IRTClientMultiplexor[F], timeout: FiniteDuration = 20.seconds): F[Throwable, List[IRTDispatcher[F]]] +} + +object WsContextStorage { + final case class WsCtxUpdate[WsCtx](previous: Option[WsCtx], updated: Option[WsCtx]) + + class WsContextStorageImpl[F[+_, +_]: IO2, WsCtx]( + wsSessionsStorage: WsSessionsStorage[F, ?] + ) extends WsContextStorage[F, WsCtx] { + private[this] val sessionToId = new ConcurrentHashMap[WsSessionId, WsCtx]() + private[this] val idToSessions = new ConcurrentHashMap[WsCtx, Set[WsSessionId]]() + + override def getCtx(wsSessionId: WsSessionId): Option[WsCtx] = { + Option(sessionToId.get(wsSessionId)) + } + + override def updateCtx(wsSessionId: WsSessionId)(updateCtx: Option[WsCtx] => Option[WsCtx]): F[Nothing, WsCtxUpdate[WsCtx]] = { + updateCtxImpl(wsSessionId)(updateCtx) + } + + override def getSessions(ctx: WsCtx): F[Throwable, List[WsClientSession[F, ?]]] = { + F.sync(synchronized(Option(idToSessions.get(ctx)).getOrElse(Set.empty).toList)).flatMap { + sessions => + F.traverse[Throwable, WsSessionId, Option[WsClientSession[F, ?]]](sessions) { + wsSessionId => wsSessionsStorage.getSession(wsSessionId) + }.map(_.flatten) + } + } + + override def dispatchersFor(ctx: WsCtx, codec: IRTClientMultiplexor[F], timeout: FiniteDuration): F[Throwable, List[IRTDispatcher[F]]] = { + F.sync(synchronized(Option(idToSessions.get(ctx)).getOrElse(Set.empty)).toList).flatMap { + sessions => + F.traverse[Throwable, WsSessionId, Option[IRTDispatcher[F]]](sessions) { + wsSessionId => wsSessionsStorage.dispatcherForSession(wsSessionId, codec, timeout) + }.map(_.flatten) + } + } + + @inline private final def updateCtxImpl( + wsSessionId: WsSessionId + )(updateCtx: Option[WsCtx] => Option[WsCtx] + ): F[Nothing, WsCtxUpdate[WsCtx]] = F.sync { + synchronized { + val mbPrevCtx = Option(sessionToId.get(wsSessionId)) + val mbNewCtx = updateCtx(mbPrevCtx) + (mbNewCtx, mbPrevCtx) match { + case (Some(updCtx), mbPrevCtx) => + mbPrevCtx.foreach(removeSessionFromCtx(_, wsSessionId)) + sessionToId.put(wsSessionId, updCtx) + addSessionToCtx(updCtx, wsSessionId) + () + case (None, Some(prevCtx)) => + sessionToId.remove(wsSessionId) + removeSessionFromCtx(prevCtx, wsSessionId) + () + case _ => + () + } + WsCtxUpdate(mbPrevCtx, mbNewCtx) + } + } + + @inline private final def addSessionToCtx(wsCtx: WsCtx, wsSessionId: WsSessionId): Unit = { + idToSessions.compute( + wsCtx, + { + case (_, null) => Set(wsSessionId) + case (_, s) => s + wsSessionId + }, + ) + () + } + + @inline private final def removeSessionFromCtx(wsCtx: WsCtx, wsSessionId: WsSessionId): Unit = { + idToSessions.compute( + wsCtx, + { + case (_, null) => null + case (_, s) => Option(s - wsSessionId).filter(_.nonEmpty).orNull + }, + ) + () + } + } +} diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala index d90e2706..3c3727f8 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala @@ -14,6 +14,9 @@ abstract class WsRpcHandler[F[+_, +_]: IO2, RequestCtx]( logger: LogIO2[F], ) { + /** Update context based on RpcPacket (or extract). + * Called on each RpcPacket messages before packet handling + */ protected def updateRequestCtx(packet: RpcPacket): F[Throwable, RequestCtx] def processRpcMessage(message: String): F[Throwable, Option[RpcPacket]] = { diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionsStorage.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionsStorage.scala index 061bf00c..b82c732f 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionsStorage.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsSessionsStorage.scala @@ -8,11 +8,11 @@ import java.util.concurrent.{ConcurrentHashMap, TimeoutException} import scala.concurrent.duration.* import scala.jdk.CollectionConverters.* -trait WsSessionsStorage[F[+_, +_], RequestCtx] { - def addSession(session: WsClientSession[F, RequestCtx]): F[Throwable, WsClientSession[F, RequestCtx]] - def deleteSession(sessionId: WsSessionId): F[Throwable, Option[WsClientSession[F, RequestCtx]]] - def allSessions(): F[Throwable, Seq[WsClientSession[F, RequestCtx]]] - def getSession(sessionId: WsSessionId): F[Throwable, Option[WsClientSession[F, RequestCtx]]] +trait WsSessionsStorage[F[+_, +_], SessionCtx] { + def addSession(session: WsClientSession[F, SessionCtx]): F[Throwable, WsClientSession[F, SessionCtx]] + def deleteSession(sessionId: WsSessionId): F[Throwable, Option[WsClientSession[F, SessionCtx]]] + def allSessions(): F[Throwable, Seq[WsClientSession[F, SessionCtx]]] + def getSession(sessionId: WsSessionId): F[Throwable, Option[WsClientSession[F, SessionCtx]]] def dispatcherForSession( sessionId: WsSessionId, @@ -23,28 +23,28 @@ trait WsSessionsStorage[F[+_, +_], RequestCtx] { object WsSessionsStorage { - class WsSessionsStorageImpl[F[+_, +_]: IO2, RequestCtx](logger: LogIO2[F]) extends WsSessionsStorage[F, RequestCtx] { - protected val sessions = new ConcurrentHashMap[WsSessionId, WsClientSession[F, RequestCtx]]() + class WsSessionsStorageImpl[F[+_, +_]: IO2, SessionCtx](logger: LogIO2[F]) extends WsSessionsStorage[F, SessionCtx] { + protected val sessions = new ConcurrentHashMap[WsSessionId, WsClientSession[F, SessionCtx]]() - override def addSession(session: WsClientSession[F, RequestCtx]): F[Throwable, WsClientSession[F, RequestCtx]] = { + override def addSession(session: WsClientSession[F, SessionCtx]): F[Throwable, WsClientSession[F, SessionCtx]] = { for { _ <- logger.debug(s"Adding a client with session - ${session.sessionId}") _ <- F.sync(sessions.put(session.sessionId, session)) } yield session } - override def deleteSession(sessionId: WsSessionId): F[Throwable, Option[WsClientSession[F, RequestCtx]]] = { + override def deleteSession(sessionId: WsSessionId): F[Throwable, Option[WsClientSession[F, SessionCtx]]] = { for { _ <- logger.debug(s"Deleting a client with session - $sessionId") res <- F.sync(Option(sessions.remove(sessionId))) } yield res } - override def getSession(sessionId: WsSessionId): F[Throwable, Option[WsClientSession[F, RequestCtx]]] = { + override def getSession(sessionId: WsSessionId): F[Throwable, Option[WsClientSession[F, SessionCtx]]] = { F.sync(Option(sessions.get(sessionId))) } - override def allSessions(): F[Throwable, Seq[WsClientSession[F, RequestCtx]]] = F.sync { + override def allSessions(): F[Throwable, Seq[WsClientSession[F, SessionCtx]]] = F.sync { sessions.values().asScala.toSeq } @@ -52,7 +52,7 @@ object WsSessionsStorage { sessionId: WsSessionId, codec: IRTClientMultiplexor[F], timeout: FiniteDuration, - ): F[Throwable, Option[WsClientDispatcher[F, RequestCtx]]] = F.sync { + ): F[Throwable, Option[WsClientDispatcher[F, SessionCtx]]] = F.sync { Option(sessions.get(sessionId)).map(new WsClientDispatcher(_, codec, logger, timeout)) } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala index 225803b7..cb65eb86 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala @@ -185,26 +185,26 @@ abstract class Http4sTransportTestBase[F[+_, +_]]( protectedClient = new ProtectedTestServiceWrappedClient[F](dispatcher) // no dispatchers yet - _ <- demo.Server.protectedWsSession.dispatcherFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) - _ <- demo.Server.privateWsSession.dispatcherFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) - _ <- demo.Server.publicWsSession.dispatcherFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) + _ <- demo.Server.protectedWsStorage.dispatchersFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) + _ <- demo.Server.privateWsStorage.dispatchersFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) + _ <- demo.Server.publicWsStorage.dispatchersFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) // all listeners are empty - _ = assert(demo.Server.protectedWsListener.connected.isEmpty) - _ = assert(demo.Server.privateWsListener.connected.isEmpty) - _ = assert(demo.Server.publicWsListener.connected.isEmpty) + _ = assert(demo.Server.protectedWsListener.connectedContexts.isEmpty) + _ = assert(demo.Server.privateWsListener.connectedContexts.isEmpty) + _ = assert(demo.Server.publicWsListener.connectedContexts.isEmpty) // public authorization _ <- dispatcher.authorize(publicHeaders) // protected and private listeners are empty - _ = assert(demo.Server.protectedWsListener.connected.isEmpty) - _ = assert(demo.Server.privateWsListener.connected.isEmpty) - _ = assert(demo.Server.publicWsListener.connected.contains(PublicContext("user"))) + _ = assert(demo.Server.protectedWsListener.connectedContexts.isEmpty) + _ = assert(demo.Server.privateWsListener.connectedContexts.isEmpty) + _ = assert(demo.Server.publicWsListener.connectedContexts.contains(PublicContext("user"))) // protected and private sessions are empty - _ <- demo.Server.protectedWsSession.dispatcherFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) - _ <- demo.Server.privateWsSession.dispatcherFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) + _ <- demo.Server.protectedWsStorage.dispatchersFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) + _ <- demo.Server.privateWsStorage.dispatchersFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) // public dispatcher works as expected - publicContextBuzzer <- demo.Server.publicWsSession - .dispatcherFor(PublicContext("user"), demo.Client.codec) + publicContextBuzzer <- demo.Server.publicWsStorage + .dispatchersFor(PublicContext("user"), demo.Client.codec).map(_.headOption) .fromOption(new RuntimeException("Missing Buzzer")) _ <- new GreeterServiceClientWrapped(publicContextBuzzer).greet("John", "Buzzer").map(res => assert(res == "Hi, John Buzzer!")) _ <- publicClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) @@ -215,13 +215,13 @@ abstract class Http4sTransportTestBase[F[+_, +_]]( // re-authorize with private _ <- dispatcher.authorize(privateHeaders) // protected listener is empty - _ = assert(demo.Server.protectedWsListener.connected.isEmpty) - _ = assert(demo.Server.privateWsListener.connected.contains(PrivateContext("user"))) - _ = assert(demo.Server.publicWsListener.connected.contains(PublicContext("user"))) + _ = assert(demo.Server.protectedWsListener.connectedContexts.isEmpty) + _ = assert(demo.Server.privateWsListener.connectedContexts.contains(PrivateContext("user"))) + _ = assert(demo.Server.publicWsListener.connectedContexts.contains(PublicContext("user"))) // protected sessions is empty - _ <- demo.Server.protectedWsSession.dispatcherFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) - _ <- demo.Server.privateWsSession.dispatcherFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) - _ <- demo.Server.publicWsSession.dispatcherFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) + _ <- demo.Server.protectedWsStorage.dispatchersFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) + _ <- demo.Server.privateWsStorage.dispatchersFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) + _ <- demo.Server.publicWsStorage.dispatchersFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) _ <- privateClient.test("test").map(res => assert(res.startsWith("Private"))) _ <- publicClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) _ <- checkUnauthorizedWsCall(protectedClient.test("")) @@ -229,13 +229,13 @@ abstract class Http4sTransportTestBase[F[+_, +_]]( // re-authorize with protected _ <- dispatcher.authorize(protectedHeaders) // private listener is empty - _ = assert(demo.Server.protectedWsListener.connected.contains(ProtectedContext("user"))) - _ = assert(demo.Server.privateWsListener.connected.isEmpty) - _ = assert(demo.Server.publicWsListener.connected.contains(PublicContext("user"))) + _ = assert(demo.Server.protectedWsListener.connectedContexts.contains(ProtectedContext("user"))) + _ = assert(demo.Server.privateWsListener.connectedContexts.isEmpty) + _ = assert(demo.Server.publicWsListener.connectedContexts.contains(PublicContext("user"))) // private sessions is empty - _ <- demo.Server.protectedWsSession.dispatcherFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) - _ <- demo.Server.privateWsSession.dispatcherFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) - _ <- demo.Server.publicWsSession.dispatcherFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) + _ <- demo.Server.protectedWsStorage.dispatchersFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) + _ <- demo.Server.privateWsStorage.dispatchersFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) + _ <- demo.Server.publicWsStorage.dispatchersFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) _ <- protectedClient.test("test").map(res => assert(res.startsWith("Protected"))) _ <- publicClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) _ <- checkUnauthorizedWsCall(privateClient.test("")) @@ -243,16 +243,16 @@ abstract class Http4sTransportTestBase[F[+_, +_]]( // auth session context update _ <- dispatcher.authorize(protectedHeaders2) // session and listeners notified - _ = assert(demo.Server.protectedWsListener.connected.contains(ProtectedContext("John"))) - _ = assert(demo.Server.protectedWsListener.connected.size == 1) - _ = assert(demo.Server.publicWsListener.connected.contains(PublicContext("John"))) - _ = assert(demo.Server.publicWsListener.connected.size == 1) - _ = assert(demo.Server.privateWsListener.connected.isEmpty) - _ <- demo.Server.privateWsSession.dispatcherFor(PrivateContext("John"), demo.Client.codec).map(b => assert(b.isEmpty)) - _ <- demo.Server.publicWsSession.dispatcherFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) - _ <- demo.Server.publicWsSession.dispatcherFor(PublicContext("John"), demo.Client.codec).map(b => assert(b.nonEmpty)) - _ <- demo.Server.protectedWsSession.dispatcherFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) - _ <- demo.Server.protectedWsSession.dispatcherFor(ProtectedContext("John"), demo.Client.codec).map(b => assert(b.nonEmpty)) + _ = assert(demo.Server.protectedWsListener.connectedContexts.contains(ProtectedContext("John"))) + _ = assert(demo.Server.protectedWsListener.connectedContexts.size == 1) + _ = assert(demo.Server.publicWsListener.connectedContexts.contains(PublicContext("John"))) + _ = assert(demo.Server.publicWsListener.connectedContexts.size == 1) + _ = assert(demo.Server.privateWsListener.connectedContexts.isEmpty) + _ <- demo.Server.privateWsStorage.dispatchersFor(PrivateContext("John"), demo.Client.codec).map(b => assert(b.isEmpty)) + _ <- demo.Server.publicWsStorage.dispatchersFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) + _ <- demo.Server.publicWsStorage.dispatchersFor(PublicContext("John"), demo.Client.codec).map(b => assert(b.nonEmpty)) + _ <- demo.Server.protectedWsStorage.dispatchersFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) + _ <- demo.Server.protectedWsStorage.dispatchersFor(ProtectedContext("John"), demo.Client.codec).map(b => assert(b.nonEmpty)) // bad authorization _ <- dispatcher.authorize(badHeaders) @@ -272,12 +272,12 @@ abstract class Http4sTransportTestBase[F[+_, +_]]( val privateClient = new PrivateTestServiceWrappedClient[F](dispatcher) val protectedClient = new ProtectedTestServiceWrappedClient[F](dispatcher) for { - _ <- demo.Server.protectedWsSession.dispatcherFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) - _ <- demo.Server.privateWsSession.dispatcherFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) - _ <- demo.Server.publicWsSession.dispatcherFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) - _ = assert(demo.Server.protectedWsListener.connected.isEmpty) - _ = assert(demo.Server.privateWsListener.connected.size == 1) - _ = assert(demo.Server.publicWsListener.connected.size == 1) + _ <- demo.Server.protectedWsStorage.dispatchersFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) + _ <- demo.Server.privateWsStorage.dispatchersFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) + _ <- demo.Server.publicWsStorage.dispatchersFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) + _ = assert(demo.Server.protectedWsListener.connectedContexts.isEmpty) + _ = assert(demo.Server.privateWsListener.connectedContexts.size == 1) + _ = assert(demo.Server.publicWsListener.connectedContexts.size == 1) _ <- privateClient.test("test").map(res => assert(res.startsWith("Private"))) _ <- publicClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) @@ -288,6 +288,30 @@ abstract class Http4sTransportTestBase[F[+_, +_]]( } } + "support websockets multiple sessions on same context" in { + withServer { + for { + privateHeaders <- F.pure(Map("Authorization" -> privateAuth("user").values.head.value)) + _ <- { + for { + c1 <- wsRpcClientDispatcher(privateHeaders) + c2 <- wsRpcClientDispatcher(privateHeaders) + } yield (c1, c2) + }.use { + case (_, _) => + for { + _ <- demo.Server.protectedWsStorage.dispatchersFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) + _ <- demo.Server.privateWsStorage.dispatchersFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.size == 2)) + _ <- demo.Server.publicWsStorage.dispatchersFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.size == 2)) + _ = assert(demo.Server.protectedWsListener.connected.isEmpty) + _ = assert(demo.Server.privateWsListener.connected.size == 2) + _ = assert(demo.Server.publicWsListener.connected.size == 2) + } yield () + } + } yield () + } + } + "support request state clean" in { executeF { val rs = new WsRequestState.Default[F]() diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/LoggingWsListener.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/LoggingWsListener.scala index 15f9067e..81107241 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/LoggingWsListener.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/LoggingWsListener.scala @@ -6,19 +6,20 @@ import izumi.idealingua.runtime.rpc.http4s.ws.{WsSessionId, WsSessionListener} import scala.collection.mutable final class LoggingWsListener[F[+_, +_]: IO2, RequestCtx, WsCtx] extends WsSessionListener[F, RequestCtx, WsCtx] { - private val connections = mutable.Set.empty[WsCtx] - def connected: Set[WsCtx] = connections.toSet + private val connections = mutable.Set.empty[(WsSessionId, WsCtx)] + def connected: Set[(WsSessionId, WsCtx)] = connections.toSet + def connectedContexts: Set[WsCtx] = connections.map(_._2).toSet override def onSessionOpened(sessionId: WsSessionId, reqCtx: RequestCtx, wsCtx: WsCtx): F[Throwable, Unit] = F.sync { - connections.add(wsCtx) + connections.add(sessionId -> wsCtx) }.void override def onSessionUpdated(sessionId: WsSessionId, reqCtx: RequestCtx, prevStx: WsCtx, newCtx: WsCtx): F[Throwable, Unit] = F.sync { - connections.remove(prevStx) - connections.add(newCtx) + connections.remove(sessionId -> prevStx) + connections.add(sessionId -> newCtx) }.void override def onSessionClosed(sessionId: WsSessionId, wsCtx: WsCtx): F[Throwable, Unit] = F.sync { - connections.remove(wsCtx) + connections.remove(sessionId -> wsCtx) }.void } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala index cfaced6d..65d8e202 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala @@ -5,9 +5,10 @@ import izumi.functional.bio.{F, IO2} import izumi.idealingua.runtime.rpc.* import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext import izumi.idealingua.runtime.rpc.http4s.context.WsIdExtractor +import izumi.idealingua.runtime.rpc.http4s.ws.* import izumi.idealingua.runtime.rpc.http4s.ws.WsContextSessions.WsContextSessionsImpl +import izumi.idealingua.runtime.rpc.http4s.ws.WsContextStorage.WsContextStorageImpl import izumi.idealingua.runtime.rpc.http4s.ws.WsSessionsStorage.WsSessionsStorageImpl -import izumi.idealingua.runtime.rpc.http4s.ws.{WsContextSessions, WsSessionId, WsSessionListener, WsSessionsStorage} import izumi.idealingua.runtime.rpc.http4s.{IRTAuthenticator, IRTContextServices} import izumi.r2.idealingua.test.generated.* import izumi.r2.idealingua.test.impls.AbstractGreeterServer @@ -53,15 +54,16 @@ class TestServices[F[+_, +_]: IO2]( } } } - final val privateWsListener: LoggingWsListener[F, PrivateContext, PrivateContext] = { - new LoggingWsListener[F, PrivateContext, PrivateContext] + final val privateWsListener: LoggingWsListener[F, PrivateContext, TestContext] = { + new LoggingWsListener[F, PrivateContext, TestContext] } + final val privateWsStorage: WsContextStorage[F, PrivateContext] = new WsContextStorageImpl(wsStorage) final val privateWsSession: WsContextSessions[F, PrivateContext, PrivateContext] = { new WsContextSessionsImpl( - wsSessionsStorage = wsStorage, + wsContextStorage = privateWsStorage, globalWsListeners = globalWsListeners, wsSessionListeners = Set(privateWsListener), - wsIdExtractor = WsIdExtractor.id[PrivateContext], + wsIdExtractor = WsIdExtractor.id, ) } final val privateService: IRTWrappedService[F, PrivateContext] = { @@ -88,12 +90,13 @@ class TestServices[F[+_, +_]: IO2]( } } } - final val protectedWsListener: LoggingWsListener[F, ProtectedContext, ProtectedContext] = { - new LoggingWsListener[F, ProtectedContext, ProtectedContext] + final val protectedWsListener: LoggingWsListener[F, ProtectedContext, TestContext] = { + new LoggingWsListener[F, ProtectedContext, TestContext] } + final val protectedWsStorage: WsContextStorage[F, ProtectedContext] = new WsContextStorageImpl(wsStorage) final val protectedWsSession: WsContextSessions[F, ProtectedContext, ProtectedContext] = { new WsContextSessionsImpl[F, ProtectedContext, ProtectedContext]( - wsSessionsStorage = wsStorage, + wsContextStorage = protectedWsStorage, globalWsListeners = globalWsListeners, wsSessionListeners = Set(protectedWsListener), wsIdExtractor = WsIdExtractor.id, @@ -123,12 +126,13 @@ class TestServices[F[+_, +_]: IO2]( } } } - final val publicWsListener: LoggingWsListener[F, PublicContext, PublicContext] = { - new LoggingWsListener[F, PublicContext, PublicContext] + final val publicWsListener: LoggingWsListener[F, PublicContext, TestContext] = { + new LoggingWsListener[F, PublicContext, TestContext] } + final val publicWsStorage: WsContextStorage[F, PublicContext] = new WsContextStorageImpl(wsStorage) final val publicWsSession: WsContextSessions[F, PublicContext, PublicContext] = { new WsContextSessionsImpl( - wsSessionsStorage = wsStorage, + wsContextStorage = publicWsStorage, globalWsListeners = globalWsListeners, wsSessionListeners = Set(publicWsListener), wsIdExtractor = WsIdExtractor.id, diff --git a/project/plugins.sbt b/project/plugins.sbt index a0d6b66c..578656af 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,6 +1,17 @@ // DO NOT EDIT THIS FILE // IT IS AUTOGENERATED BY `sbtgen.sc` SCRIPT // ALL CHANGES WILL BE LOST +// https://www.scala-js.org/ +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.13.0") + +// https://github.com/portable-scala/sbt-crossproject +addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.1") + +// https://scalacenter.github.io/scalajs-bundler/ +addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % "0.21.1") + +// https://github.com/scala-js/jsdependencies +addSbtPlugin("org.scala-js" % "sbt-jsdependencies" % "1.0.2") //////////////////////////////////////////////////////////////////////////////// From 511775713aa89cf54bdef998d6fd1e14a3339088 Mon Sep 17 00:00:00 2001 From: Alex Liubymov Date: Wed, 20 Dec 2023 18:13:20 +0200 Subject: [PATCH 17/24] Add WS session id to WsContextExtractor. --- .../izumi/idealingua/runtime/rpc/http4s/HttpServer.scala | 2 +- .../rpc/http4s/clients/WsRpcDispatcherFactory.scala | 6 ++++-- .../runtime/rpc/http4s/context/WsContextExtractor.scala | 9 +++++---- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala index 8236e183..b1b6077e 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala @@ -227,7 +227,7 @@ object HttpServer { logger: LogIO2[F], ) extends WsRpcHandler[F, AuthCtx](muxer, clientSession, logger) { override protected def updateRequestCtx(packet: RpcPacket): F[Throwable, AuthCtx] = { - clientSession.updateRequestCtx(wsContextExtractor.extract(packet)) + clientSession.updateRequestCtx(wsContextExtractor.extract(clientSession.sessionId, packet)) } } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala index 6b879d01..554ce11f 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala @@ -5,11 +5,12 @@ import io.circe.{Json, Printer} import izumi.functional.bio.{Async2, Exit, F, IO2, Primitives2, Temporal2, UnsafeRun2} import izumi.functional.lifecycle.Lifecycle import izumi.fundamentals.platform.language.Quirks.Discarder +import izumi.fundamentals.platform.uuid.UUIDGen import izumi.idealingua.runtime.rpc.* import izumi.idealingua.runtime.rpc.http4s.clients.WsRpcDispatcher.IRTDispatcherWs import izumi.idealingua.runtime.rpc.http4s.clients.WsRpcDispatcherFactory.{ClientWsRpcHandler, WsRpcClientConnection, fromNettyFuture} import izumi.idealingua.runtime.rpc.http4s.context.WsContextExtractor -import izumi.idealingua.runtime.rpc.http4s.ws.{RawResponse, WsRequestState, WsRpcHandler} +import izumi.idealingua.runtime.rpc.http4s.ws.{RawResponse, WsRequestState, WsRpcHandler, WsSessionId} import izumi.logstage.api.IzLogger import logstage.LogIO2 import org.asynchttpclient.netty.ws.NettyWebSocket @@ -187,9 +188,10 @@ object WsRpcDispatcherFactory { wsContextExtractor: WsContextExtractor[RequestCtx], logger: LogIO2[F], ) extends WsRpcHandler[F, RequestCtx](muxer, requestState, logger) { + private val wsSessionId: WsSessionId = WsSessionId(UUIDGen.getTimeUUID()) private val requestCtxRef: AtomicReference[RequestCtx] = new AtomicReference() override protected def updateRequestCtx(packet: RpcPacket): F[Throwable, RequestCtx] = F.sync { - val updated = wsContextExtractor.extract(packet) + val updated = wsContextExtractor.extract(wsSessionId, packet) requestCtxRef.updateAndGet { case null => updated case previous => wsContextExtractor.merge(previous, updated) diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/WsContextExtractor.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/WsContextExtractor.scala index abca5c4f..5e7f233d 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/WsContextExtractor.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/WsContextExtractor.scala @@ -2,21 +2,22 @@ package izumi.idealingua.runtime.rpc.http4s.context import izumi.idealingua.runtime.rpc.RpcPacket import izumi.idealingua.runtime.rpc.http4s.IRTAuthenticator.AuthContext +import izumi.idealingua.runtime.rpc.http4s.ws.WsSessionId import org.http4s.{Header, Headers} import org.typelevel.ci.CIString trait WsContextExtractor[RequestCtx] { - def extract(packet: RpcPacket): RequestCtx + def extract(wsSessionId: WsSessionId, packet: RpcPacket): RequestCtx def merge(previous: RequestCtx, updated: RequestCtx): RequestCtx } object WsContextExtractor { def unit: WsContextExtractor[Unit] = new WsContextExtractor[Unit] { - override def extract(packet: RpcPacket): Unit = () - override def merge(previous: Unit, updated: Unit): Unit = () + override def extract(wsSessionId: WsSessionId, packet: RpcPacket): Unit = () + override def merge(previous: Unit, updated: Unit): Unit = () } def authContext: WsContextExtractor[AuthContext] = new WsContextExtractor[AuthContext] { - override def extract(packet: RpcPacket): AuthContext = { + override def extract(wsSessionId: WsSessionId, packet: RpcPacket): AuthContext = { val headersMap = packet.headers.getOrElse(Map.empty) val headers = Headers.apply(headersMap.toSeq.map { case (k, v) => Header.Raw(CIString(k), v) }) AuthContext(headers, None) From 451856032ce303fee2a6318980c802dbd422d85e Mon Sep 17 00:00:00 2001 From: Alex Liubymov Date: Thu, 21 Dec 2023 13:37:47 +0200 Subject: [PATCH 18/24] helpers --- .../runtime/rpc/http4s/context/WsIdExtractor.scala | 1 + .../runtime/rpc/http4s/ws/WsClientSession.scala | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/WsIdExtractor.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/WsIdExtractor.scala index 3245a349..364d1feb 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/WsIdExtractor.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/context/WsIdExtractor.scala @@ -7,4 +7,5 @@ trait WsIdExtractor[RequestCtx, WsCtx] { object WsIdExtractor { def id[C]: WsIdExtractor[C, C] = (c, _) => Some(c) def widen[C, C0 >: C]: WsIdExtractor[C, C0] = (c, _) => Some(c) + def unit[C]: WsIdExtractor[C, Unit] = (_, _) => Some(()) } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala index 93aaf302..21857eac 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala @@ -3,7 +3,7 @@ package izumi.idealingua.runtime.rpc.http4s.ws import cats.effect.std.Queue import io.circe.syntax.EncoderOps import io.circe.{Json, Printer} -import izumi.functional.bio.{F, IO2, Primitives2, Temporal2} +import izumi.functional.bio.{Applicative2, F, IO2, Primitives2, Temporal2} import izumi.fundamentals.platform.time.IzTime import izumi.fundamentals.platform.uuid.UUIDGen import izumi.idealingua.runtime.rpc.http4s.context.WsContextExtractor @@ -31,6 +31,16 @@ trait WsClientSession[F[+_, +_], SessionCtx] extends WsResponder[F] { object WsClientSession { + def empty[F[+_, +_]: Applicative2, Ctx](wsSessionId: WsSessionId): WsClientSession[F, Ctx] = new WsClientSession[F, Ctx] { + override def sessionId: WsSessionId = wsSessionId + override def requestAndAwaitResponse(method: IRTMethodId, data: Json, timeout: FiniteDuration): F[Throwable, Option[RawResponse]] = F.pure(None) + override def updateRequestCtx(newContext: Ctx): F[Throwable, Ctx] = F.pure(newContext) + override def start(onStart: Ctx => F[Throwable, Unit]): F[Throwable, Unit] = F.unit + override def finish(onFinish: Ctx => F[Throwable, Unit]): F[Throwable, Unit] = F.unit + override def responseWith(id: RpcPacketId, response: RawResponse): F[Throwable, Unit] = F.unit + override def responseWithData(id: RpcPacketId, data: Json): F[Throwable, Unit] = F.unit + } + class WsClientSessionImpl[F[+_, +_]: IO2: Temporal2: Primitives2, SessionCtx]( outQueue: Queue[F[Throwable, _], WebSocketFrame], initialContext: SessionCtx, From cbeb38f8bd6c19853ab09da94717206d84d7cd5c Mon Sep 17 00:00:00 2001 From: Alex Liubymov Date: Fri, 22 Dec 2023 11:39:25 +0200 Subject: [PATCH 19/24] Return Session ID in WS upgrade handler. --- .../runtime/rpc/http4s/HttpServer.scala | 9 +- .../runtime/rpc/http4s/IRTAuthenticator.scala | 5 +- .../rpc/http4s/IRTContextServices.scala | 4 +- .../rpc/http4s/clients/WsRpcDispatcher.scala | 7 +- .../clients/WsRpcDispatcherFactory.scala | 9 +- .../rpc/http4s/ws/WsContextSessions.scala | 2 +- .../rpc/http4s/ws/WsContextStorage.scala | 18 +- .../rpc/http4s/Http4sTransportTest.scala | 429 +++++++++--------- .../rpc/http4s/fixtures/TestServices.scala | 6 +- .../runtime/rpc/IRTServerMethod.scala | 4 +- .../runtime/rpc/IRTServerMultiplexor.scala | 2 +- .../idealingua/runtime/rpc/packets.scala | 8 + 12 files changed, 280 insertions(+), 223 deletions(-) diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala index b1b6077e..5299e53d 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala @@ -22,6 +22,7 @@ import org.http4s.* import org.http4s.dsl.Http4sDsl import org.http4s.server.websocket.WebSocketBuilder2 import org.http4s.websocket.WebSocketFrame +import org.typelevel.ci.CIString import org.typelevel.vault.Key import java.time.ZonedDateTime @@ -83,7 +84,12 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2, AuthCtx]( } } } - response <- ws.withOnClose(handleWsClose(clientSession)).build(outStream, inStream) + wsSessionIdHeader = Header.Raw(HttpServer.`X-Ws-Session-Id`, clientSession.sessionId.sessionId.toString) + + response <- ws + .withOnClose(handleWsClose(clientSession)) + .withHeaders(Headers(wsSessionIdHeader)) + .build(outStream, inStream) } yield { response.withAttribute(wsAttributeKey, WsResponseMarker) } @@ -219,6 +225,7 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2, AuthCtx]( } object HttpServer { + val `X-Ws-Session-Id`: CIString = CIString("X-Ws-Session-Id") case object WsResponseMarker class ServerWsRpcHandler[F[+_, +_]: IO2, AuthCtx]( clientSession: WsClientSession[F, AuthCtx], diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTAuthenticator.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTAuthenticator.scala index 912462eb..4372ec46 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTAuthenticator.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTAuthenticator.scala @@ -2,17 +2,18 @@ package izumi.idealingua.runtime.rpc.http4s import io.circe.Json import izumi.functional.bio.{Applicative2, F} +import izumi.idealingua.runtime.rpc.IRTMethodId import org.http4s.Headers import java.net.InetAddress abstract class IRTAuthenticator[F[_, _], AuthCtx, RequestCtx] { - def authenticate(authContext: AuthCtx, body: Option[Json]): F[Nothing, Option[RequestCtx]] + def authenticate(authContext: AuthCtx, body: Option[Json], methodId: Option[IRTMethodId]): F[Nothing, Option[RequestCtx]] } object IRTAuthenticator { def unit[F[+_, +_]: Applicative2, C]: IRTAuthenticator[F, C, Unit] = new IRTAuthenticator[F, C, Unit] { - override def authenticate(authContext: C, body: Option[Json]): F[Nothing, Option[Unit]] = F.pure(Some(())) + override def authenticate(authContext: C, body: Option[Json], methodId: Option[IRTMethodId]): F[Nothing, Option[Unit]] = F.pure(Some(())) } final case class AuthContext(headers: Headers, networkAddress: Option[InetAddress]) } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala index f2c61b6f..50af0b8c 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala @@ -15,14 +15,14 @@ trait IRTContextServices[F[+_, +_], AuthCtx, RequestCtx, WsCtx] { case (muxer, middleware) => muxer.wrap(middleware) } val authorized: IRTServerMultiplexor[F, AuthCtx] = withMiddlewares.contramap { - case (authCtx, body) => authenticator.authenticate(authCtx, Some(body)) + case (authCtx, body, methodId) => authenticator.authenticate(authCtx, Some(body), Some(methodId)) } authorized } def authorizedWsSessions(implicit M: Monad2[F]): WsContextSessions[F, AuthCtx, WsCtx] = { val authorized: WsContextSessions[F, AuthCtx, WsCtx] = wsSessions.contramap { authCtx => - authenticator.authenticate(authCtx, None) + authenticator.authenticate(authCtx, None, None) } authorized } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcher.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcher.scala index 56462146..223b0ccd 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcher.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcher.scala @@ -5,7 +5,7 @@ import izumi.functional.bio.{Async2, F, Temporal2} import izumi.idealingua.runtime.rpc.* import izumi.idealingua.runtime.rpc.http4s.clients.WsRpcDispatcher.IRTDispatcherWs import izumi.idealingua.runtime.rpc.http4s.clients.WsRpcDispatcherFactory.WsRpcClientConnection -import izumi.idealingua.runtime.rpc.http4s.ws.RawResponse +import izumi.idealingua.runtime.rpc.http4s.ws.{RawResponse, WsSessionId} import logstage.LogIO2 import java.util.concurrent.TimeoutException @@ -18,6 +18,10 @@ class WsRpcDispatcher[F[+_, +_]: Async2]( logger: LogIO2[F], ) extends IRTDispatcherWs[F] { + override def sessionId: Option[WsSessionId] = { + connection.sessionId + } + override def authorize(headers: Map[String, String]): F[Throwable, Unit] = { connection.authorize(headers, timeout) } @@ -62,6 +66,7 @@ class WsRpcDispatcher[F[+_, +_]: Async2]( object WsRpcDispatcher { trait IRTDispatcherWs[F[_, _]] extends IRTDispatcher[F] { + def sessionId: Option[WsSessionId] def authorize(headers: Map[String, String]): F[Throwable, Unit] } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala index 554ce11f..4111d1fd 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/clients/WsRpcDispatcherFactory.scala @@ -7,6 +7,7 @@ import izumi.functional.lifecycle.Lifecycle import izumi.fundamentals.platform.language.Quirks.Discarder import izumi.fundamentals.platform.uuid.UUIDGen import izumi.idealingua.runtime.rpc.* +import izumi.idealingua.runtime.rpc.http4s.HttpServer import izumi.idealingua.runtime.rpc.http4s.clients.WsRpcDispatcher.IRTDispatcherWs import izumi.idealingua.runtime.rpc.http4s.clients.WsRpcDispatcherFactory.{ClientWsRpcHandler, WsRpcClientConnection, fromNettyFuture} import izumi.idealingua.runtime.rpc.http4s.context.WsContextExtractor @@ -18,9 +19,11 @@ import org.asynchttpclient.ws.{WebSocket, WebSocketListener, WebSocketUpgradeHan import org.asynchttpclient.{DefaultAsyncHttpClient, DefaultAsyncHttpClientConfig} import org.http4s.Uri +import java.util.UUID import java.util.concurrent.atomic.AtomicReference import scala.concurrent.duration.{DurationInt, FiniteDuration} import scala.jdk.CollectionConverters.* +import scala.util.Try class WsRpcDispatcherFactory[F[+_, +_]: Async2: Temporal2: Primitives2: UnsafeRun2]( codec: IRTClientMultiplexor[F], @@ -48,10 +51,12 @@ class WsRpcDispatcherFactory[F[+_, +_]: Async2: Temporal2: Primitives2: UnsafeRu .execute(handler).toCompletableFuture } )(nettyWebSocket => fromNettyFuture(nettyWebSocket.sendCloseFrame()).void) + sessionId = Option(nettyWebSocket.getUpgradeHeaders.get(HttpServer.`X-Ws-Session-Id`.toString)) + .flatMap(str => Try(WsSessionId(UUID.fromString(str))).toOption) // fill promises before closing WS connection, potentially giving a chance to send out an error response before closing _ <- Lifecycle.make(F.unit)(_ => wsRequestState.clear()) } yield { - new WsRpcClientConnection.Netty(nettyWebSocket, wsRequestState, printer) + new WsRpcClientConnection.Netty(nettyWebSocket, wsRequestState, printer, sessionId) } } @@ -201,6 +206,7 @@ object WsRpcDispatcherFactory { trait WsRpcClientConnection[F[_, _]] { private[clients] def requestAndAwait(id: RpcPacketId, packet: RpcPacket, method: Option[IRTMethodId], timeout: FiniteDuration): F[Throwable, Option[RawResponse]] + def sessionId: Option[WsSessionId] def authorize(headers: Map[String, String], timeout: FiniteDuration = 30.seconds): F[Throwable, Unit] } object WsRpcClientConnection { @@ -208,6 +214,7 @@ object WsRpcDispatcherFactory { nettyWebSocket: NettyWebSocket, requestState: WsRequestState[F], printer: Printer, + val sessionId: Option[WsSessionId], ) extends WsRpcClientConnection[F] { override def authorize(headers: Map[String, String], timeout: FiniteDuration): F[Throwable, Unit] = { diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala index 5823d26f..e2236cfb 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala @@ -30,7 +30,7 @@ object WsContextSessions { ) extends WsContextSessions[F, RequestCtx, WsCtx] { override def updateSession(wsSessionId: WsSessionId, requestContext: Option[RequestCtx]): F[Throwable, Unit] = { for { - ctxUpdate <- wsContextStorage.updateCtx(wsSessionId) { + ctxUpdate <- wsContextStorage.updateContext(wsSessionId) { mbPrevCtx => requestContext.flatMap(wsIdExtractor.extract(_, mbPrevCtx)) } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextStorage.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextStorage.scala index aeb1e420..4491d185 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextStorage.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextStorage.scala @@ -1,11 +1,12 @@ package izumi.idealingua.runtime.rpc.http4s.ws import izumi.functional.bio.{F, IO2} -import izumi.idealingua.runtime.rpc.http4s.ws.WsContextStorage.WsCtxUpdate +import izumi.idealingua.runtime.rpc.http4s.ws.WsContextStorage.{WsContextSessionId, WsCtxUpdate} import izumi.idealingua.runtime.rpc.{IRTClientMultiplexor, IRTDispatcher} import java.util.concurrent.ConcurrentHashMap import scala.concurrent.duration.{DurationInt, FiniteDuration} +import scala.jdk.CollectionConverters.* /** Sessions storage based on WS context. * Supports [one session - one context] and [one context - many sessions mapping] @@ -13,10 +14,10 @@ import scala.concurrent.duration.{DurationInt, FiniteDuration} * but in such case we would able to choose one from many to update session context data. */ trait WsContextStorage[F[+_, +_], WsCtx] { - def getCtx(wsSessionId: WsSessionId): Option[WsCtx] - + def getContext(wsSessionId: WsSessionId): Option[WsCtx] + def allSessions(): Set[WsContextSessionId[WsCtx]] /** Updates session context using [updateCtx] function (maybeOldContext => maybeNewContext) */ - def updateCtx(wsSessionId: WsSessionId)(updateCtx: Option[WsCtx] => Option[WsCtx]): F[Nothing, WsCtxUpdate[WsCtx]] + def updateContext(wsSessionId: WsSessionId)(updateCtx: Option[WsCtx] => Option[WsCtx]): F[Nothing, WsCtxUpdate[WsCtx]] def getSessions(ctx: WsCtx): F[Throwable, List[WsClientSession[F, ?]]] def dispatchersFor(ctx: WsCtx, codec: IRTClientMultiplexor[F], timeout: FiniteDuration = 20.seconds): F[Throwable, List[IRTDispatcher[F]]] @@ -24,6 +25,7 @@ trait WsContextStorage[F[+_, +_], WsCtx] { object WsContextStorage { final case class WsCtxUpdate[WsCtx](previous: Option[WsCtx], updated: Option[WsCtx]) + final case class WsContextSessionId[WsCtx](sessionId: WsSessionId, ctx: WsCtx) class WsContextStorageImpl[F[+_, +_]: IO2, WsCtx]( wsSessionsStorage: WsSessionsStorage[F, ?] @@ -31,11 +33,15 @@ object WsContextStorage { private[this] val sessionToId = new ConcurrentHashMap[WsSessionId, WsCtx]() private[this] val idToSessions = new ConcurrentHashMap[WsCtx, Set[WsSessionId]]() - override def getCtx(wsSessionId: WsSessionId): Option[WsCtx] = { + override def allSessions(): Set[WsContextSessionId[WsCtx]] = { + sessionToId.asScala.map { case (s, c) => WsContextSessionId(s, c) }.toSet + } + + override def getContext(wsSessionId: WsSessionId): Option[WsCtx] = { Option(sessionToId.get(wsSessionId)) } - override def updateCtx(wsSessionId: WsSessionId)(updateCtx: Option[WsCtx] => Option[WsCtx]): F[Nothing, WsCtxUpdate[WsCtx]] = { + override def updateContext(wsSessionId: WsSessionId)(updateCtx: Option[WsCtx] => Option[WsCtx]): F[Nothing, WsCtxUpdate[WsCtx]] = { updateCtxImpl(wsSessionId)(updateCtx) } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala index cb65eb86..9f9d9dda 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala @@ -27,7 +27,6 @@ import org.http4s.headers.Authorization import org.http4s.server.Router import org.scalatest.wordspec.AnyWordSpec -import java.net.InetSocketAddress import java.util.concurrent.Executors import scala.concurrent.ExecutionContext.global import scala.concurrent.duration.DurationInt @@ -54,47 +53,77 @@ object Http4sTransportTest { ) final class Ctx[F[+_, +_]: Async2: Temporal2: Primitives2: UnsafeRun2](implicit asyncThrowable: Async[F[Throwable, _]]) { - private val logger: LogIO2[F] = LogIO2.fromLogger(izLogger) - private val printer: Printer = Printer.noSpaces.copy(dropNullValues = true) - - val dsl: Http4sDsl[F[Throwable, _]] = Http4sDsl.apply[F[Throwable, _]] - val execCtx: HttpExecutionContext = HttpExecutionContext(global) - - val addr: InetSocketAddress = IzSockets.temporaryServerAddress() - val port: Int = addr.getPort - val host: String = addr.getHostName - val baseUri: Uri = Uri(Some(Uri.Scheme.http), Some(Uri.Authority(host = Uri.RegName(host), port = Some(port)))) - val wsUri: Uri = Uri.unsafeFromString(s"ws://$host:$port/ws") - - val demo: TestServices[F] = new TestServices[F](logger) - - val ioService: HttpServer[F, AuthContext] = new HttpServer[F, AuthContext]( - contextServices = demo.Server.contextServices, - httpContextExtractor = HttpContextExtractor.authContext, - wsContextExtractor = WsContextExtractor.authContext, - wsSessionsStorage = demo.Server.wsStorage, - dsl = dsl, - logger = logger, - printer = printer, - ) + private val logger: LogIO2[F] = LogIO2.fromLogger(izLogger) + private val printer: Printer = Printer.noSpaces.copy(dropNullValues = true) + private val dsl: Http4sDsl[F[Throwable, _]] = Http4sDsl.apply[F[Throwable, _]] def badAuth(): Header.ToRaw = Authorization(Credentials.Token(AuthScheme.Bearer, "token")) def publicAuth(user: String): Header.ToRaw = Authorization(BasicCredentials(user, "public")) def protectedAuth(user: String): Header.ToRaw = Authorization(BasicCredentials(user, "protected")) def privateAuth(user: String): Header.ToRaw = Authorization(BasicCredentials(user, "private")) + def withServer(f: HttpServerContext[F] => F[Throwable, Unit]): Unit = { + executeF { + (for { + testServices <- Lifecycle.liftF(F.syncThrowable(new TestServices[F](logger))) + ioService <- Lifecycle.liftF { + F.syncThrowable { + new HttpServer[F, AuthContext]( + contextServices = testServices.Server.contextServices, + httpContextExtractor = HttpContextExtractor.authContext, + wsContextExtractor = WsContextExtractor.authContext, + wsSessionsStorage = testServices.Server.wsStorage, + dsl = dsl, + logger = logger, + printer = printer, + ) + } + } + addr <- Lifecycle.liftF(F.sync(IzSockets.temporaryServerAddress())) + port = addr.getPort + host = addr.getHostName + _ <- Lifecycle.fromCats { + BlazeServerBuilder[F[Throwable, _]] + .bindHttp(port, host) + .withHttpWebSocketApp(ws => Router("/" -> ioService.service(ws)).orNotFound) + .resource + } + execCtx = HttpExecutionContext(global) + baseUri = Uri(Some(Uri.Scheme.http), Some(Uri.Authority(host = Uri.RegName(host), port = Some(port)))) + wsUri = Uri.unsafeFromString(s"ws://$host:$port/ws") + } yield HttpServerContext(baseUri, wsUri, testServices, execCtx, printer, logger)).use(f) + } + } + + def executeF(io: F[Throwable, Unit]): Unit = { + UnsafeRun2[F].unsafeRunSync(io) match { + case Success(()) => () + case failure: Exit.Failure[?] => throw failure.trace.toThrowable + } + } + } + + final case class HttpServerContext[F[+_, +_]: Async2: Temporal2: Primitives2: UnsafeRun2]( + baseUri: Uri, + wsUri: Uri, + testServices: TestServices[F], + execCtx: HttpExecutionContext, + printer: Printer, + logger: LogIO2[F], + )(implicit asyncThrowable: Async[F[Throwable, _]] + ) { val httpClientFactory: HttpRpcDispatcherFactory[F] = { - new HttpRpcDispatcherFactory[F](demo.Client.codec, execCtx, printer, logger) + new HttpRpcDispatcherFactory[F](testServices.Client.codec, execCtx, printer, logger) } def httpRpcClientDispatcher(headers: Headers): HttpRpcDispatcher.IRTDispatcherRaw[F] = { httpClientFactory.dispatcher(baseUri, headers) } val wsClientFactory: WsRpcDispatcherFactory[F] = { - new WsRpcDispatcherFactory[F](demo.Client.codec, printer, logger, izLogger) + new WsRpcDispatcherFactory[F](testServices.Client.codec, printer, logger, izLogger) } def wsRpcClientDispatcher(headers: Map[String, String] = Map.empty): Lifecycle[F[Throwable, _], WsRpcDispatcher.IRTDispatcherWs[F]] = { - wsClientFactory.dispatcherSimple(wsUri, demo.Client.buzzerMultiplexor, headers) + wsClientFactory.dispatcherSimple(wsUri, testServices.Client.buzzerMultiplexor, headers) } } @@ -131,184 +160,195 @@ abstract class Http4sTransportTestBase[F[+_, +_]]( "Http4s transport" should { "support http" in { withServer { - for { - // with credentials - privateClient <- F.sync(httpRpcClientDispatcher(Headers(privateAuth("user1")))) - protectedClient <- F.sync(httpRpcClientDispatcher(Headers(protectedAuth("user2")))) - publicClient <- F.sync(httpRpcClientDispatcher(Headers(publicAuth("user3")))) - publicOrcClient <- F.sync(httpRpcClientDispatcher(Headers(publicAuth("orc")))) - - // Private API test - _ <- new PrivateTestServiceWrappedClient(privateClient) - .test("test").map(res => assert(res.startsWith("Private"))) - _ <- checkUnauthorizedHttpCall(new PrivateTestServiceWrappedClient(protectedClient).test("test")) - _ <- checkUnauthorizedHttpCall(new ProtectedTestServiceWrappedClient(publicClient).test("test")) - - // Protected API test - _ <- new ProtectedTestServiceWrappedClient(protectedClient) - .test("test").map(res => assert(res.startsWith("Protected"))) - _ <- checkUnauthorizedHttpCall(new ProtectedTestServiceWrappedClient(privateClient).test("test")) - _ <- checkUnauthorizedHttpCall(new ProtectedTestServiceWrappedClient(publicClient).test("test")) - - // Public API test - _ <- new GreeterServiceClientWrapped(protectedClient) - .greet("Protected", "Client").map(res => assert(res == "Hi, Protected Client!")) - _ <- new GreeterServiceClientWrapped(privateClient) - .greet("Protected", "Client").map(res => assert(res == "Hi, Protected Client!")) - greaterClient = new GreeterServiceClientWrapped(publicClient) - _ <- greaterClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) - _ <- greaterClient.alternative().attempt.map(res => assert(res == Right("value"))) - - // middleware test - _ <- checkUnauthorizedHttpCall(new GreeterServiceClientWrapped(publicOrcClient).greet("Orc", "Smith")) - - // bad body test - _ <- checkBadBody("{}", publicClient) - _ <- checkBadBody("{unparseable", publicClient) - } yield () + ctx => + for { + // with credentials + privateClient <- F.sync(ctx.httpRpcClientDispatcher(Headers(privateAuth("user1")))) + protectedClient <- F.sync(ctx.httpRpcClientDispatcher(Headers(protectedAuth("user2")))) + publicClient <- F.sync(ctx.httpRpcClientDispatcher(Headers(publicAuth("user3")))) + publicOrcClient <- F.sync(ctx.httpRpcClientDispatcher(Headers(publicAuth("orc")))) + + // Private API test + _ <- new PrivateTestServiceWrappedClient(privateClient) + .test("test").map(res => assert(res.startsWith("Private"))) + _ <- checkUnauthorizedHttpCall(new PrivateTestServiceWrappedClient(protectedClient).test("test")) + _ <- checkUnauthorizedHttpCall(new ProtectedTestServiceWrappedClient(publicClient).test("test")) + + // Protected API test + _ <- new ProtectedTestServiceWrappedClient(protectedClient) + .test("test").map(res => assert(res.startsWith("Protected"))) + _ <- checkUnauthorizedHttpCall(new ProtectedTestServiceWrappedClient(privateClient).test("test")) + _ <- checkUnauthorizedHttpCall(new ProtectedTestServiceWrappedClient(publicClient).test("test")) + + // Public API test + _ <- new GreeterServiceClientWrapped(protectedClient) + .greet("Protected", "Client").map(res => assert(res == "Hi, Protected Client!")) + _ <- new GreeterServiceClientWrapped(privateClient) + .greet("Protected", "Client").map(res => assert(res == "Hi, Protected Client!")) + greaterClient = new GreeterServiceClientWrapped(publicClient) + _ <- greaterClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) + _ <- greaterClient.alternative().attempt.map(res => assert(res == Right("value"))) + + // middleware test + _ <- checkUnauthorizedHttpCall(new GreeterServiceClientWrapped(publicOrcClient).greet("Orc", "Smith")) + + // bad body test + _ <- checkBadBody("{}", publicClient) + _ <- checkBadBody("{unparseable", publicClient) + } yield () } } "support websockets" in { withServer { - wsRpcClientDispatcher().use { - dispatcher => - for { - publicHeaders <- F.pure(Map("Authorization" -> publicAuth("user").values.head.value)) - privateHeaders <- F.pure(Map("Authorization" -> privateAuth("user").values.head.value)) - protectedHeaders <- F.pure(Map("Authorization" -> protectedAuth("user").values.head.value)) - protectedHeaders2 <- F.pure(Map("Authorization" -> protectedAuth("John").values.head.value)) - badHeaders <- F.pure(Map("Authorization" -> badAuth().values.head.value)) - - publicClient = new GreeterServiceClientWrapped[F](dispatcher) - privateClient = new PrivateTestServiceWrappedClient[F](dispatcher) - protectedClient = new ProtectedTestServiceWrappedClient[F](dispatcher) - - // no dispatchers yet - _ <- demo.Server.protectedWsStorage.dispatchersFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) - _ <- demo.Server.privateWsStorage.dispatchersFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) - _ <- demo.Server.publicWsStorage.dispatchersFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) - // all listeners are empty - _ = assert(demo.Server.protectedWsListener.connectedContexts.isEmpty) - _ = assert(demo.Server.privateWsListener.connectedContexts.isEmpty) - _ = assert(demo.Server.publicWsListener.connectedContexts.isEmpty) - - // public authorization - _ <- dispatcher.authorize(publicHeaders) - // protected and private listeners are empty - _ = assert(demo.Server.protectedWsListener.connectedContexts.isEmpty) - _ = assert(demo.Server.privateWsListener.connectedContexts.isEmpty) - _ = assert(demo.Server.publicWsListener.connectedContexts.contains(PublicContext("user"))) - // protected and private sessions are empty - _ <- demo.Server.protectedWsStorage.dispatchersFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) - _ <- demo.Server.privateWsStorage.dispatchersFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) - // public dispatcher works as expected - publicContextBuzzer <- demo.Server.publicWsStorage - .dispatchersFor(PublicContext("user"), demo.Client.codec).map(_.headOption) - .fromOption(new RuntimeException("Missing Buzzer")) - _ <- new GreeterServiceClientWrapped(publicContextBuzzer).greet("John", "Buzzer").map(res => assert(res == "Hi, John Buzzer!")) - _ <- publicClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) - _ <- publicClient.alternative().attempt.map(res => assert(res == Right("value"))) - _ <- checkUnauthorizedWsCall(privateClient.test("")) - _ <- checkUnauthorizedWsCall(protectedClient.test("")) - - // re-authorize with private - _ <- dispatcher.authorize(privateHeaders) - // protected listener is empty - _ = assert(demo.Server.protectedWsListener.connectedContexts.isEmpty) - _ = assert(demo.Server.privateWsListener.connectedContexts.contains(PrivateContext("user"))) - _ = assert(demo.Server.publicWsListener.connectedContexts.contains(PublicContext("user"))) - // protected sessions is empty - _ <- demo.Server.protectedWsStorage.dispatchersFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) - _ <- demo.Server.privateWsStorage.dispatchersFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) - _ <- demo.Server.publicWsStorage.dispatchersFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) - _ <- privateClient.test("test").map(res => assert(res.startsWith("Private"))) - _ <- publicClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) - _ <- checkUnauthorizedWsCall(protectedClient.test("")) - - // re-authorize with protected - _ <- dispatcher.authorize(protectedHeaders) - // private listener is empty - _ = assert(demo.Server.protectedWsListener.connectedContexts.contains(ProtectedContext("user"))) - _ = assert(demo.Server.privateWsListener.connectedContexts.isEmpty) - _ = assert(demo.Server.publicWsListener.connectedContexts.contains(PublicContext("user"))) - // private sessions is empty - _ <- demo.Server.protectedWsStorage.dispatchersFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) - _ <- demo.Server.privateWsStorage.dispatchersFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) - _ <- demo.Server.publicWsStorage.dispatchersFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) - _ <- protectedClient.test("test").map(res => assert(res.startsWith("Protected"))) - _ <- publicClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) - _ <- checkUnauthorizedWsCall(privateClient.test("")) - - // auth session context update - _ <- dispatcher.authorize(protectedHeaders2) - // session and listeners notified - _ = assert(demo.Server.protectedWsListener.connectedContexts.contains(ProtectedContext("John"))) - _ = assert(demo.Server.protectedWsListener.connectedContexts.size == 1) - _ = assert(demo.Server.publicWsListener.connectedContexts.contains(PublicContext("John"))) - _ = assert(demo.Server.publicWsListener.connectedContexts.size == 1) - _ = assert(demo.Server.privateWsListener.connectedContexts.isEmpty) - _ <- demo.Server.privateWsStorage.dispatchersFor(PrivateContext("John"), demo.Client.codec).map(b => assert(b.isEmpty)) - _ <- demo.Server.publicWsStorage.dispatchersFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) - _ <- demo.Server.publicWsStorage.dispatchersFor(PublicContext("John"), demo.Client.codec).map(b => assert(b.nonEmpty)) - _ <- demo.Server.protectedWsStorage.dispatchersFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) - _ <- demo.Server.protectedWsStorage.dispatchersFor(ProtectedContext("John"), demo.Client.codec).map(b => assert(b.nonEmpty)) - - // bad authorization - _ <- dispatcher.authorize(badHeaders) - _ <- checkUnauthorizedWsCall(publicClient.alternative()) - } yield () - } - } - } - - "support websockets request auth" in { - withServer { - for { - privateHeaders <- F.pure(Map("Authorization" -> privateAuth("user").values.head.value)) - _ <- wsRpcClientDispatcher(privateHeaders).use { + ctx => + import ctx.testServices.{Client, Server} + ctx.wsRpcClientDispatcher().use { dispatcher => - val publicClient = new GreeterServiceClientWrapped[F](dispatcher) - val privateClient = new PrivateTestServiceWrappedClient[F](dispatcher) - val protectedClient = new ProtectedTestServiceWrappedClient[F](dispatcher) for { - _ <- demo.Server.protectedWsStorage.dispatchersFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) - _ <- demo.Server.privateWsStorage.dispatchersFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) - _ <- demo.Server.publicWsStorage.dispatchersFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.nonEmpty)) - _ = assert(demo.Server.protectedWsListener.connectedContexts.isEmpty) - _ = assert(demo.Server.privateWsListener.connectedContexts.size == 1) - _ = assert(demo.Server.publicWsListener.connectedContexts.size == 1) + publicHeaders <- F.pure(Map("Authorization" -> publicAuth("user").values.head.value)) + privateHeaders <- F.pure(Map("Authorization" -> privateAuth("user").values.head.value)) + protectedHeaders <- F.pure(Map("Authorization" -> protectedAuth("user").values.head.value)) + protectedHeaders2 <- F.pure(Map("Authorization" -> protectedAuth("John").values.head.value)) + badHeaders <- F.pure(Map("Authorization" -> badAuth().values.head.value)) + + publicClient = new GreeterServiceClientWrapped[F](dispatcher) + privateClient = new PrivateTestServiceWrappedClient[F](dispatcher) + protectedClient = new ProtectedTestServiceWrappedClient[F](dispatcher) + + // session id is set + sessionId <- F.fromOption(new RuntimeException("Missing Ws Session Id."))(dispatcher.sessionId) + _ <- Server.wsStorage.getSession(sessionId).fromOption(new RuntimeException("Missing Ws Session.")) + + // no dispatchers yet + _ <- Server.protectedWsStorage.dispatchersFor(ProtectedContext("user"), Client.codec).map(b => assert(b.isEmpty)) + _ <- Server.privateWsStorage.dispatchersFor(PrivateContext("user"), Client.codec).map(b => assert(b.isEmpty)) + _ <- Server.publicWsStorage.dispatchersFor(PublicContext("user"), Client.codec).map(b => assert(b.isEmpty)) + // all listeners are empty + _ = assert(Server.protectedWsListener.connectedContexts.isEmpty) + _ = assert(Server.privateWsListener.connectedContexts.isEmpty) + _ = assert(Server.publicWsListener.connectedContexts.isEmpty) + + // public authorization + _ <- dispatcher.authorize(publicHeaders) + // protected and private listeners are empty + _ = assert(Server.protectedWsListener.connectedContexts.isEmpty) + _ = assert(Server.privateWsListener.connectedContexts.isEmpty) + _ = assert(Server.publicWsListener.connectedContexts.contains(PublicContext("user"))) + // protected and private sessions are empty + _ <- Server.protectedWsStorage.dispatchersFor(ProtectedContext("user"), Client.codec).map(b => assert(b.isEmpty)) + _ <- Server.privateWsStorage.dispatchersFor(PrivateContext("user"), Client.codec).map(b => assert(b.isEmpty)) + // public dispatcher works as expected + publicContextBuzzer <- Server.publicWsStorage + .dispatchersFor(PublicContext("user"), Client.codec).map(_.headOption) + .fromOption(new RuntimeException("Missing Buzzer")) + _ <- new GreeterServiceClientWrapped(publicContextBuzzer).greet("John", "Buzzer").map(res => assert(res == "Hi, John Buzzer!")) + _ <- publicClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) + _ <- publicClient.alternative().attempt.map(res => assert(res == Right("value"))) + _ <- checkUnauthorizedWsCall(privateClient.test("")) + _ <- checkUnauthorizedWsCall(protectedClient.test("")) + // re-authorize with private + _ <- dispatcher.authorize(privateHeaders) + // protected listener is empty + _ = assert(Server.protectedWsListener.connectedContexts.isEmpty) + _ = assert(Server.privateWsListener.connectedContexts.contains(PrivateContext("user"))) + _ = assert(Server.publicWsListener.connectedContexts.contains(PublicContext("user"))) + // protected sessions is empty + _ <- Server.protectedWsStorage.dispatchersFor(ProtectedContext("user"), Client.codec).map(b => assert(b.isEmpty)) + _ <- Server.privateWsStorage.dispatchersFor(PrivateContext("user"), Client.codec).map(b => assert(b.nonEmpty)) + _ <- Server.publicWsStorage.dispatchersFor(PublicContext("user"), Client.codec).map(b => assert(b.nonEmpty)) _ <- privateClient.test("test").map(res => assert(res.startsWith("Private"))) _ <- publicClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) _ <- checkUnauthorizedWsCall(protectedClient.test("")) + + // re-authorize with protected + _ <- dispatcher.authorize(protectedHeaders) + // private listener is empty + _ = assert(Server.protectedWsListener.connectedContexts.contains(ProtectedContext("user"))) + _ = assert(Server.privateWsListener.connectedContexts.isEmpty) + _ = assert(Server.publicWsListener.connectedContexts.contains(PublicContext("user"))) + // private sessions is empty + _ <- Server.protectedWsStorage.dispatchersFor(ProtectedContext("user"), Client.codec).map(b => assert(b.nonEmpty)) + _ <- Server.privateWsStorage.dispatchersFor(PrivateContext("user"), Client.codec).map(b => assert(b.isEmpty)) + _ <- Server.publicWsStorage.dispatchersFor(PublicContext("user"), Client.codec).map(b => assert(b.nonEmpty)) + _ <- protectedClient.test("test").map(res => assert(res.startsWith("Protected"))) + _ <- publicClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) + _ <- checkUnauthorizedWsCall(privateClient.test("")) + + // auth session context update + _ <- dispatcher.authorize(protectedHeaders2) + // session and listeners notified + _ = assert(Server.protectedWsListener.connectedContexts.contains(ProtectedContext("John"))) + _ = assert(Server.protectedWsListener.connectedContexts.size == 1) + _ = assert(Server.publicWsListener.connectedContexts.contains(PublicContext("John"))) + _ = assert(Server.publicWsListener.connectedContexts.size == 1) + _ = assert(Server.privateWsListener.connectedContexts.isEmpty) + _ <- Server.privateWsStorage.dispatchersFor(PrivateContext("John"), Client.codec).map(b => assert(b.isEmpty)) + _ <- Server.publicWsStorage.dispatchersFor(PublicContext("user"), Client.codec).map(b => assert(b.isEmpty)) + _ <- Server.publicWsStorage.dispatchersFor(PublicContext("John"), Client.codec).map(b => assert(b.nonEmpty)) + _ <- Server.protectedWsStorage.dispatchersFor(ProtectedContext("user"), Client.codec).map(b => assert(b.isEmpty)) + _ <- Server.protectedWsStorage.dispatchersFor(ProtectedContext("John"), Client.codec).map(b => assert(b.nonEmpty)) + + // bad authorization + _ <- dispatcher.authorize(badHeaders) + _ <- checkUnauthorizedWsCall(publicClient.alternative()) } yield () } - } yield () + } + } + + "support websockets request auth" in { + withServer { + ctx => + import ctx.testServices.{Client, Server} + for { + privateHeaders <- F.pure(Map("Authorization" -> privateAuth("user").values.head.value)) + _ <- ctx.wsRpcClientDispatcher(privateHeaders).use { + dispatcher => + val publicClient = new GreeterServiceClientWrapped[F](dispatcher) + val privateClient = new PrivateTestServiceWrappedClient[F](dispatcher) + val protectedClient = new ProtectedTestServiceWrappedClient[F](dispatcher) + for { + _ <- Server.protectedWsStorage.dispatchersFor(ProtectedContext("user"), Client.codec).map(b => assert(b.isEmpty)) + _ <- Server.privateWsStorage.dispatchersFor(PrivateContext("user"), Client.codec).map(b => assert(b.nonEmpty)) + _ <- Server.publicWsStorage.dispatchersFor(PublicContext("user"), Client.codec).map(b => assert(b.nonEmpty)) + _ = assert(Server.protectedWsListener.connectedContexts.isEmpty) + _ = assert(Server.privateWsListener.connectedContexts.size == 1) + _ = assert(Server.publicWsListener.connectedContexts.size == 1) + + _ <- privateClient.test("test").map(res => assert(res.startsWith("Private"))) + _ <- publicClient.greet("John", "Smith").map(res => assert(res == "Hi, John Smith!")) + _ <- checkUnauthorizedWsCall(protectedClient.test("")) + } yield () + } + } yield () } } "support websockets multiple sessions on same context" in { withServer { - for { - privateHeaders <- F.pure(Map("Authorization" -> privateAuth("user").values.head.value)) - _ <- { - for { - c1 <- wsRpcClientDispatcher(privateHeaders) - c2 <- wsRpcClientDispatcher(privateHeaders) - } yield (c1, c2) - }.use { - case (_, _) => + ctx => + import ctx.testServices.{Client, Server} + for { + privateHeaders <- F.pure(Map("Authorization" -> privateAuth("user").values.head.value)) + _ <- { for { - _ <- demo.Server.protectedWsStorage.dispatchersFor(ProtectedContext("user"), demo.Client.codec).map(b => assert(b.isEmpty)) - _ <- demo.Server.privateWsStorage.dispatchersFor(PrivateContext("user"), demo.Client.codec).map(b => assert(b.size == 2)) - _ <- demo.Server.publicWsStorage.dispatchersFor(PublicContext("user"), demo.Client.codec).map(b => assert(b.size == 2)) - _ = assert(demo.Server.protectedWsListener.connected.isEmpty) - _ = assert(demo.Server.privateWsListener.connected.size == 2) - _ = assert(demo.Server.publicWsListener.connected.size == 2) - } yield () - } - } yield () + c1 <- ctx.wsRpcClientDispatcher(privateHeaders) + c2 <- ctx.wsRpcClientDispatcher(privateHeaders) + } yield (c1, c2) + }.use { + case (_, _) => + for { + _ <- Server.protectedWsStorage.dispatchersFor(ProtectedContext("user"), Client.codec).map(b => assert(b.isEmpty)) + _ <- Server.privateWsStorage.dispatchersFor(PrivateContext("user"), Client.codec).map(b => assert(b.size == 2)) + _ <- Server.publicWsStorage.dispatchersFor(PublicContext("user"), Client.codec).map(b => assert(b.size == 2)) + _ = assert(Server.protectedWsListener.connected.isEmpty) + _ = assert(Server.privateWsListener.connected.size == 2) + _ = assert(Server.publicWsListener.connected.size == 2) + } yield () + } + } yield () } } @@ -332,23 +372,6 @@ abstract class Http4sTransportTestBase[F[+_, +_]]( } } - def withServer(f: F[Throwable, Unit]): Unit = { - executeF { - BlazeServerBuilder[F[Throwable, _]] - .bindHttp(port, host) - .withHttpWebSocketApp(ws => Router("/" -> ioService.service(ws)).orNotFound) - .resource - .use(_ => f) - } - } - - def executeF(io: F[Throwable, Unit]): Unit = { - UnsafeRun2[F].unsafeRunSync(io) match { - case Success(()) => () - case failure: Exit.Failure[?] => throw failure.trace.toThrowable - } - } - def checkUnauthorizedHttpCall[E, A](call: F[E, A]): F[Throwable, Unit] = { call.sandboxExit.map { case Termination(exception: IRTUnexpectedHttpStatus, _, _) => assert(exception.status == Status.Unauthorized) diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala index 65d8e202..6d241434 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala @@ -48,7 +48,7 @@ class TestServices[F[+_, +_]: IO2]( ) // PRIVATE final val privateAuth = new IRTAuthenticator[F, AuthContext, PrivateContext] { - override def authenticate(authContext: AuthContext, body: Option[Json]): F[Nothing, Option[PrivateContext]] = F.sync { + override def authenticate(authContext: AuthContext, body: Option[Json], method: Option[IRTMethodId]): F[Nothing, Option[PrivateContext]] = F.sync { authContext.headers.get[Authorization].map(_.credentials).collect { case BasicCredentials(user, "private") => PrivateContext(user) } @@ -84,7 +84,7 @@ class TestServices[F[+_, +_]: IO2]( // PROTECTED final val protectedAuth = new IRTAuthenticator[F, AuthContext, ProtectedContext] { - override def authenticate(authContext: AuthContext, body: Option[Json]): F[Nothing, Option[ProtectedContext]] = F.sync { + override def authenticate(authContext: AuthContext, body: Option[Json], method: Option[IRTMethodId]): F[Nothing, Option[ProtectedContext]] = F.sync { authContext.headers.get[Authorization].map(_.credentials).collect { case BasicCredentials(user, "protected") => ProtectedContext(user) } @@ -120,7 +120,7 @@ class TestServices[F[+_, +_]: IO2]( // PUBLIC final val publicAuth = new IRTAuthenticator[F, AuthContext, PublicContext] { - override def authenticate(authContext: AuthContext, body: Option[Json]): F[Nothing, Option[PublicContext]] = F.sync { + override def authenticate(authContext: AuthContext, body: Option[Json], method: Option[IRTMethodId]): F[Nothing, Option[PublicContext]] = F.sync { authContext.headers.get[Authorization].map(_.credentials).collect { case BasicCredentials(user, _) => PublicContext(user) } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMethod.scala b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMethod.scala index 6169b25e..7fc6b4da 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMethod.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMethod.scala @@ -9,10 +9,10 @@ trait IRTServerMethod[F[+_, +_], C] { def invoke(context: C, parsedBody: Json): F[Throwable, Json] /** Contramap eval on context C2 -> C. If context is missing IRTUnathorizedRequestContextException will raise. */ - final def contramap[C2](updateContext: (C2, Json) => F[Throwable, Option[C]])(implicit E: Error2[F]): IRTServerMethod[F, C2] = new IRTServerMethod[F, C2] { + final def contramap[C2](updateContext: (C2, Json, IRTMethodId) => F[Throwable, Option[C]])(implicit E: Error2[F]): IRTServerMethod[F, C2] = new IRTServerMethod[F, C2] { override def methodId: IRTMethodId = self.methodId override def invoke(context: C2, parsedBody: Json): F[Throwable, Json] = { - updateContext(context, parsedBody) + updateContext(context, parsedBody, methodId) .fromOption(new IRTUnathorizedRequestContextException(s"Unauthorized $methodId call. Context: $context.")) .flatMap(self.invoke(_, parsedBody)) } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala index 7774c702..52a20851 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/IRTServerMultiplexor.scala @@ -14,7 +14,7 @@ trait IRTServerMultiplexor[F[+_, +_], C] { /** Contramap eval on context C2 -> C. If context is missing IRTUnathorizedRequestContextException will raise. */ final def contramap[C2]( - updateContext: (C2, Json) => F[Throwable, Option[C]] + updateContext: (C2, Json, IRTMethodId) => F[Throwable, Option[C]] )(implicit io2: IO2[F] ): IRTServerMultiplexor[F, C2] = { val mappedMethods = self.methods.map { case (k, v) => k -> v.contramap(updateContext) } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/packets.scala b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/packets.scala index 7784827c..4cfde254 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/packets.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-scala/src/main/scala/izumi/idealingua/runtime/rpc/packets.scala @@ -93,6 +93,14 @@ case class RpcPacket( def withHeaders(h: Map[String, String]): RpcPacket = { copy(headers = Option(h).filter(_.nonEmpty)) } + def methodId: Option[IRTMethodId] = { + for { + m <- method + s <- service + } yield { + IRTMethodId(IRTServiceId(s), IRTMethodName(m)) + } + } } object RpcPacket { From 981d508ddf7cea5a496697c974e71ae0be0f2127 Mon Sep 17 00:00:00 2001 From: Alex Liubymov Date: Sat, 23 Dec 2023 15:23:33 +0200 Subject: [PATCH 20/24] add names to the IRT contexts --- .../runtime/rpc/http4s/IRTContextServices.scala | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala index 50af0b8c..3f130680 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala @@ -3,8 +3,10 @@ package izumi.idealingua.runtime.rpc.http4s import izumi.functional.bio.{IO2, Monad2} import izumi.idealingua.runtime.rpc.http4s.ws.WsContextSessions import izumi.idealingua.runtime.rpc.{IRTServerMiddleware, IRTServerMultiplexor} +import izumi.reflect.Tag trait IRTContextServices[F[+_, +_], AuthCtx, RequestCtx, WsCtx] { + def name: String def authenticator: IRTAuthenticator[F, AuthCtx, RequestCtx] def serverMuxer: IRTServerMultiplexor[F, RequestCtx] def middlewares: Set[IRTServerMiddleware[F, RequestCtx]] @@ -30,18 +32,21 @@ trait IRTContextServices[F[+_, +_], AuthCtx, RequestCtx, WsCtx] { object IRTContextServices { type AnyContext[F[+_, +_], AuthCtx] = IRTContextServices[F, AuthCtx, ?, ?] + type AnyWsContext[F[+_, +_], AuthCtx, RequestCtx] = IRTContextServices[F, AuthCtx, RequestCtx, ?] - def apply[F[+_, +_], AuthCtx, RequestCtx, WsCtx]( + def apply[F[+_, +_], AuthCtx, RequestCtx: Tag, WsCtx: Tag]( authenticator: IRTAuthenticator[F, AuthCtx, RequestCtx], serverMuxer: IRTServerMultiplexor[F, RequestCtx], middlewares: Set[IRTServerMiddleware[F, RequestCtx]], wsSessions: WsContextSessions[F, RequestCtx, WsCtx], ): Default[F, AuthCtx, RequestCtx, WsCtx] = Default(authenticator, serverMuxer, middlewares, wsSessions) - final case class Default[F[+_, +_], AuthCtx, RequestCtx, WsCtx]( + final case class Default[F[+_, +_], AuthCtx, RequestCtx: Tag, WsCtx: Tag]( authenticator: IRTAuthenticator[F, AuthCtx, RequestCtx], serverMuxer: IRTServerMultiplexor[F, RequestCtx], middlewares: Set[IRTServerMiddleware[F, RequestCtx]], wsSessions: WsContextSessions[F, RequestCtx, WsCtx], - ) extends IRTContextServices[F, AuthCtx, RequestCtx, WsCtx] + ) extends IRTContextServices[F, AuthCtx, RequestCtx, WsCtx] { + override def name: String = s"${Tag[RequestCtx].tag}:${Tag[WsCtx].tag}" + } } From 8ea393e145243edd48ed5eedbc0e0ddff358e2ef Mon Sep 17 00:00:00 2001 From: Alex Liubymov Date: Tue, 26 Dec 2023 20:39:16 +0200 Subject: [PATCH 21/24] Add custom names to contexts, add Dummy WsClientSessions for local WS infra tests. --- .../runtime/rpc/http4s/HttpServer.scala | 3 +- .../rpc/http4s/IRTContextServices.scala | 24 ++++++++-- .../rpc/http4s/ws/WsClientSession.scala | 45 +++++++++++++++---- .../rpc/http4s/fixtures/TestServices.scala | 6 +-- 4 files changed, 60 insertions(+), 18 deletions(-) diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala index 5299e53d..6ef35614 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/HttpServer.scala @@ -16,7 +16,6 @@ import izumi.idealingua.runtime.rpc.* import izumi.idealingua.runtime.rpc.http4s.HttpServer.{ServerWsRpcHandler, WsResponseMarker} import izumi.idealingua.runtime.rpc.http4s.context.{HttpContextExtractor, WsContextExtractor} import izumi.idealingua.runtime.rpc.http4s.ws.* -import izumi.idealingua.runtime.rpc.http4s.ws.WsClientSession.WsClientSessionImpl import logstage.LogIO2 import org.http4s.* import org.http4s.dsl.Http4sDsl @@ -71,7 +70,7 @@ class HttpServer[F[+_, +_]: IO2: Temporal2: Primitives2: UnsafeRun2, AuthCtx]( for { outQueue <- Queue.unbounded[F[Throwable, _], WebSocketFrame] authContext <- F.syncThrowable(httpContextExtractor.extract(request)) - clientSession = new WsClientSessionImpl(outQueue, authContext, wsContextsSessions, wsSessionsStorage, wsContextExtractor, logger, printer) + clientSession = new WsClientSession.Queued(outQueue, authContext, wsContextsSessions, wsSessionsStorage, wsContextExtractor, logger, printer) _ <- clientSession.start(onWsConnected) outStream = Stream.fromQueueUnterminated(outQueue).merge(pingStream) diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala index 3f130680..91c0e7fa 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/IRTContextServices.scala @@ -31,17 +31,33 @@ trait IRTContextServices[F[+_, +_], AuthCtx, RequestCtx, WsCtx] { } object IRTContextServices { - type AnyContext[F[+_, +_], AuthCtx] = IRTContextServices[F, AuthCtx, ?, ?] + type AnyContext[F[+_, +_], AuthCtx] = IRTContextServices[F, AuthCtx, ?, ?] type AnyWsContext[F[+_, +_], AuthCtx, RequestCtx] = IRTContextServices[F, AuthCtx, RequestCtx, ?] - def apply[F[+_, +_], AuthCtx, RequestCtx: Tag, WsCtx: Tag]( + def tagged[F[+_, +_], AuthCtx, RequestCtx: Tag, WsCtx: Tag]( authenticator: IRTAuthenticator[F, AuthCtx, RequestCtx], serverMuxer: IRTServerMultiplexor[F, RequestCtx], middlewares: Set[IRTServerMiddleware[F, RequestCtx]], wsSessions: WsContextSessions[F, RequestCtx, WsCtx], - ): Default[F, AuthCtx, RequestCtx, WsCtx] = Default(authenticator, serverMuxer, middlewares, wsSessions) + ): Tagged[F, AuthCtx, RequestCtx, WsCtx] = Tagged(authenticator, serverMuxer, middlewares, wsSessions) - final case class Default[F[+_, +_], AuthCtx, RequestCtx: Tag, WsCtx: Tag]( + def named[F[+_, +_], AuthCtx, RequestCtx, WsCtx]( + name: String + )(authenticator: IRTAuthenticator[F, AuthCtx, RequestCtx], + serverMuxer: IRTServerMultiplexor[F, RequestCtx], + middlewares: Set[IRTServerMiddleware[F, RequestCtx]], + wsSessions: WsContextSessions[F, RequestCtx, WsCtx], + ): Named[F, AuthCtx, RequestCtx, WsCtx] = Named(name, authenticator, serverMuxer, middlewares, wsSessions) + + final case class Named[F[+_, +_], AuthCtx, RequestCtx, WsCtx]( + name: String, + authenticator: IRTAuthenticator[F, AuthCtx, RequestCtx], + serverMuxer: IRTServerMultiplexor[F, RequestCtx], + middlewares: Set[IRTServerMiddleware[F, RequestCtx]], + wsSessions: WsContextSessions[F, RequestCtx, WsCtx], + ) extends IRTContextServices[F, AuthCtx, RequestCtx, WsCtx] + + final case class Tagged[F[+_, +_], AuthCtx, RequestCtx: Tag, WsCtx: Tag]( authenticator: IRTAuthenticator[F, AuthCtx, RequestCtx], serverMuxer: IRTServerMultiplexor[F, RequestCtx], middlewares: Set[IRTServerMiddleware[F, RequestCtx]], diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala index 21857eac..63c4695c 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala @@ -41,14 +41,12 @@ object WsClientSession { override def responseWithData(id: RpcPacketId, data: Json): F[Throwable, Unit] = F.unit } - class WsClientSessionImpl[F[+_, +_]: IO2: Temporal2: Primitives2, SessionCtx]( - outQueue: Queue[F[Throwable, _], WebSocketFrame], + abstract class Base[F[+_, +_]: IO2: Temporal2: Primitives2, SessionCtx]( initialContext: SessionCtx, wsSessionsContext: Set[WsContextSessions.AnyContext[F, SessionCtx]], wsSessionStorage: WsSessionsStorage[F, SessionCtx], wsContextExtractor: WsContextExtractor[SessionCtx], logger: LogIO2[F], - printer: Printer, ) extends WsClientSession[F, SessionCtx] { private val requestCtxRef = new AtomicReference[SessionCtx](initialContext) private val openingTime: ZonedDateTime = IzTime.utcNow @@ -56,6 +54,9 @@ object WsClientSession { override val sessionId: WsSessionId = WsSessionId(UUIDGen.getTimeUUID()) + protected def sendMessage(message: RpcPacket): F[Throwable, Unit] + protected def sendCloseMessage(): F[Throwable, Unit] + override def updateRequestCtx(newContext: SessionCtx): F[Throwable, SessionCtx] = { for { contexts <- F.sync { @@ -79,11 +80,9 @@ object WsClientSession { val id = RpcPacketId.random() val request = RpcPacket.buzzerRequest(id, method, data) for { - _ <- logger.debug(s"WS Session: enqueue $request with $id to request state & send queue.") - response <- requestState.requestAndAwait(id, Some(method), timeout) { - outQueue.offer(Text(printer.print(request.asJson))) - } - _ <- logger.debug(s"WS Session: $method, ${id -> "id"}: cleaning request state.") + _ <- logger.debug(s"WS Session: enqueue $request with $id to request state & send queue.") + response <- requestState.requestAndAwait(id, Some(method), timeout)(sendMessage(request)) + _ <- logger.debug(s"WS Session: $method, ${id -> "id"}: cleaning request state.") } yield response } @@ -97,7 +96,7 @@ object WsClientSession { override def finish(onFinish: SessionCtx => F[Throwable, Unit]): F[Throwable, Unit] = { val requestCtx = requestCtxRef.get() - F.fromEither(WebSocketFrame.Close(1000)).flatMap(outQueue.offer(_)) *> + sendCloseMessage() *> requestState.clear() *> wsSessionStorage.deleteSession(sessionId) *> F.traverse_(wsSessionsContext)(_.updateSession(sessionId, None)) *> @@ -119,4 +118,32 @@ object WsClientSession { FiniteDuration(d.toNanos, TimeUnit.NANOSECONDS) } } + + final class Dummy[F[+_, +_]: IO2: Temporal2: Primitives2, SessionCtx]( + initialContext: SessionCtx, + wsSessionsContext: Set[WsContextSessions.AnyContext[F, SessionCtx]], + wsSessionStorage: WsSessionsStorage[F, SessionCtx], + wsContextExtractor: WsContextExtractor[SessionCtx], + logger: LogIO2[F], + ) extends Base[F, SessionCtx](initialContext, wsSessionsContext, wsSessionStorage, wsContextExtractor, logger) { + override protected def sendMessage(message: RpcPacket): F[Throwable, Unit] = F.unit + override protected def sendCloseMessage(): F[Throwable, Unit] = F.unit + } + + final class Queued[F[+_, +_]: IO2: Temporal2: Primitives2, SessionCtx]( + outQueue: Queue[F[Throwable, _], WebSocketFrame], + initialContext: SessionCtx, + wsSessionsContext: Set[WsContextSessions.AnyContext[F, SessionCtx]], + wsSessionStorage: WsSessionsStorage[F, SessionCtx], + wsContextExtractor: WsContextExtractor[SessionCtx], + logger: LogIO2[F], + printer: Printer, + ) extends Base[F, SessionCtx](initialContext, wsSessionsContext, wsSessionStorage, wsContextExtractor, logger) { + override protected def sendMessage(message: RpcPacket): F[Throwable, Unit] = { + outQueue.offer(Text(printer.print(message.asJson))) + } + override protected def sendCloseMessage(): F[Throwable, Unit] = { + F.fromEither(WebSocketFrame.Close(1000)).flatMap(outQueue.offer(_)) + } + } } diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala index 6d241434..5b99521b 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/fixtures/TestServices.scala @@ -74,7 +74,7 @@ class TestServices[F[+_, +_]: IO2]( ) } final val privateServices: IRTContextServices[F, AuthContext, PrivateContext, PrivateContext] = { - IRTContextServices[F, AuthContext, PrivateContext, PrivateContext]( + IRTContextServices.tagged[F, AuthContext, PrivateContext, PrivateContext]( authenticator = privateAuth, serverMuxer = new IRTServerMultiplexor.FromServices(Set(privateService)), middlewares = Set.empty, @@ -110,7 +110,7 @@ class TestServices[F[+_, +_]: IO2]( ) } final val protectedServices: IRTContextServices[F, AuthContext, ProtectedContext, ProtectedContext] = { - IRTContextServices[F, AuthContext, ProtectedContext, ProtectedContext]( + IRTContextServices.tagged[F, AuthContext, ProtectedContext, ProtectedContext]( authenticator = protectedAuth, serverMuxer = new IRTServerMultiplexor.FromServices(Set(protectedService)), middlewares = Set.empty, @@ -144,7 +144,7 @@ class TestServices[F[+_, +_]: IO2]( ) } final val publicServices: IRTContextServices[F, AuthContext, PublicContext, PublicContext] = { - IRTContextServices[F, AuthContext, PublicContext, PublicContext]( + IRTContextServices.tagged[F, AuthContext, PublicContext, PublicContext]( authenticator = publicAuth, serverMuxer = new IRTServerMultiplexor.FromServices(Set(publicService)), middlewares = Set(userBlacklistMiddleware(Set("orc"))), From d2b354511a928ebf5166fa27c12875399425ab20 Mon Sep 17 00:00:00 2001 From: Alex Liubymov Date: Tue, 26 Dec 2023 21:52:28 +0200 Subject: [PATCH 22/24] empty sessions --- .../runtime/rpc/http4s/ws/WsContextSessions.scala | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala index e2236cfb..8d903e53 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextSessions.scala @@ -1,6 +1,6 @@ package izumi.idealingua.runtime.rpc.http4s.ws -import izumi.functional.bio.{F, IO2, Monad2} +import izumi.functional.bio.{Applicative2, F, IO2, Monad2} import izumi.idealingua.runtime.rpc.http4s.context.WsIdExtractor trait WsContextSessions[F[+_, +_], RequestCtx, WsCtx] { @@ -18,7 +18,9 @@ trait WsContextSessions[F[+_, +_], RequestCtx, WsCtx] { object WsContextSessions { type AnyContext[F[+_, +_], RequestCtx] = WsContextSessions[F, RequestCtx, ?] - def empty[F[+_, +_]: IO2, RequestCtx]: WsContextSessions[F, RequestCtx, Unit] = new WsContextSessions[F, RequestCtx, Unit] { + def unit[F[+_, +_]: Applicative2, RequestCtx]: WsContextSessions[F, RequestCtx, Unit] = new Empty + + final class Empty[F[+_, +_]: Applicative2, RequestCtx, WsCtx] extends WsContextSessions[F, RequestCtx, WsCtx] { override def updateSession(wsSessionId: WsSessionId, requestContext: Option[RequestCtx]): F[Throwable, Unit] = F.unit } From be34b2caf0148d90bbfeb8996af33733f37d96c6 Mon Sep 17 00:00:00 2001 From: Alex Liubymov Date: Thu, 28 Dec 2023 13:55:10 +0200 Subject: [PATCH 23/24] Support client muxer in dummy WS sessions. --- .../rpc/http4s/ws/WsClientSession.scala | 27 ++++++++++---- .../runtime/rpc/http4s/ws/WsRpcHandler.scala | 6 +++ .../rpc/http4s/Http4sTransportTest.scala | 37 ++++++++++++++++++- 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala index 63c4695c..addb1b33 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsClientSession.scala @@ -6,9 +6,10 @@ import io.circe.{Json, Printer} import izumi.functional.bio.{Applicative2, F, IO2, Primitives2, Temporal2} import izumi.fundamentals.platform.time.IzTime import izumi.fundamentals.platform.uuid.UUIDGen +import izumi.idealingua.runtime.rpc.* +import izumi.idealingua.runtime.rpc.http4s.clients.WsRpcDispatcherFactory.ClientWsRpcHandler import izumi.idealingua.runtime.rpc.http4s.context.WsContextExtractor import izumi.idealingua.runtime.rpc.http4s.ws.WsRpcHandler.WsResponder -import izumi.idealingua.runtime.rpc.{IRTMethodId, RpcPacket, RpcPacketId} import logstage.LogIO2 import org.http4s.websocket.WebSocketFrame import org.http4s.websocket.WebSocketFrame.Text @@ -48,15 +49,15 @@ object WsClientSession { wsContextExtractor: WsContextExtractor[SessionCtx], logger: LogIO2[F], ) extends WsClientSession[F, SessionCtx] { - private val requestCtxRef = new AtomicReference[SessionCtx](initialContext) - private val openingTime: ZonedDateTime = IzTime.utcNow - private val requestState: WsRequestState[F] = WsRequestState.create[F] - - override val sessionId: WsSessionId = WsSessionId(UUIDGen.getTimeUUID()) + private val requestCtxRef = new AtomicReference[SessionCtx](initialContext) + private val openingTime: ZonedDateTime = IzTime.utcNow + protected val requestState: WsRequestState[F] = WsRequestState.create[F] protected def sendMessage(message: RpcPacket): F[Throwable, Unit] protected def sendCloseMessage(): F[Throwable, Unit] + override val sessionId: WsSessionId = WsSessionId(UUIDGen.getTimeUUID()) + override def updateRequestCtx(newContext: SessionCtx): F[Throwable, SessionCtx] = { for { contexts <- F.sync { @@ -121,13 +122,23 @@ object WsClientSession { final class Dummy[F[+_, +_]: IO2: Temporal2: Primitives2, SessionCtx]( initialContext: SessionCtx, + muxer: IRTServerMultiplexor[F, Unit], wsSessionsContext: Set[WsContextSessions.AnyContext[F, SessionCtx]], wsSessionStorage: WsSessionsStorage[F, SessionCtx], wsContextExtractor: WsContextExtractor[SessionCtx], logger: LogIO2[F], ) extends Base[F, SessionCtx](initialContext, wsSessionsContext, wsSessionStorage, wsContextExtractor, logger) { - override protected def sendMessage(message: RpcPacket): F[Throwable, Unit] = F.unit - override protected def sendCloseMessage(): F[Throwable, Unit] = F.unit + private val clientHandler = new ClientWsRpcHandler(muxer, requestState, WsContextExtractor.unit, logger) + override protected def sendMessage(message: RpcPacket): F[Throwable, Unit] = { + clientHandler.processRpcPacket(message).flatMap { + case Some(RpcPacket(_, Some(json), None, Some(ref), _, _, _)) => + // discard any errors here (it's only possible to fail if the packet reference is missing) + requestState.responseWithData(ref, json).attempt.void + case _ => + F.unit + } + } + override protected def sendCloseMessage(): F[Throwable, Unit] = F.unit } final class Queued[F[+_, +_]: IO2: Temporal2: Primitives2, SessionCtx]( diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala index 3c3727f8..100d64f5 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsRpcHandler.scala @@ -24,6 +24,12 @@ abstract class WsRpcHandler[F[+_, +_]: IO2, RequestCtx]( packet <- F .fromEither(io.circe.parser.decode[RpcPacket](message)) .leftMap(err => new IRTDecodingException(s"Can not decode Rpc Packet '$message'.\nError: $err.")) + response <- processRpcPacket(packet) + } yield response + } + + def processRpcPacket(packet: RpcPacket): F[Throwable, Option[RpcPacket]] = { + for { requestCtx <- updateRequestCtx(packet) response <- packet match { // auth diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala index 9f9d9dda..abbc3a4c 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/test/scala/izumi/idealingua/runtime/rpc/http4s/Http4sTransportTest.scala @@ -15,10 +15,10 @@ import izumi.idealingua.runtime.rpc.http4s.clients.HttpRpcDispatcher.IRTDispatch import izumi.idealingua.runtime.rpc.http4s.clients.{HttpRpcDispatcher, HttpRpcDispatcherFactory, WsRpcDispatcher, WsRpcDispatcherFactory} import izumi.idealingua.runtime.rpc.http4s.context.{HttpContextExtractor, WsContextExtractor} import izumi.idealingua.runtime.rpc.http4s.fixtures.TestServices -import izumi.idealingua.runtime.rpc.http4s.ws.{RawResponse, WsRequestState} +import izumi.idealingua.runtime.rpc.http4s.ws.{RawResponse, WsClientSession, WsRequestState} import izumi.logstage.api.routing.{ConfigurableLogRouter, StaticLogRouter} import izumi.logstage.api.{IzLogger, Log} -import izumi.r2.idealingua.test.generated.{GreeterServiceClientWrapped, GreeterServiceMethods, PrivateTestServiceWrappedClient, ProtectedTestServiceWrappedClient} +import izumi.r2.idealingua.test.generated.* import logstage.LogIO2 import org.http4s.* import org.http4s.blaze.server.* @@ -370,6 +370,39 @@ abstract class Http4sTransportTestBase[F[+_, +_]]( } yield () } } + + "support dummy ws client" in { + // server not used here + // but we need to construct test contexts + withServer { + ctx => + import ctx.testServices.{Client, Server} + val client = new WsClientSession.Dummy[F, AuthContext]( + AuthContext(Headers(publicAuth("user")), None), + Client.buzzerMultiplexor, + Server.contextServices.map(_.authorizedWsSessions), + Server.wsStorage, + WsContextExtractor.authContext, + ctx.logger, + ) + for { + _ <- client.start(_ => F.unit) + _ = assert(Server.protectedWsListener.connected.isEmpty) + _ = assert(Server.privateWsListener.connected.isEmpty) + _ = assert(Server.publicWsListener.connected.size == 1) + dispatcher <- Server.publicWsStorage + .dispatchersFor(PublicContext("user"), Client.codec).map(_.headOption) + .fromOption(new RuntimeException("Missing dispatcher")) + _ <- new GreeterServiceClientWrapped(dispatcher) + .greet("John", "Buzzer") + .map(res => assert(res == "Hi, John Buzzer!")) + _ <- client.finish(_ => F.unit) + _ = assert(Server.protectedWsListener.connected.isEmpty) + _ = assert(Server.privateWsListener.connected.isEmpty) + _ = assert(Server.publicWsListener.connected.isEmpty) + } yield () + } + } } def checkUnauthorizedHttpCall[E, A](call: F[E, A]): F[Throwable, Unit] = { From 858deff70f0e1e1d39d4b6a915242dd549891e4c Mon Sep 17 00:00:00 2001 From: Alex Liubymov Date: Mon, 8 Jan 2024 16:44:14 +0200 Subject: [PATCH 24/24] Update WsContextStorage. --- .../runtime/rpc/http4s/ws/WsContextStorage.scala | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextStorage.scala b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextStorage.scala index 4491d185..ef99bacf 100644 --- a/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextStorage.scala +++ b/idealingua-v1/idealingua-v1-runtime-rpc-http4s/src/main/scala/izumi/idealingua/runtime/rpc/http4s/ws/WsContextStorage.scala @@ -14,10 +14,10 @@ import scala.jdk.CollectionConverters.* * but in such case we would able to choose one from many to update session context data. */ trait WsContextStorage[F[+_, +_], WsCtx] { - def getContext(wsSessionId: WsSessionId): Option[WsCtx] - def allSessions(): Set[WsContextSessionId[WsCtx]] + def getContext(wsSessionId: WsSessionId): F[Throwable, Option[WsCtx]] + def allSessions(): F[Throwable, Set[WsContextSessionId[WsCtx]]] /** Updates session context using [updateCtx] function (maybeOldContext => maybeNewContext) */ - def updateContext(wsSessionId: WsSessionId)(updateCtx: Option[WsCtx] => Option[WsCtx]): F[Nothing, WsCtxUpdate[WsCtx]] + def updateContext(wsSessionId: WsSessionId)(updateCtx: Option[WsCtx] => Option[WsCtx]): F[Throwable, WsCtxUpdate[WsCtx]] def getSessions(ctx: WsCtx): F[Throwable, List[WsClientSession[F, ?]]] def dispatchersFor(ctx: WsCtx, codec: IRTClientMultiplexor[F], timeout: FiniteDuration = 20.seconds): F[Throwable, List[IRTDispatcher[F]]] @@ -33,11 +33,11 @@ object WsContextStorage { private[this] val sessionToId = new ConcurrentHashMap[WsSessionId, WsCtx]() private[this] val idToSessions = new ConcurrentHashMap[WsCtx, Set[WsSessionId]]() - override def allSessions(): Set[WsContextSessionId[WsCtx]] = { + override def allSessions(): F[Throwable, Set[WsContextSessionId[WsCtx]]] = F.sync { sessionToId.asScala.map { case (s, c) => WsContextSessionId(s, c) }.toSet } - override def getContext(wsSessionId: WsSessionId): Option[WsCtx] = { + override def getContext(wsSessionId: WsSessionId): F[Throwable, Option[WsCtx]] = F.sync { Option(sessionToId.get(wsSessionId)) }