base server
This commit is contained in:
parent
2241bdae00
commit
a058d89f3e
17
.idea/dataSources.xml
generated
Normal file
17
.idea/dataSources.xml
generated
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="postgres@localhost" uuid="287c8ab2-ecce-4d79-8e73-e9b1e4cfecc4">
|
||||||
|
<driver-ref>postgresql</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:postgresql://localhost:5432/postgres</jdbc-url>
|
||||||
|
<jdbc-additional-properties>
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.container.port" />
|
||||||
|
</jdbc-additional-properties>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
30
src/main/scala/org/yobble/scala_monolith/Main.scala
Normal file
30
src/main/scala/org/yobble/scala_monolith/Main.scala
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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])
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
package org.yobble.scala_monolith.api.response
|
||||||
|
|
||||||
|
case class BaseResponse(status: String, message: String)
|
||||||
@ -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"))
|
||||||
|
}
|
||||||
@ -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")))
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user