This commit is contained in:
cheykrym 2025-07-09 00:27:32 +03:00
parent dd388ca2c1
commit 00da0e3303
6 changed files with 166 additions and 36 deletions

View File

@ -17,13 +17,17 @@ 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", "co.fs2" %% "fs2-io" % "3.12.0",
// 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.tapir" %% "tapir-openapi-docs" % tapirVersion,
"com.softwaremill.sttp.apispec" %% "openapi-circe-yaml" % "0.11.10" "com.softwaremill.sttp.apispec" %% "openapi-circe-yaml" % "0.11.10",
// logs
"org.typelevel" %% "log4cats-slf4j" % "2.6.0",
"ch.qos.logback" % "logback-classic" % "1.4.11"
) )
) )

View File

@ -23,10 +23,12 @@ object Main extends IOApp {
.withHost(host"0.0.0.0") .withHost(host"0.0.0.0")
.withPort(Port.fromInt(AppConfig.PORT).get) .withPort(Port.fromInt(AppConfig.PORT).get)
.withHttpApp(GlobalErrorHandler.withGlobalErrorHandler(routes.all.orNotFound)) .withHttpApp(GlobalErrorHandler.withGlobalErrorHandler(routes.all.orNotFound))
// .withTLS()
.withHttp2
.build .build
}.use(_ => // внутри use }.use(_ => // внутри use
IO.println(s"Server running at http://${AppConfig.HOST}:${AppConfig.PORT}") *> IO.println(s"Server running at http://0.0.0.0:${AppConfig.PORT}") *>
IO.println(s"Swagger UI available at http://${AppConfig.HOST}:${AppConfig.PORT}/docs") *> IO.println(s"Swagger UI available at http://0.0.0.0:${AppConfig.PORT}/docs") *>
IO.never IO.never
).as(ExitCode.Success) ).as(ExitCode.Success)
} }

View File

