HackToTech

Hack To Technology

dependabotのgradleにKotlin DSLでpluginsとdependenciesで同じバージョンを指定してPRを作らせたい

残念ながら、gradleのバージョンが古くて(6.9系)version catalogが使えなかったので、その場合にしか役に立たない備忘録

おそらくgradleのバージョンが7.2以上なら多分version catalog使って、こんな面倒なことしなくても色々解決する気がしている developer.android.com Add support for Gradle Version Catalogs (Gradle 7.0) · Issue #3121 · dependabot/dependabot-core · GitHub

後は、最近GAされたdependabotの grouped version を使っても出来るような気もするが、個人的にはktsで完結している方が好みではある github.blog

以下は、通常であればそれぞれで指定する意味はないケースだが、今回は例としてあげる
できればバージョンを一箇所で指定して読み取るのがベストだが、
後述の理由によりそれができないので springBootVersion の変数をそれぞれ指定して作ることによって、dependabotのPRとしては1つ作成させるようにしている

  • build.gradle.kts
plugins {
  val springBootVersion = "3.1.1"
  id("org.springframework.boot") version springBootVersion 
}
  • module/build.gradle.kts
dependencies {
  val springBootVersion = "3.1.1"
  implementation("org.springframework.boot:spring-boot-starter:$springBootVersion")
}

そうすると↓みたいな感じで、変数に対してまとめてPRを出してくれる

で、なぜ一箇所で定義して読み取る方式ができないかというと、
古い gradle のージョンで恐らく出来ることといえば、
gradle.properties を使って pluginManagement 側で指定しつつ、 dependencies 側は inejct することになると思う

  • gradle.properties
springBootVersion=3.1.1
  • settings.gradle.kts
pluginManagement {
  plugins {
    val springBootVersion: String by settings
    id("org.springframework.boot") version springBootVersion
  }
}
  • build.gradle.kts
plugins {
  // pluginManagement 側で指定しているので特に指定しない
  id("org.springframework.boot")
}
  • module/build.gradle.kts
dependencies {
  val springBootVersion: String  by project
  implementation("org.springframework.boot:spring-boot-starter:$springBootVersion")
}

ただこれだと、gradleは正しく読み取って処理を行えるが、
dependabotは gradle.properties で指定して埋め込んだバージョンを理解できなくてPRを作ってくれないので、
最初に紹介した変数を複数定義する方式を今回は採用した

ちなみにこの変数を使う方式だと同じバージョンを複数のライブラリに使ってまとめてアップデートすることも可能だったりする

  • build.gradle.kts
dependencies {
  val awsSdkVersion = "2.20.135"
  implementation("software.amazon.awssdk:sts:$awsSdkVersion")
  implementation("software.amazon.awssdk:s3:$awsSdkVersion")
  implementation("software.amazon.awssdk:cognitoidentityprovider:$awsSdkVersion")
}

APIの権限チェックに@PreAuthorizeを使うのをやめた話

tl;dr

@PreAuthorize@Valid を同時に使用した場合に、評価の順番に対して直感的にはわかりづらい問題がある
Issueとしては↓があたるが、
要はコントローラーのリクエストハンドラーの変数の評価よりもあとに @PreAuthorize の評価がされる為、
本来であれば権限的に叩けないようなAPIに対してもリクエストボディなどの検証が先に動き、403で返すべきところを400で返す問題がある github.com

Issueにコメントしている人もいる通り、 BindingResultをハンドラー側で受け取れば回避も出来るだろうからそうするのも勿論ありではあると思う
ただ既に @Valid を大半に使っていて作りを変える気がない場合などは、 @PreAuthorize をそのまま使うことは控えたほうが個人的には良いと思う

@PreAuthorizeをやめてどうすることにしたか

自前でInterceptor書いてリクエストハンドラーの前に割り込むことにした
とりあえず対象のパスに来たらInterceptorを呼ばせる

@Configuration
class WebMcvConfig(
    private val authorizationInterceptor: AuthorizationInterceptor,
): WebMvcConfigurer {
    override fun addInterceptors(register: InterceptorRegistry) {
        registry.addInterceptor(authorizationInterceptor).addPathPatterns("<対象とするパス>")
    }
}

詳細はドキュメントを見たほうが早いが、
これでControllerのリクエストハンドラーより先に評価させることが出来る
なぜ HandlerMethod かどうかを確認しているかというと、 ResourceHttpRequestHandler が入るケースがあって、今回はその場合は評価させたくなかったのでこうしている

@Component
class AuthorizationInterceptor(
    private val authorizationEvaluator: AuthorizationEvaluator,
): HandlerInterceptor {
    override fun preHandle(
        request: HttpServletRequest,
        response: HttpServletResponse,
        handler: Any,
    ): Boolean {
        if (handler is HandlerMethod) authorizationEvaluator.evaluate(handler.method)
        return true
    }
}
@Component
class AuthorizationEvaluator {
    fun evaluate(method: Method): Boolean {
        // MethodからAnnotationを取得するなりして、権限が足りていればTrueを返して足りなければFalseを返す
    }
}

あとは自前でアノテーションを作って好きに評価することにした

今のところ特に問題がなくうまくいっているが、
もちろん @PreAuthorize のほうが便利だし、
今回はあくまで扱いたいケースが単純だったので楽に実装出来ただけなので、その辺もちゃんと見極めた上でAPIの権限チェックに何を使うかは考えたほうが良いと思う
ただ、ライブラリを使うのに比べれば自前で後で捨てやすいように作るほうが、個人的には好きなので今回はこうしてみた

