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 // logs
"org.typelevel" %% "log4cats-slf4j" % "2.6.0", "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 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 io.circe.generic.auto.*
import sttp.model.StatusCode import sttp.model.StatusCode
import sttp.tapir.*
import sttp.tapir.generic.auto.*
import sttp.tapir.json.circe.*
import sttp.model.headers.WWWAuthenticateChallenge import sttp.model.headers.WWWAuthenticateChallenge
import scala.reflect.ClassTag
import org.yobble.module.response.{BaseResponse, ErrorResponse} import org.yobble.module.response.{BaseResponse, ErrorResponse}
import org.yobble.module.response.ErrorResponse._
import org.yobble.module.util.ErrorExamples import org.yobble.module.util.ErrorExamples
object TestErrorEndpoint { 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 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(query[Option[Int]]("code").description("Optional HTTP status code").example(Some(404))) .in(query[Option[Int]]("code").description("Optional HTTP status code").example(Some(404)))
.errorOut( .errorOut(
oneOf[(StatusCode, ErrorResponse)]( oneOf[ErrorResponse](
oneOfVariantValueMatcher( errorVariant(StatusCode.BadRequest, ErrorExamples.badRequest, "Bad Request"),
statusCode.and( errorVariant(StatusCode.Unauthorized, ErrorExamples.unauthorized, "Unauthorized"),
jsonBody[ErrorResponse] errorVariant(StatusCode.Forbidden, ErrorExamples.forbidden, "Forbidden"),
.description("Bad Request") errorVariant(StatusCode.NotFound, ErrorExamples.notFound, "Not Found"),
.example(ErrorExamples.badRequest) errorVariant(StatusCode.MethodNotAllowed, ErrorExamples.notAllowed, "Method Not Allowed"),
) errorVariant(StatusCode.Conflict, ErrorExamples.conflict, "Conflict"),
) { case (code, _) => code == StatusCode.BadRequest }, errorVariant(StatusCode(418), ErrorExamples.teapot, "I'm a teapot (In Development)"),
errorVariant(StatusCode.UnprocessableEntity, ErrorExamples.validation, "Validation Error"),
oneOfVariantValueMatcher( errorVariant(StatusCode.InternalServerError, ErrorExamples.internal, "Internal Server Error"),
statusCode.and( errorVariant(StatusCode.ServiceUnavailable, ErrorExamples.unavailable, "Service Unavailable")
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

@ -1,19 +1,23 @@
package org.yobble.chat_private_service.api.route package org.yobble.chat_private_service.api.route
import cats.effect.IO import cats.effect.IO
import org.yobble.chat_private_service.api.endpoint.{ErrorsEndpoint, PingEndpoint, TestErrorEndpoint} import org.http4s.Request
import org.yobble.module.response.{BaseResponse, ErrorDetail, ErrorResponse} import org.yobble.module.middleware.RealIpAndUserAgentMiddleware.{ipKey, uaKey}
import org.yobble.module.service.auth.{AuthClient, TokenServiceException} import sttp.tapir.server.http4s.Http4sServerOptions
import org.yobble.module.service.profile.ProfileClient
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
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 { object AllServerEndpoints {
def all(authClient: AuthClient, profileClient: ProfileClient): List[ServerEndpoint[Any, IO]] = List( def all(authClient: AuthClient, profileClient: ProfileClient): List[ServerEndpoint[Any, IO]] = List(
PingEndpoint.pingEndpoint.serverLogicSuccess(_ => IO.pure(BaseResponse("fine", "pong"))), PingEndpoint.pingEndpoint.serverLogicSuccess(_ => IO.pure(BaseResponse("fine", "pong"))),
ErrorsEndpoint.errorsEndpoint.serverLogicSuccess(_ => IO.pure(BaseResponse("fine", "errors"))), ErrorsEndpoint.errorsEndpoint.serverLogicSuccess(_ => IO.pure(BaseResponse("fine", "errors"))),
TestErrorEndpoint.testErrorEndpoint TestErrorEndpoint.testErrorEndpoint
@ -21,37 +25,29 @@ object AllServerEndpoints {
val cleanToken = token.stripPrefix("Bearer ").trim val cleanToken = token.stripPrefix("Bearer ").trim
val ip = "127.0.0.1" val ip = "127.0.0.1"
val ua = "scala-client" 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 { authClient.getCurrentUser(cleanToken, ip, ua).attempt.map {
case Left(TokenServiceException(code, msg)) => case Left(TokenServiceException(_, msg)) =>
Left((code, ErrorResponse( Left(ErrorUtils.unauthorized(msg))
status = code.code.toString,
errors = List(ErrorDetail(field = "Authorization", message = msg))
)))
// case Left(_) =>
// val code = StatusCode.ServiceUnavailable
// Left((code, errorByStatus(code))) // <-- Исправлено!
case Left(_) => case Left(_) =>
val code = StatusCode.ServiceUnavailable Left(ErrorUtils.serviceUnavailableError("Token service unavailable"))
Left((code, ErrorResponse(
status = "error",
errors = List(ErrorDetail(field = "request", message = "Token service unavailable"))
)))
case Right(_) => case Right(_) =>
Right(token) Right(token)
} }
} }
.serverLogic { _ => .serverLogic { _ => maybeCode =>
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((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 org.yobble.module.service.profile.ProfileClient
import sttp.tapir.server.http4s.Http4sServerInterpreter import sttp.tapir.server.http4s.Http4sServerInterpreter
import sttp.tapir.swagger.bundle.SwaggerInterpreter import sttp.tapir.swagger.bundle.SwaggerInterpreter
import org.yobble.module.middleware.RealIpAndUserAgentMiddleware
class Routes(authClient: AuthClient, profileClient: ProfileClient) { class Routes(authClient: AuthClient, profileClient: ProfileClient) {
private val allServerEndpoints = AllServerEndpoints.all(authClient, 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 package org.yobble.module.response
final case class ErrorDetail(field: String, message: String) import io.circe.{Encoder, Json}
final case class ErrorResponse(status: String = "error", errors: List[ErrorDetail]) 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, method = POST,
uri = Uri.unsafeFromString(AppConfig.TOKEN_SERVICE + "/decode") uri = Uri.unsafeFromString(AppConfig.TOKEN_SERVICE + "/decode")
).withEntity(requestBody) ).withEntity(requestBody)
.withHttpVersion(HttpVersion.`HTTP/2`)
println(s"$req") println(s"$req")

View File

@ -5,87 +5,86 @@ import sttp.model.StatusCode
object ErrorUtils { object ErrorUtils {
def badRequest(message: String = "Bad request"): ErrorResponse = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = def serviceUnavailableError(message: String = "Service unavailable"): ErrorResponse =
ErrorResponse(errors = List(ErrorDetail("request", message))) ErrorResponse(code = 503, errors = List(ErrorDetail("request", message)))
} }
object ErrorExamples { object ErrorExamples {
val unauthorized: ErrorResponse = ErrorResponse( val badRequest: ErrorResponse =
status = "error", ErrorResponse(
errors = List(ErrorDetail("login", "Invalid login or password")) code = 400,
)
val badRequest: ErrorResponse = ErrorResponse(
status = "error",
errors = List(ErrorDetail("general", "Bad request syntax or invalid parameters")) 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( val forbidden: ErrorResponse = ErrorResponse(
status = "error", code = 403,
errors = List(ErrorDetail("permission", "You don't have access to this resource")) errors = List(ErrorDetail("permission", "You don't have access to this resource"))
) )
val notFound: ErrorResponse = ErrorResponse( val notFound: ErrorResponse = ErrorResponse(
status = "error", code = 404,
errors = List(ErrorDetail("resource", "Requested resource not found")) errors = List(ErrorDetail("resource", "Requested resource not found"))
) )
val notAllowed: ErrorResponse = ErrorResponse( val notAllowed: ErrorResponse = ErrorResponse(
status = "error", code = 405,
errors = List(ErrorDetail("resource", "Method not allowed on this endpoint")) errors = List(ErrorDetail("resource", "Method not allowed on this endpoint"))
) )
val conflict: ErrorResponse = ErrorResponse( val conflict: ErrorResponse = ErrorResponse(
status = "error", code = 409,
errors = List(ErrorDetail("conflict", "Resource already exists or conflict occurred")) errors = List(ErrorDetail("conflict", "Resource already exists or conflict occurred"))
) )
val teapot: ErrorResponse = ErrorResponse( val teapot: ErrorResponse = ErrorResponse(
status = "error", code = 418,
errors = List(ErrorDetail("debug", "This feature is under development")) errors = List(ErrorDetail("debug", "This feature is under development"))
) )
val validation: ErrorResponse = ErrorResponse( val validation: ErrorResponse = ErrorResponse(
status = "error", code = 422,
errors = List(ErrorDetail("login", "Login must not contain whitespace characters")) errors = List(ErrorDetail("login", "Login must not contain whitespace characters"))
) )
val internal: ErrorResponse = ErrorResponse( val internal: ErrorResponse = ErrorResponse(
status = "error", code = 500,
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( val unavailable: ErrorResponse = ErrorResponse(
status = "error", code = 503,
errors = List(ErrorDetail("request", "Service unavailable.")) errors = List(ErrorDetail("request", "Service unavailable."))
) )
} }