chore: clean up api, add user support
Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
package io.visus.demos.kotlinapi.api
|
||||
|
||||
@Target(AnnotationTarget.CLASS)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class ApiVersion(
|
||||
val version: Int,
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -5,10 +5,9 @@ import io.visus.demos.kotlinapi.domain.model.ComponentHealth
|
||||
import tech.mappie.api.ObjectMappie
|
||||
|
||||
object ComponentHealthDtoMapper : ObjectMappie<ComponentHealth, ComponentHealthDto>() {
|
||||
override fun map(from: ComponentHealth): ComponentHealthDto {
|
||||
return ComponentHealthDto(
|
||||
override fun map(from: ComponentHealth): ComponentHealthDto =
|
||||
ComponentHealthDto(
|
||||
status = from.status.name,
|
||||
message = from.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ import io.visus.demos.kotlinapi.domain.model.HealthStatus
|
||||
import tech.mappie.api.ObjectMappie
|
||||
|
||||
object HealthResponseMapper : ObjectMappie<HealthStatus, HealthResponse>() {
|
||||
override fun map(from: HealthStatus): HealthResponse {
|
||||
return HealthResponse(
|
||||
override fun map(from: HealthStatus): HealthResponse =
|
||||
HealthResponse(
|
||||
status = from.status.name,
|
||||
timestamp = from.timestamp.toString(),
|
||||
components = from.components.mapValues { (_, componentHealth) ->
|
||||
ComponentHealthDtoMapper.map(componentHealth)
|
||||
}
|
||||
components =
|
||||
from.components.mapValues { (_, componentHealth) ->
|
||||
ComponentHealthDtoMapper.map(componentHealth)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,15 @@ import com.mongodb.client.MongoClients
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.data.mongodb.config.EnableMongoAuditing
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Configuration
|
||||
@EnableMongoAuditing
|
||||
class MongoConfig {
|
||||
@Bean
|
||||
fun mongoClient(
|
||||
@Value("\${spring.mongodb.uri}") uri: String,
|
||||
@Value($$"${spring.mongodb.uri}") uri: String,
|
||||
): MongoClient {
|
||||
val connectionString = ConnectionString(uri)
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
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.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.Configuration
|
||||
|
||||
@@ -13,7 +17,25 @@ class OpenApiConfig {
|
||||
.info(
|
||||
Info()
|
||||
.title("Kotlin API")
|
||||
.version("0.0.1-SNAPSHOT")
|
||||
.version("1.0.0")
|
||||
.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()
|
||||
}
|
||||
|
||||
@@ -2,22 +2,52 @@ package io.visus.demos.kotlinapi.config
|
||||
|
||||
import org.springframework.context.annotation.Bean
|
||||
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.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.web.cors.CorsConfiguration
|
||||
import org.springframework.web.cors.CorsConfigurationSource
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
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
|
||||
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
http
|
||||
.csrf { it.disable() }
|
||||
.cors { it.configurationSource(corsConfigurationSource()) }
|
||||
.authorizeHttpRequests { authorize ->
|
||||
authorize
|
||||
.requestMatchers("/health")
|
||||
.permitAll()
|
||||
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**")
|
||||
.permitAll()
|
||||
.requestMatchers(
|
||||
"/swagger-ui.html",
|
||||
"/swagger-ui/**",
|
||||
"/v3/api-docs/**",
|
||||
"/api-docs/**",
|
||||
"/api/v1/health",
|
||||
).permitAll()
|
||||
.anyRequest()
|
||||
.authenticated()
|
||||
}
|
||||
|
||||
@@ -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.Schema
|
||||
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.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.mappers.HealthResponseMapper
|
||||
import io.visus.demos.kotlinapi.domain.model.Status
|
||||
import io.visus.demos.kotlinapi.domain.service.HealthService
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.MediaType
|
||||
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.RestController
|
||||
|
||||
@RestController
|
||||
@ApiVersion(version = 1)
|
||||
@PreAuthorize("permitAll()")
|
||||
@SecurityRequirements(SecurityRequirement(name = "bearerAuth"))
|
||||
@Tag(name = "Health", description = "Health check endpoints")
|
||||
class HealthController(
|
||||
private val healthCheckService: HealthService,
|
||||
@@ -23,7 +34,7 @@ class HealthController(
|
||||
description = "Returns the health status of the API",
|
||||
responses = [
|
||||
ApiResponse(
|
||||
responseCode = "200",
|
||||
responseCode = HttpStatusCodes.OK,
|
||||
description = "API is healthy",
|
||||
content = [
|
||||
Content(
|
||||
@@ -33,7 +44,7 @@ class HealthController(
|
||||
],
|
||||
),
|
||||
ApiResponse(
|
||||
responseCode = "503",
|
||||
responseCode = HttpStatusCodes.SERVICE_UNAVAILABLE,
|
||||
description = "API is down or degraded",
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,29 +1,15 @@
|
||||
package io.visus.demos.kotlinapi.domain.service
|
||||
|
||||
import io.visus.demos.kotlinapi.api.dto.HealthResponse
|
||||
import io.visus.demos.kotlinapi.api.mappers.HealthResponseMapper
|
||||
import io.visus.demos.kotlinapi.domain.port.HealthIndicator
|
||||
import io.visus.demos.kotlinapi.domain.HealthIndicator
|
||||
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
|
||||
|
||||
@Service
|
||||
class HealthService(
|
||||
private val healthIndicators: List<HealthIndicator>,
|
||||
) {
|
||||
fun check(): ResponseEntity<HealthResponse> {
|
||||
fun check(): HealthStatus {
|
||||
val componentHealths = healthIndicators.map { it.check() }
|
||||
val healthStatus = 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)
|
||||
return HealthStatus.fromComponents(componentHealths)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package io.visus.demos.kotlinapi.infrastructure.health
|
||||
|
||||
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.ComponentStatus
|
||||
import io.visus.demos.kotlinapi.domain.port.HealthIndicator
|
||||
import org.bson.Document
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
54
src/main/kotlin/io/visus/demos/kotlinapi/seed/DataSeeder.kt
Normal file
54
src/main/kotlin/io/visus/demos/kotlinapi/seed/DataSeeder.kt
Normal 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}")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user