From 460849801966efeecbd5d52aaf546dabb3ffb166 Mon Sep 17 00:00:00 2001 From: xcodeassociated Date: Mon, 16 Sep 2024 22:19:32 +0200 Subject: [PATCH] application refactor --- build.gradle | 2 +- .../com/softeno/template/SoftenoMvcJpaApp.kt | 587 +----------------- .../api/config/GlobalExceptionHandler.kt | 36 ++ .../template/app/common/db/BaseEntity.kt | 63 ++ .../template/app/common/error/ErrorType.kt | 13 + .../template/app/common/events/AppEvent.kt | 5 + .../app/common/events/AppEventHandler.kt | 21 + .../app/config/security/AuditorConfig.kt | 42 ++ .../app/config/security/SecurityConfig.kt | 110 ++++ .../template/app/kafka/KafkaMessage.kt | 6 + .../template/app/kafka/config/KafkaConfig.kt | 57 ++ .../app/kafka/publisher/KafkaPublisher.kt | 22 + .../app/kafka/receiver/KafkaReceiver.kt | 21 + .../template/app/permission/Permission.kt | 19 + .../permission/api/PermissionController.kt | 72 +++ .../app/permission/db/PermissionRepository.kt | 32 + .../app/permission/mapper/PermissionMapper.kt | 36 ++ .../permission/service/PermissionService.kt | 69 ++ .../http/external/api/ExternalController.kt | 24 + .../http/external/client/ExternalClient.kt | 28 + .../external/config/ExternalClientConfig.kt | 42 ++ .../softeno/template/app}/BaseAppSpec.groovy | 3 +- .../template/app}/SoftenoAppSpec.groovy | 2 +- .../permission/api}/PermissionITSpec.groovy | 6 +- .../config/TestRestTemplateConfig.kt | 2 +- .../config/security}/SecurityConfig.kt | 2 +- .../permission}/PermissionFixture.kt | 5 +- .../http/external}/config/WebClientConfig.kt | 3 +- 28 files changed, 732 insertions(+), 598 deletions(-) create mode 100644 src/main/kotlin/com/softeno/template/app/common/api/config/GlobalExceptionHandler.kt create mode 100644 src/main/kotlin/com/softeno/template/app/common/db/BaseEntity.kt create mode 100644 src/main/kotlin/com/softeno/template/app/common/error/ErrorType.kt create mode 100644 src/main/kotlin/com/softeno/template/app/common/events/AppEvent.kt create mode 100644 src/main/kotlin/com/softeno/template/app/common/events/AppEventHandler.kt create mode 100644 src/main/kotlin/com/softeno/template/app/config/security/AuditorConfig.kt create mode 100644 src/main/kotlin/com/softeno/template/app/config/security/SecurityConfig.kt create mode 100644 src/main/kotlin/com/softeno/template/app/kafka/KafkaMessage.kt create mode 100644 src/main/kotlin/com/softeno/template/app/kafka/config/KafkaConfig.kt create mode 100644 src/main/kotlin/com/softeno/template/app/kafka/publisher/KafkaPublisher.kt create mode 100644 src/main/kotlin/com/softeno/template/app/kafka/receiver/KafkaReceiver.kt create mode 100644 src/main/kotlin/com/softeno/template/app/permission/Permission.kt create mode 100644 src/main/kotlin/com/softeno/template/app/permission/api/PermissionController.kt create mode 100644 src/main/kotlin/com/softeno/template/app/permission/db/PermissionRepository.kt create mode 100644 src/main/kotlin/com/softeno/template/app/permission/mapper/PermissionMapper.kt create mode 100644 src/main/kotlin/com/softeno/template/app/permission/service/PermissionService.kt create mode 100644 src/main/kotlin/com/softeno/template/sample/http/external/api/ExternalController.kt create mode 100644 src/main/kotlin/com/softeno/template/sample/http/external/client/ExternalClient.kt create mode 100644 src/main/kotlin/com/softeno/template/sample/http/external/config/ExternalClientConfig.kt rename src/test/groovy/{com.softeno.template => com/softeno/template/app}/BaseAppSpec.groovy (93%) rename src/test/groovy/{com.softeno.template => com/softeno/template/app}/SoftenoAppSpec.groovy (97%) rename src/test/groovy/{com.softeno.template => com/softeno/template/app/permission/api}/PermissionITSpec.groovy (89%) rename src/test/kotlin/com/softeno/template/{ => app}/config/TestRestTemplateConfig.kt (94%) rename src/test/kotlin/com/softeno/template/{config => app/config/security}/SecurityConfig.kt (94%) rename src/test/kotlin/com/softeno/template/{fixture => app/permission}/PermissionFixture.kt (73%) rename src/test/kotlin/com/softeno/template/{ => sample/http/external}/config/WebClientConfig.kt (88%) diff --git a/build.gradle b/build.gradle index 5a3f0b1..c69217b 100644 --- a/build.gradle +++ b/build.gradle @@ -62,7 +62,7 @@ dependencies { // liquibase implementation ('org.liquibase:liquibase-core:4.29.2') { - exclude group: 'javax.xml.bind', module: 'jaxb-api' + exclude group: 'javax.xml.bind', module: 'jaxb-receiver' exclude group: 'org.liquibase.ext', module: 'liquibase-hibernate5' } liquibaseRuntime 'org.jetbrains.kotlin:kotlin-stdlib-jdk8' diff --git a/src/main/kotlin/com/softeno/template/SoftenoMvcJpaApp.kt b/src/main/kotlin/com/softeno/template/SoftenoMvcJpaApp.kt index 5455a8f..76653c7 100644 --- a/src/main/kotlin/com/softeno/template/SoftenoMvcJpaApp.kt +++ b/src/main/kotlin/com/softeno/template/SoftenoMvcJpaApp.kt @@ -1,602 +1,17 @@ package com.softeno.template -import com.fasterxml.jackson.annotation.JsonIgnoreProperties -import com.fasterxml.jackson.annotation.JsonValue -import com.fasterxml.jackson.databind.JsonNode -import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker -import jakarta.persistence.* -import jakarta.transaction.Transactional -import org.apache.commons.logging.LogFactory -import org.apache.kafka.clients.consumer.ConsumerConfig -import org.apache.kafka.clients.producer.ProducerConfig -import org.apache.kafka.common.serialization.StringDeserializer -import org.apache.kafka.common.serialization.StringSerializer -import org.hibernate.annotations.OptimisticLockType -import org.hibernate.annotations.OptimisticLocking -import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.beans.factory.annotation.Value import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.runApplication -import org.springframework.context.ApplicationEvent -import org.springframework.context.ApplicationEventPublisher -import org.springframework.context.ApplicationListener -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.context.annotation.Profile -import org.springframework.core.convert.converter.Converter -import org.springframework.data.annotation.CreatedBy -import org.springframework.data.annotation.CreatedDate -import org.springframework.data.annotation.LastModifiedBy -import org.springframework.data.annotation.LastModifiedDate -import org.springframework.data.domain.* -import org.springframework.data.jpa.domain.support.AuditingEntityListener -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Modifying -import org.springframework.data.jpa.repository.Query -import org.springframework.data.jpa.repository.config.EnableJpaAuditing import org.springframework.data.jpa.repository.config.EnableJpaRepositories -import org.springframework.data.querydsl.QuerydslPredicateExecutor -import org.springframework.data.repository.query.Param -import org.springframework.http.HttpStatus -import org.springframework.http.MediaType -import org.springframework.http.ResponseEntity -import org.springframework.kafka.annotation.EnableKafka -import org.springframework.kafka.annotation.KafkaListener -import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory -import org.springframework.kafka.core.* -import org.springframework.kafka.support.serializer.JsonDeserializer -import org.springframework.kafka.support.serializer.JsonSerializer -import org.springframework.security.authentication.AbstractAuthenticationToken -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.core.GrantedAuthority -import org.springframework.security.core.authority.SimpleGrantedAuthority -import org.springframework.security.core.context.SecurityContextHolder -import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager -import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager -import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder -import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService -import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository -import org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction -import org.springframework.security.oauth2.jwt.* -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken -import org.springframework.security.web.SecurityFilterChain -import org.springframework.stereotype.Component -import org.springframework.stereotype.Controller -import org.springframework.stereotype.Repository -import org.springframework.stereotype.Service import org.springframework.transaction.annotation.EnableTransactionManagement -import org.springframework.web.bind.annotation.* -import org.springframework.web.context.request.WebRequest -import org.springframework.web.cors.CorsConfiguration -import org.springframework.web.cors.CorsConfigurationSource -import org.springframework.web.cors.UrlBasedCorsConfigurationSource -import org.springframework.web.reactive.function.client.WebClient -import java.time.Instant -import java.util.* - - -@MappedSuperclass -@EntityListeners(AuditingEntityListener::class) -@OptimisticLocking(type = OptimisticLockType.VERSION) -open class BaseEntity { - - constructor(uuid: UUID) { - this.uuid = uuid - } - - @Column(updatable = false) - var uuid: UUID - - @Id - @GeneratedValue(strategy = GenerationType.SEQUENCE) - var id: Long? = null - - @CreatedDate - @Column(nullable = false, updatable = false) - var createdDate: Long? = null - - @LastModifiedDate - var modifiedDate: Long? = null - - @CreatedBy - @Column(nullable = false, updatable = false) - var createdBy: String? = null - - @LastModifiedBy - var modifiedBy: String? = null - - @Version - var version: Long? = null - - override fun equals(other: Any?): Boolean { - return other is BaseEntity && (uuid == other.uuid) - } - - override fun hashCode(): Int { - return uuid.hashCode() - } - - override fun toString(): String { - return "${javaClass.simpleName}(id = $id, uuid = $uuid, version = $version)" - } -} - -@Entity -@Table(name = "permissions") -class Permission(uuid: UUID = UUID.randomUUID()) : BaseEntity(uuid) { - - @Column(unique = true, nullable = false) - var name: String? = null - - @Column(nullable = false, columnDefinition = "TEXT") - var description: String? = null -} - -data class PermissionDto( - val id: Long?, - val createdBy: String?, - val createdDate: Long?, - val modifiedBy: String?, - val modifiedDate: Long?, - val version: Long?, - - val name: String, - val description: String -) - -fun Permission.toDto(): PermissionDto { - return PermissionDto( - id = this.id, - createdBy = this.createdBy, - createdDate = this.createdDate, - modifiedBy = this.modifiedBy, - modifiedDate = this.modifiedDate, - version = this.version, - - name = this.name!!, - description = this.description!! - ) -} - -fun Permission.updateFromDto(permissionDto: PermissionDto): Permission { - this.name = permissionDto.name - this.description = permissionDto.description - this.version = permissionDto.version - return this -} - -@Repository -interface PermissionRepository : JpaRepository, QuerydslPredicateExecutor { - override fun findAll(pageable: Pageable): Page - - @Modifying - @Query("UPDATE Permission p SET p.name = :name, p.description = :description, p.version = :newVersion, p.modifiedDate = :modifiedDate, p.modifiedBy = :modifiedBy WHERE p.id = :id AND p.version = :version") - fun updatePermissionNameAndDescriptionByIdAudited( - @Param("id") id: Long, @Param("name") name: String, @Param("description") description: String, @Param("version") version: Long, - @Param("newVersion") newVersion: Long, @Param("modifiedBy") modifiedBy: String, @Param("modifiedDate") modifiedDate: Long - ): Int - - @Query("SELECT p.version FROM Permission p WHERE p.id = :id") - fun findVersionById(@Param("id") id: Long): Long -} - -fun getPageRequest(page: Int, size: Int, sort: String, direction: String) = - Sort.by(Sort.Order(if (direction == "ASC") Sort.Direction.ASC else Sort.Direction.DESC, sort)) - .let { PageRequest.of(page, size, it) } - -@Service -class PermissionService( - private val permissionRepository: PermissionRepository, - private val entityManager: EntityManager -) { - private val log = LogFactory.getLog(javaClass) - - fun getAllPermissions(pageable: Pageable): Page { - return permissionRepository.findAll(pageable).map { it.toDto() } - } - - fun getPermission(id: Long): PermissionDto { - return permissionRepository.findById(id).get().toDto() - } - - @Transactional - fun createPermission(permissionDto: PermissionDto): PermissionDto { - val permission = Permission() - permission.name = permissionDto.name - permission.description = permissionDto.description - return permissionRepository.save(permission).toDto() - } - - @Transactional - fun updatePermission(id: Long, permissionDto: PermissionDto): PermissionDto { - val permission = entityManager.find(Permission::class.java, id) - entityManager.detach(permission) - permission.updateFromDto(permissionDto) - return entityManager.merge(permission).toDto() - } - - @Transactional - fun updatePermissionJpql(id: Long, permissionDto: PermissionDto): PermissionDto { - val currentVersion = permissionRepository.findVersionById(id) - if (currentVersion != permissionDto.version) { - throw OptimisticLockException("Version mismatch") - } - - val newVersion = permissionDto.version + 1 - val currentTime = System.currentTimeMillis() - val modifiedBy = "system" // todo: get from security context - - val affectedRows = permissionRepository - .updatePermissionNameAndDescriptionByIdAudited( - id, permissionDto.name, permissionDto.description, currentVersion, newVersion, modifiedBy, currentTime) - - log.debug("[updatePermissionJpql] affectedRows: $affectedRows") - return permissionRepository.findById(id).get().toDto() - } - - fun deletePermission(id: Long) { - permissionRepository.deleteById(id) - } -} - -@RestController -@RequestMapping("") -class PermissionController( - private val permissionService: PermissionService, - private val applicationEventPublisher: ApplicationEventPublisher, - private val externalServiceClient: ExternalServiceClient -) { - private val log = LogFactory.getLog(javaClass) - - @GetMapping("/permissions") - fun getPermissions(@RequestParam(required = false, defaultValue = "0") page: Int, - @RequestParam(required = false, defaultValue = "10") size: Int, - @RequestParam(required = false, defaultValue = "id") sort: String, - @RequestParam(required = false, defaultValue = "ASC") direction: String - ): ResponseEntity> { - val result = permissionService.getAllPermissions(getPageRequest(page, size, sort, direction)) - return ResponseEntity.ok(result) - } - - @GetMapping("/permissions/{id}") - fun getPermission(@PathVariable id: Long): ResponseEntity { - val result = permissionService.getPermission(id) - return ResponseEntity.ok(result) - } - - @PostMapping("/permissions") - fun createPermission(@RequestBody permissionDto: PermissionDto): ResponseEntity { - val result = permissionService.createPermission(permissionDto) - log.info("sending event: PERMISSION_CREATED_JPA: ${result.id}") - applicationEventPublisher.publishEvent(AppEvent("PERMISSION_CREATED_JPA: ${result.id}")) - return ResponseEntity.ok(result) - } - - @PutMapping("/permissions/{id}") - fun updatePermission(@PathVariable id: Long, @RequestBody permissionDto: PermissionDto): ResponseEntity { - val result = permissionService.updatePermission(id, permissionDto) - return ResponseEntity.ok(result) - } - - @PutMapping("/permissions/{id}/jpql") - fun updatePermissionJpql(@PathVariable id: Long, @RequestBody permissionDto: PermissionDto): ResponseEntity { - val result = permissionService.updatePermissionJpql(id, permissionDto) - return ResponseEntity.ok(result) - } - - @DeleteMapping("/permissions/{id}") - fun deletePermission(@PathVariable id: Long) { - permissionService.deletePermission(id) - } - - @GetMapping("/error") - fun error(@RequestParam(required = false, defaultValue = "generic error") message: String) { - throw RuntimeException(message) - } - - // todo: move to new controller - @GetMapping("/external/{id}") - fun getExternalResource(@PathVariable id: String): ResponseEntity { - val data = externalServiceClient.fetchExternalResource(id) - return ResponseEntity.ok(data) - } - -} - -@Component -class ExternalServiceClient(@Qualifier(value = "external") private val webClient: WebClient) { - private val log = LogFactory.getLog(javaClass) - - @CircuitBreaker(name = "fallbackExample", fallbackMethod = "localCacheFallback") - fun fetchExternalResource(id: String): String? { - return webClient.get().uri("/${id}") - .accept(MediaType.APPLICATION_JSON) - .retrieve() - .bodyToMono(String::class.java) - .block() - } - - private fun localCacheFallback(id: String, e: Throwable): String? { - log.error("fallback: $id, $e") - return "fallback" - } - -} - -@ControllerAdvice -class GlobalExceptionHandler { - private val log = LogFactory.getLog(javaClass) - - @ExceptionHandler(value = [OptimisticLockException::class]) - fun handleOptimisticLockingException(e: Exception, request: WebRequest): ResponseEntity { - log.error("[exception handler]: optimistic exception: ${e.message}, request: ${request.headerNames}") - val errorType = ErrorType.OPTIMISTIC_LOCKING_EXCEPTION - val errorDetails = ErrorDetails(timestamp = Instant.now(), errorType = errorType, errorCode = errorType.code, - message = e.message, request = request.getDescription(true)) - return ResponseEntity(errorDetails, HttpStatus.INTERNAL_SERVER_ERROR) - } - - @ExceptionHandler(value = [Exception::class]) - fun handleException(e: Exception, request: WebRequest): ResponseEntity { - log.error("[exception handler]: generic exception: ${e.message}, request: ${request.getDescription(true)}") - val errorType = ErrorType.GENERIC_EXCEPTION - val errorDetails = ErrorDetails(timestamp = Instant.now(), errorType = errorType, errorCode = errorType.code, - message = e.message, request = request.getDescription(true)) - return ResponseEntity(errorDetails, HttpStatus.INTERNAL_SERVER_ERROR) - } -} - -enum class ErrorType(val code: Int) { - OPTIMISTIC_LOCKING_EXCEPTION(1), - GENERIC_EXCEPTION(0); - - @JsonValue - fun toJsonValue(): String { - return this.name - } - -} - -data class ErrorDetails(val timestamp: Instant, val errorType: ErrorType, val errorCode: Int, val message: String?, val request: String?) - -class AuditorAwareImpl : AuditorAware { - private val log = LogFactory.getLog(javaClass) - - override fun getCurrentAuditor(): Optional { - val authentication = SecurityContextHolder.getContext().authentication - if (authentication == null || !authentication.isAuthenticated) { - return Optional.of("system") - } - - return when (authentication.principal) { - is String -> Optional.of(authentication.principal as String) - is Jwt -> { - val principal = (authentication.principal as Jwt).claims["sub"] as String - log.debug("[auditor] authentication principal: $principal") - - Optional.of(principal) - } - else -> Optional.of("system") - } - } -} - -@Configuration -@EnableJpaAuditing(auditorAwareRef = "auditorProvider") -class AuditConfiguration { - - @Bean - fun auditorProvider(): AuditorAware { - return AuditorAwareImpl() - } -} - -@Profile(value = ["!integration"]) -@EnableWebSecurity -@Configuration -@EnableMethodSecurity(prePostEnabled = true) -class SecurityConfig { - - class Jwt2AuthenticationConverter : Converter> { - override fun convert(jwt: Jwt): Collection { - val realmAccess = jwt.claims.getOrDefault("realm_access", mapOf()) as Map - val realmRoles = (realmAccess["roles"] ?: listOf()) as Collection - - return realmRoles - .map { role: String -> SimpleGrantedAuthority(role) }.toList() - } - - } - - class AuthenticationConverter: Converter { - override fun convert(jwt: Jwt): AbstractAuthenticationToken { - return JwtAuthenticationToken(jwt, Jwt2AuthenticationConverter().convert(jwt)) - } - - } - - class UsernameSubClaimAdapter : Converter, Map> { - private val delegate = MappedJwtClaimSetConverter.withDefaults(Collections.emptyMap()) - override fun convert(claims: Map): Map { - val convertedClaims = delegate.convert(claims) - val username = convertedClaims?.get("sub") as String - convertedClaims["sub"] = username - return convertedClaims - } - } - - fun jwtDecoder(issuer: String, jwkSetUri: String): JwtDecoder { - val jwtDecoder: NimbusJwtDecoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build() - jwtDecoder.setClaimSetConverter(UsernameSubClaimAdapter()) - jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer)) - return jwtDecoder - } - - fun corsConfigurationSource(): CorsConfigurationSource { - val configuration = CorsConfiguration() - configuration.allowedOrigins = listOf("*") - configuration.allowedMethods = listOf("*") - configuration.allowedHeaders = listOf("*") - configuration.exposedHeaders = listOf("*") - - val source = UrlBasedCorsConfigurationSource() - source.registerCorsConfiguration("/**", configuration) - // note: swagger can be restricted by cors - return source - } - - @Bean - fun securityFilterChain(http: HttpSecurity, - @Value("\${spring.security.oauth2.resourceserver.jwt.issuer-uri}") issuer: String, - @Value("\${spring.security.oauth2.client.provider.keycloak.jwk-set-uri}") jwkSetUri: String - ): SecurityFilterChain { - return http - .cors { it.configurationSource(corsConfigurationSource()) } - .csrf { it.disable() } - .authorizeHttpRequests { - it.requestMatchers( - // monitoring - "/actuator/**", - // springdocs - "/swagger-ui.html", - "/webjars/**", - "/swagger-resources/**", - "/swagger-ui/**", - "/v3/api-docs/**").permitAll() - it.requestMatchers("/permissions/**", "/external/**", "/error/**") - .hasAuthority("ROLE_ADMIN") - } - .oauth2ResourceServer { rss -> - rss.jwt { jwtDecoder(issuer, jwkSetUri) } - rss.jwt { it.jwtAuthenticationConverter { jwt -> - AuthenticationConverter().convert(jwt) - } } - } - .build() - } -} - -@ConfigurationProperties(prefix = "com.softeno.external") -data class ExternalClientConfig(val url: String = "", val name: String = "") - -@Profile(value = ["!integration"]) -@Configuration -class WebClientConfig { - - @Bean - fun authorizedClientManager(clients: ClientRegistrationRepository, service: OAuth2AuthorizedClientService): OAuth2AuthorizedClientManager { - val manager = AuthorizedClientServiceOAuth2AuthorizedClientManager(clients, service) - val authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder.builder() - .clientCredentials() - .build() - manager.setAuthorizedClientProvider(authorizedClientProvider) - return manager - } - - @Bean(value = ["external"]) - fun webClient(authorizedClientManager: OAuth2AuthorizedClientManager, config: ExternalClientConfig): WebClient { - val oauth2 = ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager) - oauth2.setDefaultClientRegistrationId("keycloak") - return WebClient.builder() - .filter(oauth2) - .baseUrl(config.url) - .build() - } - -} - -@Profile(value = ["!integration"]) -@Configuration -@EnableKafka -class KafkaConfig { - - @Bean - fun kafkaListenerContainerFactory(consumerFactory: ConsumerFactory) = - ConcurrentKafkaListenerContainerFactory().also { it.consumerFactory = consumerFactory } - - @Bean - fun consumerFactory() = DefaultKafkaConsumerFactory(consumerProps) - - val consumerProps = mapOf( - ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to "localhost:9094", - ConsumerConfig.GROUP_ID_CONFIG to "sample-group-jvm-jpa", - ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, - ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to JsonDeserializer::class.java, - JsonDeserializer.USE_TYPE_INFO_HEADERS to false, - JsonDeserializer.TRUSTED_PACKAGES to "*", - JsonDeserializer.VALUE_DEFAULT_TYPE to JsonNode::class.java, - ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to "earliest" - ) - - @Bean - fun producerFactory() = DefaultKafkaProducerFactory(senderProps) - - val senderProps = mapOf( - ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to "localhost:9094", - ProducerConfig.LINGER_MS_CONFIG to 10, - ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, - ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to JsonSerializer::class.java - ) - - @Bean - fun kafkaTemplate(producerFactory: ProducerFactory) = KafkaTemplate(producerFactory) -} - -@Profile(value = ["!integration"]) -@Controller -class KafkaListenerController { - private val log = LogFactory.getLog(javaClass) - - @KafkaListener(id = "template-mvc-jpa-0", topics = ["\${com.softeno.kafka.rx}"]) - fun onMessage(payload: JsonNode) { - log.info("received payload: $payload") - // todo: handle incoming event - } - -} - -@JsonIgnoreProperties(ignoreUnknown = true) -data class KafkaMessage(val content: String) - -@Profile(value = ["!integration"]) -@Service -class KafkaService( - private val kafkaTemplate: KafkaTemplate, - @Value("\${com.softeno.kafka.tx}") private val topic: String -) { - private val log = LogFactory.getLog(javaClass) - - fun send(message: KafkaMessage) { - log.info("sending kafka message: $message") - kafkaTemplate.send(topic, message) - } -} - -data class AppEvent(val source: String) : ApplicationEvent(source) - -@Profile(value = ["!integration"]) -@Component -class SampleApplicationEventPublisher(private val kafkaService: KafkaService) : ApplicationListener { - private val log = LogFactory.getLog(javaClass) - - override fun onApplicationEvent(event: AppEvent) { - log.info("received application event: $event") - kafkaService.send(event.toKafkaMessage()) - } -} - -fun AppEvent.toKafkaMessage() = KafkaMessage(content = this.source) +@SpringBootApplication @EnableJpaRepositories @EnableTransactionManagement @EnableConfigurationProperties @ConfigurationPropertiesScan("com.softeno") -@SpringBootApplication class SoftenoMvcJpaApp fun main(args: Array) { diff --git a/src/main/kotlin/com/softeno/template/app/common/api/config/GlobalExceptionHandler.kt b/src/main/kotlin/com/softeno/template/app/common/api/config/GlobalExceptionHandler.kt new file mode 100644 index 0000000..45c2286 --- /dev/null +++ b/src/main/kotlin/com/softeno/template/app/common/api/config/GlobalExceptionHandler.kt @@ -0,0 +1,36 @@ +package com.softeno.template.app.common.api.config + +import com.softeno.template.app.common.error.ErrorType +import jakarta.persistence.OptimisticLockException +import org.apache.commons.logging.LogFactory +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.context.request.WebRequest +import java.time.Instant + +@ControllerAdvice +class GlobalExceptionHandler { + private val log = LogFactory.getLog(javaClass) + + @ExceptionHandler(value = [OptimisticLockException::class]) + fun handleOptimisticLockingException(e: Exception, request: WebRequest): ResponseEntity { + log.error("[exception handler]: optimistic exception: ${e.message}, request: ${request.headerNames}") + val errorType = ErrorType.OPTIMISTIC_LOCKING_EXCEPTION + val errorDetails = ErrorDetails(timestamp = Instant.now(), errorType = errorType, errorCode = errorType.code, + message = e.message, request = request.getDescription(true)) + return ResponseEntity(errorDetails, HttpStatus.INTERNAL_SERVER_ERROR) + } + + @ExceptionHandler(value = [Exception::class]) + fun handleException(e: Exception, request: WebRequest): ResponseEntity { + log.error("[exception handler]: generic exception: ${e.message}, request: ${request.getDescription(true)}") + val errorType = ErrorType.GENERIC_EXCEPTION + val errorDetails = ErrorDetails(timestamp = Instant.now(), errorType = errorType, errorCode = errorType.code, + message = e.message, request = request.getDescription(true)) + return ResponseEntity(errorDetails, HttpStatus.INTERNAL_SERVER_ERROR) + } +} + +data class ErrorDetails(val timestamp: Instant, val errorType: ErrorType, val errorCode: Int, val message: String?, val request: String?) diff --git a/src/main/kotlin/com/softeno/template/app/common/db/BaseEntity.kt b/src/main/kotlin/com/softeno/template/app/common/db/BaseEntity.kt new file mode 100644 index 0000000..a7ca0fc --- /dev/null +++ b/src/main/kotlin/com/softeno/template/app/common/db/BaseEntity.kt @@ -0,0 +1,63 @@ +package com.softeno.template.app.common.db + +import jakarta.persistence.Column +import jakarta.persistence.EntityListeners +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.MappedSuperclass +import jakarta.persistence.Version +import org.hibernate.annotations.OptimisticLockType +import org.hibernate.annotations.OptimisticLocking +import org.springframework.data.annotation.CreatedBy +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.annotation.LastModifiedBy +import org.springframework.data.annotation.LastModifiedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import java.util.UUID + +@MappedSuperclass +@EntityListeners(AuditingEntityListener::class) +@OptimisticLocking(type = OptimisticLockType.VERSION) +open class BaseEntity { + + constructor(uuid: UUID) { + this.uuid = uuid + } + + @Column(updatable = false) + var uuid: UUID + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + var id: Long? = null + + @CreatedDate + @Column(nullable = false, updatable = false) + var createdDate: Long? = null + + @LastModifiedDate + var modifiedDate: Long? = null + + @CreatedBy + @Column(nullable = false, updatable = false) + var createdBy: String? = null + + @LastModifiedBy + var modifiedBy: String? = null + + @Version + var version: Long? = null + + override fun equals(other: Any?): Boolean { + return other is BaseEntity && (uuid == other.uuid) + } + + override fun hashCode(): Int { + return uuid.hashCode() + } + + override fun toString(): String { + return "${javaClass.simpleName}(id = $id, uuid = $uuid, version = $version)" + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/app/common/error/ErrorType.kt b/src/main/kotlin/com/softeno/template/app/common/error/ErrorType.kt new file mode 100644 index 0000000..acff2cd --- /dev/null +++ b/src/main/kotlin/com/softeno/template/app/common/error/ErrorType.kt @@ -0,0 +1,13 @@ +package com.softeno.template.app.common.error + +import com.fasterxml.jackson.annotation.JsonValue + +enum class ErrorType(val code: Int) { + OPTIMISTIC_LOCKING_EXCEPTION(1), + GENERIC_EXCEPTION(0); + + @JsonValue + fun toJsonValue(): String { + return this.name + } +} diff --git a/src/main/kotlin/com/softeno/template/app/common/events/AppEvent.kt b/src/main/kotlin/com/softeno/template/app/common/events/AppEvent.kt new file mode 100644 index 0000000..f63b8df --- /dev/null +++ b/src/main/kotlin/com/softeno/template/app/common/events/AppEvent.kt @@ -0,0 +1,5 @@ +package com.softeno.template.app.common.events + +import org.springframework.context.ApplicationEvent + +data class AppEvent(val source: String) : ApplicationEvent(source) diff --git a/src/main/kotlin/com/softeno/template/app/common/events/AppEventHandler.kt b/src/main/kotlin/com/softeno/template/app/common/events/AppEventHandler.kt new file mode 100644 index 0000000..ef97c1b --- /dev/null +++ b/src/main/kotlin/com/softeno/template/app/common/events/AppEventHandler.kt @@ -0,0 +1,21 @@ +package com.softeno.template.app.common.events + +import com.softeno.template.app.kafka.KafkaMessage +import com.softeno.template.app.kafka.publisher.KafkaPublisher +import org.apache.commons.logging.LogFactory +import org.springframework.context.ApplicationListener +import org.springframework.context.annotation.Profile +import org.springframework.stereotype.Component + +@Profile(value = ["!integration"]) +@Component +class AppEventHandler(private val kafkaPublisher: KafkaPublisher) : ApplicationListener { + private val log = LogFactory.getLog(javaClass) + + override fun onApplicationEvent(event: AppEvent) { + log.info("received application event: $event") + kafkaPublisher.send(event.toKafkaMessage()) + } +} + +fun AppEvent.toKafkaMessage() = KafkaMessage(content = this.source) \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/app/config/security/AuditorConfig.kt b/src/main/kotlin/com/softeno/template/app/config/security/AuditorConfig.kt new file mode 100644 index 0000000..dc950f2 --- /dev/null +++ b/src/main/kotlin/com/softeno/template/app/config/security/AuditorConfig.kt @@ -0,0 +1,42 @@ +package com.softeno.template.app.config.security + +import org.apache.commons.logging.LogFactory +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.domain.AuditorAware +import org.springframework.data.jpa.repository.config.EnableJpaAuditing +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.oauth2.jwt.Jwt +import java.util.Optional + +class AuditorAwareImpl : AuditorAware { + private val log = LogFactory.getLog(javaClass) + + override fun getCurrentAuditor(): Optional { + val authentication = SecurityContextHolder.getContext().authentication + if (authentication == null || !authentication.isAuthenticated) { + return Optional.of("system") + } + + return when (authentication.principal) { + is String -> Optional.of(authentication.principal as String) + is Jwt -> { + val principal = (authentication.principal as Jwt).claims["sub"] as String + log.debug("[auditor] authentication principal: $principal") + + Optional.of(principal) + } + else -> Optional.of("system") + } + } +} + +@Configuration +@EnableJpaAuditing(auditorAwareRef = "auditorProvider") +class AuditConfiguration { + + @Bean + fun auditorProvider(): AuditorAware { + return AuditorAwareImpl() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/app/config/security/SecurityConfig.kt b/src/main/kotlin/com/softeno/template/app/config/security/SecurityConfig.kt new file mode 100644 index 0000000..bc64d57 --- /dev/null +++ b/src/main/kotlin/com/softeno/template/app/config/security/SecurityConfig.kt @@ -0,0 +1,110 @@ +package com.softeno.template.app.config.security + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import org.springframework.core.convert.converter.Converter +import org.springframework.security.authentication.AbstractAuthenticationToken +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.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.security.oauth2.jwt.JwtValidators +import org.springframework.security.oauth2.jwt.MappedJwtClaimSetConverter +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken +import org.springframework.security.web.SecurityFilterChain +import org.springframework.web.cors.CorsConfiguration +import org.springframework.web.cors.CorsConfigurationSource +import org.springframework.web.cors.UrlBasedCorsConfigurationSource +import java.util.Collections +import kotlin.collections.set + +@Profile(value = ["!integration"]) +@EnableWebSecurity +@Configuration +@EnableMethodSecurity(prePostEnabled = true) +class SecurityConfig { + + class Jwt2AuthenticationConverter : Converter> { + override fun convert(jwt: Jwt): Collection { + val realmAccess = jwt.claims.getOrDefault("realm_access", mapOf()) as Map + val realmRoles = (realmAccess["roles"] ?: listOf()) as Collection + + return realmRoles + .map { role: String -> SimpleGrantedAuthority(role) }.toList() + } + + } + + class AuthenticationConverter: Converter { + override fun convert(jwt: Jwt): AbstractAuthenticationToken { + return JwtAuthenticationToken(jwt, Jwt2AuthenticationConverter().convert(jwt)) + } + + } + + class UsernameSubClaimAdapter : Converter, Map> { + private val delegate = MappedJwtClaimSetConverter.withDefaults(Collections.emptyMap()) + override fun convert(claims: Map): Map { + val convertedClaims = delegate.convert(claims) + val username = convertedClaims?.get("sub") as String + convertedClaims["sub"] = username + return convertedClaims + } + } + + fun jwtDecoder(issuer: String, jwkSetUri: String): JwtDecoder { + val jwtDecoder: NimbusJwtDecoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build() + jwtDecoder.setClaimSetConverter(UsernameSubClaimAdapter()) + jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer)) + return jwtDecoder + } + + fun corsConfigurationSource(): CorsConfigurationSource { + val configuration = CorsConfiguration() + configuration.allowedOrigins = listOf("*") + configuration.allowedMethods = listOf("*") + configuration.allowedHeaders = listOf("*") + configuration.exposedHeaders = listOf("*") + + val source = UrlBasedCorsConfigurationSource() + source.registerCorsConfiguration("/**", configuration) + // note: swagger can be restricted by cors + return source + } + + @Bean + fun securityFilterChain(http: HttpSecurity, + @Value("\${spring.security.oauth2.resourceserver.jwt.issuer-uri}") issuer: String, + @Value("\${spring.security.oauth2.client.provider.keycloak.jwk-set-uri}") jwkSetUri: String + ): SecurityFilterChain { + return http + .cors { it.configurationSource(corsConfigurationSource()) } + .csrf { it.disable() } + .authorizeHttpRequests { + it.requestMatchers( + // monitoring + "/actuator/**", + // springdocs + "/swagger-ui.html", + "/webjars/**", + "/swagger-resources/**", + "/swagger-ui/**", + "/v3/api-docs/**").permitAll() + it.requestMatchers("/permissions/**", "/external/**", "/error/**") + .hasAuthority("ROLE_ADMIN") + } + .oauth2ResourceServer { rss -> + rss.jwt { jwtDecoder(issuer, jwkSetUri) } + rss.jwt { it.jwtAuthenticationConverter { jwt -> + AuthenticationConverter().convert(jwt) + } } + } + .build() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/app/kafka/KafkaMessage.kt b/src/main/kotlin/com/softeno/template/app/kafka/KafkaMessage.kt new file mode 100644 index 0000000..cbc1c5c --- /dev/null +++ b/src/main/kotlin/com/softeno/template/app/kafka/KafkaMessage.kt @@ -0,0 +1,6 @@ +package com.softeno.template.app.kafka + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +data class KafkaMessage(val content: String) \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/app/kafka/config/KafkaConfig.kt b/src/main/kotlin/com/softeno/template/app/kafka/config/KafkaConfig.kt new file mode 100644 index 0000000..89c98f5 --- /dev/null +++ b/src/main/kotlin/com/softeno/template/app/kafka/config/KafkaConfig.kt @@ -0,0 +1,57 @@ +package com.softeno.template.app.kafka.config + +import com.fasterxml.jackson.databind.JsonNode +import com.softeno.template.app.kafka.KafkaMessage +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.clients.producer.ProducerConfig +import org.apache.kafka.common.serialization.StringDeserializer +import org.apache.kafka.common.serialization.StringSerializer +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import org.springframework.kafka.annotation.EnableKafka +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.ConsumerFactory +import org.springframework.kafka.core.DefaultKafkaConsumerFactory +import org.springframework.kafka.core.DefaultKafkaProducerFactory +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.core.ProducerFactory +import org.springframework.kafka.support.serializer.JsonDeserializer +import org.springframework.kafka.support.serializer.JsonSerializer + +@Profile(value = ["!integration"]) +@Configuration +@EnableKafka +class KafkaConfig { + + @Bean + fun kafkaListenerContainerFactory(consumerFactory: ConsumerFactory) = + ConcurrentKafkaListenerContainerFactory().also { it.consumerFactory = consumerFactory } + + @Bean + fun consumerFactory() = DefaultKafkaConsumerFactory(consumerProps) + + val consumerProps = mapOf( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to "localhost:9094", + ConsumerConfig.GROUP_ID_CONFIG to "sample-group-jvm-jpa", + ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, + ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to JsonDeserializer::class.java, + JsonDeserializer.USE_TYPE_INFO_HEADERS to false, + JsonDeserializer.TRUSTED_PACKAGES to "*", + JsonDeserializer.VALUE_DEFAULT_TYPE to JsonNode::class.java, + ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to "earliest" + ) + + @Bean + fun producerFactory() = DefaultKafkaProducerFactory(senderProps) + + val senderProps = mapOf( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to "localhost:9094", + ProducerConfig.LINGER_MS_CONFIG to 10, + ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, + ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to JsonSerializer::class.java + ) + + @Bean + fun kafkaTemplate(producerFactory: ProducerFactory) = KafkaTemplate(producerFactory) +} \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/app/kafka/publisher/KafkaPublisher.kt b/src/main/kotlin/com/softeno/template/app/kafka/publisher/KafkaPublisher.kt new file mode 100644 index 0000000..d715218 --- /dev/null +++ b/src/main/kotlin/com/softeno/template/app/kafka/publisher/KafkaPublisher.kt @@ -0,0 +1,22 @@ +package com.softeno.template.app.kafka.publisher + +import com.softeno.template.app.kafka.KafkaMessage +import org.apache.commons.logging.LogFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Profile +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.stereotype.Service + +@Profile(value = ["!integration"]) +@Service +class KafkaPublisher( + private val kafkaTemplate: KafkaTemplate, + @Value("\${com.softeno.kafka.tx}") private val topic: String +) { + private val log = LogFactory.getLog(javaClass) + + fun send(message: KafkaMessage) { + log.info("sending kafka message: $message") + kafkaTemplate.send(topic, message) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/app/kafka/receiver/KafkaReceiver.kt b/src/main/kotlin/com/softeno/template/app/kafka/receiver/KafkaReceiver.kt new file mode 100644 index 0000000..4e7a763 --- /dev/null +++ b/src/main/kotlin/com/softeno/template/app/kafka/receiver/KafkaReceiver.kt @@ -0,0 +1,21 @@ +package com.softeno.template.app.kafka.receiver + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.commons.logging.LogFactory +import org.springframework.context.annotation.Profile +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.stereotype.Controller + + +@Profile(value = ["!integration"]) +@Controller +class KafkaReceiver { + private val log = LogFactory.getLog(javaClass) + + @KafkaListener(id = "template-mvc-jpa-0", topics = ["\${com.softeno.kafka.rx}"]) + fun onMessage(payload: JsonNode) { + log.info("received payload: $payload") + // todo: handle incoming event + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/app/permission/Permission.kt b/src/main/kotlin/com/softeno/template/app/permission/Permission.kt new file mode 100644 index 0000000..61d02c5 --- /dev/null +++ b/src/main/kotlin/com/softeno/template/app/permission/Permission.kt @@ -0,0 +1,19 @@ +package com.softeno.template.app.permission + +import com.softeno.template.app.common.db.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.MappedSuperclass +import jakarta.persistence.Table +import java.util.UUID + +@Entity +@Table(name = "permissions") +class Permission(uuid: UUID = UUID.randomUUID()) : BaseEntity(uuid) { + + @Column(unique = true, nullable = false) + var name: String? = null + + @Column(nullable = false, columnDefinition = "TEXT") + var description: String? = null +} \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/app/permission/api/PermissionController.kt b/src/main/kotlin/com/softeno/template/app/permission/api/PermissionController.kt new file mode 100644 index 0000000..2978226 --- /dev/null +++ b/src/main/kotlin/com/softeno/template/app/permission/api/PermissionController.kt @@ -0,0 +1,72 @@ +package com.softeno.template.app.permission.api + +import com.softeno.template.app.common.events.AppEvent +import com.softeno.template.app.permission.db.getPageRequest +import com.softeno.template.app.permission.mapper.PermissionDto +import com.softeno.template.app.permission.service.PermissionService +import org.apache.commons.logging.LogFactory +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.domain.Page +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +class PermissionController( + private val permissionService: PermissionService, + private val applicationEventPublisher: ApplicationEventPublisher +) { + private val log = LogFactory.getLog(javaClass) + + @GetMapping("/permissions") + fun getPermissions(@RequestParam(required = false, defaultValue = "0") page: Int, + @RequestParam(required = false, defaultValue = "10") size: Int, + @RequestParam(required = false, defaultValue = "id") sort: String, + @RequestParam(required = false, defaultValue = "ASC") direction: String + ): ResponseEntity> { + val result = permissionService.getAllPermissions(getPageRequest(page, size, sort, direction)) + return ResponseEntity.ok(result) + } + + @GetMapping("/permissions/{id}") + fun getPermission(@PathVariable id: Long): ResponseEntity { + val result = permissionService.getPermission(id) + return ResponseEntity.ok(result) + } + + @PostMapping("/permissions") + fun createPermission(@RequestBody permissionDto: PermissionDto): ResponseEntity { + val result = permissionService.createPermission(permissionDto) + log.info("sending event: PERMISSION_CREATED_JPA: ${result.id}") + applicationEventPublisher.publishEvent(AppEvent("PERMISSION_CREATED_JPA: ${result.id}")) + return ResponseEntity.ok(result) + } + + @PutMapping("/permissions/{id}") + fun updatePermission(@PathVariable id: Long, @RequestBody permissionDto: PermissionDto): ResponseEntity { + val result = permissionService.updatePermission(id, permissionDto) + return ResponseEntity.ok(result) + } + + @PutMapping("/permissions/{id}/jpql") + fun updatePermissionJpql(@PathVariable id: Long, @RequestBody permissionDto: PermissionDto): ResponseEntity { + val result = permissionService.updatePermissionJpql(id, permissionDto) + return ResponseEntity.ok(result) + } + + @DeleteMapping("/permissions/{id}") + fun deletePermission(@PathVariable id: Long) { + permissionService.deletePermission(id) + } + + @GetMapping("/error") + fun error(@RequestParam(required = false, defaultValue = "generic error") message: String) { + throw RuntimeException(message) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/app/permission/db/PermissionRepository.kt b/src/main/kotlin/com/softeno/template/app/permission/db/PermissionRepository.kt new file mode 100644 index 0000000..c610862 --- /dev/null +++ b/src/main/kotlin/com/softeno/template/app/permission/db/PermissionRepository.kt @@ -0,0 +1,32 @@ +package com.softeno.template.app.permission.db + +import com.softeno.template.app.permission.Permission +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.querydsl.QuerydslPredicateExecutor +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository + +@Repository +interface PermissionRepository : JpaRepository, QuerydslPredicateExecutor { + override fun findAll(pageable: Pageable): Page + + @Modifying + @Query("UPDATE Permission p SET p.name = :name, p.description = :description, p.version = :newVersion, p.modifiedDate = :modifiedDate, p.modifiedBy = :modifiedBy WHERE p.id = :id AND p.version = :version") + fun updatePermissionNameAndDescriptionByIdAudited( + @Param("id") id: Long, @Param("name") name: String, @Param("description") description: String, @Param("version") version: Long, + @Param("newVersion") newVersion: Long, @Param("modifiedBy") modifiedBy: String, @Param("modifiedDate") modifiedDate: Long + ): Int + + @Query("SELECT p.version FROM Permission p WHERE p.id = :id") + fun findVersionById(@Param("id") id: Long): Long +} + +fun getPageRequest(page: Int, size: Int, sort: String, direction: String) = + Sort.by(Sort.Order(if (direction == "ASC") Sort.Direction.ASC else Sort.Direction.DESC, sort)) + .let { PageRequest.of(page, size, it) } \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/app/permission/mapper/PermissionMapper.kt b/src/main/kotlin/com/softeno/template/app/permission/mapper/PermissionMapper.kt new file mode 100644 index 0000000..1d34f9a --- /dev/null +++ b/src/main/kotlin/com/softeno/template/app/permission/mapper/PermissionMapper.kt @@ -0,0 +1,36 @@ +package com.softeno.template.app.permission.mapper + +import com.softeno.template.app.permission.Permission + +data class PermissionDto( + val id: Long?, + val createdBy: String?, + val createdDate: Long?, + val modifiedBy: String?, + val modifiedDate: Long?, + val version: Long?, + + val name: String, + val description: String +) + +fun Permission.toDto(): PermissionDto { + return PermissionDto( + id = this.id, + createdBy = this.createdBy, + createdDate = this.createdDate, + modifiedBy = this.modifiedBy, + modifiedDate = this.modifiedDate, + version = this.version, + + name = this.name!!, + description = this.description!! + ) +} + +fun Permission.updateFromDto(permissionDto: PermissionDto): Permission { + this.name = permissionDto.name + this.description = permissionDto.description + this.version = permissionDto.version + return this +} diff --git a/src/main/kotlin/com/softeno/template/app/permission/service/PermissionService.kt b/src/main/kotlin/com/softeno/template/app/permission/service/PermissionService.kt new file mode 100644 index 0000000..8b4c08d --- /dev/null +++ b/src/main/kotlin/com/softeno/template/app/permission/service/PermissionService.kt @@ -0,0 +1,69 @@ +package com.softeno.template.app.permission.service + +import com.softeno.template.app.permission.Permission +import com.softeno.template.app.permission.db.PermissionRepository +import com.softeno.template.app.permission.mapper.PermissionDto +import com.softeno.template.app.permission.mapper.toDto +import com.softeno.template.app.permission.mapper.updateFromDto +import jakarta.persistence.EntityManager +import jakarta.persistence.OptimisticLockException +import jakarta.transaction.Transactional +import org.apache.commons.logging.LogFactory +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service + +@Service +class PermissionService( + private val permissionRepository: PermissionRepository, + private val entityManager: EntityManager +) { + private val log = LogFactory.getLog(javaClass) + + fun getAllPermissions(pageable: Pageable): Page { + return permissionRepository.findAll(pageable).map { it.toDto() } + } + + fun getPermission(id: Long): PermissionDto { + return permissionRepository.findById(id).get().toDto() + } + + @Transactional + fun createPermission(permissionDto: PermissionDto): PermissionDto { + val permission = Permission() + permission.name = permissionDto.name + permission.description = permissionDto.description + return permissionRepository.save(permission).toDto() + } + + @Transactional + fun updatePermission(id: Long, permissionDto: PermissionDto): PermissionDto { + val permission = entityManager.find(Permission::class.java, id) + entityManager.detach(permission) + permission.updateFromDto(permissionDto) + return entityManager.merge(permission).toDto() + } + + @Transactional + fun updatePermissionJpql(id: Long, permissionDto: PermissionDto): PermissionDto { + val currentVersion = permissionRepository.findVersionById(id) + if (currentVersion != permissionDto.version) { + throw OptimisticLockException("Version mismatch") + } + + val newVersion = permissionDto.version + 1 + val currentTime = System.currentTimeMillis() + val modifiedBy = "system" // todo: get from security context + + val affectedRows = permissionRepository + .updatePermissionNameAndDescriptionByIdAudited( + id, permissionDto.name, permissionDto.description, currentVersion, newVersion, modifiedBy, currentTime) + + log.debug("[updatePermissionJpql] affectedRows: $affectedRows") + return permissionRepository.findById(id).get().toDto() + } + + fun deletePermission(id: Long) { + permissionRepository.deleteById(id) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/sample/http/external/api/ExternalController.kt b/src/main/kotlin/com/softeno/template/sample/http/external/api/ExternalController.kt new file mode 100644 index 0000000..e3031c3 --- /dev/null +++ b/src/main/kotlin/com/softeno/template/sample/http/external/api/ExternalController.kt @@ -0,0 +1,24 @@ +package com.softeno.template.sample.http.external.api + +import com.softeno.template.sample.http.external.client.ExternalServiceClient +import org.apache.commons.logging.LogFactory +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/external") +class ExternalController( + private val externalServiceClient: ExternalServiceClient +) { + private val log = LogFactory.getLog(javaClass) + + @GetMapping("/{id}") + fun getExternalResource(@PathVariable id: String): ResponseEntity { + val data = externalServiceClient.fetchExternalResource(id) + log.info("External: Received $id, sending: ${data.toString()}") + return ResponseEntity.ok(data) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/sample/http/external/client/ExternalClient.kt b/src/main/kotlin/com/softeno/template/sample/http/external/client/ExternalClient.kt new file mode 100644 index 0000000..210d8c1 --- /dev/null +++ b/src/main/kotlin/com/softeno/template/sample/http/external/client/ExternalClient.kt @@ -0,0 +1,28 @@ +package com.softeno.template.sample.http.external.client + +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker +import org.apache.commons.logging.LogFactory +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient + +@Component +class ExternalServiceClient(@Qualifier(value = "external") private val webClient: WebClient) { + private val log = LogFactory.getLog(javaClass) + + @CircuitBreaker(name = "fallbackExample", fallbackMethod = "localCacheFallback") + fun fetchExternalResource(id: String): String? { + return webClient.get().uri("/${id}") + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .bodyToMono(String::class.java) + .block() + } + + private fun localCacheFallback(id: String, e: Throwable): String? { + log.error("fallback: $id, $e") + return "fallback" + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/softeno/template/sample/http/external/config/ExternalClientConfig.kt b/src/main/kotlin/com/softeno/template/sample/http/external/config/ExternalClientConfig.kt new file mode 100644 index 0000000..5c57bb9 --- /dev/null +++ b/src/main/kotlin/com/softeno/template/sample/http/external/config/ExternalClientConfig.kt @@ -0,0 +1,42 @@ +package com.softeno.template.sample.http.external.config + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository +import org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction +import org.springframework.web.reactive.function.client.WebClient + +@ConfigurationProperties(prefix = "com.softeno.external") +data class ExternalClientConfig(val url: String = "", val name: String = "") + +@Profile(value = ["!integration"]) +@Configuration +class WebClientConfig { + + @Bean + fun authorizedClientManager(clients: ClientRegistrationRepository, service: OAuth2AuthorizedClientService): OAuth2AuthorizedClientManager { + val manager = AuthorizedClientServiceOAuth2AuthorizedClientManager(clients, service) + val authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder.builder() + .clientCredentials() + .build() + manager.setAuthorizedClientProvider(authorizedClientProvider) + return manager + } + + @Bean(value = ["external"]) + fun webClient(authorizedClientManager: OAuth2AuthorizedClientManager, config: ExternalClientConfig): WebClient { + val oauth2 = ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager) + oauth2.setDefaultClientRegistrationId("keycloak") + return WebClient.builder() + .filter(oauth2) + .baseUrl(config.url) + .build() + } + +} \ No newline at end of file diff --git a/src/test/groovy/com.softeno.template/BaseAppSpec.groovy b/src/test/groovy/com/softeno/template/app/BaseAppSpec.groovy similarity index 93% rename from src/test/groovy/com.softeno.template/BaseAppSpec.groovy rename to src/test/groovy/com/softeno/template/app/BaseAppSpec.groovy index f4d9c24..d8e796c 100644 --- a/src/test/groovy/com.softeno.template/BaseAppSpec.groovy +++ b/src/test/groovy/com/softeno/template/app/BaseAppSpec.groovy @@ -1,5 +1,6 @@ -package com.softeno.template +package com.softeno.template.app +import com.softeno.template.SoftenoMvcJpaApp import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.test.context.SpringBootTest diff --git a/src/test/groovy/com.softeno.template/SoftenoAppSpec.groovy b/src/test/groovy/com/softeno/template/app/SoftenoAppSpec.groovy similarity index 97% rename from src/test/groovy/com.softeno.template/SoftenoAppSpec.groovy rename to src/test/groovy/com/softeno/template/app/SoftenoAppSpec.groovy index 70635e7..4d59159 100644 --- a/src/test/groovy/com.softeno.template/SoftenoAppSpec.groovy +++ b/src/test/groovy/com/softeno/template/app/SoftenoAppSpec.groovy @@ -1,4 +1,4 @@ -package com.softeno.template +package com.softeno.template.app import com.zaxxer.hikari.HikariConfig import com.zaxxer.hikari.HikariDataSource diff --git a/src/test/groovy/com.softeno.template/PermissionITSpec.groovy b/src/test/groovy/com/softeno/template/app/permission/api/PermissionITSpec.groovy similarity index 89% rename from src/test/groovy/com.softeno.template/PermissionITSpec.groovy rename to src/test/groovy/com/softeno/template/app/permission/api/PermissionITSpec.groovy index 90999a4..5ff9268 100644 --- a/src/test/groovy/com.softeno.template/PermissionITSpec.groovy +++ b/src/test/groovy/com/softeno/template/app/permission/api/PermissionITSpec.groovy @@ -1,8 +1,10 @@ -package com.softeno.template +package com.softeno.template.app.permission.api import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper -import com.softeno.template.fixture.PermissionFixture +import com.softeno.template.app.BaseAppSpec +import com.softeno.template.app.permission.PermissionFixture +import com.softeno.template.app.permission.mapper.PermissionDto import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.web.client.TestRestTemplate import org.springframework.http.HttpStatus diff --git a/src/test/kotlin/com/softeno/template/config/TestRestTemplateConfig.kt b/src/test/kotlin/com/softeno/template/app/config/TestRestTemplateConfig.kt similarity index 94% rename from src/test/kotlin/com/softeno/template/config/TestRestTemplateConfig.kt rename to src/test/kotlin/com/softeno/template/app/config/TestRestTemplateConfig.kt index 033fccc..2a2241f 100644 --- a/src/test/kotlin/com/softeno/template/config/TestRestTemplateConfig.kt +++ b/src/test/kotlin/com/softeno/template/app/config/TestRestTemplateConfig.kt @@ -1,4 +1,4 @@ -package com.softeno.template.config +package com.softeno.template.app.config import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.web.client.TestRestTemplate diff --git a/src/test/kotlin/com/softeno/template/config/SecurityConfig.kt b/src/test/kotlin/com/softeno/template/app/config/security/SecurityConfig.kt similarity index 94% rename from src/test/kotlin/com/softeno/template/config/SecurityConfig.kt rename to src/test/kotlin/com/softeno/template/app/config/security/SecurityConfig.kt index cd7de57..fd0b284 100644 --- a/src/test/kotlin/com/softeno/template/config/SecurityConfig.kt +++ b/src/test/kotlin/com/softeno/template/app/config/security/SecurityConfig.kt @@ -1,4 +1,4 @@ -package com.softeno.template.config +package com.softeno.template.app.config.security import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration diff --git a/src/test/kotlin/com/softeno/template/fixture/PermissionFixture.kt b/src/test/kotlin/com/softeno/template/app/permission/PermissionFixture.kt similarity index 73% rename from src/test/kotlin/com/softeno/template/fixture/PermissionFixture.kt rename to src/test/kotlin/com/softeno/template/app/permission/PermissionFixture.kt index 624c80e..7c6f12f 100644 --- a/src/test/kotlin/com/softeno/template/fixture/PermissionFixture.kt +++ b/src/test/kotlin/com/softeno/template/app/permission/PermissionFixture.kt @@ -1,7 +1,6 @@ -@file:JvmName("PermissionFixture") -package com.softeno.template.fixture +package com.softeno.template.app.permission -import com.softeno.template.PermissionDto +import com.softeno.template.app.permission.mapper.PermissionDto class PermissionFixture { companion object { diff --git a/src/test/kotlin/com/softeno/template/config/WebClientConfig.kt b/src/test/kotlin/com/softeno/template/sample/http/external/config/WebClientConfig.kt similarity index 88% rename from src/test/kotlin/com/softeno/template/config/WebClientConfig.kt rename to src/test/kotlin/com/softeno/template/sample/http/external/config/WebClientConfig.kt index 174401a..c6278a2 100644 --- a/src/test/kotlin/com/softeno/template/config/WebClientConfig.kt +++ b/src/test/kotlin/com/softeno/template/sample/http/external/config/WebClientConfig.kt @@ -1,6 +1,5 @@ -package com.softeno.template.config +package com.softeno.template.sample.http.external.config -import com.softeno.template.ExternalClientConfig import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Profile