diff --git a/build.sbt b/build.sbt index 2fbce13..b6e258d 100644 --- a/build.sbt +++ b/build.sbt @@ -28,6 +28,8 @@ lazy val root = (project in file(".")) // logs "org.typelevel" %% "log4cats-slf4j" % "2.6.0", - "ch.qos.logback" % "logback-classic" % "1.4.11" + "ch.qos.logback" % "logback-classic" % "1.4.11", + + "org.typelevel" %% "vault" % "3.6.0" ) ) 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 5ef232e..166dab6 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 @@ -1,103 +1,52 @@ package org.yobble.chat_private_service.api.endpoint +import sttp.tapir.* +import sttp.tapir.EndpointOutput.* +import sttp.tapir.json.circe.* +import sttp.tapir.generic.auto.* + import io.circe.generic.auto.* import sttp.model.StatusCode -import sttp.tapir.* -import sttp.tapir.generic.auto.* -import sttp.tapir.json.circe.* import sttp.model.headers.WWWAuthenticateChallenge +import scala.reflect.ClassTag + import org.yobble.module.response.{BaseResponse, ErrorResponse} +import org.yobble.module.response.ErrorResponse._ import org.yobble.module.util.ErrorExamples object TestErrorEndpoint { - val testErrorEndpoint: Endpoint[String, Option[Int], (StatusCode, ErrorResponse), BaseResponse, Any] = + private def errorVariant( + code: StatusCode, + example: ErrorResponse, + description: String + ): OneOfVariant[ErrorResponse] = { + oneOfVariantValueMatcher[ErrorResponse]( + code, + statusCode(code).and(jsonBody[ErrorResponse].description(description).example(example)) + ) { + case resp: ErrorResponse => resp.code == example.code + } + } + + val testErrorEndpoint: Endpoint[String, Option[Int], 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").example(Some(404))) .errorOut( - 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 } + oneOf[ErrorResponse]( + errorVariant(StatusCode.BadRequest, ErrorExamples.badRequest, "Bad Request"), + errorVariant(StatusCode.Unauthorized, ErrorExamples.unauthorized, "Unauthorized"), + errorVariant(StatusCode.Forbidden, ErrorExamples.forbidden, "Forbidden"), + errorVariant(StatusCode.NotFound, ErrorExamples.notFound, "Not Found"), + errorVariant(StatusCode.MethodNotAllowed, ErrorExamples.notAllowed, "Method Not Allowed"), + errorVariant(StatusCode.Conflict, ErrorExamples.conflict, "Conflict"), + errorVariant(StatusCode(418), ErrorExamples.teapot, "I'm a teapot (In Development)"), + errorVariant(StatusCode.UnprocessableEntity, ErrorExamples.validation, "Validation Error"), + errorVariant(StatusCode.InternalServerError, ErrorExamples.internal, "Internal Server Error"), + errorVariant(StatusCode.ServiceUnavailable, ErrorExamples.unavailable, "Service Unavailable") ) ) .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 bc44100..764747b 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 @@ -1,19 +1,23 @@ 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 org.http4s.Request +import org.yobble.module.middleware.RealIpAndUserAgentMiddleware.{ipKey, uaKey} +import sttp.tapir.server.http4s.Http4sServerOptions import sttp.model.StatusCode import sttp.tapir.server.ServerEndpoint -import sttp.tapir.server.ServerEndpoint.Full + +import org.yobble.chat_private_service.api.endpoint.{ErrorsEndpoint, PingEndpoint, TestErrorEndpoint} +import org.yobble.module.response.{BaseResponse, ErrorDetail, ErrorResponse} +import org.yobble.module.util.{ErrorUtils, errorByStatus} +import org.yobble.module.service.auth.{AuthClient, TokenServiceException} +import org.yobble.module.service.profile.ProfileClient +import org.yobble.module.util.ErrorExamples + 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 @@ -21,37 +25,29 @@ object AllServerEndpoints { val cleanToken = token.stripPrefix("Bearer ").trim val ip = "127.0.0.1" val ua = "scala-client" +// val ip = req.attributes.lookup(ipKey).getOrElse("unknown") +// val ua = req.attributes.lookup(uaKey).getOrElse("unknown") +// +// println(s"ip: $ip") +// println(s"ua: $ua") authClient.getCurrentUser(cleanToken, ip, ua).attempt.map { - case Left(TokenServiceException(code, msg)) => - Left((code, ErrorResponse( - status = code.code.toString, - errors = List(ErrorDetail(field = "Authorization", message = msg)) - ))) - -// case Left(_) => -// val code = StatusCode.ServiceUnavailable -// Left((code, errorByStatus(code))) // <-- Исправлено! + case Left(TokenServiceException(_, msg)) => + Left(ErrorUtils.unauthorized(msg)) case Left(_) => - val code = StatusCode.ServiceUnavailable - Left((code, ErrorResponse( - status = "error", - errors = List(ErrorDetail(field = "request", message = "Token service unavailable")) - ))) + Left(ErrorUtils.serviceUnavailableError("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((status, errorByStatus(status)))) // ❗ важно: кортеж! + IO.pure(Left(errorByStatus(status))) } ) -} - +} \ No newline at end of file diff --git a/src/main/scala/org/yobble/chat_private_service/api/route/Routes.scala b/src/main/scala/org/yobble/chat_private_service/api/route/Routes.scala index d5ce28e..048d7de 100644 --- a/src/main/scala/org/yobble/chat_private_service/api/route/Routes.scala +++ b/src/main/scala/org/yobble/chat_private_service/api/route/Routes.scala @@ -7,6 +7,7 @@ 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 +import org.yobble.module.middleware.RealIpAndUserAgentMiddleware class Routes(authClient: AuthClient, profileClient: ProfileClient) { private val allServerEndpoints = AllServerEndpoints.all(authClient, profileClient) @@ -21,5 +22,6 @@ class Routes(authClient: AuthClient, profileClient: ProfileClient) { ) ) - val all: HttpRoutes[IO] = docsRoutes <+> httpRoutes +// val all: HttpRoutes[IO] = RealIpAndUserAgentMiddleware(docsRoutes <+> httpRoutes) + val all: HttpRoutes[IO] = docsRoutes <+> httpRoutes } diff --git a/src/main/scala/org/yobble/module/middleware/RealIpAndUserAgentMiddleware.scala b/src/main/scala/org/yobble/module/middleware/RealIpAndUserAgentMiddleware.scala new file mode 100644 index 0000000..8198d1f --- /dev/null +++ b/src/main/scala/org/yobble/module/middleware/RealIpAndUserAgentMiddleware.scala @@ -0,0 +1,33 @@ +package org.yobble.module.middleware + +import cats.data.Kleisli +import cats.effect.IO +import cats.effect.unsafe.implicits.global +import org.http4s.{HttpRoutes, Request} +import org.typelevel.vault.Key +import org.typelevel.ci.CIString + + +object RealIpAndUserAgentMiddleware { + // Эти ключи создаются один раз при запуске приложения + lazy val ipKey: Key[String] = Key.newKey[IO, String].unsafeRunSync() + lazy val uaKey: Key[String] = Key.newKey[IO, String].unsafeRunSync() + + def apply(routes: HttpRoutes[IO]): HttpRoutes[IO] = Kleisli { req => + val ip = req.headers + .get(CIString("X-Real-IP")).map(_.head.value) + .orElse(req.headers.get(CIString("X-Forwarded-For")).map(_.head.value.split(",").head.trim)) + .getOrElse(req.remote.map(_.host.toString).getOrElse("unknown")) + + val ua = req.headers.get(CIString("User-Agent")).map(_.head.value).getOrElse("unknown") + + val enriched = req.withAttributes( + req.attributes + .insert(ipKey, ip) + .insert(uaKey, ua) + ) + + routes(enriched) + } +} + diff --git a/src/main/scala/org/yobble/module/response/ErrorResponse.scala b/src/main/scala/org/yobble/module/response/ErrorResponse.scala index 88aea57..c97f9b2 100644 --- a/src/main/scala/org/yobble/module/response/ErrorResponse.scala +++ b/src/main/scala/org/yobble/module/response/ErrorResponse.scala @@ -1,4 +1,16 @@ package org.yobble.module.response -final case class ErrorDetail(field: String, message: String) -final case class ErrorResponse(status: String = "error", errors: List[ErrorDetail]) +import io.circe.{Encoder, Json} +import io.circe.generic.semiauto._ + +case class ErrorDetail(field: String, message: String) +case class ErrorResponse(status: String = "error", code: Int, errors: List[ErrorDetail]) + +object ErrorResponse { + // Добавляем encoder для вложенного класса + implicit val errorDetailEncoder: Encoder[ErrorDetail] = deriveEncoder[ErrorDetail] + + // Encoder без поля code + implicit val errorResponseEncoder: Encoder[ErrorResponse] = + deriveEncoder[ErrorResponse].mapJsonObject(_.remove("code")) +} diff --git a/src/main/scala/org/yobble/module/service/auth/AuthClient.scala b/src/main/scala/org/yobble/module/service/auth/AuthClient.scala index 59c46c5..0188f61 100644 --- a/src/main/scala/org/yobble/module/service/auth/AuthClient.scala +++ b/src/main/scala/org/yobble/module/service/auth/AuthClient.scala @@ -26,6 +26,7 @@ class AuthClient(client: Client[IO]) { method = POST, uri = Uri.unsafeFromString(AppConfig.TOKEN_SERVICE + "/decode") ).withEntity(requestBody) + .withHttpVersion(HttpVersion.`HTTP/2`) println(s"$req") diff --git a/src/main/scala/org/yobble/module/util/ErrorUtils.scala b/src/main/scala/org/yobble/module/util/ErrorUtils.scala index 3ca5ebd..4aa7106 100644 --- a/src/main/scala/org/yobble/module/util/ErrorUtils.scala +++ b/src/main/scala/org/yobble/module/util/ErrorUtils.scala @@ -5,87 +5,86 @@ import sttp.model.StatusCode object ErrorUtils { def badRequest(message: String = "Bad request"): ErrorResponse = - ErrorResponse(errors = List(ErrorDetail("general", message))) + ErrorResponse(code = 400, errors = List(ErrorDetail("general", message))) def unauthorized(message: String = "Unauthorized"): ErrorResponse = - ErrorResponse(errors = List(ErrorDetail("login", message))) + ErrorResponse(code = 401, errors = List(ErrorDetail("login", message))) def forbidden(message: String = "Forbidden"): ErrorResponse = - ErrorResponse(errors = List(ErrorDetail("permission", message))) + ErrorResponse(code = 403, errors = List(ErrorDetail("permission", message))) def notFound(message: String = "Not found"): ErrorResponse = - ErrorResponse(errors = List(ErrorDetail("resource", message))) + ErrorResponse(code = 404, errors = List(ErrorDetail("resource", message))) def methodNotAllowed(message: String = "Method not allowed"): ErrorResponse = - ErrorResponse(errors = List(ErrorDetail("method", message))) + ErrorResponse(code = 405, errors = List(ErrorDetail("method", message))) def conflict(message: String = "Conflict"): ErrorResponse = - ErrorResponse(errors = List(ErrorDetail("conflict", message))) + ErrorResponse(code = 409, errors = List(ErrorDetail("conflict", message))) def imATeapot(message: String = "This feature is under development"): ErrorResponse = - ErrorResponse(errors = List(ErrorDetail("debug", message))) + ErrorResponse(code = 418, errors = List(ErrorDetail("debug", message))) def validation(errors: List[(String, String)]): ErrorResponse = - ErrorResponse(errors = errors.map((ErrorDetail.apply _).tupled)) + ErrorResponse(code = 422, errors = errors.map((ErrorDetail.apply _).tupled)) def internalServerError(message: String = "Internal Server Error"): ErrorResponse = - ErrorResponse(errors = List(ErrorDetail("server", message))) + ErrorResponse(code = 500, errors = List(ErrorDetail("server", message))) def serviceUnavailableError(message: String = "Service unavailable"): ErrorResponse = - ErrorResponse(errors = List(ErrorDetail("request", message))) - + ErrorResponse(code = 503, errors = List(ErrorDetail("request", message))) } object ErrorExamples { - val unauthorized: ErrorResponse = ErrorResponse( - status = "error", - errors = List(ErrorDetail("login", "Invalid login or password")) - ) - - val badRequest: ErrorResponse = ErrorResponse( - status = "error", + val badRequest: ErrorResponse = + ErrorResponse( + code = 400, errors = List(ErrorDetail("general", "Bad request syntax or invalid parameters")) ) + val unauthorized: ErrorResponse = ErrorResponse( + code = 401, + errors = List(ErrorDetail("login", "Invalid login or password")) + ) + val forbidden: ErrorResponse = ErrorResponse( - status = "error", + code = 403, errors = List(ErrorDetail("permission", "You don't have access to this resource")) ) val notFound: ErrorResponse = ErrorResponse( - status = "error", + code = 404, errors = List(ErrorDetail("resource", "Requested resource not found")) ) val notAllowed: ErrorResponse = ErrorResponse( - status = "error", + code = 405, errors = List(ErrorDetail("resource", "Method not allowed on this endpoint")) ) - val conflict: ErrorResponse = ErrorResponse( - status = "error", + code = 409, errors = List(ErrorDetail("conflict", "Resource already exists or conflict occurred")) ) val teapot: ErrorResponse = ErrorResponse( - status = "error", + code = 418, errors = List(ErrorDetail("debug", "This feature is under development")) ) val validation: ErrorResponse = ErrorResponse( - status = "error", + code = 422, errors = List(ErrorDetail("login", "Login must not contain whitespace characters")) ) val internal: ErrorResponse = ErrorResponse( - status = "error", + code = 500, errors = List(ErrorDetail("server", "An unexpected error occurred. Please try again later.")) ) val unavailable: ErrorResponse = ErrorResponse( - status = "error", + code = 503, errors = List(ErrorDetail("request", "Service unavailable.")) ) }