add docs errors

This commit is contained in:
cheykrym 2025-07-05 15:46:18 +03:00
parent 5180c78e8f
commit f3f458a1b0
8 changed files with 212 additions and 6 deletions

View File

@ -14,6 +14,7 @@ import com.comcast.ip4s.{Port, host}
import org.http4s.circe.CirceEntityCodec.*
import org.yobble.chat_private_service.api.Routes
import org.yobble.chat_private_service.config.AppConfig
import org.yobble.chat_private_service.middleware.GlobalErrorHandler
object Main extends IOApp {
override def run(args: List[String]): IO[ExitCode] =
@ -21,7 +22,7 @@ object Main extends IOApp {
.default[IO]
.withHost(host"0.0.0.0")
.withPort(port = Port.fromInt(AppConfig.PORT).get)
.withHttpApp(Routes.all.orNotFound)
.withHttpApp(GlobalErrorHandler.withGlobalErrorHandler(Routes.all.orNotFound))
.build
.use(_ =>
IO.println(s"Server running at http://${AppConfig.HOST}:${AppConfig.PORT}") *>

View File

@ -2,24 +2,38 @@ package org.yobble.chat_private_service.api
import cats.effect.IO
import org.http4s.HttpRoutes
import sttp.tapir.server.http4s._
import sttp.tapir.server.http4s.*
import sttp.tapir.swagger.bundle.SwaggerInterpreter
import org.yobble.chat_private_service.api.endpoint.PingEndpoint._
import cats.syntax.semigroupk._
import org.yobble.chat_private_service.api.endpoint.PingEndpoint.*
import org.yobble.chat_private_service.api.endpoint.ErrorsEndpoint.*
import org.yobble.chat_private_service.api.endpoint.TestErrorEndpoint.*
import cats.syntax.semigroupk.*
import org.yobble.chat_private_service.api.response.BaseResponse
import sttp.model.StatusCode
import org.yobble.chat_private_service.api.util.errorByStatus
object Routes {
private val pingRoute = Http4sServerInterpreter[IO]().toRoutes(
pingEndpoint.serverLogicSuccess(_ => IO.pure(BaseResponse("fine", "pong")))
)
private val errorsRoute = Http4sServerInterpreter[IO]().toRoutes(
errorsEndpoint.serverLogicSuccess(_ => IO.pure(BaseResponse("fine", "errors")))
)
private val testErrorRoute = Http4sServerInterpreter[IO]().toRoutes(
testErrorEndpoint.serverLogic((code: Int) =>
IO.pure(Left(errorByStatus(StatusCode.safeApply(code).getOrElse(StatusCode.InternalServerError))))
)
)
private val docsRoutes = Http4sServerInterpreter[IO]().toRoutes(
SwaggerInterpreter().fromEndpoints[IO](
List(pingEndpoint),
List(pingEndpoint, errorsEndpoint, testErrorEndpoint),
"chat_private_service API",
"1.0"
)
)
val all: HttpRoutes[IO] = pingRoute <+> docsRoutes
val all: HttpRoutes[IO] = pingRoute <+> errorsRoute <+> testErrorRoute <+> docsRoutes
}

View File

@ -0,0 +1,34 @@
package org.yobble.chat_private_service.api.endpoint
import io.circe.generic.auto.*
import org.yobble.chat_private_service.api.response.BaseResponse
import org.yobble.chat_private_service.config.AppConfig
import sttp.tapir.*
import sttp.tapir.generic.auto.*
import sttp.tapir.json.circe.*
import org.yobble.chat_private_service.api.response.ErrorResponse
import org.yobble.chat_private_service.api.util.ErrorExamples
import sttp.model.StatusCode
object ErrorsEndpoint {
val errorsEndpoint: PublicEndpoint[Unit, ErrorResponse, BaseResponse, Any] =
endpoint.get
.in("errors")
.tags(List("Info"))
.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))
)
)
.out(jsonBody[BaseResponse])
}

View File

@ -12,5 +12,6 @@ object PingEndpoint {
val pingEndpoint: PublicEndpoint[Unit, Unit, BaseResponse, Any] =
endpoint.get
.in("ping")
.tags(List("Info"))
.out(jsonBody[BaseResponse])
}

View File

@ -0,0 +1,33 @@
package org.yobble.chat_private_service.api.endpoint
import io.circe.generic.auto.*
import org.yobble.chat_private_service.api.response.{BaseResponse, ErrorResponse}
import org.yobble.chat_private_service.api.util.ErrorExamples
import org.yobble.chat_private_service.config.AppConfig
import sttp.model.StatusCode
import sttp.tapir.*
import sttp.tapir.generic.auto.*
import sttp.tapir.json.circe.*
object TestErrorEndpoint {
val testErrorEndpoint: Endpoint[Unit, Int, ErrorResponse, BaseResponse, Any] =
endpoint.get
.in("test-error")
.in(query[Int]("code").description("HTTP status code to simulate (e.g., 401, 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))
)
)
.out(jsonBody[BaseResponse])
}

