Skip to content
Merged
1 change: 1 addition & 0 deletions backend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ dependencies {
implementation("ch.qos.logback:logback-classic:1.4.12")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.mockito.kotlin:mockito-kotlin:3.2.0")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@ package com.lightswitch

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.data.jpa.repository.config.EnableJpaAuditing

@SpringBootApplication
@EnableJpaAuditing
class LightswitchApplication

fun main(args: Array<String>) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.lightswitch.application.service

import com.lightswitch.infrastructure.database.entity.Condition
import com.lightswitch.infrastructure.database.entity.FeatureFlag
import com.lightswitch.infrastructure.database.entity.User
import com.lightswitch.infrastructure.database.model.Type
import com.lightswitch.infrastructure.database.repository.ConditionRepository
import com.lightswitch.infrastructure.database.repository.FeatureFlagRepository
import com.lightswitch.presentation.exception.BusinessException
import com.lightswitch.presentation.model.flag.CreateFeatureFlagRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
class FeatureFlagService(
private val conditionRepository: ConditionRepository,
private val featureFlagRepository: FeatureFlagRepository,
) {
@Transactional
fun create(
user: User,
request: CreateFeatureFlagRequest,
): FeatureFlag {
featureFlagRepository.findByName(request.key)?.let {
throw BusinessException("FeatureFlag with key ${request.key} already exists")
}

val flag =
featureFlagRepository.save(
FeatureFlag(
name = request.key,
type = Type.from(request.type),
enabled = request.status,
description = request.description,
createdBy = user,
updatedBy = user,
),
)

request.defaultValueAsPair()
.let { (key, value) -> Condition(flag = flag, key = key, value = value) }
.let { conditionRepository.save(it) }
.also {
flag.defaultCondition = it
flag.conditions.add(it)
}

request.variantPairs()
?.map { variant -> Condition(flag = flag, key = variant.first, value = variant.second) }
?.let { conditionRepository.saveAll(it) }
?.also { flag.conditions.addAll(it) }

return featureFlagRepository.save(flag)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.lightswitch.infrastructure.database

import org.springframework.context.annotation.Configuration
import org.springframework.data.jpa.repository.config.EnableJpaAuditing

@Configuration
@EnableJpaAuditing
class JpaConfig
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,19 @@ import jakarta.persistence.Converter
@Converter
class JsonConverter : AttributeConverter<Any, String> {
override fun convertToDatabaseColumn(attribute: Any?): String {
return objectMapper.writeValueAsString(attribute)
return try {
objectMapper.writeValueAsString(attribute)
} catch (e: Exception) {
throw IllegalArgumentException("Failed to convert object to JSON: ${e.message}", e)
}
}

override fun convertToEntityAttribute(dbData: String?): Any? {
return dbData?.let { objectMapper.readValue(it, Any::class.java) }
return try {
dbData?.let { objectMapper.readValue(it, Any::class.java) }
} catch (e: Exception) {
throw IllegalArgumentException("Failed to convert JSON to object: ${e.message}", e)
}
}

companion object {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
package com.lightswitch.infrastructure.database.entity

import com.lightswitch.infrastructure.database.model.Type
import jakarta.persistence.CascadeType
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.FetchType
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.OneToMany
import jakarta.persistence.OneToOne
import org.springframework.data.annotation.CreatedBy
import org.springframework.data.annotation.LastModifiedBy

Expand All @@ -15,13 +22,20 @@ class FeatureFlag(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null,
var defaultConditionId: Long? = null,
@Column(nullable = false)
val name: String,
@Column(nullable = false)
val type: String,
val description: String,
@Column(nullable = false)
@Enumerated(value = EnumType.STRING)
val type: Type,
@Column(nullable = false)
var enabled: Boolean,
@OneToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL], optional = true)
@JoinColumn(name = "default_condition_id", referencedColumnName = "id", nullable = true)
var defaultCondition: Condition? = null,
@OneToMany(fetch = FetchType.LAZY, mappedBy = "flag", cascade = [CascadeType.ALL], orphanRemoval = true)
val conditions: MutableList<Condition> = mutableListOf(),
@CreatedBy
@ManyToOne(fetch = FetchType.LAZY)
var createdBy: User,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.lightswitch.infrastructure.database.model

enum class Type {
NUMBER,
BOOLEAN,
STRING;

companion object {
fun from(type: String): Type {
return when (type.uppercase()) {
"NUMBER" -> NUMBER
"BOOLEAN" -> BOOLEAN
"STRING" -> STRING
else -> throw IllegalArgumentException("Unsupported type: $type")
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.lightswitch.infrastructure.database.repository

import com.lightswitch.infrastructure.database.entity.Condition
import org.springframework.data.jpa.repository.JpaRepository

interface ConditionRepository : JpaRepository<Condition, Long>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.lightswitch.infrastructure.database.repository

import com.lightswitch.infrastructure.database.entity.FeatureFlag
import org.springframework.data.jpa.repository.JpaRepository

interface FeatureFlagRepository : JpaRepository<FeatureFlag, Int> {
fun findByName(name: String): FeatureFlag?
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
package com.lightswitch.presentation.controller

import com.lightswitch.application.service.FeatureFlagService
import com.lightswitch.infrastructure.database.repository.UserRepository
import com.lightswitch.presentation.exception.BusinessException
import com.lightswitch.presentation.model.PayloadResponse
import com.lightswitch.presentation.model.StatusResponse
import com.lightswitch.presentation.model.flag.CreateFeatureFlagRequest
import com.lightswitch.presentation.model.flag.FeatureFlagResponse
import com.lightswitch.presentation.model.flag.UpdateFeatureFlagRequest
import io.swagger.v3.oas.annotations.Operation
import jakarta.validation.Valid
import org.springframework.data.repository.findByIdOrNull
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PatchMapping
Expand All @@ -19,7 +25,10 @@ import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/api/v1/flags")
class FeatureFlagController {
class FeatureFlagController(
private val featureFlagService: FeatureFlagService,
private val userRepository: UserRepository,
) {

@Operation(
summary = "Retrieve all feature flags",
Expand Down Expand Up @@ -52,12 +61,18 @@ class FeatureFlagController {
)
@PostMapping
fun createFlag(
@RequestBody request: CreateFeatureFlagRequest,
@RequestBody @Valid request: CreateFeatureFlagRequest,
): PayloadResponse<FeatureFlagResponse> {
return PayloadResponse<FeatureFlagResponse>(
status = "status",
message = "message",
data = null
// TODO: Improve the way finding the authenticated user.
val authentication = SecurityContextHolder.getContext().authentication
val userId = authentication.name
val user = userRepository.findByIdOrNull(userId.toLong()) ?: throw BusinessException("User not found")

val flag = featureFlagService.create(user, request)

return PayloadResponse.success(
message = "Created flag successfully",
data = FeatureFlagResponse.from(flag)
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,10 @@ data class PayloadResponse<T>(
val status: String,
val message: String,
val data: T? = null,
)
) {
companion object {
fun <T> success(message: String, data: T): PayloadResponse<T> {
return PayloadResponse("Success", message, data)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,22 @@ package com.lightswitch.presentation.model.flag
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.NotEmpty
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Pattern

data class CreateFeatureFlagRequest(
@field:NotBlank(message = "Key is required.")
val key: String,
@field:NotNull(message = "Status is required.")
val status: Boolean,
@field:NotNull(message = "Type is required.")
@field:NotBlank(message = "Type is required.")
@field:Pattern(regexp = "(?i)^(number|boolean|string)$", message = "Type must be one of: number, boolean, string")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

val type: String,
@field:NotEmpty(message = "Default value is required.")
val defaultValue: Map<String, Any>,
@field:NotBlank(message = "Description is required.")
val description: String,
val variants: List<Map<String, Any>>? = null,
@field:NotBlank(message = "CreatedBy is required.")
val createdBy: String,
)
) {
fun defaultValueAsPair(): Pair<String, Any> = defaultValue.entries.first().toPair()
fun variantPairs(): List<Pair<String, Any>>? = variants?.map { it.entries.first().toPair() }
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.lightswitch.presentation.model.flag

import com.lightswitch.infrastructure.database.entity.FeatureFlag
import java.time.Instant

data class FeatureFlagResponse(
Expand All @@ -13,4 +14,21 @@ data class FeatureFlagResponse(
val updatedBy: String,
val createdAt: Instant,
val updatedAt: Instant,
)
) {
companion object {
fun from(flag: FeatureFlag): FeatureFlagResponse {
return FeatureFlagResponse(
key = flag.name,
status = flag.enabled,
type = flag.type.name,
defaultValue = mapOf(flag.defaultCondition!!.key to flag.defaultCondition!!.value),
description = flag.description,
variants = flag.conditions.map { mapOf(it.key to it.value) },
createdBy = flag.createdBy.username,
updatedBy = flag.updatedBy.username,
createdAt = flag.createdAt,
updatedAt = flag.updatedAt,
)
}
}
}
21 changes: 11 additions & 10 deletions backend/src/main/resources/application.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
spring:
application:
name: LightSwitch
jpa:
hibernate:
ddl-auto: create
database-platform: org.hibernate.community.dialect.SQLiteDialect
datasource:
url: jdbc:sqlite:lightswitch.sqlite
driver-class-name: org.sqlite.JDBC
application:
name: LightSwitch
jpa:
database-platform: org.hibernate.community.dialect.SQLiteDialect
hibernate:
ddl-auto: create
open-in-view: false
datasource:
driver-class-name: org.sqlite.JDBC
url: jdbc:sqlite:lightswitch.sqlite

jwt:
secret: 64461f01e1af406da538b9c48d801ce59142452199ff112fb5404c8e7e98e3ff
secret: 64461f01e1af406da538b9c48d801ce59142452199ff112fb5404c8e7e98e3ff
Loading
Loading