@ -11,27 +11,93 @@ import org.yobble.module.util.ErrorExamples
object TestErrorEndpoint { object TestErrorEndpoint {
val testErrorEndpoint: Endpoint[String, Option[Int], ErrorResponse, BaseResponse, Any] = val testErrorEndpoint: Endpoint[String, Option[Int], (StatusCode, ErrorResponse), BaseResponse, Any] =
endpoint.get endpoint.get
.in("test-error") .in("test-error")
.name("Test Error") .name("Test Error")
.securityIn(auth.bearer[String](WWWAuthenticateChallenge.bearer)) .securityIn(auth.bearer[String](WWWAuthenticateChallenge.bearer))
.in( .in(query[Option[Int]]("code").description("Optional HTTP status code").example(Some(404)))
query[Option[Int]]("code")
.description("Optional HTTP status code to simulate. Defaults to 500.")
.example(Some(404))
)
.errorOut( .errorOut(
oneOf[ErrorResponse]( oneOf[(StatusCode, ErrorResponse)](
oneOfVariant(StatusCode.BadRequest, jsonBody[ErrorResponse].description("Bad Request").example(ErrorExamples.badRequest)), oneOfVariantValueMatcher(
oneOfVariant(StatusCode.Unauthorized, jsonBody[ErrorResponse].description("Unauthorized").example(ErrorExamples.unauthorized)), statusCode.and(
oneOfVariant(StatusCode.Forbidden, jsonBody[ErrorResponse].description("Forbidden").example(ErrorExamples.forbidden)), jsonBody[ErrorResponse]
oneOfVariant(StatusCode.NotFound, jsonBody[ErrorResponse].description("Not Found").example(ErrorExamples.notFound)), .description("Bad Request")
oneOfVariant(StatusCode.MethodNotAllowed, jsonBody[ErrorResponse].description("Method Not Allowed").example(ErrorExamples.notAllowed)), .example(ErrorExamples.badRequest)
oneOfVariant(StatusCode.Conflict, jsonBody[ErrorResponse].description("Conflict").example(ErrorExamples.conflict)), )
oneOfVariant(StatusCode(418), jsonBody[ErrorResponse].description("I'm a teapot (In Development)").example(ErrorExamples.teapot)), ) { case (code, _) => code == StatusCode.BadRequest },
oneOfVariant(StatusCode.UnprocessableEntity, jsonBody[ErrorResponse].description("Validation Error").example(ErrorExamples.validation)),
oneOfVariant(StatusCode.InternalServerError, jsonBody[ErrorResponse].description("Internal Server Error").example(ErrorExamples.internal)) oneOfVariantValueMatcher(
statusCode.and(
jsonBody[ErrorResponse]
.description("Unauthorized")
.example(ErrorExamples.unauthorized)
)
) { case (code, _) => code == StatusCode.Unauthorized },
oneOfVariantValueMatcher(
statusCode.and(
jsonBody[ErrorResponse]
.description("Forbidden")
.example(ErrorExamples.forbidden)
)
) { case (code, _) => code == StatusCode.Forbidden },
oneOfVariantValueMatcher(
statusCode.and(
jsonBody[ErrorResponse]
.description("Not Found")
.example(ErrorExamples.notFound)
)
) { case (code, _) => code == StatusCode.NotFound },
oneOfVariantValueMatcher(
statusCode.and(
jsonBody[ErrorResponse]
.description("Method Not Allowed")
.example(ErrorExamples.notAllowed)
)
) { case (code, _) => code == StatusCode.MethodNotAllowed },
oneOfVariantValueMatcher(
statusCode.and(
jsonBody[ErrorResponse]
.description("Conflict")
.example(ErrorExamples.conflict)
)
) { case (code, _) => code == StatusCode.Conflict },
oneOfVariantValueMatcher(
statusCode.and(
jsonBody[ErrorResponse]
.description("I'm a teapot (In Development)")
.example(ErrorExamples.teapot)
)
) { case (code, _) => code == StatusCode(418) },
oneOfVariantValueMatcher(
statusCode.and(
jsonBody[ErrorResponse]
.description("Validation Error")
.example(ErrorExamples.validation)
)
) { case (code, _) => code == StatusCode.UnprocessableEntity },
oneOfVariantValueMatcher(
statusCode.and(
jsonBody[ErrorResponse]
.description("Internal Server Error")
.example(ErrorExamples.internal)
)
) { case (code, _) => code == StatusCode.InternalServerError },
oneOfVariantValueMatcher(
statusCode.and(
jsonBody[ErrorResponse]
.description("Service Unavailable")
.example(ErrorExamples.unavailable)
)
) { case (code, _) => code == StatusCode.ServiceUnavailable }
) )
) )
.out(jsonBody[BaseResponse]) .out(jsonBody[BaseResponse])

View File

@ -8,6 +8,7 @@ import org.yobble.module.service.profile.ProfileClient
import org.yobble.module.util.errorByStatus import org.yobble.module.util.errorByStatus
import sttp.model.StatusCode import sttp.model.StatusCode
import sttp.tapir.server.ServerEndpoint import sttp.tapir.server.ServerEndpoint
import sttp.tapir.server.ServerEndpoint.Full
object AllServerEndpoints { object AllServerEndpoints {
def all(authClient: AuthClient, profileClient: ProfileClient): List[ServerEndpoint[Any, IO]] = List( def all(authClient: AuthClient, profileClient: ProfileClient): List[ServerEndpoint[Any, IO]] = List(
@ -23,27 +24,34 @@ object AllServerEndpoints {
authClient.getCurrentUser(cleanToken, ip, ua).attempt.map { authClient.getCurrentUser(cleanToken, ip, ua).attempt.map {
case Left(TokenServiceException(code, msg)) => case Left(TokenServiceException(code, msg)) =>
Left(ErrorResponse( Left((code, ErrorResponse(
status = code.code.toString, status = code.code.toString,
errors = List(ErrorDetail(field = "Authorization", message = msg)) errors = List(ErrorDetail(field = "Authorization", message = msg))
)) )))
case Left(other) => // case Left(_) =>
Left(ErrorResponse( // val code = StatusCode.ServiceUnavailable
status = StatusCode.InternalServerError.code.toString, // Left((code, errorByStatus(code))) // <-- Исправлено!
errors = List(ErrorDetail(field = "Authorization", message = "token_service: service unavailable"))
)) case Left(_) =>
val code = StatusCode.ServiceUnavailable
Left((code, ErrorResponse(
status = "error",
errors = List(ErrorDetail(field = "request", message = "Token service unavailable"))
)))
case Right(_) => case Right(_) =>
Right(token) Right(token)
} }
} }
.serverLogic { _ => maybeCode => .serverLogic { _ =>
maybeCode =>
val status = maybeCode val status = maybeCode
.flatMap(code => StatusCode.safeApply(code).toOption) .flatMap(code => StatusCode.safeApply(code).toOption)
.getOrElse(StatusCode.InternalServerError) .getOrElse(StatusCode.InternalServerError)
IO.pure(Left(errorByStatus(status))) IO.pure(Left((status, errorByStatus(status)))) // важно: кортеж!
} }
) )
} }

View File

@ -1,12 +1,52 @@
package org.yobble.module.service package org.yobble.module.service
import cats.effect.* import cats.effect.{IO, Resource}
import fs2.io.net.tls.TLSContext
import org.http4s.client.Client import org.http4s.client.Client
import org.http4s.ember.client.EmberClientBuilder import org.http4s.ember.client.EmberClientBuilder
import org.http4s.headers.`User-Agent`
import java.io.FileInputStream
import scala.util.chaining.*
import java.security.KeyStore
import java.security.cert.{Certificate, CertificateFactory}
import javax.net.ssl.{SSLContext, TrustManagerFactory}
object HttpClient { object HttpClient {
def create: Resource[IO, Client[IO]] = def create: Resource[IO, Client[IO]] =
EmberClientBuilder for {
// tlsContext <- customTLSContext("/home/cardinalnsk/IdeaProjects/chat_private_service/src/main/resources/fullchain.pem")
tlsContext <- Resource.eval(TLSContext.Builder.forAsync[IO].insecure)
client <- EmberClientBuilder
.default[IO] .default[IO]
.withTLSContext(tlsContext)
.withHttp2
.build .build
} yield client
private def customTLSContext(certPath: String): Resource[IO, TLSContext[IO]] =
Resource.eval {
IO.blocking {
// 1. Загружаем X.509 сертификат из файла
val certFactory = CertificateFactory.getInstance("X.509")
val cert: Certificate = certFactory.generateCertificate(new FileInputStream(certPath))
// 2. Создаём пустой KeyStore и добавляем туда сертификат
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType)
keyStore.load(null, null)
keyStore.setCertificateEntry("custom-ca", cert)
// 3. Инициализируем TrustManagerFactory этим keystore
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm)
tmf.init(keyStore)
// 4. Создаём SSLContext, используя TrustManager
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, tmf.getTrustManagers, new java.security.SecureRandom())
// Новый способ: TLSContext.Builder.forAsyncWithSSLContext
TLSContext.Builder.forAsync[IO].fromSSLContext(sslContext)
}
}
} }

