SpringのMetaAnnotationを検出する

# はじめに

みなさん、こんにちは。空中清高です。
この記事は、[株式会社エス・エム・エス Advent Calendar 2023](https://qiita.com/advent-calendar/2023/bm-sms)の4日目の投稿です。

# MetaAnnotation

SpringではMetaAnnotationという機能がサポートされています。
MetaAnnotationはAnnotationにAnnotationをつける機能です。
使用例として認可処理を実装するAnnotationの`@PreAuthorize`を使って説明したいと思います。

# `@PreAuthorize`

`@PreAuthorize`はメソッドにつけることで認可処理が実装できるAnnotationです。
例えばSpring for GraphQLでGraphQL APIを開発している場合、HTTPエンドポイントは一つになります。
そのためURLエンドポイント毎に認可処理を実装するRequest levelの認可処理では細かな認可処理は実装できません。
またSpring for GraphQLではqueryやmutationはControllerコンポーネントのメソッドにマッピングして実装します。
上記の事情によりSpring for GraphQLでの認可処理は`@PreAuthorize`などを使ったメソッド毎に認可処理を実装するMethod Securityの方法で実装していくことになります。
例としてOfficeに対して読み取り権限を持っているかどうかを確認するため、次のような実装をしたとします。

```graphql:schema.graphqls
type Query {
    office: Office
}
type Office {
    id: ID!
    name: String!
}
```

```kotlin
@Component
class OfficeAuthorizer {
    fun canRead(principal: Principal): Boolean {
        return // principal からOfficeへの読み取り権限があることを確認してBooleanを返す
    }
}

@Controller
class OfficeController {
    @PreAuthorize("@officeAuthorizer.canRead(principal)")
    @QueryMapping
    fun office(): Office
}
```

GraphQLの`office`queryを実装するため`OfficeController#office`に`@QueryMapping`をつけています。
また認可処理を実装するため`OfficeController#office`に`@PreAuthorize`をつけています。
`@PreAuthorize`ではSpEL式の記述ができるので、Spring Componentの`OfficeAuthorizer`を使って認可処理を実装しています。

# MetaAnnotationを使う

`@PreAuthorize`はSpEL式が渡せるため表現力は高いのですが書き間違いを防ぐために特定のSpEL式を渡しているものが欲しくなります。
そこで`@PreAuthorizeCanReadOffice`というMetaAnnotationを定義して書き換えてみます。

```kotlin
@Component
class OfficeAuthorizer {
    fun canRead(principal: Principal): Boolean {
        return // principal からOfficeへの読み取り権限があることを確認してBooleanを返す
    }
}

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
@PreAuthorize("@officeAuthorizer.canRead(principal)")
annotation class PreAuthorizeCanReadOffice

@Controller
class OfficeController() {
    @PreAuthorizeCanReadOffice
    @QueryMapping
    fun office(): Office
}
```
このように実装すると他のメソッドに同じ認可処理を実装したい時も書き間違いをしなくなりますし、認可処理のAnnotationを書くときにコード補完も効いて良いです。

# ユニットテストで`@PreAuthorize`を検出する

さて、上記のようにMethod Securityを使って認可処理を実装するときは`@PreAuthorize`を付け忘れてしまうと素通りになってしまうため、ユニットテストなどでちゃんと付けられていることを確認したくなります。
そこでReflectionを使ったコード解析で確認しようとすると、こんな感じになります。

```kotlin
class ControllerTest {

    @Test
    fun `publicメソッドにPreAuthorizeアノテーションが付けられている`() {
        findControllers().forEach { controller ->
            // publicでstaticでないメソッドに対して@PreAuthorizeが付けられているかチェックする
            controller.declaredMethods
                .filter { Modifier.isPublic(it.modifiers) && !Modifier.isStatic(it.modifiers) }
                .forEach { method ->
                    val methodName = "${controller.canonicalName}#${method.name}"
                    Assert.assertTrue(
                        method.isAnnotationPresent(PreAuthorize::class.java)
                    ) { "$methodName must be annotated with PreAuthorize" }
                }
        }
    }
    
    private fun findControllers() =
        ClassPathScanningCandidateComponentProvider(false)
            .apply { addIncludeFilter(AnnotationTypeFilter(Controller::class.java)) }
            .findCandidateComponents("com.example.presentation")
            .map { it.beanClassName }
            .map { Class.forName(it) }
}
```
このテストでは`com.example.presentation`パッケージ配下にある`@Controller`Annotationが付けられているクラスを探してきて、そのクラスのpublicでstaticでないメソッドに`@PreAuthorize`が付けられていることを確認するようになっています。
これで`@PreAuthorize`をつけ忘れてしまって素通りになっていないかどうかを確認できていそうです。
しかし、MetaAnnotationが使われている場合はこれではチェックできません。
MetaAnnotationを使って認可処理を実装している場合、`method.isAnnotationPresent(PreAuthorize::class.java)`は`false`になってしまいます。
なぜかというと単純にReflectionで取得した`method`には`@PreAuthorizeCanReadOffice`と `@QueryMapping`しか付いていないからです。
そのため上に書いたユニットテストはMetaAnnotationが使われているとコケるようになってしまいます。

# MetaAnnotationを検出する

ようやく記事の題名まで辿り着きました。
このようなMetaAnnotationを検出するために`MergedAnnotations`というクラスがあります。
`MergedAnnotations`は合成したAnnotation情報を取り扱うためのクラスで`MergedAnnotations#isPresent`を使うとMetaAnnotationも含めてチェックできるようになります。

```kotlin
class ControllerTest {
    
    @Test
    fun `publicメソッドにPreAuthorizeアノテーションが付けられている`() {
        findControllers().forEach { controller ->
            // publicでstaticでないメソッドに対して@PreAuthorizeが付けられているかチェックする
            controller.declaredMethods
                .filter { Modifier.isPublic(it.modifiers) && !Modifier.isStatic(it.modifiers) }
                .forEach { method ->
                    val methodName = "${controller.canonicalName}#${method.name}"
                    Assert.assertTrue(
                        // MetaAnnotation に対応するため MergedAnnotations を使う
                        MergedAnnotations.from(method, SearchStrategy.TYPE_HIERARCHY).isPresent(PreAuthorize::class.java)
                    ) { "$methodName must be annotated with PreAuthorize" }
                }
        }
    }
    
    private fun findControllers() =
        ClassPathScanningCandidateComponentProvider(false)
            .apply { addIncludeFilter(AnnotationTypeFilter(Controller::class.java)) }
            .findCandidateComponents("com.example.presentation")
            .map { it.beanClassName }
            .map { Class.forName(it) }
}
```
これでMetaAnnotationが使われていても`@PreAuthorize`をつけ忘れてしまって素通りになっていないかどうかを確認できるようになりました。
ただし、このテストではあくまで「`@PreAuthorize`が付いていて、なんらかの認可処理を実装している」ということしか確認できないため、正しい認可処理が実装されているかどうかは個別のケース毎にちゃんとテストする必要がありますので補助的に使うことを目的にしてください。

# 補足1

`MergedAnnotations`を直接使う他に`AnnotationUtils`を使って`AnnotationUtils.findAnnotation(method, PreAuthorize::class.java)`って探しても良いです。

# 補足2

`com.example.presentation`パッケージ配下にある`@Controller`Annotationが付けられているクラスを探してくる時もMetaAnnotationを考慮することができます。
それは単に`AnnotationTypeFilter`のコンストラクタに第二引数を与えて`AnnotationTypeFilter(Controller::class.java, true))`とすれば良いです。
ただ、`@Controller`でMetaAnnotationを使う具体的な場面は思いつきませんでした。