This commit is contained in:
cheykrym 2025-07-08 18:59:52 +03:00
parent f3f458a1b0
commit dd388ca2c1
21 changed files with 270 additions and 72 deletions

View File

@ -17,10 +17,13 @@ lazy val root = (project in file("."))
"org.http4s" %% "http4s-dsl" % http4sVersion, "org.http4s" %% "http4s-dsl" % http4sVersion,
"org.http4s" %% "http4s-circe" % http4sVersion, "org.http4s" %% "http4s-circe" % http4sVersion,
"io.circe" %% "circe-generic" % circeVersion, "io.circe" %% "circe-generic" % circeVersion,
"co.fs2" %% "fs2-io" % "3.10.1",
// Tapir + Swagger // Tapir + Swagger
"com.softwaremill.sttp.tapir" %% "tapir-http4s-server" % tapirVersion, "com.softwaremill.sttp.tapir" %% "tapir-http4s-server" % tapirVersion,
"com.softwaremill.sttp.tapir" %% "tapir-json-circe" % tapirVersion, "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % tapirVersion,
"com.softwaremill.sttp.tapir" %% "tapir-swagger-ui-bundle" % tapirVersion "com.softwaremill.sttp.tapir" %% "tapir-swagger-ui-bundle" % tapirVersion,
"com.softwaremill.sttp.tapir" %% "tapir-openapi-docs" % tapirVersion,
"com.softwaremill.sttp.apispec" %% "openapi-circe-yaml" % "0.11.10"
) )
) )

View File

@ -0,0 +1,19 @@
package org.yobble.chat_private_service
import cats.effect.*
import org.http4s.client.Client
import org.yobble.chat_private_service.api.route.Routes
import org.yobble.module.service.auth.AuthClient
import org.yobble.module.service.profile.ProfileClient
import org.yobble.module.service.HttpClient
object AppModule {
def create: Resource[IO, Routes] =
for {
httpClient <- HttpClient.create
authClient = new AuthClient(httpClient)
profileClient = new ProfileClient(httpClient)
routes = new Routes(authClient, profileClient)
} yield routes
}

View File

@ -12,22 +12,21 @@ import org.http4s.server.Router
import cats.implicits.toSemigroupKOps import cats.implicits.toSemigroupKOps
import com.comcast.ip4s.{Port, host} import com.comcast.ip4s.{Port, host}
import org.http4s.circe.CirceEntityCodec.* import org.http4s.circe.CirceEntityCodec.*
import org.yobble.chat_private_service.api.Routes import org.yobble.chat_private_service.api.route.Routes
import org.yobble.chat_private_service.config.AppConfig import org.yobble.chat_private_service.config.AppConfig
import org.yobble.chat_private_service.middleware.GlobalErrorHandler import org.yobble.module.middleware.GlobalErrorHandler
object Main extends IOApp { object Main extends IOApp {
override def run(args: List[String]): IO[ExitCode] = override def run(args: List[String]): IO[ExitCode] =
EmberServerBuilder AppModule.create.flatMap { routes => // создаём routes тут
.default[IO] EmberServerBuilder.default[IO]
.withHost(host"0.0.0.0") .withHost(host"0.0.0.0")
.withPort(port = Port.fromInt(AppConfig.PORT).get) .withPort(Port.fromInt(AppConfig.PORT).get)
.withHttpApp(GlobalErrorHandler.withGlobalErrorHandler(Routes.all.orNotFound)) .withHttpApp(GlobalErrorHandler.withGlobalErrorHandler(routes.all.orNotFound))
.build .build
.use(_ => }.use(_ => // внутри use
IO.println(s"Server running at http://${AppConfig.HOST}:${AppConfig.PORT}") *> IO.println(s"Server running at http://${AppConfig.HOST}:${AppConfig.PORT}") *>
IO.println(s"Swagger UI available at http://${AppConfig.HOST}:${AppConfig.PORT}/docs") *> IO.println(s"Swagger UI available at http://${AppConfig.HOST}:${AppConfig.PORT}/docs") *>
IO.never IO.never
) ).as(ExitCode.Success)
.as(ExitCode.Success)
} }