View File

@ -0,0 +1,4 @@
package org.yobble.chat_private_service.api.response
final case class ErrorDetail(field: String, message: String)
final case class ErrorResponse(status: String = "error", errors: List[ErrorDetail])

View File

@ -0,0 +1,96 @@
package org.yobble.chat_private_service.api.util
import sttp.model.StatusCode
import org.yobble.chat_private_service.api.response.{ErrorDetail, ErrorResponse}
object ErrorUtils {
def badRequest(message: String = "Bad request"): ErrorResponse =
ErrorResponse(errors = List(ErrorDetail("general", message)))
def unauthorized(message: String = "Unauthorized"): ErrorResponse =
ErrorResponse(errors = List(ErrorDetail("login", message)))
def forbidden(message: String = "Forbidden"): ErrorResponse =
ErrorResponse(errors = List(ErrorDetail("permission", message)))
def notFound(message: String = "Not found"): ErrorResponse =
ErrorResponse(errors = List(ErrorDetail("resource", message)))
def methodNotAllowed(message: String = "Method not allowed"): ErrorResponse =
ErrorResponse(errors = List(ErrorDetail("method", message)))
def conflict(message: String = "Conflict"): ErrorResponse =
ErrorResponse(errors = List(ErrorDetail("conflict", message)))
def imATeapot(message: String = "This feature is under development"): ErrorResponse =
ErrorResponse(errors = List(ErrorDetail("debug", message)))
def validation(errors: List[(String, String)]): ErrorResponse =
ErrorResponse(errors = errors.map((ErrorDetail.apply _).tupled))
def internalServerError(message: String = "Internal Server Error"): ErrorResponse =
ErrorResponse(errors = List(ErrorDetail("server", message)))
}
object ErrorExamples {
val unauthorized: ErrorResponse = ErrorResponse(
status = "error",
errors = List(ErrorDetail("login", "Invalid login or password"))
)
val badRequest: ErrorResponse = ErrorResponse(
status = "error",
errors = List(ErrorDetail("general", "Bad request syntax or invalid parameters"))
)
val forbidden: ErrorResponse = ErrorResponse(
status = "error",
errors = List(ErrorDetail("permission", "You don't have access to this resource"))
)
val notFound: ErrorResponse = ErrorResponse(
status = "error",
errors = List(ErrorDetail("resource", "Requested resource not found"))
)
val notAllowed: ErrorResponse = ErrorResponse(
status = "error",
errors = List(ErrorDetail("resource", "Method not allowed on this endpoint"))
)
val conflict: ErrorResponse = ErrorResponse(
status = "error",
errors = List(ErrorDetail("conflict", "Resource already exists or conflict occurred"))
)
val teapot: ErrorResponse = ErrorResponse(
status = "error",
errors = List(ErrorDetail("debug", "This feature is under development"))
)
val validation: ErrorResponse = ErrorResponse(
status = "error",
errors = List(ErrorDetail("login", "Login must not contain whitespace characters"))
)
val internal: ErrorResponse = ErrorResponse(
status = "error",
errors = List(ErrorDetail("server", "An unexpected error occurred. Please try again later."))
)
}
// example use
// IO.pure(Left(errorByStatus(StatusCode.Unauthorized)))
def errorByStatus(code: StatusCode): ErrorResponse = code match {
case c if c == StatusCode.BadRequest => ErrorExamples.badRequest
case c if c == StatusCode.Unauthorized => ErrorExamples.unauthorized
case c if c == StatusCode.Forbidden => ErrorExamples.forbidden
case c if c == StatusCode.NotFound => ErrorExamples.notFound
case c if c == StatusCode.MethodNotAllowed => ErrorExamples.notAllowed
case c if c == StatusCode.Conflict => ErrorExamples.conflict
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 _ => ErrorExamples.internal
}

View File

@ -0,0 +1,23 @@
package org.yobble.chat_private_service.middleware
import cats.effect.Sync
import org.http4s.HttpApp
import org.http4s.server.middleware.ErrorAction
import org.yobble.chat_private_service.api.util.ErrorUtils
object GlobalErrorHandler {
def withGlobalErrorHandler[F[_] : Sync](httpApp: HttpApp[F]): HttpApp[F] = {
val messageFailureLogAction: (Throwable, => String) => F[Unit] =
(t, msg) => Sync[F].delay(println(s"Message failure: $msg"))
val serviceErrorLogAction: (Throwable, => String) => F[Unit] =
(t, msg) => Sync[F].delay(println(s"Unhandled error: ${t.getMessage}\n$msg"))
ErrorAction.log(
httpApp,
messageFailureLogAction = messageFailureLogAction,
serviceErrorLogAction = serviceErrorLogAction
)
}
}