View File

@ -30,6 +30,10 @@ object ErrorUtils {
def internalServerError(message: String = "Internal Server Error"): ErrorResponse = def internalServerError(message: String = "Internal Server Error"): ErrorResponse =
ErrorResponse(errors = List(ErrorDetail("server", message))) ErrorResponse(errors = List(ErrorDetail("server", message)))
def serviceUnavailableError(message: String = "Service unavailable"): ErrorResponse =
ErrorResponse(errors = List(ErrorDetail("request", message)))
} }
object ErrorExamples { object ErrorExamples {
@ -79,6 +83,11 @@ object ErrorExamples {
status = "error", status = "error",
errors = List(ErrorDetail("server", "An unexpected error occurred. Please try again later.")) errors = List(ErrorDetail("server", "An unexpected error occurred. Please try again later."))
) )
val unavailable: ErrorResponse = ErrorResponse(
status = "error",
errors = List(ErrorDetail("request", "Service unavailable."))
)
} }
// example use // example use
@ -93,5 +102,6 @@ def errorByStatus(code: StatusCode): ErrorResponse = code match {
case c if c.code == 418 => ErrorExamples.teapot case c if c.code == 418 => ErrorExamples.teapot
case c if c == StatusCode.UnprocessableEntity => ErrorExamples.validation case c if c == StatusCode.UnprocessableEntity => ErrorExamples.validation
case c if c == StatusCode.InternalServerError => ErrorExamples.internal case c if c == StatusCode.InternalServerError => ErrorExamples.internal
case c if c == StatusCode.ServiceUnavailable => ErrorExamples.unavailable
case _ => ErrorExamples.internal case _ => ErrorExamples.internal
} }