add errors and docs

This commit is contained in:
cheykrym 2025-07-09 04:25:00 +03:00
parent 00da0e3303
commit 90d0b1d5c7
8 changed files with 138 additions and 144 deletions

View File

@ -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"
)
)

View File

@ -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])

View File

@ -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)))
}
)
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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"))
}

View File

@ -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")

View File

@ -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."))
)
}