ConstraintValidatorのisValidでエラーメッセージを変更する

isValid 内でValidationMessagseのキー指定して切り替えって出来るんだっけ?ってなったので、個人的なメモ書き

ドキュメントを読んでいたら出来そうだったので試した
あんまり使う機会はなさそうではある(基本的にはConstraintsは分けるし、相関チェックみたいなパターンぐらいの認識)

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [CustomConstraints.CustomConstraintValidator::class])
annotation class CustomConstraints(
    val message: String = "{com.example.customconstraintssample.constraints.CustomConstraints.message}",
    val groups: Array<KClass<*>> = [],
    val payload: Array<KClass<out Payload>> = []
) {
    class CustomConstraintValidator : ConstraintValidator<CustomConstraints, IndexRequest> {
        override fun isValid(value: IndexRequest, context: ConstraintValidatorContext): Boolean {
            return when (value.type) {
                IndexType.TYPE1 -> {
                    if (value.type1Value != null && value.type2Value == null) return true
                    context.disableDefaultConstraintViolation()
                    context.buildConstraintViolationWithTemplate("{com.example.customconstraintssample.constraints.CustomConstraints.type1.message}")
                        .addConstraintViolation()
                    false
                }
                IndexType.TYPE2 -> {
                    if (value.type1Value == null && value.type2Value != null) return true
                    context.disableDefaultConstraintViolation()
                    context.buildConstraintViolationWithTemplate("{com.example.customconstraintssample.constraints.CustomConstraints.type2.message}")
                        .addConstraintViolation()
                    false
                }
            }
        }
    }
}

使う側

enum class IndexType {
    TYPE1,
    TYPE2,
}

@CustomConstraints
data class IndexRequest(
    val type: IndexType,
    val type1Value: String?,
    val type2Value: Int?,
)

試したソースはここ github.com

BeanValidationでControlCharacterが含まれる場合をバリデーションする

リクエストとかでControlCharacterが混じっていると邪魔になるので弾きたかったという話
調べてもすぐにぱぱぱっと出てこなかったりして面倒なのでメモがてら書く

どちらも空文字は許可するようにしてある
テストはソースに書いてあるので興味があれば

import jakarta.validation.Constraint
import jakarta.validation.Payload
import jakarta.validation.constraints.Pattern
import kotlin.reflect.KClass

@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [])
@Pattern(regexp = "^$|[^\\p{Cntrl}]+")
annotation class RejectControlCharacters(
    val message: String = "{com.example.validatecontrolcharacterssample.constraints.RejectControlCharacters.message}",
    val groups: Array<KClass<*>> = [],
    val payload: Array<KClass<out Payload>> = []
)

改行だけは許可したいみたいな場合はこっち

import jakarta.validation.Constraint
import jakarta.validation.Payload
import jakarta.validation.constraints.Pattern
import kotlin.reflect.KClass

@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [])
@Pattern(regexp = "^$|[^[\\x00-\\x09\\x0B\\x0C\\x0E-\\x1F\\x7F]]+")
annotation class RejectControlCharactersOtherThanLineBreak(
    val message: String = "{com.example.validatecontrolcharacterssample.constraints.RejectControlCharactersOtherThanLineBreak.message}",
    val groups: Array<KClass<*>> = [],
    val payload: Array<KClass<out Payload>> = []
)

書いたソースはここ github.com

SpringでRollbackされた時に別トランザクションでデータの保存をしようとしてたのでメモ

大まかなコードのイメージ

package com.example.app.application

import com.example.app.domain.Sample
import com.example.app.domain.SampleRepository
import org.springframework.aop.framework.AopContext
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.interceptor.TransactionAspectSupport

@Service
class SampleService(
    private val otherService: OtherService,
    private val fallbackService: FallbackService,
) {
    @Transactional
    fun handle() {
        try {
            // ここの呼び出しがロールバックするなら処理を保存しときたい
           // 実際は他にもトランザクションを使ったなんらかの処理
            otherService.handle()
        } catch (e: RuntimeException) {
            // UnexpectedRollbackExceptionにならないように明示的に指定する必要がある
            val transactionStatus = TransactionAspectSupport.currentTransactionStatus()
            transactionStatus.setRollbackOnly()
            fallbackService.handle()
        }
    }
}

@Service
class OtherService {
    @Transactional
    fun handle() {
        // 実際はなんらかの処理
        throw RuntimeException("runtime error")
    }
}

@Service
class FallbackService(
    private val repository: SampleRepository,
) {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    fun handle() {
        // 実際はデータ受け取って書き込むだけ
        val sample = Sample.of()
        repository.save(sample)
    }
}

なんかこの為だけに別クラス作って呼び出したり、EventPublisher経由で別メソッドを呼び出したりするのがイマイチだなあと思ってたらAOPで解決する方法もあるとのことだった
www.youtube.com 詳解Springトランザクション -初級から上級まで- #jsug | ドクセル


別メソッドで Propagation.REQUIRES_NEW しているなら、トランザクション別で自動的に貼り直してくれれば良いのに
と思っていたらAOPの仕組み的に SampleService#handle に入る際にProxy経由で呼び出されて、
SampleService#handle 内で 別メソッドを呼び出しても割り込まれずに直接呼び出される(Annotationが効かない)という話っぽい
この辺ちゃんと頭の中でイメージ出来ていないと、なんで書いた通りに呼び出されないんだ???となるのでとても良い資料だった

解説されていたサイトの方、どうやってProxy経由で呼び出しているのかちょっとだけ見てみた

あとは SampleService#handle から SampleService(Proxy経由)#別メソッド を呼びだしている、ということっぽい