Skip to content

Commit

Permalink
[TASK-81] backend setup (#67)
Browse files Browse the repository at this point in the history
* feat(backend): Mediator implementation for Kotlin.

- added notification, pipeline, command and query support

* feat(backend): Basic Subscriber Register Process

* feat(backend): Basic Subscriber Register Process

* test(backend): email send test

* style(core): updated and formated frontend and backend dependencies

* build(core): split docker compose files

* build(core): split docker compose files

* build(core): example env variables

* build(core): added spring boot library convention

* test(criteria): testing the criteria layer

* test(criteria): testing the criteria parser

* test(criteria): testing the criteria parser

* test(criteria): testing event configuration and emitter
  • Loading branch information
yacosta738 authored Jan 13, 2024
1 parent abf8ebd commit 0c8cfda
Show file tree
Hide file tree
Showing 158 changed files with 6,233 additions and 25 deletions.
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
COMPOSE_PATH_SEPARATOR=:
COMPOSE_FILE=postgresql-compose.yml:docker-compose.yml

SENDGRID_API_KEY=YOUR_API_KEY
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ yarn-error.log*
pnpm-debug.log*

# environment variables
.env
.env.production

# macOS-specific files
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,14 @@

<div align="center">

![Astro Badge](https://img.shields.io/badge/Astro-BC52EE?logo=astro&logoColor=fff&style=flat)
![Typescript Badge](https://img.shields.io/badge/Typescript-007ACC?logo=typescript&logoColor=fff&style=flat)
[![Deploy to Main Stage 💫](https://github.com/yacosta738/lyra/actions/workflows/deploy-main-stage.yml/badge.svg)](https://github.com/yacosta738/lyra/actions/workflows/deploy-main-stage.yml)
[![codecov](https://codecov.io/gh/yacosta738/lyra/graph/badge.svg?token=4MN8TEOGD9)](https://codecov.io/gh/yacosta738/lyra)
![GitHub stars](https://img.shields.io/github/stars/yacosta738/lyra)
![GitHub issues](https://img.shields.io/github/issues/yacosta738/lyra)
![GitHub forks](https://img.shields.io/github/forks/yacosta738/lyra)
![GitHub PRs](https://img.shields.io/github/issues-pr/yacosta738/lyra)
![Astro Badge](https://img.shields.io/badge/Astro-BC52EE?logo=astro&logoColor=fff&style=flat)
![Typescript Badge](https://img.shields.io/badge/Typescript-007ACC?logo=typescript&logoColor=fff&style=flat)
![Tailwind CSS Badge](https://img.shields.io/badge/Tailwind%20CSS-06B6D4?logo=tailwindcss&logoColor=fff&style=flat)
![Spring Boot Badge](https://img.shields.io/badge/Spring%20Boot-6DB33F?logo=springboot&logoColor=fff&style=flat)
![Kotlin Badge](https://img.shields.io/badge/Kotlin-0095D5?logo=kotlin&logoColor=fff&style=flat)
Expand Down
17 changes: 17 additions & 0 deletions apps/backend/backend.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,23 @@ repositories {
dependencies {
// L O C A L D E P E N D E N C I E S
implementation(project(":shared:common"))
implementation(project(":shared:spring-boot-common"))

implementation(libs.spring.boot.starter.data.r2dbc)
implementation("org.springframework:spring-r2dbc")
implementation("org.springframework.data:spring-data-r2dbc")
implementation("org.postgresql:r2dbc-postgresql")

implementation(libs.spring.dotenv)
implementation(libs.sendgrid)

testImplementation(libs.faker)
testImplementation(libs.mockk)

testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:postgresql")
testImplementation("org.testcontainers:r2dbc")
}

tasks.register<Copy>("processFrontendResources") {
Expand Down
9 changes: 8 additions & 1 deletion apps/backend/src/main/kotlin/com/lyra/app/LyraApplication.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.lyra.app

import com.lyra.common.domain.Service
import com.lyra.spring.boot.bus.event.EventConfiguration
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.SpringBootApplication
Expand All @@ -9,7 +10,13 @@ import org.springframework.context.annotation.ComponentScan
import org.springframework.context.annotation.FilterType

@SpringBootApplication
@ComponentScan(includeFilters = [ComponentScan.Filter(type = FilterType.ANNOTATION, classes = [Service::class])])
@ComponentScan(
basePackages = ["com.lyra"],
basePackageClasses = [
EventConfiguration::class,
],
includeFilters = [ComponentScan.Filter(type = FilterType.ANNOTATION, classes = [Service::class])],
)
class LyraApplication

private val log: Logger = LoggerFactory.getLogger(LyraApplication::class.java)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.lyra.app.config.db

import com.lyra.app.newsletter.domain.SubscriberStatus
import com.lyra.app.newsletter.infrastructure.persistence.converter.SubscriberConverter
import com.lyra.app.newsletter.infrastructure.persistence.converter.SubscriberStatusWriterConverter
import io.r2dbc.postgresql.codec.EnumCodec
import io.r2dbc.postgresql.codec.EnumCodec.Builder.RegistrationPriority
import io.r2dbc.postgresql.extension.CodecRegistrar
import io.r2dbc.spi.ConnectionFactoryOptions
import io.r2dbc.spi.Option
import org.springframework.boot.autoconfigure.r2dbc.ConnectionFactoryOptionsBuilderCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.convert.CustomConversions
import org.springframework.data.r2dbc.config.EnableR2dbcAuditing
import org.springframework.data.r2dbc.convert.R2dbcCustomConversions
import org.springframework.data.r2dbc.dialect.DialectResolver
import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories
import org.springframework.r2dbc.core.DatabaseClient
import org.springframework.transaction.annotation.EnableTransactionManagement

@Configuration
@EnableTransactionManagement
@EnableR2dbcRepositories
@EnableR2dbcAuditing
class DatabaseConfig {
/**
* Use the customizer to add EnumCodec to R2DBC
*/
@Bean
fun connectionFactoryOptionsBuilderCustomizer(): ConnectionFactoryOptionsBuilderCustomizer {
return ConnectionFactoryOptionsBuilderCustomizer { builder: ConnectionFactoryOptions.Builder ->
builder.option(
Option.valueOf("extensions"),
listOf<CodecRegistrar>(
EnumCodec.builder()
.withEnum("subscriber_status", SubscriberStatus::class.java)
.withRegistrationPriority(RegistrationPriority.FIRST)
.build(),
),
)
}
}

/**
* Register converter to make sure Spring data treat enum correctly
*/
@Bean
fun r2dbcCustomConversions(databaseClient: DatabaseClient): R2dbcCustomConversions {
val dialect = DialectResolver.getDialect(databaseClient.connectionFactory)
val converters: MutableList<Any?> = ArrayList(dialect.converters)
converters.addAll(R2dbcCustomConversions.STORE_CONVERTERS)
return R2dbcCustomConversions(
CustomConversions.StoreConversions.of(dialect.simpleTypeHolder, converters),
listOf(
SubscriberConverter(),
SubscriberStatusWriterConverter(),
),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.lyra.app.controllers

import com.lyra.common.domain.error.BusinessRuleValidationException
import java.net.URI
import java.time.Instant
import org.springframework.http.HttpStatus
import org.springframework.http.ProblemDetail
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.reactive.result.method.annotation.ResponseEntityExceptionHandler

private const val ERROR_PAGE = "https://lyra.io/errors"

/**
* This class provides a global exception handling mechanism for the application.
*
* It extends the [ResponseEntityExceptionHandler] class to handle exceptions and return appropriate responses.
*
* @created 4/8/23
*/
@RestControllerAdvice
class GlobalExceptionHandler : ResponseEntityExceptionHandler() {
/**
* Handles the [UserAuthenticationException] by creating a ProblemDetail object with the appropriate status,
* detail and properties.
*
* @param e The UserAuthenticationException that was thrown.
* @return The ProblemDetail object representing the exception.
*/
// @ResponseStatus(HttpStatus.UNAUTHORIZED)
// @ExceptionHandler(UserAuthenticationException::class)
// fun handleUserAuthenticationException(e: UserAuthenticationException): ProblemDetail {
// val problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, e.message)
// problemDetail.title = "User authentication failed"
// problemDetail.setType(URI.create("$ERROR_PAGE/user-authentication-failed"))
// problemDetail.setProperty("errorCategory", "AUTHENTICATION")
// problemDetail.setProperty("timestamp", Instant.now())
// return problemDetail
// }

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(
IllegalArgumentException::class,
BusinessRuleValidationException::class,
// UserRefreshTokenException::class
)
fun handleIllegalArgumentException(e: Exception): ProblemDetail {
val problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, e.message ?: "Bad request")
problemDetail.title = "Bad request"
problemDetail.setType(URI.create("$ERROR_PAGE/bad-request"))
problemDetail.setProperty("errorCategory", "BAD_REQUEST")
problemDetail.setProperty("timestamp", Instant.now())
return problemDetail
}

@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(Exception::class)
fun handleException(e: Exception): ProblemDetail {
val problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR,
e.message ?: "Internal server error",
)
problemDetail.title = "Internal server error"
problemDetail.setType(URI.create("$ERROR_PAGE/internal-server-error"))
problemDetail.setProperty("errorCategory", "INTERNAL_SERVER_ERROR")
problemDetail.setProperty("timestamp", Instant.now())
return problemDetail
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.lyra.app.newsletter.application

import com.lyra.app.newsletter.domain.FirstName
import com.lyra.app.newsletter.domain.LastName
import com.lyra.app.newsletter.domain.Name
import com.lyra.app.newsletter.domain.SubscriberId
import com.lyra.common.domain.Service
import com.lyra.common.domain.bus.command.CommandHandler
import com.lyra.common.domain.email.Email

@Service
class CreateSubscribeNewsletterCommandHandler(
private val subscriberRegistrator: SubscriberRegistrator
) : CommandHandler<SubscribeNewsletterCommand> {
override suspend fun handle(command: SubscribeNewsletterCommand) {
val id = SubscriberId(command.id)
val email = Email(command.email)
val name = Name(FirstName(command.firstname), command.lastname?.let { LastName(it) })
subscriberRegistrator.register(id, email, name)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.lyra.app.newsletter.application

import com.lyra.common.domain.bus.command.Command

/**
*
* @created 7/1/24
*/
data class SubscribeNewsletterCommand(
val id: String,
val email: String,
val firstname: String,
val lastname: String?
) : Command
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.lyra.app.newsletter.application

import com.lyra.app.newsletter.domain.Name
import com.lyra.app.newsletter.domain.Subscriber
import com.lyra.app.newsletter.domain.SubscriberId
import com.lyra.app.newsletter.domain.SubscriberRepository
import com.lyra.app.newsletter.domain.event.SubscriberCreatedEvent
import com.lyra.common.domain.Service
import com.lyra.common.domain.bus.event.EventBroadcaster
import com.lyra.common.domain.bus.event.EventPublisher
import com.lyra.common.domain.email.Email
import org.slf4j.LoggerFactory

/**
*
* @created 6/1/24
*/
@Service
class SubscriberRegistrator(
private val subscriberRepository: SubscriberRepository,
eventPublisher: EventPublisher<SubscriberCreatedEvent>
) {
private val eventPublisher = EventBroadcaster<SubscriberCreatedEvent>()

init {
this.eventPublisher.use(eventPublisher)
}

suspend fun register(id: SubscriberId, email: Email, name: Name) {
log.info("Registering subscriber with email: $email")

val subscriber = Subscriber.create(id, email, name)
subscriberRepository.create(subscriber)
val domainEvents = subscriber.pullDomainEvents()
log.debug("Pulling {} events from subscriber", domainEvents.size)

domainEvents.forEach {
eventPublisher.publish(it as SubscriberCreatedEvent)
}
}

companion object {
private val log = LoggerFactory.getLogger(SubscriberRegistrator::class.java)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.lyra.app.newsletter.application

import com.lyra.common.domain.bus.query.Response

data class SubscribersResponse(val subscribers: List<SubscriberResponse>) : Response

data class SubscriberResponse(
val id: String,
val email: String,
val name: String,
val status: String,
) : Response
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.lyra.app.newsletter.application.search.active

import com.lyra.app.newsletter.application.SubscriberResponse
import com.lyra.app.newsletter.application.SubscribersResponse
import com.lyra.app.newsletter.domain.Subscriber
import com.lyra.app.newsletter.domain.SubscriberRepository
import com.lyra.common.domain.Service
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import org.slf4j.LoggerFactory

/**
*
* @created 10/1/24
*/
@Service
class ActiveSubscriberSearcher(private val repository: SubscriberRepository) {

suspend fun search(): SubscribersResponse {
log.info("Searching active subscribers")
val subscribers: Flow<Subscriber> = repository.searchActive()
return SubscribersResponse(
subscribers.map {
SubscriberResponse(
it.id.toString(),
it.email.value,
it.name.fullName(),
it.status.name,
)
}.toList(),
)
}
companion object {
private val log = LoggerFactory.getLogger(ActiveSubscriberSearcher::class.java)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.lyra.app.newsletter.application.search.active

import com.lyra.app.newsletter.application.SubscribersResponse
import com.lyra.common.domain.bus.query.Query

/**
*
* @created 9/1/24
*/
class SearchAllActiveSubscribersQuery : Query<SubscribersResponse> {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is SearchAllActiveSubscribersQuery) return false
return true
}

override fun hashCode(): Int = javaClass.hashCode()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.lyra.app.newsletter.application.search.active

import com.lyra.app.newsletter.application.SubscribersResponse
import com.lyra.common.domain.Service
import com.lyra.common.domain.bus.query.QueryHandler
import org.slf4j.LoggerFactory

/**
*
* @created 9/1/24
*/
@Service
class SearchAllActiveSubscribersQueryHandler(
private val searcher: ActiveSubscriberSearcher,
) : QueryHandler<SearchAllActiveSubscribersQuery, SubscribersResponse> {
override suspend fun handle(query: SearchAllActiveSubscribersQuery): SubscribersResponse {
log.info("Searching all active subscribers")
return searcher.search()
}
companion object {
private val log = LoggerFactory.getLogger(SearchAllActiveSubscribersQueryHandler::class.java)
}
}
Loading

0 comments on commit 0c8cfda

Please sign in to comment.