diff --git a/modules/server/src/main/scala/io/kevchuang/reviewboard/Application.scala b/modules/server/src/main/scala/io/kevchuang/reviewboard/Application.scala index 6fcfcc3..b2de283 100644 --- a/modules/server/src/main/scala/io/kevchuang/reviewboard/Application.scala +++ b/modules/server/src/main/scala/io/kevchuang/reviewboard/Application.scala @@ -3,20 +3,30 @@ package io.kevchuang.reviewboard import io.kevchuang.reviewboard.http.routes.* import io.kevchuang.reviewboard.http.server.{HttpServer, HttpServerLive} import io.kevchuang.reviewboard.services.Services +import io.kevchuang.reviewboard.services.company.CompanyServiceLive +import io.kevchuang.reviewboard.services.db.{DatabaseService, InMemoryDatabase} import io.kevchuang.reviewboard.services.health.* import zio.* import zio.http.Server object Application extends ZIOAppDefault: - private val program: ZIO[HttpServer & Server & Services, Throwable, Unit] = + private val program + : ZIO[HttpServer & Server & DatabaseService & Services, Throwable, Unit] = for - healthRoutes <- HealthRoutes.make - routes = healthRoutes.routes - _ <- HttpServer.start(routes) + companiesRoutes <- CompaniesRoutes.make + healthRoutes <- HealthRoutes.make + routes = companiesRoutes.routes ++ healthRoutes.routes + _ <- HttpServer.start(routes) yield () override def run: ZIO[Any with ZIOAppArgs with Scope, Any, Any] = program - .provide(Server.default, HttpServerLive.live, HealthServiceLive.live) + .provide( + Server.default, + HttpServerLive.live, + CompanyServiceLive.live, + InMemoryDatabase.live, + HealthServiceLive.live + ) end Application diff --git a/modules/server/src/main/scala/io/kevchuang/reviewboard/domain/company.scala b/modules/server/src/main/scala/io/kevchuang/reviewboard/domain/company.scala new file mode 100644 index 0000000..439f629 --- /dev/null +++ b/modules/server/src/main/scala/io/kevchuang/reviewboard/domain/company.scala @@ -0,0 +1,82 @@ +package io.kevchuang.reviewboard.domain + +import io.github.iltotore.iron.constraint.all.* +import io.github.iltotore.iron.zioJson.given +import zio.json.* +import io.github.iltotore.iron.* +import io.kevchuang.reviewboard.domain.error.DomainError +import io.kevchuang.reviewboard.types.{NotEmpty, OnlyLetters} +import sttp.tapir.Schema +import sttp.tapir.generic.auto.* + +object company: + opaque type CompanyId = Long :| Positive + object CompanyId extends RefinedTypeOps[Long, Positive, CompanyId] + + opaque type CompanyName <: String :| Alphanumeric = String :| Alphanumeric + object CompanyName extends RefinedTypeOps[String, Alphanumeric, CompanyName] + + opaque type CompanySlug = String :| NotEmpty + object CompanySlug extends RefinedTypeOps[String, NotEmpty, CompanySlug] + + opaque type CountryName = String :| OnlyLetters + object CountryName extends RefinedTypeOps[String, OnlyLetters, CountryName] + + opaque type IndustryName = String :| OnlyLetters + object IndustryName extends RefinedTypeOps[String, OnlyLetters, IndustryName] + + opaque type LocationName = String :| OnlyLetters + object LocationName extends RefinedTypeOps[String, OnlyLetters, LocationName] + + private type TagNameConstraint = NotEmpty & LettersLowerCase + opaque type TagName = String :| TagNameConstraint + object TagName extends RefinedTypeOps[String, TagNameConstraint, TagName] + + opaque type Url = String :| ValidURL + object Url extends RefinedTypeOps[String, ValidURL, Url] + + final case class Company( + id: CompanyId, + slug: CompanySlug, + name: CompanyName, + url: Url, + location: Option[LocationName] = None, + country: Option[CountryName] = None, + industry: Option[IndustryName] = None, + image: Option[Url] = None, + tags: List[TagName] = List.empty[TagName] + ) + object Company: + given JsonCodec[Company] = DeriveJsonCodec.gen[Company] + inline given Schema[Company] = Schema.derived[Company] + end Company + + final case class CreateCompany( + name: CompanyName, + url: Url, + location: Option[LocationName] = None, + country: Option[CountryName] = None, + industry: Option[IndustryName] = None, + image: Option[Url] = None, + tags: List[TagName] = List.empty[TagName] + ) + object CreateCompany: + given JsonCodec[CreateCompany] = DeriveJsonCodec.gen[CreateCompany] + inline given Schema[CreateCompany] = Schema.derived[CreateCompany] + end CreateCompany + + sealed trait CompanyDomainError extends DomainError + final case class CompanyAlreadyExists(companyName: CompanyName) + extends CompanyDomainError + object CompanyAlreadyExists: + given JsonCodec[CompanyAlreadyExists] = + DeriveJsonCodec.gen[CompanyAlreadyExists] + end CompanyAlreadyExists + + final case class UnableToGenerateCompanyId(message: String) + extends CompanyDomainError + object UnableToGenerateCompanyId: + given JsonCodec[UnableToGenerateCompanyId] = + DeriveJsonCodec.gen[UnableToGenerateCompanyId] + end UnableToGenerateCompanyId +end company diff --git a/modules/server/src/main/scala/io/kevchuang/reviewboard/domain/error.scala b/modules/server/src/main/scala/io/kevchuang/reviewboard/domain/error.scala new file mode 100644 index 0000000..ee418a9 --- /dev/null +++ b/modules/server/src/main/scala/io/kevchuang/reviewboard/domain/error.scala @@ -0,0 +1,5 @@ +package io.kevchuang.reviewboard.domain + +object error: + trait DomainError extends Throwable +end error diff --git a/modules/server/src/main/scala/io/kevchuang/reviewboard/http/endpoints/CreateCompanyEndpoint.scala b/modules/server/src/main/scala/io/kevchuang/reviewboard/http/endpoints/CreateCompanyEndpoint.scala new file mode 100644 index 0000000..84d10b5 --- /dev/null +++ b/modules/server/src/main/scala/io/kevchuang/reviewboard/http/endpoints/CreateCompanyEndpoint.scala @@ -0,0 +1,54 @@ +package io.kevchuang.reviewboard.http.endpoints + +import io.kevchuang.reviewboard.domain.company.{*, given} +import io.kevchuang.reviewboard.services.company.CompanyService +import io.kevchuang.reviewboard.services.db.DatabaseService +import sttp.model.StatusCode +import sttp.tapir.* +import sttp.tapir.codec.iron.{*, given} +import sttp.tapir.generic.auto.* +import sttp.tapir.json.zio.* +import zio.* + +final case class CreateCompanyEndpoint() + extends HttpEndpoint[ + DatabaseService & CompanyService, + CreateCompany, + CompanyDomainError, + Company + ]: + def endpointDescription + : ApiEndpoint[CreateCompany, CompanyDomainError, Company] = + endpoint + .tag("companies") + .name("create") + .description("create a listing for a company") + .in("companies") + .post + .in(jsonBody[CreateCompany]) + .errorOut( + oneOf[CompanyDomainError]( + oneOfVariant[CompanyAlreadyExists]( + StatusCode.BadRequest, + jsonBody[CompanyAlreadyExists] + ), + oneOfVariant[UnableToGenerateCompanyId]( + StatusCode.BadRequest, + jsonBody[UnableToGenerateCompanyId] + ) + ) + ) + .out(jsonBody[Company]) + + def endpointLogic: CreateCompany => ZIO[ + DatabaseService & CompanyService, + CompanyDomainError, + Company + ] = + CompanyService.createCompany +end CreateCompanyEndpoint + +object CreateCompanyEndpoint: + def make: UIO[CreateCompanyEndpoint] = + ZIO.succeed(CreateCompanyEndpoint()) +end CreateCompanyEndpoint diff --git a/modules/server/src/main/scala/io/kevchuang/reviewboard/http/endpoints/HealthEndpoint.scala b/modules/server/src/main/scala/io/kevchuang/reviewboard/http/endpoints/HealthEndpoint.scala index a4f1f61..5e6890b 100644 --- a/modules/server/src/main/scala/io/kevchuang/reviewboard/http/endpoints/HealthEndpoint.scala +++ b/modules/server/src/main/scala/io/kevchuang/reviewboard/http/endpoints/HealthEndpoint.scala @@ -9,7 +9,7 @@ import sttp.tapir.generic.auto.* import sttp.tapir.json.zio.{*, given} import zio.* -final case class HealthEndpoint(health: HealthService) +final case class HealthEndpoint() extends HttpEndpoint[HealthService, Unit, Unit, HealthCheckResponse]: def endpointDescription: ApiEndpoint[Unit, Unit, HealthCheckResponse] = endpoint @@ -20,12 +20,11 @@ final case class HealthEndpoint(health: HealthService) .in("health") .out(jsonBody[HealthCheckResponse]) - def endpointLogic: Unit => UIO[HealthCheckResponse] = _ => health.checkHealth + def endpointLogic: Unit => URIO[HealthService, HealthCheckResponse] = _ => + HealthService.checkHealth end HealthEndpoint object HealthEndpoint: - def make: RIO[HealthService, HealthEndpoint] = - for health <- ZIO.service[HealthService] - yield HealthEndpoint(health) - + def make: UIO[HealthEndpoint] = + ZIO.succeed(HealthEndpoint()) end HealthEndpoint diff --git a/modules/server/src/main/scala/io/kevchuang/reviewboard/http/routes/CompaniesRoutes.scala b/modules/server/src/main/scala/io/kevchuang/reviewboard/http/routes/CompaniesRoutes.scala new file mode 100644 index 0000000..db0c579 --- /dev/null +++ b/modules/server/src/main/scala/io/kevchuang/reviewboard/http/routes/CompaniesRoutes.scala @@ -0,0 +1,22 @@ +package io.kevchuang.reviewboard.http.routes + +import io.kevchuang.reviewboard.http.endpoints.CreateCompanyEndpoint +import io.kevchuang.reviewboard.services.company.CompanyService +import io.kevchuang.reviewboard.services.db.DatabaseService +import sttp.tapir.ztapir.ZServerEndpoint +import zio.* + +private case class CompaniesRoutes( + createCompanyEndpoint: CreateCompanyEndpoint +) extends HttpRoute[DatabaseService & CompanyService]: + def endpoints: List[ZServerEndpoint[DatabaseService & CompanyService, Any]] = + List( + createCompanyEndpoint.serverEndpoint + ) +end CompaniesRoutes + +object CompaniesRoutes: + def make: UIO[CompaniesRoutes] = + for createCompany <- CreateCompanyEndpoint.make + yield CompaniesRoutes(createCompany) +end CompaniesRoutes diff --git a/modules/server/src/main/scala/io/kevchuang/reviewboard/http/routes/HealthRoutes.scala b/modules/server/src/main/scala/io/kevchuang/reviewboard/http/routes/HealthRoutes.scala index 989c8f5..fce2344 100644 --- a/modules/server/src/main/scala/io/kevchuang/reviewboard/http/routes/HealthRoutes.scala +++ b/modules/server/src/main/scala/io/kevchuang/reviewboard/http/routes/HealthRoutes.scala @@ -2,7 +2,6 @@ package io.kevchuang.reviewboard.http.routes import io.kevchuang.reviewboard.http.endpoints.HealthEndpoint import io.kevchuang.reviewboard.services.health.HealthService -import sttp.tapir.server.ServerEndpoint import sttp.tapir.ztapir.* import zio.* diff --git a/modules/server/src/main/scala/io/kevchuang/reviewboard/services/company/CompanyService.scala b/modules/server/src/main/scala/io/kevchuang/reviewboard/services/company/CompanyService.scala new file mode 100644 index 0000000..e219b51 --- /dev/null +++ b/modules/server/src/main/scala/io/kevchuang/reviewboard/services/company/CompanyService.scala @@ -0,0 +1,18 @@ +package io.kevchuang.reviewboard.services.company + +import io.kevchuang.reviewboard.domain.company.* +import io.kevchuang.reviewboard.services.db.DatabaseService +import zio.* + +trait CompanyService: + def createCompany( + company: CreateCompany + ): ZIO[DatabaseService, CompanyDomainError, Company] +end CompanyService + +object CompanyService: + def createCompany( + company: CreateCompany + ): ZIO[DatabaseService & CompanyService, CompanyDomainError, Company] = + ZIO.serviceWithZIO[CompanyService](_.createCompany(company)) +end CompanyService diff --git a/modules/server/src/main/scala/io/kevchuang/reviewboard/services/company/CompanyServiceLive.scala b/modules/server/src/main/scala/io/kevchuang/reviewboard/services/company/CompanyServiceLive.scala new file mode 100644 index 0000000..c00386a --- /dev/null +++ b/modules/server/src/main/scala/io/kevchuang/reviewboard/services/company/CompanyServiceLive.scala @@ -0,0 +1,65 @@ +package io.kevchuang.reviewboard.services.company + +import io.kevchuang.reviewboard.domain.company.* +import io.kevchuang.reviewboard.services.db.DatabaseService +import zio.* +import io.github.iltotore.iron.* +import io.github.iltotore.iron.constraint.all.* +import io.kevchuang.reviewboard.types.NotEmpty + +object CompanyServiceLive: + lazy val live: ULayer[CompanyService] = ZLayer.succeed( + new CompanyService: + private def generateCompanyId + : ZIO[DatabaseService, UnableToGenerateCompanyId, CompanyId] = + DatabaseService.countCompanies + .flatMap: id => + ZIO + .fromEither(id.refineEither[Positive]) + .mapBoth( + error => UnableToGenerateCompanyId(error), + companyId => CompanyId(companyId) + ) + + private def generateCompanySlug( + companyName: CompanyName + ): UIO[CompanySlug] = + ZIO.succeed( + CompanySlug( + companyName + .replaceAll(" +", " ") + .split(" ") + .map(_.toLowerCase) + .mkString("-") + .assume[NotEmpty] + ) + ) + + private def generateCompany( + company: CreateCompany + ): ZIO[DatabaseService, UnableToGenerateCompanyId, Company] = + for + companyId <- generateCompanyId + companySlug <- generateCompanySlug(company.name) + yield Company( + id = companyId, + slug = companySlug, + name = company.name, + url = company.url, + location = company.location, + country = company.country, + industry = company.industry, + image = company.image, + tags = company.tags + ) + + override def createCompany( + company: CreateCompany + ): ZIO[DatabaseService, CompanyDomainError, Company] = + DatabaseService + .existsCompany(company.name) + .flatMap: exists => + if exists then ZIO.fail(CompanyAlreadyExists(company.name)) + else generateCompany(company).tap(DatabaseService.insertCompany) + ) +end CompanyServiceLive diff --git a/modules/server/src/main/scala/io/kevchuang/reviewboard/services/db/DatabaseService.scala b/modules/server/src/main/scala/io/kevchuang/reviewboard/services/db/DatabaseService.scala new file mode 100644 index 0000000..a15dbe8 --- /dev/null +++ b/modules/server/src/main/scala/io/kevchuang/reviewboard/services/db/DatabaseService.scala @@ -0,0 +1,25 @@ +package io.kevchuang.reviewboard.services.db + +import io.kevchuang.reviewboard.domain.company.* +import zio.* + +trait DatabaseService: + def countCompanies: UIO[Long] + def existsCompany(companyName: CompanyName): UIO[Boolean] + def insertCompany(company: Company): UIO[Unit] +end DatabaseService + +object DatabaseService: + def countCompanies: URIO[DatabaseService, Long] = + ZIO.serviceWithZIO[DatabaseService](_.countCompanies) + + def existsCompany( + companyName: CompanyName + ): URIO[DatabaseService, Boolean] = + ZIO.serviceWithZIO[DatabaseService](_.existsCompany(companyName)) + + def insertCompany( + company: Company + ): URIO[DatabaseService, Unit] = + ZIO.serviceWithZIO[DatabaseService](_.insertCompany(company)) +end DatabaseService diff --git a/modules/server/src/main/scala/io/kevchuang/reviewboard/services/db/InMemoryDatabase.scala b/modules/server/src/main/scala/io/kevchuang/reviewboard/services/db/InMemoryDatabase.scala new file mode 100644 index 0000000..10c877f --- /dev/null +++ b/modules/server/src/main/scala/io/kevchuang/reviewboard/services/db/InMemoryDatabase.scala @@ -0,0 +1,26 @@ +package io.kevchuang.reviewboard.services.db + +import io.kevchuang.reviewboard.domain.company.* +import zio.* + +import scala.collection.mutable + +final case class InMemoryDatabase(database: mutable.Map[CompanyId, Company]) + extends DatabaseService: + override def countCompanies: UIO[Long] = + ZIO.succeed(database.size.toLong) + + override def existsCompany(companyName: CompanyName): UIO[Boolean] = + ZIO.succeed(database.exists((_, c) => c.name == companyName)) + + override def insertCompany( + company: Company + ): UIO[Unit] = + ZIO.succeed(database.addOne(company.id -> company)) +end InMemoryDatabase + +object InMemoryDatabase: + val live: ULayer[InMemoryDatabase] = + ZLayer.succeed: + InMemoryDatabase(mutable.Map.empty[CompanyId, Company]) +end InMemoryDatabase diff --git a/modules/server/src/main/scala/io/kevchuang/reviewboard/services/package.scala b/modules/server/src/main/scala/io/kevchuang/reviewboard/services/package.scala index ac17fb1..735c5b7 100644 --- a/modules/server/src/main/scala/io/kevchuang/reviewboard/services/package.scala +++ b/modules/server/src/main/scala/io/kevchuang/reviewboard/services/package.scala @@ -1,7 +1,8 @@ package io.kevchuang.reviewboard +import io.kevchuang.reviewboard.services.company.CompanyService import io.kevchuang.reviewboard.services.health.HealthService package object services: - type Services = HealthService + type Services = CompanyService & HealthService end services diff --git a/modules/server/src/main/scala/io/kevchuang/reviewboard/types/package.scala b/modules/server/src/main/scala/io/kevchuang/reviewboard/types/package.scala index b2308d6..c8e592a 100644 --- a/modules/server/src/main/scala/io/kevchuang/reviewboard/types/package.scala +++ b/modules/server/src/main/scala/io/kevchuang/reviewboard/types/package.scala @@ -1,7 +1,15 @@ package io.kevchuang.reviewboard import io.github.iltotore.iron.constraint.all.* +import zio.http.URL +import zio.json.* package object types: - type NotEmpty = Not[Empty] + type OnlyLetters = ForAll[Letter] DescribedAs "Should contain only letters" + type NotEmpty = Not[Empty] DescribedAs "Should not be empty" + + given JsonDecoder[URL] = JsonDecoder[String].mapOrFail(url => + URL.decode(url).fold(e => Left(e.getMessage), value => Right(value)) + ) + given JsonEncoder[URL] = JsonEncoder[String].contramap(_.encode) end types diff --git a/project/ScalaSettings.scala b/project/ScalaSettings.scala index c9ba1cf..09e0fe5 100644 --- a/project/ScalaSettings.scala +++ b/project/ScalaSettings.scala @@ -18,6 +18,8 @@ object ScalaSettings { "-language:higherKinds", "-language:implicitConversions", "-unchecked", - "-deprecation" + "-deprecation", + "-Xmax-inlines", + "64" ) }