diff --git a/build.sbt b/build.sbt index fc6ebbf..2fbce13 100644 --- a/build.sbt +++ b/build.sbt @@ -17,13 +17,17 @@ lazy val root = (project in file(".")) "org.http4s" %% "http4s-dsl" % http4sVersion, "org.http4s" %% "http4s-circe" % http4sVersion, "io.circe" %% "circe-generic" % circeVersion, - "co.fs2" %% "fs2-io" % "3.10.1", + "co.fs2" %% "fs2-io" % "3.12.0", // Tapir + Swagger "com.softwaremill.sttp.tapir" %% "tapir-http4s-server" % tapirVersion, "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % 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" + "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" ) ) diff --git a/src/main/scala/org/yobble/chat_private_service/Main.scala b/src/main/scala/org/yobble/chat_private_service/Main.scala index 2399037..50f890b 100644 --- a/src/main/scala/org/yobble/chat_private_service/Main.scala +++ b/src/main/scala/org/yobble/chat_private_service/Main.scala @@ -23,10 +23,12 @@ object Main extends IOApp { .withHost(host"0.0.0.0") .withPort(Port.fromInt(AppConfig.PORT).get) .withHttpApp(GlobalErrorHandler.withGlobalErrorHandler(routes.all.orNotFound)) +// .withTLS() + .withHttp2 .build }.use(_ => // ✅ внутри use - 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"Server running at http://0.0.0.0:${AppConfig.PORT}") *> + IO.println(s"Swagger UI available at http://0.0.0.0:${AppConfig.PORT}/docs") *> IO.never ).as(ExitCode.Success) } diff --git a/src/main/scala/org/yobble/chat_private_service/api/endpoint/TestErrorEndpoint.scala b/src/main/scala/org/yobble/chat_private_service/api/endpoint/TestErrorEndpoint.scala index 8406bec..5ef232e 100644 --- a/src/main/scala/org/yobble/chat_private_service/api/endpoint/TestErrorEndpoint.scala +++ b/src/main/scala/org/yobble/chat_private_service/api/endpoint/TestErrorEndpoint.scala @@ -11,27 +11,93 @@ import org.yobble.module.util.ErrorExamples object TestErrorEndpoint { - val testErrorEndpoint: Endpoint[String, Option[Int], ErrorResponse, BaseResponse, Any] = + val testErrorEndpoint: Endpoint[String, Option[Int], (StatusCode, ErrorResponse), BaseResponse, Any] = endpoint.get .in("test-error") .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)) - ) + .in(query[Option[Int]]("code").description("Optional HTTP status code").example(Some(404))) .errorOut( - oneOf[ErrorResponse]( - oneOfVariant(StatusCode.BadRequest, jsonBody[ErrorResponse].description("Bad Request").example(ErrorExamples.badRequest)), - oneOfVariant(StatusCode.Unauthorized, jsonBody[ErrorResponse].description("Unauthorized").example(ErrorExamples.unauthorized)), - oneOfVariant(StatusCode.Forbidden, jsonBody[ErrorResponse].description("Forbidden").example(ErrorExamples.forbidden)), - oneOfVariant(StatusCode.NotFound, jsonBody[ErrorResponse].description("Not Found").example(ErrorExamples.notFound)), - oneOfVariant(StatusCode.MethodNotAllowed, jsonBody[ErrorResponse].description("Method Not Allowed").example(ErrorExamples.notAllowed)), - 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)), - oneOfVariant(StatusCode.UnprocessableEntity, jsonBody[ErrorResponse].description("Validation Error").example(ErrorExamples.validation)), - oneOfVariant(StatusCode.InternalServerError, jsonBody[ErrorResponse].description("Internal Server Error").example(ErrorExamples.internal)) + oneOf[(StatusCode, ErrorResponse)]( + oneOfVariantValueMatcher( + statusCode.and( + jsonBody[ErrorResponse] + .description("Bad Request") + .example(ErrorExamples.badRequest) + ) + ) { case (code, _) => code == StatusCode.BadRequest }, + + 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]) diff --git a/src/main/scala/org/yobble/chat_private_service/api/route/AllServerEndpoints.scala b/src/main/scala/org/yobble/chat_private_service/api/route/AllServerEndpoints.scala index 56bac32..bc44100 100644 --- a/src/main/scala/org/yobble/chat_private_service/api/route/AllServerEndpoints.scala +++ b/src/main/scala/org/yobble/chat_private_service/api/route/AllServerEndpoints.scala @@ -8,6 +8,7 @@ import org.yobble.module.service.profile.ProfileClient import org.yobble.module.util.errorByStatus import sttp.model.StatusCode import sttp.tapir.server.ServerEndpoint +import sttp.tapir.server.ServerEndpoint.Full object AllServerEndpoints { def all(authClient: AuthClient, profileClient: ProfileClient): List[ServerEndpoint[Any, IO]] = List( @@ -23,27 +24,34 @@ object AllServerEndpoints { authClient.getCurrentUser(cleanToken, ip, ua).attempt.map { case Left(TokenServiceException(code, msg)) => - Left(ErrorResponse( + Left((code, 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 Left(_) => +// val code = StatusCode.ServiceUnavailable +// Left((code, errorByStatus(code))) // <-- Исправлено! + + case Left(_) => + val code = StatusCode.ServiceUnavailable + Left((code, ErrorResponse( + status = "error", + errors = List(ErrorDetail(field = "request", message = "Token service unavailable")) + ))) case Right(_) => Right(token) } } - .serverLogic { _ => maybeCode => - val status = maybeCode - .flatMap(code => StatusCode.safeApply(code).toOption) - .getOrElse(StatusCode.InternalServerError) + .serverLogic { _ => + maybeCode => + val status = maybeCode + .flatMap(code => StatusCode.safeApply(code).toOption) + .getOrElse(StatusCode.InternalServerError) - IO.pure(Left(errorByStatus(status))) + IO.pure(Left((status, errorByStatus(status)))) // ❗ важно: кортеж! } ) } + diff --git a/src/main/scala/org/yobble/module/service/HttpClient.scala b/src/main/scala/org/yobble/module/service/HttpClient.scala index db1956b..e9ec01b 100644 --- a/src/main/scala/org/yobble/module/service/HttpClient.scala +++ b/src/main/scala/org/yobble/module/service/HttpClient.scala @@ -1,12 +1,52 @@ 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.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 { def create: Resource[IO, Client[IO]] = - EmberClientBuilder - .default[IO] - .build + 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] + .withTLSContext(tlsContext) + .withHttp2 + .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) + } + } } diff --git a/src/main/scala/org/yobble/module/util/ErrorUtils.scala b/src/main/scala/org/yobble/module/util/ErrorUtils.scala index dc72a07..3ca5ebd 100644 --- a/src/main/scala/org/yobble/module/util/ErrorUtils.scala +++ b/src/main/scala/org/yobble/module/util/ErrorUtils.scala @@ -30,6 +30,10 @@ object ErrorUtils { def internalServerError(message: String = "Internal Server Error"): ErrorResponse = ErrorResponse(errors = List(ErrorDetail("server", message))) + + def serviceUnavailableError(message: String = "Service unavailable"): ErrorResponse = + ErrorResponse(errors = List(ErrorDetail("request", message))) + } object ErrorExamples { @@ -79,6 +83,11 @@ object ErrorExamples { status = "error", 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 @@ -93,5 +102,6 @@ def errorByStatus(code: StatusCode): ErrorResponse = code match { case c if c.code == 418 => ErrorExamples.teapot case c if c == StatusCode.UnprocessableEntity => ErrorExamples.validation case c if c == StatusCode.InternalServerError => ErrorExamples.internal + case c if c == StatusCode.ServiceUnavailable => ErrorExamples.unavailable case _ => ErrorExamples.internal } \ No newline at end of file