From f3f458a1b057cc1ade830270693f7488cce916bb Mon Sep 17 00:00:00 2001 From: cheykrym Date: Sat, 5 Jul 2025 15:46:18 +0300 Subject: [PATCH] add docs errors --- .../yobble/chat_private_service/Main.scala | 3 +- .../chat_private_service/api/Routes.scala | 24 ++++- .../api/endpoint/ErrorsEndpoint.scala | 34 +++++++ .../api/endpoint/PingEndpoint.scala | 1 + .../api/endpoint/TestErrorEndpoint.scala | 33 +++++++ .../api/response/ErrorResponse.scala | 4 + .../api/util/ErrorUtils.scala | 96 +++++++++++++++++++ .../middleware/GlobalErrorHandler.scala | 23 +++++ 8 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 src/main/scala/org/yobble/chat_private_service/api/endpoint/ErrorsEndpoint.scala create mode 100644 src/main/scala/org/yobble/chat_private_service/api/endpoint/TestErrorEndpoint.scala create mode 100644 src/main/scala/org/yobble/chat_private_service/api/response/ErrorResponse.scala create mode 100644 src/main/scala/org/yobble/chat_private_service/api/util/ErrorUtils.scala create mode 100644 src/main/scala/org/yobble/chat_private_service/middleware/GlobalErrorHandler.scala 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 350e21f..f7f3c87 100644 --- a/src/main/scala/org/yobble/chat_private_service/Main.scala +++ b/src/main/scala/org/yobble/chat_private_service/Main.scala @@ -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}") *> diff --git a/src/main/scala/org/yobble/chat_private_service/api/Routes.scala b/src/main/scala/org/yobble/chat_private_service/api/Routes.scala index 55fcb60..9fb2075 100644 --- a/src/main/scala/org/yobble/chat_private_service/api/Routes.scala +++ b/src/main/scala/org/yobble/chat_private_service/api/Routes.scala @@ -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 } \ No newline at end of file diff --git a/src/main/scala/org/yobble/chat_private_service/api/endpoint/ErrorsEndpoint.scala b/src/main/scala/org/yobble/chat_private_service/api/endpoint/ErrorsEndpoint.scala new file mode 100644 index 0000000..95f346c --- /dev/null +++ b/src/main/scala/org/yobble/chat_private_service/api/endpoint/ErrorsEndpoint.scala @@ -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]) +} diff --git a/src/main/scala/org/yobble/chat_private_service/api/endpoint/PingEndpoint.scala b/src/main/scala/org/yobble/chat_private_service/api/endpoint/PingEndpoint.scala index 10e34de..c9117ea 100644 --- a/src/main/scala/org/yobble/chat_private_service/api/endpoint/PingEndpoint.scala +++ b/src/main/scala/org/yobble/chat_private_service/api/endpoint/PingEndpoint.scala @@ -12,5 +12,6 @@ object PingEndpoint { val pingEndpoint: PublicEndpoint[Unit, Unit, BaseResponse, Any] = endpoint.get .in("ping") + .tags(List("Info")) .out(jsonBody[BaseResponse]) } 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 new file mode 100644 index 0000000..df1d684 --- /dev/null +++ b/src/main/scala/org/yobble/chat_private_service/api/endpoint/TestErrorEndpoint.scala @@ -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]) +} diff --git a/src/main/scala/org/yobble/chat_private_service/api/response/ErrorResponse.scala b/src/main/scala/org/yobble/chat_private_service/api/response/ErrorResponse.scala new file mode 100644 index 0000000..9b9a2a2 --- /dev/null +++ b/src/main/scala/org/yobble/chat_private_service/api/response/ErrorResponse.scala @@ -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]) diff --git a/src/main/scala/org/yobble/chat_private_service/api/util/ErrorUtils.scala b/src/main/scala/org/yobble/chat_private_service/api/util/ErrorUtils.scala new file mode 100644 index 0000000..afe9961 --- /dev/null +++ b/src/main/scala/org/yobble/chat_private_service/api/util/ErrorUtils.scala @@ -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 +} \ No newline at end of file diff --git a/src/main/scala/org/yobble/chat_private_service/middleware/GlobalErrorHandler.scala b/src/main/scala/org/yobble/chat_private_service/middleware/GlobalErrorHandler.scala new file mode 100644 index 0000000..e331492 --- /dev/null +++ b/src/main/scala/org/yobble/chat_private_service/middleware/GlobalErrorHandler.scala @@ -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 + ) + } +} \ No newline at end of file