From a058d89f3eb40e02979cbe14ced41a7ae88d61fd Mon Sep 17 00:00:00 2001 From: cheykrym Date: Tue, 5 Aug 2025 07:03:27 +0300 Subject: [PATCH] base server --- .idea/dataSources.xml | 17 +++ .../org/yobble/scala_monolith/Main.scala | 30 +++++ .../api/endpoint/ErrorsEndpoint.scala | 31 +++++ .../api/endpoint/PingEndpoint.scala | 19 ++++ .../api/response/BaseResponse.scala | 3 + .../api/response/ErrorResponse.scala | 16 +++ .../api/route/AllServerEndpoints.scala | 15 +++ .../scala_monolith/api/route/Routes.scala | 24 ++++ .../scala_monolith/api/util/ErrorUtils.scala | 106 ++++++++++++++++++ .../middleware/GlobalErrorHandler.scala | 23 ++++ .../RealIpAndUserAgentMiddleware.scala | 32 ++++++ 11 files changed, 316 insertions(+) create mode 100644 .idea/dataSources.xml create mode 100644 src/main/scala/org/yobble/scala_monolith/Main.scala create mode 100644 src/main/scala/org/yobble/scala_monolith/api/endpoint/ErrorsEndpoint.scala create mode 100644 src/main/scala/org/yobble/scala_monolith/api/endpoint/PingEndpoint.scala create mode 100644 src/main/scala/org/yobble/scala_monolith/api/response/BaseResponse.scala create mode 100644 src/main/scala/org/yobble/scala_monolith/api/response/ErrorResponse.scala create mode 100644 src/main/scala/org/yobble/scala_monolith/api/route/AllServerEndpoints.scala create mode 100644 src/main/scala/org/yobble/scala_monolith/api/route/Routes.scala create mode 100644 src/main/scala/org/yobble/scala_monolith/api/util/ErrorUtils.scala create mode 100644 src/main/scala/org/yobble/scala_monolith/middleware/GlobalErrorHandler.scala create mode 100644 src/main/scala/org/yobble/scala_monolith/middleware/RealIpAndUserAgentMiddleware.scala diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..95fc1e5 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,17 @@ + + + + + postgresql + true + org.postgresql.Driver + jdbc:postgresql://localhost:5432/postgres + + + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/src/main/scala/org/yobble/scala_monolith/Main.scala b/src/main/scala/org/yobble/scala_monolith/Main.scala new file mode 100644 index 0000000..855037c --- /dev/null +++ b/src/main/scala/org/yobble/scala_monolith/Main.scala @@ -0,0 +1,30 @@ +package org.yobble.scala_monolith + +import cats.effect.{ExitCode, IO, IOApp} +import org.http4s.ember.server.EmberServerBuilder +import org.http4s.server.Router +import com.comcast.ip4s._ +import org.yobble.scala_monolith.api.route.Routes +import org.yobble.scala_monolith.middleware.GlobalErrorHandler +import cats.implicits._ + +object Main extends IOApp { + override def run(args: List[String]): IO[ExitCode] = { + val httpApp = Router("/" -> Routes.all).orNotFound + val httpAppWithMiddleware = GlobalErrorHandler.withGlobalErrorHandler(httpApp) + + val port = sys.env.get("HTTP_PORT").flatMap(_.toIntOption).getOrElse(8080) + + EmberServerBuilder + .default[IO] + .withHost(ipv4"0.0.0.0") + .withPort(Port.fromInt(port).get) + .withHttpApp(httpAppWithMiddleware) + .build + .use { server => + IO.println(s"Server running at http://localhost:${server.address.getPort}") *> + IO.never + } + .as(ExitCode.Success) + } +} \ No newline at end of file diff --git a/src/main/scala/org/yobble/scala_monolith/api/endpoint/ErrorsEndpoint.scala b/src/main/scala/org/yobble/scala_monolith/api/endpoint/ErrorsEndpoint.scala new file mode 100644 index 0000000..5e1c85b --- /dev/null +++ b/src/main/scala/org/yobble/scala_monolith/api/endpoint/ErrorsEndpoint.scala @@ -0,0 +1,31 @@ +package org.yobble.scala_monolith.api.endpoint + +import io.circe.generic.auto._ +import org.yobble.scala_monolith.api.response.{BaseResponse, ErrorResponse} +import sttp.tapir._ +import sttp.tapir.generic.auto._ +import sttp.tapir.json.circe._ +import org.yobble.scala_monolith.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)), + oneOfVariant(StatusCode.ServiceUnavailable, jsonBody[ErrorResponse].description("Service Unavailable").example(ErrorExamples.unavailable)) + ) + ) + .out(jsonBody[BaseResponse]) +} diff --git a/src/main/scala/org/yobble/scala_monolith/api/endpoint/PingEndpoint.scala b/src/main/scala/org/yobble/scala_monolith/api/endpoint/PingEndpoint.scala new file mode 100644 index 0000000..2b3a4b3 --- /dev/null +++ b/src/main/scala/org/yobble/scala_monolith/api/endpoint/PingEndpoint.scala @@ -0,0 +1,19 @@ +package org.yobble.scala_monolith.api.endpoint + +import sttp.tapir._ +import sttp.tapir.json.circe.jsonBody +import sttp.tapir.generic.auto._ +import org.yobble.scala_monolith.api.response.ErrorResponse +import io.circe.generic.auto._ + +object PingEndpoint { + case class Pong(status: String, message: String) + + val pingEndpoint = endpoint + .get + .in("ping") + .tags(List("Info")) + .out(jsonBody[Pong]) + .errorOut(jsonBody[ErrorResponse]) + .description("Check if the server is running") +} \ No newline at end of file diff --git a/src/main/scala/org/yobble/scala_monolith/api/response/BaseResponse.scala b/src/main/scala/org/yobble/scala_monolith/api/response/BaseResponse.scala new file mode 100644 index 0000000..5e1f679 --- /dev/null +++ b/src/main/scala/org/yobble/scala_monolith/api/response/BaseResponse.scala @@ -0,0 +1,3 @@ +package org.yobble.scala_monolith.api.response + +case class BaseResponse(status: String, message: String) diff --git a/src/main/scala/org/yobble/scala_monolith/api/response/ErrorResponse.scala b/src/main/scala/org/yobble/scala_monolith/api/response/ErrorResponse.scala new file mode 100644 index 0000000..f687717 --- /dev/null +++ b/src/main/scala/org/yobble/scala_monolith/api/response/ErrorResponse.scala @@ -0,0 +1,16 @@ +package org.yobble.scala_monolith.api.response + +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/scala_monolith/api/route/AllServerEndpoints.scala b/src/main/scala/org/yobble/scala_monolith/api/route/AllServerEndpoints.scala new file mode 100644 index 0000000..dc93044 --- /dev/null +++ b/src/main/scala/org/yobble/scala_monolith/api/route/AllServerEndpoints.scala @@ -0,0 +1,15 @@ +package org.yobble.scala_monolith.api.route + +import cats.effect.IO +import sttp.tapir.server.ServerEndpoint +import org.yobble.scala_monolith.api.endpoint.PingEndpoint +import org.yobble.scala_monolith.api.endpoint.PingEndpoint.Pong +import org.yobble.scala_monolith.api.endpoint.ErrorsEndpoint +import org.yobble.scala_monolith.api.response.BaseResponse + +object AllServerEndpoints { + val all: List[ServerEndpoint[Any, IO]] = List( + PingEndpoint.pingEndpoint.serverLogicSuccess(_ => IO.pure(Pong("ok", "pong"))), + ErrorsEndpoint.errorsEndpoint.serverLogicSuccess(_ => IO.pure(BaseResponse("fine", "errors"))) + ) +} \ No newline at end of file diff --git a/src/main/scala/org/yobble/scala_monolith/api/route/Routes.scala b/src/main/scala/org/yobble/scala_monolith/api/route/Routes.scala new file mode 100644 index 0000000..250ac3f --- /dev/null +++ b/src/main/scala/org/yobble/scala_monolith/api/route/Routes.scala @@ -0,0 +1,24 @@ +package org.yobble.scala_monolith.api.route + +import cats.effect.IO +import org.http4s.HttpRoutes +import sttp.tapir.server.http4s.Http4sServerInterpreter +import sttp.tapir.swagger.bundle.SwaggerInterpreter +import org.yobble.scala_monolith.middleware.RealIpAndUserAgentMiddleware +import cats.syntax.semigroupk._ + +object Routes { + private val allServerEndpoints = AllServerEndpoints.all + + private val httpRoutes = Http4sServerInterpreter[IO]().toRoutes(allServerEndpoints) + + private val docsRoutes = Http4sServerInterpreter[IO]().toRoutes( + SwaggerInterpreter().fromServerEndpoints[IO]( + allServerEndpoints, + "scala_monolith API", + "1.0" + ) + ) + + val all: HttpRoutes[IO] = RealIpAndUserAgentMiddleware(docsRoutes <+> httpRoutes) +} \ No newline at end of file diff --git a/src/main/scala/org/yobble/scala_monolith/api/util/ErrorUtils.scala b/src/main/scala/org/yobble/scala_monolith/api/util/ErrorUtils.scala new file mode 100644 index 0000000..c238ed5 --- /dev/null +++ b/src/main/scala/org/yobble/scala_monolith/api/util/ErrorUtils.scala @@ -0,0 +1,106 @@ +package org.yobble.scala_monolith.api.util + +import org.yobble.scala_monolith.api.response.{ErrorDetail, ErrorResponse} +import sttp.model.StatusCode + +object ErrorUtils { + def badRequest(message: String = "Bad request"): ErrorResponse = + ErrorResponse(code = 400, errors = List(ErrorDetail("general", message))) + + def unauthorized(message: String = "Unauthorized"): ErrorResponse = + ErrorResponse(code = 401, errors = List(ErrorDetail("login", message))) + + def forbidden(message: String = "Forbidden"): ErrorResponse = + ErrorResponse(code = 403, errors = List(ErrorDetail("permission", message))) + + def notFound(message: String = "Not found"): ErrorResponse = + ErrorResponse(code = 404, errors = List(ErrorDetail("resource", message))) + + def methodNotAllowed(message: String = "Method not allowed"): ErrorResponse = + ErrorResponse(code = 405, errors = List(ErrorDetail("method", message))) + + def conflict(message: String = "Conflict"): ErrorResponse = + ErrorResponse(code = 409, errors = List(ErrorDetail("conflict", message))) + + def imATeapot(message: String = "This feature is under development"): ErrorResponse = + ErrorResponse(code = 418, errors = List(ErrorDetail("debug", message))) + + def validation(errors: List[(String, String)]): ErrorResponse = + ErrorResponse(code = 422, errors = errors.map((ErrorDetail.apply _).tupled)) + + def internalServerError(message: String = "Internal Server Error"): ErrorResponse = + ErrorResponse(code = 500, errors = List(ErrorDetail("server", message))) + + def serviceUnavailableError(message: String = "Service unavailable"): ErrorResponse = + ErrorResponse(code = 503, errors = List(ErrorDetail("request", message))) +} + +object ErrorExamples { + + 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( + code = 403, + errors = List(ErrorDetail("permission", "You don't have access to this resource")) + ) + + val notFound: ErrorResponse = ErrorResponse( + code = 404, + errors = List(ErrorDetail("resource", "Requested resource not found")) + ) + + val notAllowed: ErrorResponse = ErrorResponse( + code = 405, + errors = List(ErrorDetail("resource", "Method not allowed on this endpoint")) + ) + + val conflict: ErrorResponse = ErrorResponse( + code = 409, + errors = List(ErrorDetail("conflict", "Resource already exists or conflict occurred")) + ) + + val teapot: ErrorResponse = ErrorResponse( + code = 418, + errors = List(ErrorDetail("debug", "This feature is under development")) + ) + + val validation: ErrorResponse = ErrorResponse( + code = 422, + errors = List(ErrorDetail("login", "Login must not contain whitespace characters")) + ) + + val internal: ErrorResponse = ErrorResponse( + code = 500, + errors = List(ErrorDetail("server", "An unexpected error occurred. Please try again later.")) + ) + + val unavailable: ErrorResponse = ErrorResponse( + code = 503, + errors = List(ErrorDetail("request", "Service unavailable.")) + ) +} + +// 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 c if c == StatusCode.ServiceUnavailable => ErrorExamples.unavailable + case _ => ErrorExamples.internal +} diff --git a/src/main/scala/org/yobble/scala_monolith/middleware/GlobalErrorHandler.scala b/src/main/scala/org/yobble/scala_monolith/middleware/GlobalErrorHandler.scala new file mode 100644 index 0000000..aa7147c --- /dev/null +++ b/src/main/scala/org/yobble/scala_monolith/middleware/GlobalErrorHandler.scala @@ -0,0 +1,23 @@ +package org.yobble.scala_monolith.middleware + +import cats.effect.Sync +import org.http4s.HttpApp +import org.http4s.server.middleware.ErrorAction +import org.yobble.scala_monolith.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 + ) + } +} + diff --git a/src/main/scala/org/yobble/scala_monolith/middleware/RealIpAndUserAgentMiddleware.scala b/src/main/scala/org/yobble/scala_monolith/middleware/RealIpAndUserAgentMiddleware.scala new file mode 100644 index 0000000..0752e49 --- /dev/null +++ b/src/main/scala/org/yobble/scala_monolith/middleware/RealIpAndUserAgentMiddleware.scala @@ -0,0 +1,32 @@ +package org.yobble.scala_monolith.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) + } +}