Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(server): Implement create company endpoint #10

Merged
merged 1 commit into from
Mar 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package io.kevchuang.reviewboard.domain

object error:
trait DomainError extends Throwable
end error
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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.*

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading