1
0

chore: clean up api, add user support

Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
2026-03-03 07:04:46 -05:00
parent 506a6095e1
commit 67c1460217
18 changed files with 326 additions and 38 deletions

View File

@@ -42,10 +42,12 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-mongodb") implementation("org.springframework.boot:spring-boot-starter-mongodb")
implementation("org.springframework.boot:spring-boot-starter-restclient") implementation("org.springframework.boot:spring-boot-starter-restclient")
implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-security")
implementation("com.password4j:password4j:1.8.2")
implementation("org.springframework.boot:spring-boot-starter-webmvc") implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("tech.mappie:mappie-api:2.2.21-2.1.1") implementation("tech.mappie:mappie-api:2.2.21-2.1.1")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.4") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.4")
implementation("org.springframework.boot:spring-boot-starter-data-mongodb")
testImplementation("org.springframework.boot:spring-boot-starter-mongodb-test") testImplementation("org.springframework.boot:spring-boot-starter-mongodb-test")
testImplementation("org.springframework.boot:spring-boot-starter-restclient-test") testImplementation("org.springframework.boot:spring-boot-starter-restclient-test")
testImplementation("org.springframework.boot:spring-boot-starter-security-test") testImplementation("org.springframework.boot:spring-boot-starter-security-test")

View File

@@ -0,0 +1,7 @@
package io.visus.demos.kotlinapi.api
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class ApiVersion(
val version: Int,
)

View File

@@ -0,0 +1,66 @@
package io.visus.demos.kotlinapi.api
object HttpStatusCodes {
const val CONTINUE = "100"
const val SWITCHING_PROTOCOLS = "101"
const val PROCESSING = "102"
const val EARLY_HINTS = "103"
const val OK = "200"
const val CREATED = "201"
const val ACCEPTED = "202"
const val NON_AUTHORITATIVE_INFORMATION = "203"
const val NO_CONTENT = "204"
const val RESET_CONTENT = "205"
const val PARTIAL_CONTENT = "206"
const val MULTI_STATUS = "207"
const val ALREADY_REPORTED = "208"
const val IM_USED = "226"
const val MULTIPLE_CHOICES = "300"
const val MOVED_PERMANENTLY = "301"
const val FOUND = "302"
const val SEE_OTHER = "303"
const val NOT_MODIFIED = "304"
const val USE_PROXY = "305"
const val TEMPORARY_REDIRECT = "307"
const val PERMANENT_REDIRECT = "308"
const val BAD_REQUEST = "400"
const val UNAUTHORIZED = "401"
const val PAYMENT_REQUIRED = "402"
const val FORBIDDEN = "403"
const val NOT_FOUND = "404"
const val METHOD_NOT_ALLOWED = "405"
const val NOT_ACCEPTABLE = "406"
const val PROXY_AUTHENTICATION_REQUIRED = "407"
const val REQUEST_TIMEOUT = "408"
const val CONFLICT = "409"
const val GONE = "410"
const val LENGTH_REQUIRED = "411"
const val PRECONDITION_FAILED = "412"
const val PAYLOAD_TOO_LARGE = "413"
const val URI_TOO_LONG = "414"
const val UNSUPPORTED_MEDIA_TYPE = "415"
const val RANGE_NOT_SATISFIABLE = "416"
const val EXPECTATION_FAILED = "417"
const val IM_A_TEAPOT = "418"
const val MISDIRECTED_REQUEST = "421"
const val UNPROCESSABLE_ENTITY = "422"
const val LOCKED = "423"
const val FAILED_DEPENDENCY = "424"
const val TOO_EARLY = "425"
const val UPGRADE_REQUIRED = "426"
const val PRECONDITION_REQUIRED = "428"
const val TOO_MANY_REQUESTS = "429"
const val REQUEST_HEADER_FIELDS_TOO_LARGE = "431"
const val UNAVAILABLE_FOR_LEGAL_REASONS = "451"
const val INTERNAL_SERVER_ERROR = "500"
const val NOT_IMPLEMENTED = "501"
const val BAD_GATEWAY = "502"
const val SERVICE_UNAVAILABLE = "503"
const val GATEWAY_TIMEOUT = "504"
const val HTTP_VERSION_NOT_SUPPORTED = "505"
const val VARIANT_ALSO_NEGOTIATES = "506"
const val INSUFFICIENT_STORAGE = "507"
const val LOOP_DETECTED = "508"
const val NOT_EXTENDED = "510"
const val NETWORK_AUTHENTICATION_REQUIRED = "511"
}

View File

