SpringfoxとVironでお手軽管理画面を作る！

YUMEMI Advent Calendar 2021　19日目の記事です。
# はじめに

[Viron](https://cam-inc.github.io/viron-doc/)とはフロントの開発要らずで管理画面を構築することのできるツールです。
管理画面用のAPIとOAS2.0準拠のjsonファイル(swagger.json)を用意さえすれば、あとは自動的に管理画面を構築してくれます。
今回はSpringBoot+KotlinでAPIを用意し、Springfoxでjson形式のSwagger定義ファイルを自動生成してVironを試してみました!

# 参考文献
主にこれら2つの記事が参考になりました。
- [Vironを半年近く使ってみた](https://qiita.com/arakawa_moriyuki/items/da1f07b6f47522832337) 
- [公式ドキュメント](https://cam-inc.github.io/viron-doc/docs/demo.html)

# Vironを立ち上げる
Vironのソースコードはこちらにありますのでcloneしておきます。

```sh
 git clone https://github.com/cam-inc/viron
```

[こちら](https://qiita.com/arakawa_moriyuki/items/da1f07b6f47522832337#viron%E7%AB%8B%E3%81%A1%E4%B8%8A%E3%81%92%E3%81%A6%E3%81%BF%E3%82%8B)を参考にVironのビルドを行います。
2021/12/19現在は`v1.3.2`が最新のようで、こちらも動作確認済みです。

ビルド後、`dist/`以下をWebサーバ上で(WebServerForChromeを使いました)で立ち上げます。

# 必須APIを作る
[公式ドキュメント/必須API](https://cam-inc.github.io/viron-doc/docs/dev_api.html)

```
Vironサーバを開発するにあたって、まず3つのAPIを作成する必要があります。

認証方式を取得する /viron_authtype
swaggerを取得する /swagger.json
グローバルメニューを取得する /viron
です。
```

管理画面が立ち上がる前に、Viron側からAPIへ上記のパスにリクエストが投げられます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564587/9e993c8e-4eae-3b17-bcd5-89096e78b73a.png)

Vironの動きとしてはこうなっているようです。
1. この画面でまずはswagger.jsonへのパス(http://localhost:8080/swagger.json) を入力すると、このパスにアクセスされます。
2-a. API側でそのまま返せば、次に`/viron`にリクエストします。
2-b. API側で認証機能を挟んでいる場合は401を返すかと思いますが、401が返されると、今度は`/viron_authtype`というパスにリクエストされます。
3. ここで認証方式をViron側に伝えて、次に認証APIが叩かれます。
4. 認証APIでtokenを返すとVironはそのtokenをheaderに込めて再度`/swagger.json`をリクエストします。
5. jsonファイルが取れたら、`/viron`をリクエストしてグローバルメニューの定義を取得します。

認証機能を組み込む場合は準備としてtokenを返却するエンドポイントも追加する必要があります。
というわけで、作るAPIは以下

- `/swagger.json(認証あり)`
- `/viron_authtype（認証なし）`
- `/viron/sign_in(認証なし)`
- `/viron(認証あり)`

tokenの生成や検証方法などはあんまり本質的ではないので省略します。

## /swagger.json
`build.gradle.kts`に以下を追記。Springfoxは`3.0.0`で試しました。

```kts
implementation("io.springfox:springfox-boot-starter:3.0.0")
```
swagger関連のConfig

```kotlin
@Configuration
@EnableSwagger2
class SwaggerConfig() {
    @Bean
    fun customSwaggerConfig(): Docket {
        return Docket(DocumentationType.SWAGGER_2)
            .securitySchemes(securitySchemes())
            .securityContexts(securityContext())
            // ここをhttpsにした場合はVironから/vironや管理画面用APIはhttpsでリクエストされます。
　　　　　　　　　　　　　　　　　　　　　　　　// localhostで動かす場合はhttpで大丈夫です。
            .protocols(setOf("https"))
            .select()
　　　　　　　　　　　　　　　　　　　　　　　　// 管理画面用のAPIは全て以下のようにエンドポイントを切るなどして管理画面に不要なエンドポイントは公開しないようにするならば
            .paths(PathSelectors.regex("/viron.*"))
            .build()
    }

    @Bean
    fun uiConfig(): UiConfiguration {
        return UiConfigurationBuilder.builder()
            .docExpansion(DocExpansion.LIST)
            .build()
    }

    private fun securitySchemes(): List<ApiKey> {
        return listOf(
            ApiKey("Bearer", HttpHeaders.AUTHORIZATION, In.HEADER.name)
        )
    }

    private fun securityContext(): List<SecurityContext> {
        return listOf(SecurityContext
            .builder()
            .securityReferences(defaultAuth())
            .build())
    }

    private fun defaultAuth(): List<SecurityReference> {
        val authorizationScope = AuthorizationScope("global", "accessEverything")　
        return listOf(SecurityReference("Bearer", arrayOf(authorizationScope)))
    }
}
```

`.protocols(setOf("https"))`
実際に管理画面をデプロイしてhttpsでリクエスト投げたいときはこのメソッドを挟みます。

次にapplication.propertiesに以下の記述を追加してswaggerのjsonファイルを以下のエンドポイントで返すようにします。

```
springfox.documentation.swagger.v2.path=/swagger.json
```

## /viron_authtype
雑ですが例なので...

```kotlin
@GetMapping("/viron_authtype", produces = ["application/json;charset=UTF-8"])
fun getVironAuthType(): String {
        return """
            [
              {
                # このtypeを指定するとemailとパスワードを入力させる画面がViron上で出現します。
                "type": "email",
                "provider": "sample-api",
　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　# 以下のパスにemailとパスワードをボディに込めてリクエストされますので、OKならtokenを返してあげます。
                "url": "/viron/sign_in",
                "method": "POST"
              },
              {
　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　# このエンドポイント使われる場面が分からなくて一応ありますが要らないかも?
                "type": "signout",
                "provider": "sample-api",
                "url": "/viron/sign_out",
                "method" : "POST"
              }
            ]
        """.trimIndent()
}
```
上記のjsonを返すと、1.で401を返した後にemailとパスワードを入力させる画面がViron上で出現します。
入力されたemailとパスワードをボディに込めて`/viron/sign_in`にリクエストされます。
正しい組み合わせであればtokenを返しますが、詳しくは次へ

## /viron/sign_in
```kotlin
@PostMapping("/viron/sign_in", produces = ["application/json;charset=UTF-8"])
fun signIn(
    @RequestBody signInRequest: signInRequest,
    response: HttpServletResponse
) {
    // sign in成功したらtokenを返す関数
    val token = login(signInRequest.email, signInRequest.password)
　　　　　　　　// tokenはautorizationヘッダに入れて200を返す。
    response.addHeader("authorization", "Bearer $token")
}
```

トークンは上記のようにヘッダに込めて返します。

## /viron
各項目の意味は以下の公式ドキュメントに記載されておりますので、よしなに参考にしていきます。
https://cam-inc.github.io/viron-doc/docs/dev_api_menu.html

```kotlin 
@GetMapping("/viron", produces = ["application/json;charset=UTF-8"])
fun getVironGlobalMenu(): String {
    return """
        {
          "theme": "stadndard",
          "color": "black",
          "name": "sample CMS",
          "tags": [
            "CMS"
          ],
          "thumbnail": "https://example.com/thumbnail",
          "pages": [
            {
              "section": "manage",
              "group": "books",
              "id": "books",
              "name": "Books",
              "components": [
                {
                  "api": {
                    "method": "get",
                    "path": "/viron/books"
                  },
                  "name": "Books",
                  "style": "table",
                  "query": [
                    {"key": "name", "value": "string"}
                  ],
                  "primary" : "id",
                  "pagination": true,
                  "table_labels": [
                    "id",
                    "name"
                  ]
                }
              ]
            }
          ]
        }        
    """.trimIndent()
}
```

# CORS

```kotlin
override fun addCorsMappings(registry: CorsRegistry) {
    registry.addMapping("/**")
        .allowedMethods("HEAD", "GET", "PUT", "POST", "DELETE", "PATCH", "OPTION")
        .allowedOrigins(
           arrayOf("http://localhost:3000", "other app client origin") // ここにVironのOrigin
        )
        .exposedHeaders(
           "authorization"　// Access-Control-Expose-Headers に authorizationを追加する
        )
}
```
CORSの設定を追加します。
認証ライブラリに何を使うか次第なところはありますが、共通するのはVironフロントのOriginを許可するのと、Viron側からトークンをauthorizationヘッダに込めてViron側に渡すので読み出し許可します。
例ではこちらを使用しています。
https://spring.pleiades.io/spring-framework/docs/current/javadoc-api/org/springframework/web/servlet/config/annotation/WebMvcConfigurer.html

# 管理画面用API
本の一覧、作成、更新、削除ができるようにAPIを追加しましょう。
## 一覧
一覧画面では、ページネーションと検索クエリの追加ができますので両方追加します。
ページネーションに必要なリクエストパラメータは`limit`, `offset`で、これ以外の名前のパラメータは以下の写真のように検索クエリとして認識させることができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564587/020d78b2-f4c8-f2fa-a13c-ea4698c0dbec.png)

以下の部分の`/viron`のレスポンスが該当する設定になります。

```
"query": [
   {"key": "name", "value": "string"}
 ],
```
ページネーションと検索クエリを使用した場合のControllerの例です。

```kotlin
@GetMapping("/viron/books", produces = ["application/json;charset=UTF-8"])
fun getAllNews(
    @RequestParam(name = "limit", required = false) limit: Int?,
    @RequestParam(name = "offset", required = false) offset: Int?,
    @RequestParam(name = "name", required = false) name: String?,
    response: HttpServletResponse
): List<Book> {
    bookService.getPaginatedBooksByName(limit, offset, name).apply {
        response.addHeader("x-pagination-current-page", this.currentPage.toString())
        response.addHeader("x-pagination-limit", this.limit.toString())
        response.addHeader("x-pagination-total-pages", this.totalPage.toString())
        return this.books
    }
}
```
### ページネーション
ページネーションを行うには以下のヘッダに適切な値を込めて返却する必要があります。
Viron側でこれらのヘッダを参照してよしなにページネーションしてくれます。
- `x-pagination-current-page`
- `x-pagination-limit`
- `x-pagination-total-pages`
これらの計算方法は例えばこんな感じでしょうか

```kotlin
fun getPaginatedBooks(
    limit: Int?,
    offset: Int?,
): PaginatedBooks {
    val usingLimit = limit ?: 50
    val usingOffset = offset ?: 0
    val currentPage = usingOffset / usingLimit + 1
　　　　　　　　// DBに存在する総レコード数を取得して、ページ数の計算
    val totalPages = booksRepository.getBooksCount().let {
        if (it < 1) 0 else it / usingLimit + 1
    }
    val books = bookRepository.findAllWithLimitAndOffset(usingLimit, usingOffset)

    return PaginatedBooks(usingLimit, currentPage, totalPages, books)
}
```
これらをControllerに返却したら、それぞれヘッダに込めますが、Vironで読めるようにする設定を追加します。

```kotlin
override fun addCorsMappings(registry: CorsRegistry) {
     registry.addMapping("/**")
        .allowedMethods("HEAD", "GET", "PUT", "POST", "DELETE", "PATCH", "OPTION")
        .allowedOrigins(
           arrayOf("http://localhost:3000", "other app client origin")
        )
        .exposedHeaders(
            "x-pagination-current-page",　// 追加
            "x-pagination-limit",　// 追加
            "x-pagination-total-pages",　// 追加
            "authorization"
        )
```

### 検索クエリ
この検索クエリ用パラメータは自由に使えます。
例えば、nameをもらってこれで本の名前で曖昧検索+ページネーションをしてみます。
Repositoryにこんな感じで実装してServiceから呼んだら出来上がりです。

```kotlin
    @Query(
        """
            SELECT * FROM book 
            WHERE :name IS NULL OR name LIKE CONCAT('%', :name, '%')
            ORDER BY update_time DESC 
            LIMIT :limit
            OFFSET :offset         
        """, nativeQuery = true
    )
    fun findAllWithLimitAndOffsetAndName(
        @Value("limit") limit: Int,
        @Value("offset") offset: Int,
        @Value("name") name: String?
    ): List<Book>
```

## 作成,更新,削除
作成,更新,削除のAPIも作っていきます。

```kotlin 
@PostMapping("/viron/book", produces = ["application/json;charset=UTF-8"])
fun createBook(
@Validated @RequestBody body: BookRequest
): Book {
    return bookService.create(body.name)
}

@PutMapping("/viron/book/{id}", produces = ["application/json;charset=UTF-8"])
fun updateBook(
    @Validated @RequestBody body: BookRequest,
    @PathVariable("id") id: Int
): Book {
    return bookService.update(id, body.name)
}

@DeleteMapping("/viron/book/{id}", produces = ["application/json;charset=UTF-8"])
fun deleteBook(
    @PathVariable("id") id: Int
) {
    bookService.delete(id)
}
```
これらエンドポイントもswagger.jsonで自動出力されたのをViron側が読み取ってよしなにこれらのパスにリクエストを投げてくれます。

# 最後に
これで土台はできました。
swagger.jsonもAPIで自動生成するのであとは管理画面用のAPIを追加してそのままAPIをデプロイするだけでどんどん管理画面が充実していきます。
ただSpringfoxを使用してjsonを自動生成しているためspringfoxで認識できないformatなどを指定しようとしてもできない事態に遭遇しました。
例えば[こちら](https://cam-inc.github.io/viron-doc/docs/dev_form_wyswyg.html)のWYSIWYS入力フォームを追加するためには

```json 
  format: "wyswyg"
```
swagger.json上の追加したいプロパティに対して上記のformatを指定する必要があります。
これを自動生成するために、

```kotlin
class BookRequest(
    @field:NotNull
    var name: String?,

    @field:NotNull
    @field:ApiParam(format = "wyswig")
    var overview: String?,
)
```
こういうことがしたかったのですが`wyswig`はどうやらSpringfoxで認識できないようで、できませんでした。
引き続き調査は進めたいですね...

今のところ少し制約はありますが引き続き便利なVironを使っていきたいと思いますー。