View File

@ -1,39 +0,0 @@
package org.yobble.chat_private_service.api
import cats.effect.IO
import org.http4s.HttpRoutes
import sttp.tapir.server.http4s.*
import sttp.tapir.swagger.bundle.SwaggerInterpreter
import org.yobble.chat_private_service.api.endpoint.PingEndpoint.*
import org.yobble.chat_private_service.api.endpoint.ErrorsEndpoint.*
import org.yobble.chat_private_service.api.endpoint.TestErrorEndpoint.*
import cats.syntax.semigroupk.*
import org.yobble.chat_private_service.api.response.BaseResponse
import sttp.model.StatusCode
import org.yobble.chat_private_service.api.util.errorByStatus
object Routes {
private val pingRoute = Http4sServerInterpreter[IO]().toRoutes(
pingEndpoint.serverLogicSuccess(_ => IO.pure(BaseResponse("fine", "pong")))
)
private val errorsRoute = Http4sServerInterpreter[IO]().toRoutes(
errorsEndpoint.serverLogicSuccess(_ => IO.pure(BaseResponse("fine", "errors")))
)
private val testErrorRoute = Http4sServerInterpreter[IO]().toRoutes(
testErrorEndpoint.serverLogic((code: Int) =>
IO.pure(Left(errorByStatus(StatusCode.safeApply(code).getOrElse(StatusCode.InternalServerError))))
)
)
private val docsRoutes = Http4sServerInterpreter[IO]().toRoutes(
SwaggerInterpreter().fromEndpoints[IO](
List(pingEndpoint, errorsEndpoint, testErrorEndpoint),
"chat_private_service API",
"1.0"
)
)
val all: HttpRoutes[IO] = pingRoute <+> errorsRoute <+> testErrorRoute <+> docsRoutes
}

View File

@ -1,13 +1,12 @@
package org.yobble.chat_private_service.api.endpoint package org.yobble.chat_private_service.api.endpoint
import io.circe.generic.auto.* import io.circe.generic.auto.*
import org.yobble.chat_private_service.api.response.BaseResponse
import org.yobble.chat_private_service.config.AppConfig import org.yobble.chat_private_service.config.AppConfig
import org.yobble.module.response.{BaseResponse, ErrorResponse}
import sttp.tapir.* import sttp.tapir.*
import sttp.tapir.generic.auto.* import sttp.tapir.generic.auto.*
import sttp.tapir.json.circe.* import sttp.tapir.json.circe.*
import org.yobble.chat_private_service.api.response.ErrorResponse import org.yobble.module.util.ErrorExamples
import org.yobble.chat_private_service.api.util.ErrorExamples
import sttp.model.StatusCode import sttp.model.StatusCode

View File