@@ -5,10 +5,9 @@ import io.visus.demos.kotlinapi.domain.model.ComponentHealth
import tech.mappie.api.ObjectMappie import tech.mappie.api.ObjectMappie
object ComponentHealthDtoMapper : ObjectMappie<ComponentHealth, ComponentHealthDto>() { object ComponentHealthDtoMapper : ObjectMappie<ComponentHealth, ComponentHealthDto>() {
override fun map(from: ComponentHealth): ComponentHealthDto { override fun map(from: ComponentHealth): ComponentHealthDto =
return ComponentHealthDto( ComponentHealthDto(
status = from.status.name, status = from.status.name,
message = from.message, message = from.message,
) )
} }
}

View File

@@ -5,13 +5,13 @@ import io.visus.demos.kotlinapi.domain.model.HealthStatus
import tech.mappie.api.ObjectMappie import tech.mappie.api.ObjectMappie
object HealthResponseMapper : ObjectMappie<HealthStatus, HealthResponse>() { object HealthResponseMapper : ObjectMappie<HealthStatus, HealthResponse>() {
override fun map(from: HealthStatus): HealthResponse { override fun map(from: HealthStatus): HealthResponse =
return HealthResponse( HealthResponse(
status = from.status.name, status = from.status.name,
timestamp = from.timestamp.toString(), timestamp = from.timestamp.toString(),
components = from.components.mapValues { (_, componentHealth) -> components =
from.components.mapValues { (_, componentHealth) ->
ComponentHealthDtoMapper.map(componentHealth) ComponentHealthDtoMapper.map(componentHealth)
} },
) )
} }
}

View File

@@ -0,0 +1,18 @@
package io.visus.demos.kotlinapi.config
import io.visus.demos.kotlinapi.api.ApiVersion
import org.springframework.context.annotation.Configuration
import org.springframework.core.annotation.AnnotatedElementUtils
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.servlet.config.annotation.PathMatchConfigurer
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@Configuration
class ApiVersionConfig : WebMvcConfigurer {
override fun configurePathMatch(configurer: PathMatchConfigurer) {
configurer.addPathPrefix("/api/v1") { controller ->
val apiVersion = AnnotatedElementUtils.findMergedAnnotation(controller, ApiVersion::class.java)
controller.isAnnotationPresent(RestController::class.java) && apiVersion?.version == 1
}
}
}

View File

