From 67c146021726cfe8019d3aacf29ae70c99fa3c52 Mon Sep 17 00:00:00 2001 From: Alan Brault Date: Tue, 3 Mar 2026 07:04:46 -0500 Subject: [PATCH] chore: clean up api, add user support Signed-off-by: Alan Brault --- build.gradle.kts | 2 + .../visus/demos/kotlinapi/api/ApiVersion.kt | 7 ++ .../demos/kotlinapi/api/HttpStatusCodes.kt | 66 +++++++++++++++++++ .../api/mappers/ComponentHealthDtoMapper.kt | 5 +- .../api/mappers/HealthResponseMapper.kt | 12 ++-- .../kotlinapi/config/ApiVersionConfig.kt | 18 +++++ .../demos/kotlinapi/config/MongoConfig.kt | 4 +- .../demos/kotlinapi/config/OpenApiConfig.kt | 26 +++++++- .../demos/kotlinapi/config/SecurityConfig.kt | 38 +++++++++-- .../kotlinapi/controller/HealthController.kt | 28 +++++++- .../domain/{port => }/HealthIndicator.kt | 2 +- .../demos/kotlinapi/domain/model/User.kt | 42 ++++++++++++ .../kotlinapi/domain/service/HealthService.kt | 20 +----- .../kotlinapi/domain/service/UserService.kt | 19 ++++++ .../health/MongoHealthService.kt | 2 +- .../infrastructure/user/UserRepository.kt | 10 +++ .../visus/demos/kotlinapi/seed/DataSeeder.kt | 54 +++++++++++++++ src/main/resources/application.yaml | 9 +++ 18 files changed, 326 insertions(+), 38 deletions(-) create mode 100644 src/main/kotlin/io/visus/demos/kotlinapi/api/ApiVersion.kt create mode 100644 src/main/kotlin/io/visus/demos/kotlinapi/api/HttpStatusCodes.kt create mode 100644 src/main/kotlin/io/visus/demos/kotlinapi/config/ApiVersionConfig.kt rename src/main/kotlin/io/visus/demos/kotlinapi/domain/{port => }/HealthIndicator.kt (73%) create mode 100644 src/main/kotlin/io/visus/demos/kotlinapi/domain/model/User.kt create mode 100644 src/main/kotlin/io/visus/demos/kotlinapi/domain/service/UserService.kt create mode 100644 src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/UserRepository.kt create mode 100644 src/main/kotlin/io/visus/demos/kotlinapi/seed/DataSeeder.kt diff --git a/build.gradle.kts b/build.gradle.kts index ebbfe47..fe8afcc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -42,10 +42,12 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-mongodb") implementation("org.springframework.boot:spring-boot-starter-restclient") 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.jetbrains.kotlin:kotlin-reflect") implementation("tech.mappie:mappie-api:2.2.21-2.1.1") 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-restclient-test") testImplementation("org.springframework.boot:spring-boot-starter-security-test") diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/api/ApiVersion.kt b/src/main/kotlin/io/visus/demos/kotlinapi/api/ApiVersion.kt new file mode 100644 index 0000000..6fe2518 --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/api/ApiVersion.kt @@ -0,0 +1,7 @@ +package io.visus.demos.kotlinapi.api + +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class ApiVersion( + val version: Int, +) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/api/HttpStatusCodes.kt b/src/main/kotlin/io/visus/demos/kotlinapi/api/HttpStatusCodes.kt new file mode 100644 index 0000000..10d3b89 --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/api/HttpStatusCodes.kt @@ -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" +} diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/api/mappers/ComponentHealthDtoMapper.kt b/src/main/kotlin/io/visus/demos/kotlinapi/api/mappers/ComponentHealthDtoMapper.kt index 52c389a..08bb874 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/api/mappers/ComponentHealthDtoMapper.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/api/mappers/ComponentHealthDtoMapper.kt @@ -5,10 +5,9 @@ import io.visus.demos.kotlinapi.domain.model.ComponentHealth import tech.mappie.api.ObjectMappie object ComponentHealthDtoMapper : ObjectMappie() { - override fun map(from: ComponentHealth): ComponentHealthDto { - return ComponentHealthDto( + override fun map(from: ComponentHealth): ComponentHealthDto = + ComponentHealthDto( status = from.status.name, message = from.message, ) - } } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/api/mappers/HealthResponseMapper.kt b/src/main/kotlin/io/visus/demos/kotlinapi/api/mappers/HealthResponseMapper.kt index 8107019..125a5d3 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/api/mappers/HealthResponseMapper.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/api/mappers/HealthResponseMapper.kt @@ -5,13 +5,13 @@ import io.visus.demos.kotlinapi.domain.model.HealthStatus import tech.mappie.api.ObjectMappie object HealthResponseMapper : ObjectMappie() { - 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) + }, ) - } } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/config/ApiVersionConfig.kt b/src/main/kotlin/io/visus/demos/kotlinapi/config/ApiVersionConfig.kt new file mode 100644 index 0000000..b286235 --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/config/ApiVersionConfig.kt @@ -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 + } + } +} diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/config/MongoConfig.kt b/src/main/kotlin/io/visus/demos/kotlinapi/config/MongoConfig.kt index 69c4875..0207caa 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/config/MongoConfig.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/config/MongoConfig.kt @@ -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) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/config/OpenApiConfig.kt b/src/main/kotlin/io/visus/demos/kotlinapi/config/OpenApiConfig.kt index 4e41683..c47d275 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/config/OpenApiConfig.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/config/OpenApiConfig.kt @@ -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() } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/config/SecurityConfig.kt b/src/main/kotlin/io/visus/demos/kotlinapi/config/SecurityConfig.kt index 73cfc55..d0553ac 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/config/SecurityConfig.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/config/SecurityConfig.kt @@ -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() } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/controller/HealthController.kt b/src/main/kotlin/io/visus/demos/kotlinapi/controller/HealthController.kt index 028f61a..2021c25 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/controller/HealthController.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/controller/HealthController.kt @@ -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 = healthCheckService.check() + fun health(): ResponseEntity { + 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) + } } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/port/HealthIndicator.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/HealthIndicator.kt similarity index 73% rename from src/main/kotlin/io/visus/demos/kotlinapi/domain/port/HealthIndicator.kt rename to src/main/kotlin/io/visus/demos/kotlinapi/domain/HealthIndicator.kt index 64d9481..a56d692 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/port/HealthIndicator.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/HealthIndicator.kt @@ -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 diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/User.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/User.kt new file mode 100644 index 0000000..f5d4014 --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/User.kt @@ -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 = 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 +} diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/HealthService.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/HealthService.kt index 4fd259f..d33b996 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/HealthService.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/HealthService.kt @@ -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, ) { - fun check(): ResponseEntity { + 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) } } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/UserService.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/UserService.kt new file mode 100644 index 0000000..eabf1ed --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/UserService.kt @@ -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") + } +} diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/health/MongoHealthService.kt b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/health/MongoHealthService.kt index e5a62d2..4aa975d 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/health/MongoHealthService.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/health/MongoHealthService.kt @@ -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 diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/UserRepository.kt b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/UserRepository.kt new file mode 100644 index 0000000..ca01db2 --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/UserRepository.kt @@ -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 { + fun findByEmail(email: String): User? +} diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/seed/DataSeeder.kt b/src/main/kotlin/io/visus/demos/kotlinapi/seed/DataSeeder.kt new file mode 100644 index 0000000..b6d585b --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/seed/DataSeeder.kt @@ -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}") + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index c157dd6..324177a 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,5 +1,14 @@ spring: application: name: kotlin-api + data: + mongodb: + auto-index-creation: true mongodb: uri: ${MONGODB_URI:mongodb://localhost:27017/kotlin-api} + +springdoc: + api-docs: + path: /api-docs + swagger-ui: + path: /swagger-ui.html