@ -4,8 +4,8 @@ import sttp.tapir._
import sttp.tapir.generic.auto._ import sttp.tapir.generic.auto._
import sttp.tapir.json.circe._ import sttp.tapir.json.circe._
import io.circe.generic.auto._ import io.circe.generic.auto._
import org.yobble.chat_private_service.api.response.BaseResponse
import org.yobble.chat_private_service.config.AppConfig import org.yobble.chat_private_service.config.AppConfig
import org.yobble.module.response.BaseResponse
object PingEndpoint { object PingEndpoint {

View File

@ -1,21 +1,26 @@
package org.yobble.chat_private_service.api.endpoint package org.yobble.chat_private_service.api.endpoint
import io.circe.generic.auto.* import io.circe.generic.auto.*
import org.yobble.chat_private_service.api.response.{BaseResponse, ErrorResponse}
import org.yobble.chat_private_service.api.util.ErrorExamples
import org.yobble.chat_private_service.config.AppConfig
import sttp.model.StatusCode import sttp.model.StatusCode
import sttp.tapir.* import sttp.tapir.*
import sttp.tapir.generic.auto.* import sttp.tapir.generic.auto.*
import sttp.tapir.json.circe.* import sttp.tapir.json.circe.*
import sttp.model.headers.WWWAuthenticateChallenge
import org.yobble.module.response.{BaseResponse, ErrorResponse}
import org.yobble.module.util.ErrorExamples
object TestErrorEndpoint { object TestErrorEndpoint {
val testErrorEndpoint: Endpoint[Unit, Int, ErrorResponse, BaseResponse, Any] = val testErrorEndpoint: Endpoint[String, Option[Int], ErrorResponse, BaseResponse, Any] =
endpoint.get endpoint.get
.in("test-error") .in("test-error")
.in(query[Int]("code").description("HTTP status code to simulate (e.g., 401, 404)")) .name("Test Error")
.securityIn(auth.bearer[String](WWWAuthenticateChallenge.bearer))
.in(
query[Option[Int]]("code")
.description("Optional HTTP status code to simulate. Defaults to 500.")
.example(Some(404))
)
.errorOut( .errorOut(
oneOf[ErrorResponse]( oneOf[ErrorResponse](
oneOfVariant(StatusCode.BadRequest, jsonBody[ErrorResponse].description("Bad Request").example(ErrorExamples.badRequest)), oneOfVariant(StatusCode.BadRequest, jsonBody[ErrorResponse].description("Bad Request").example(ErrorExamples.badRequest)),

View File

@ -0,0 +1,49 @@
package org.yobble.chat_private_service.api.route
import cats.effect.IO
import org.yobble.chat_private_service.api.endpoint.{ErrorsEndpoint, PingEndpoint, TestErrorEndpoint}
import org.yobble.module.response.{BaseResponse, ErrorDetail, ErrorResponse}
import org.yobble.module.service.auth.{AuthClient, TokenServiceException}
import org.yobble.module.service.profile.ProfileClient
import org.yobble.module.util.errorByStatus
import sttp.model.StatusCode
import sttp.tapir.server.ServerEndpoint
object AllServerEndpoints {
def all(authClient: AuthClient, profileClient: ProfileClient): List[ServerEndpoint[Any, IO]] = List(
PingEndpoint.pingEndpoint.serverLogicSuccess(_ => IO.pure(BaseResponse("fine", "pong"))),
ErrorsEndpoint.errorsEndpoint.serverLogicSuccess(_ => IO.pure(BaseResponse("fine", "errors"))),
TestErrorEndpoint.testErrorEndpoint
.serverSecurityLogic { token =>
val cleanToken = token.stripPrefix("Bearer ").trim
val ip = "127.0.0.1"
val ua = "scala-client"
authClient.getCurrentUser(cleanToken, ip, ua).attempt.map {
case Left(TokenServiceException(code, msg)) =>
Left(ErrorResponse(
status = code.code.toString,
errors = List(ErrorDetail(field = "Authorization", message = msg))
))
case Left(other) =>
Left(ErrorResponse(
status = StatusCode.InternalServerError.code.toString,
errors = List(ErrorDetail(field = "Authorization", message = "token_service: service unavailable"))
))
case Right(_) =>
Right(token)
}
}
.serverLogic { _ => maybeCode =>
val status = maybeCode
.flatMap(code => StatusCode.safeApply(code).toOption)
.getOrElse(StatusCode.InternalServerError)
IO.pure(Left(errorByStatus(status)))
}
)
}

View File

@ -0,0 +1,25 @@
package org.yobble.chat_private_service.api.route
import cats.effect.IO
import cats.syntax.semigroupk.*
import org.http4s.HttpRoutes
import org.yobble.module.service.auth.AuthClient
import org.yobble.module.service.profile.ProfileClient
import sttp.tapir.server.http4s.Http4sServerInterpreter
import sttp.tapir.swagger.bundle.SwaggerInterpreter
class Routes(authClient: AuthClient, profileClient: ProfileClient) {
private val allServerEndpoints = AllServerEndpoints.all(authClient, profileClient)
private val httpRoutes = Http4sServerInterpreter[IO]().toRoutes(allServerEndpoints)
private val docsRoutes = Http4sServerInterpreter[IO]().toRoutes(
SwaggerInterpreter().fromServerEndpoints[IO](
allServerEndpoints,
"chat_private_service API",
"1.0"
)
)
val all: HttpRoutes[IO] = docsRoutes <+> httpRoutes
}

View File

@ -5,4 +5,6 @@ object AppConfig {
val HOST = "0.0.0.0" val HOST = "0.0.0.0"
val PORT = 5205 val PORT = 5205
val TOKEN_SERVICE = "https://localhost:5200"
val PROFILE_SERVICE = ""
} }

View File

@ -1,10 +1,9 @@
package org.yobble.chat_private_service.middleware package org.yobble.module.middleware
import cats.effect.Sync import cats.effect.Sync
import org.http4s.HttpApp import org.http4s.HttpApp
import org.http4s.server.middleware.ErrorAction import org.http4s.server.middleware.ErrorAction
import org.yobble.chat_private_service.api.util.ErrorUtils import org.yobble.module.util.ErrorUtils
object GlobalErrorHandler { object GlobalErrorHandler {
def withGlobalErrorHandler[F[_] : Sync](httpApp: HttpApp[F]): HttpApp[F] = { def withGlobalErrorHandler[F[_] : Sync](httpApp: HttpApp[F]): HttpApp[F] = {

View File

@ -1,3 +1,3 @@
package org.yobble.chat_private_service.api.response package org.yobble.module.response
final case class BaseResponse(status: String, message: String) final case class BaseResponse(status: String, message: String)

View File

@ -1,4 +1,4 @@
package org.yobble.chat_private_service.api.response package org.yobble.module.response
final case class ErrorDetail(field: String, message: String) final case class ErrorDetail(field: String, message: String)
final case class ErrorResponse(status: String = "error", errors: List[ErrorDetail]) final case class ErrorResponse(status: String = "error", errors: List[ErrorDetail])

View File

@ -0,0 +1,12 @@
package org.yobble.module.service
import cats.effect.*
import org.http4s.client.Client
import org.http4s.ember.client.EmberClientBuilder
object HttpClient {
def create: Resource[IO, Client[IO]] =
EmberClientBuilder
.default[IO]
.build
}

View File

@ -0,0 +1,44 @@
package org.yobble.module.service.auth
import cats.effect.IO
import org.http4s.Method._
import org.http4s.client.dsl.io._
import org.http4s.client.Client
import org.http4s._
import org.http4s.circe.CirceEntityEncoder._ // for .withEntity(json)
import org.http4s.circe.CirceEntityDecoder._ // for .as[CurrentUser]
import io.circe.syntax._ // for .asJson
import io.circe.generic.auto._
import org.yobble.chat_private_service.config.AppConfig
import sttp.model.StatusCode
class AuthClient(client: Client[IO]) {
def getCurrentUser(token: String, ip: String, userAgent: String): IO[CurrentUser] = {
val requestBody = DecodeTokenRequest(
token = token,
ip = ip,
user_agent = userAgent,
require_permissions = false
).asJson
val req = Request[IO](
method = POST,
uri = Uri.unsafeFromString(AppConfig.TOKEN_SERVICE + "/decode")
).withEntity(requestBody)
println(s"$req")
client.run(req).use { resp =>
println(s"$resp")
if (resp.status.isSuccess) {
resp.as[CurrentUser]
} else {
resp.bodyText.compile.string.flatMap { body =>
val status = StatusCode.safeApply(resp.status.code).getOrElse(StatusCode.InternalServerError)
IO.raiseError(TokenServiceException(status, s"token_service: $body"))
}
}
}
}
}

View File

@ -0,0 +1,8 @@
package org.yobble.module.service.auth
case class CurrentUser(
token: String,
user_id: String,
session_id: String,
permissions: List[String]
)

View File

@ -0,0 +1,8 @@
package org.yobble.module.service.auth
case class DecodeTokenRequest(
token: String,
ip: String,
user_agent: String,
require_permissions: Boolean = false
)

View File

@ -0,0 +1,12 @@
package org.yobble.module.service.auth
import sttp.model.StatusCode
case class TokenServiceException(code: StatusCode, msg: String) extends Exception(msg)
object TokenServiceException {
def unapply(e: Throwable): Option[(StatusCode, String)] = e match {
case ex: TokenServiceException => Some((ex.code, ex.msg))
case _ => None
}
}

View File

@ -0,0 +1,46 @@
package org.yobble.module.service.profile
import cats.effect.IO
import io.circe.syntax.*
import io.circe.generic.auto.*
import io.circe.Json
import org.http4s.Method.*
import org.http4s.client.dsl.io.*
import org.http4s.{Header, Request, Status, Uri}
import org.http4s.client.Client
import org.typelevel.ci.CIStringSyntax
import org.http4s.circe.*
import java.util.UUID
import org.yobble.chat_private_service.config.AppConfig
case class ProfileClient(client: Client[IO]) {
def getProfileByUserId(userId: UUID, token: String): IO[Json] = {
val uri = Uri.unsafeFromString(s"https://$AppConfig.PROFILE_SERVICE/user_id/$userId")
val req = Request[IO](method = GET, uri = uri)
.withHeaders(Header.Raw(ci"Authorization", s"Bearer $token"))
client.fetch(req) {
case Status.Successful(resp) => resp.as[Json]
case resp => resp.bodyText.compile.string.flatMap(body =>
IO.raiseError(new Exception(s"profile_service: ${resp.status.code} $body"))
)
}
}
def getProfilesByUserIds(userIds: List[UUID], token: String, userId: UUID): IO[Json] = {
val uri = Uri.unsafeFromString(s"https://$AppConfig.PROFILE_SERVICE/user_ids/internal")
val json = ProfilesByUserIdsRequest(userIds.map(_.toString), userId.toString).asJson
val req = Request[IO](method = POST, uri = uri)
.withHeaders(Header.Raw(ci"Authorization", s"Bearer $token"))
.withEntity(json)
client.fetch(req) {
case Status.Successful(resp) => resp.as[Json]
case resp => resp.bodyText.compile.string.flatMap(body =>
IO.raiseError(new Exception(s"profile_service: ${resp.status.code} $body"))
)
}
}
}

View File

@ -0,0 +1,6 @@
package org.yobble.module.service.profile
case class ProfilesByUserIdsRequest(
user_ids: List[String],
user_id: String
)

View File

@ -1,7 +1,7 @@
package org.yobble.chat_private_service.api.util package org.yobble.module.util
import org.yobble.module.response.{ErrorDetail, ErrorResponse}
import sttp.model.StatusCode import sttp.model.StatusCode
import org.yobble.chat_private_service.api.response.{ErrorDetail, ErrorResponse}
object ErrorUtils { object ErrorUtils {
def badRequest(message: String = "Bad request"): ErrorResponse = def badRequest(message: String = "Bad request"): ErrorResponse =
@ -33,6 +33,7 @@ object ErrorUtils {
} }
object ErrorExamples { object ErrorExamples {
val unauthorized: ErrorResponse = ErrorResponse( val unauthorized: ErrorResponse = ErrorResponse(
status = "error", status = "error",
errors = List(ErrorDetail("login", "Invalid login or password")) errors = List(ErrorDetail("login", "Invalid login or password"))