@@ -7,13 +7,15 @@ import com.mongodb.client.MongoClients
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.data.mongodb.config.EnableMongoAuditing
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@Configuration @Configuration
@EnableMongoAuditing
class MongoConfig { class MongoConfig {
@Bean @Bean
fun mongoClient( fun mongoClient(
@Value("\${spring.mongodb.uri}") uri: String, @Value($$"${spring.mongodb.uri}") uri: String,
): MongoClient { ): MongoClient {
val connectionString = ConnectionString(uri) val connectionString = ConnectionString(uri)

View File

@@ -1,7 +1,11 @@
package io.visus.demos.kotlinapi.config package io.visus.demos.kotlinapi.config
import io.swagger.v3.oas.models.Components
import io.swagger.v3.oas.models.OpenAPI import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.info.Info import io.swagger.v3.oas.models.info.Info
import io.swagger.v3.oas.models.security.SecurityRequirement
import io.swagger.v3.oas.models.security.SecurityScheme
import org.springdoc.core.models.GroupedOpenApi
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
@@ -13,7 +17,25 @@ class OpenApiConfig {
.info( .info(
Info() Info()
.title("Kotlin API") .title("Kotlin API")
.version("0.0.1-SNAPSHOT") .version("1.0.0")
.description("API documentation for Kotlin Spring Boot application"), .description("API documentation for Kotlin Spring Boot application"),
) ).components(
Components()
.addSecuritySchemes(
"bearerAuth",
SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
.description("Enter JWT Bearer token"),
),
).addSecurityItem(SecurityRequirement().addList("bearerAuth"))
@Bean
fun apiV1(): GroupedOpenApi =
GroupedOpenApi
.builder()
.group("v1")
.pathsToMatch("/api/v1/**")
.build()
} }

View File

@@ -2,22 +2,52 @@ package io.visus.demos.kotlinapi.config
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.crypto.password4j.Argon2Password4jPasswordEncoder
import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.SecurityFilterChain
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.CorsConfigurationSource
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig { class SecurityConfig {
@Bean
fun corsConfigurationSource(): CorsConfigurationSource {
val configuration =
CorsConfiguration().apply {
allowedOriginPatterns = listOf("*")
allowedMethods = listOf("*")
allowedHeaders = listOf("*")
allowCredentials = true
}
return UrlBasedCorsConfigurationSource().apply {
registerCorsConfiguration("/**", configuration)
}
}
@Bean
fun passwordEncoder(): PasswordEncoder = Argon2Password4jPasswordEncoder()
@Bean @Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http http
.csrf { it.disable() }
.cors { it.configurationSource(corsConfigurationSource()) }
.authorizeHttpRequests { authorize -> .authorizeHttpRequests { authorize ->
authorize authorize
.requestMatchers("/health") .requestMatchers(
.permitAll() "/swagger-ui.html",
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**") "/swagger-ui/**",
.permitAll() "/v3/api-docs/**",
"/api-docs/**",
"/api/v1/health",
).permitAll()
.anyRequest() .anyRequest()
.authenticated() .authenticated()
} }

View File

@@ -4,15 +4,26 @@ import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import io.swagger.v3.oas.annotations.security.SecurityRequirements
import io.swagger.v3.oas.annotations.tags.Tag import io.swagger.v3.oas.annotations.tags.Tag
import io.visus.demos.kotlinapi.api.ApiVersion
import io.visus.demos.kotlinapi.api.HttpStatusCodes
import io.visus.demos.kotlinapi.api.dto.HealthResponse import io.visus.demos.kotlinapi.api.dto.HealthResponse
import io.visus.demos.kotlinapi.api.mappers.HealthResponseMapper
import io.visus.demos.kotlinapi.domain.model.Status
import io.visus.demos.kotlinapi.domain.service.HealthService import io.visus.demos.kotlinapi.domain.service.HealthService
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
@RestController @RestController
@ApiVersion(version = 1)
@PreAuthorize("permitAll()")
@SecurityRequirements(SecurityRequirement(name = "bearerAuth"))
@Tag(name = "Health", description = "Health check endpoints") @Tag(name = "Health", description = "Health check endpoints")
class HealthController( class HealthController(
private val healthCheckService: HealthService, private val healthCheckService: HealthService,
@@ -23,7 +34,7 @@ class HealthController(
description = "Returns the health status of the API", description = "Returns the health status of the API",
responses = [ responses = [
ApiResponse( ApiResponse(
responseCode = "200", responseCode = HttpStatusCodes.OK,
description = "API is healthy", description = "API is healthy",
content = [ content = [
Content( Content(
@@ -33,7 +44,7 @@ class HealthController(
], ],
), ),
ApiResponse( ApiResponse(
responseCode = "503", responseCode = HttpStatusCodes.SERVICE_UNAVAILABLE,
description = "API is down or degraded", description = "API is down or degraded",
content = [ content = [
Content( Content(
@@ -44,5 +55,16 @@ class HealthController(
), ),
], ],
) )
fun health(): ResponseEntity<HealthResponse> = healthCheckService.check() fun health(): ResponseEntity<HealthResponse> {
val result = healthCheckService.check()
val response = HealthResponseMapper.map(result)
val httpStatus =
when (result.status) {
Status.UP -> HttpStatus.OK
Status.DOWN, Status.DEGRADED -> HttpStatus.SERVICE_UNAVAILABLE
}
return ResponseEntity.status(httpStatus).body(response)
}
} }

View File

@@ -1,4 +1,4 @@
package io.visus.demos.kotlinapi.domain.port package io.visus.demos.kotlinapi.domain
import io.visus.demos.kotlinapi.domain.model.ComponentHealth import io.visus.demos.kotlinapi.domain.model.ComponentHealth

View File

@@ -0,0 +1,42 @@
package io.visus.demos.kotlinapi.domain.model
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.mongodb.core.index.Indexed
import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.mongodb.core.mapping.Field
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.UserDetails
import java.time.Instant
@Document(collection = "users")
data class User(
@Id
val id: String? = null,
@Indexed(unique = true)
val email: String,
@Field("password")
private val passwordHash: String,
@Indexed
val role: String,
@CreatedDate
val createdAt: Instant? = null,
@LastModifiedDate
val updatedAt: Instant? = null,
) : UserDetails {
override fun getAuthorities(): Collection<GrantedAuthority> = listOf(SimpleGrantedAuthority(role))
override fun getPassword(): String = passwordHash
override fun getUsername(): String = email
override fun isAccountNonExpired() = true
override fun isAccountNonLocked() = true
override fun isCredentialsNonExpired() = true
override fun isEnabled() = true
}

View File

@@ -1,29 +1,15 @@
package io.visus.demos.kotlinapi.domain.service package io.visus.demos.kotlinapi.domain.service
import io.visus.demos.kotlinapi.api.dto.HealthResponse import io.visus.demos.kotlinapi.domain.HealthIndicator
import io.visus.demos.kotlinapi.api.mappers.HealthResponseMapper
import io.visus.demos.kotlinapi.domain.port.HealthIndicator
import io.visus.demos.kotlinapi.domain.model.HealthStatus import io.visus.demos.kotlinapi.domain.model.HealthStatus
import io.visus.demos.kotlinapi.domain.model.Status
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@Service @Service
class HealthService( class HealthService(
private val healthIndicators: List<HealthIndicator>, private val healthIndicators: List<HealthIndicator>,
) { ) {
fun check(): ResponseEntity<HealthResponse> { fun check(): HealthStatus {
val componentHealths = healthIndicators.map { it.check() } val componentHealths = healthIndicators.map { it.check() }
val healthStatus = HealthStatus.fromComponents(componentHealths) return HealthStatus.fromComponents(componentHealths)
val response = HealthResponseMapper.map(healthStatus)
val httpStatus =
when (healthStatus.status) {
Status.UP -> HttpStatus.OK
Status.DOWN, Status.DEGRADED -> HttpStatus.SERVICE_UNAVAILABLE
}
return ResponseEntity.status(httpStatus).body(response)
} }
} }

View File

@@ -0,0 +1,19 @@
package io.visus.demos.kotlinapi.domain.service
import io.visus.demos.kotlinapi.infrastructure.user.UserRepository
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.stereotype.Service
@Service
class UserService(
private val userRepository: UserRepository,
) : UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails {
if (username.isBlank()) throw UsernameNotFoundException("Username must not be null or empty")
return userRepository.findByEmail(username)
?: throw UsernameNotFoundException("User not found with email: $username")
}
}

View File

@@ -1,9 +1,9 @@
package io.visus.demos.kotlinapi.infrastructure.health package io.visus.demos.kotlinapi.infrastructure.health
import com.mongodb.client.MongoClient import com.mongodb.client.MongoClient
import io.visus.demos.kotlinapi.domain.HealthIndicator
import io.visus.demos.kotlinapi.domain.model.ComponentHealth import io.visus.demos.kotlinapi.domain.model.ComponentHealth
import io.visus.demos.kotlinapi.domain.model.ComponentStatus import io.visus.demos.kotlinapi.domain.model.ComponentStatus
import io.visus.demos.kotlinapi.domain.port.HealthIndicator
import org.bson.Document import org.bson.Document
import org.springframework.stereotype.Service import org.springframework.stereotype.Service

View File

@@ -0,0 +1,10 @@
package io.visus.demos.kotlinapi.infrastructure.user
import io.visus.demos.kotlinapi.domain.model.User
import org.springframework.data.mongodb.repository.MongoRepository
import org.springframework.stereotype.Repository
@Repository
interface UserRepository : MongoRepository<User, String> {
fun findByEmail(email: String): User?
}

View File

@@ -0,0 +1,54 @@
package io.visus.demos.kotlinapi.seed
import io.visus.demos.kotlinapi.domain.model.User
import io.visus.demos.kotlinapi.infrastructure.user.UserRepository
import org.springframework.boot.ApplicationArguments
import org.springframework.boot.ApplicationRunner
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Component
@Component
class DataSeeder(
private val userRepository: UserRepository,
private val passwordEncoder: PasswordEncoder,
) : ApplicationRunner {
override fun run(args: ApplicationArguments) {
seedUser(
email = "user@example.com",
rawPassword = "user12345",
role = "ROLE_USER",
)
seedUser(
email = "admin@example.com",
rawPassword = "admin12345",
role = "ROLE_ADMIN",
)
}
private fun seedUser(
email: String,
rawPassword: String,
role: String,
) {
if (userRepository.findByEmail(email) != null) {
println("User '$email' already exists — skipping.")
return
}
val hashedPassword =
passwordEncoder.encode(rawPassword)
?: error("Password encoding returned null")
val user =
User(
email = email,
passwordHash = hashedPassword,
role = role,
)
val saved = userRepository.save(user)
val savedId = saved.id ?: "unknown"
println("Seeded user '${saved.email}' with id=$savedId and role=${saved.role}")
}
}

View File

@@ -1,5 +1,14 @@
spring: spring:
application: application:
name: kotlin-api name: kotlin-api
data:
mongodb:
auto-index-creation: true
mongodb: mongodb:
uri: ${MONGODB_URI:mongodb://localhost:27017/kotlin-api} uri: ${MONGODB_URI:mongodb://localhost:27017/kotlin-api}
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html