Spring Security 5.4(Spring Boot 2.4)から不正なURLでのリクエストに任意のステータスコードを返せるようになった

Spring Security 5.4(Spring Boot 2.4)から不正なURLでのリクエストに対して、任意のステータスコードを返せるようになりました。とっても嬉しい!!!

Spring SecurityのHttpFirewall

公式ドキュメント#HttpFirewall に詳しく書いてあるのですが、Spring Securityには ;\などが含まれている不正なURLを弾いてくれるHttpFirewallインターフェースがあります(デフォルトでStrictHttpFirewallクラスが使われる)。

不正なURLだと、RequestRejectedExceptionが投げられます(ソースコード)。

2020-12-26 16:12:54.917 ERROR --- Servlet.service() for servlet [dispatcherServlet] in context with path [/] threw exception
org.springframework.security.web.firewall.RequestRejectedException: The request was rejected because the URL contained a potentially malicious String ";"

Spring Security 5.3.x(Spring Boot 2.3.x)まで

RequestRejectedExceptionのExceptionHandlerがなくて、ステータスコード500が返されていました。(なんでエラー扱いされないといけないんだと何回思ったことか...)

Spring Security 5.4(Spring Boot 2.4)

RequestRejectedHandlerインターフェースが追加されて、RequestRejectedExceptionをハンドリングできるようになりました(プルリク)。

単に任意のステータスコードを返せばよいだけの場合は、HttpStatusRequestRejectedHandlerクラスをBean登録してください。(デフォルトだと400を返します)

@Bean
public RequestRejectedHandler requestRejectedHandler() {
  return new HttpStatusRequestRejectedHandler();
}

404を返したいんだけど...というときは、コンストラクタで渡してください。

@Bean
public RequestRejectedHandler requestRejectedHandler() {
  return new HttpStatusRequestRejectedHandler(HttpStatus.NOT_FOUND.value());
}

Spring BootアプリケーションでAWS Systems Manager パラメータストアを利用する

Spring Bootのアプリケーション起動時に、AWS Systems Manager パラメータストアからパラメータを取得できるみたいなので試してみました。

AWS Systems Manager パラメータストアとは

設定データ管理と機密管理のための安全な階層型ストレージを提供してくれていて、パスワード、データベース文字列、AMI ID、ライセンスコードなどのデータをパラメータ値として保存できます。値はプレーンテキストまたは暗号化されたデータとして保存できます。詳しい説明は、公式ドキュメントを見てください。

サンプル

環境

Spring Boot等のバージョンは次の通りです。

Java 11
Gradle 6.6.1
Spring Boot 2.3.4.RELEASE
Spring Cloud Hoxton.SR8

パラメータの登録

登録するパラメータのkey

デフォルトだと次のような3階層で設定するようになっています。[prefix]/[name]_[アプリケーションのprofile(defaultの場合は省略可)]/[key]

設定値の抜粋です。(値はデフォルト値)

- パラメータストアから取得される全プロパティで共有されるプレフィクス。第1階層の値。
  - aws.paramstore.prefix=/config
- 全サービスで共有されるコンテキスト名。第2階層の値。
  - aws.paramstore.default-context=application
- コンテキスト名とプロファイルの区切り文字.
  - aws.paramstore.profile-separator=_
- パラメータストアを利用するかどうか
  - aws.paramstore.enabled=true

取得するパラメータを組み立てているロジックは AwsParamStorePropertySourceLocator#PropertySource<?> locate(Environment) にあります。

パラメータストアへの登録

パラメータストアにパラメータを登録します。今回は、DBへのアクセス情報を登録します。

/config/sample/spring.datasource.url=jdbc:mysql://localhost:33306/sample
/config/sample/spring.datasource.username=docker
/config/sample/spring.datasource.password=docker

依存関係

パラメータストアを利用するために追加する依存関係はこれだけです。

build.gradle

dependencies {
  ...
  
  implementation 'org.springframework.cloud:spring-cloud-starter-aws-parameter-store-config:2.2.4.RELEASE'
  ...
}

設定ファイル

appliaction.yml(application.properties)には何も書かなくてOKですが、bootstrap.ymlにいくつか設定を書く必要があります。

bootstrap.ymlの全体

spring:
  datasource:
    url:
    username:
    password:
cloud:
  aws:
    stack:
      auto: false
    region:
      auto: false
      static: ap-northeast-1
aws:
  paramstore:
    region: ${cloud.aws.region.static}
    default-context: sample

logging:
  level:
    com:
      amazonaws:
        util:
          EC2MetadataUtils: error

パラメータストアから取得するのでkeyのみ書きます。

spring:
  datasource:
    url:
    username:
    password:

ローカル環境でSpring Cloud AWSを使うためのおまじないです。アプリケーション起動時にメタデータからリージョン等を取得するのですが、ローカル環境だと取得に失敗してアプリケーションが起動しなくなってしまうために無効にしています。詳しくは、Configuring regionCloudFormation configuration in Spring Boot にあります。

cloud:
  aws:
    stack:
      auto: false
    region:
      auto: false
      static: ap-northeast-1

spring-cloud-starter-aws-parameter-store-configの設定です。

aws:
  paramstore:
    region: ${cloud.aws.region.static}
    default-context: sample

EC2MetadataUtilsがAWS上で動いているか取得しますが、AWS環境以外でのアプリケーション起動時にエラーログを出してしまって気持ち悪いので設定します。(なくても動作には何も問題ないです)

logging:
  level:
    com:
      amazonaws:
        util:
          EC2MetadataUtils: error

アプリケーションの起動

あとはアプリケーションを起動するだけです。

どのパラメータをパラメータストアから取得しているかを確認したい場合は、次の設定をbootstrap.ymlに書いてください。

logging:
  level:
    org:
      springframework:
        cloud:
          aws:
            paramstore:
              AwsParamStorePropertySource: debug

設定するとログにこんな感じで出力されます。

2020-10-11 18:35:27.847 DEBUG 55136 --- [           main] o.s.c.a.p.AwsParamStorePropertySource    : Populating property retrieved from AWS Parameter Store: spring.datasource.password
2020-10-11 18:35:27.849 DEBUG 55136 --- [           main] o.s.c.a.p.AwsParamStorePropertySource    : Populating property retrieved from AWS Parameter Store: spring.datasource.url
2020-10-11 18:35:27.849 DEBUG 55136 --- [           main] o.s.c.a.p.AwsParamStorePropertySource    : Populating property retrieved from AWS Parameter Store: spring.datasource.username

プロファイルで設定値を分ける

一般的に、defaultプロファイルしか存在しないことはないかと思います。testプロファイルでは、テスト環境用のDBアクセス情報を取得するみたいなことも可能です。

パラメータストアへの登録

パラメータストアへの登録の仕方については上で説明したので省略します。 これらを登録します。

/config/sample_test/spring.datasource.url=jdbc:mysql://localhost:33307/sample
/config/sample_test/spring.datasource.username=test
/config/sample_test/spring.datasource.password=test

アプリケーションの起動

アクティブプロファイルをtestにして、起動するだけで、 /config/sample_test/* のパラメータを取得してくれます。 デフォルトプロファイルの/config/sample/を読み込んでそのあとに/config/sample_testで上書きするようです。

まとめ

思っていたよりは簡単にできました!AWSのプロファイルはdefaultが使われるので変更したい場合は自前で設定書かないといけないみたいです。

ちなみに設定値の優先順位は パラメータストア > bootstram.yml > application.yml でした。bootstrap.ymlとかapplication.ymlに同じ設定書いてあってもパラメータストアに設定している場合は、パラメータストアの値が優先されます。

springdoc-openapiでOpenAPI形式のAPIドキュメントを生成する

こんにちは! みなさん、API Spec書いてますか?私はお仕事で、OpenAPI(Swagger)形式のYAMLを書いています!

ソースコードとドキュメントを別にしてしまうと、実装途中で仕様変更になったときにyml書き直すのが手間だったり、APIの実装と合ってない箇所があったりなどの問題が発生してしまいます。(そもそもyml書くの辛いし)

プロダクションコードに手を加えることなくプロダクションコードをベースにOpenAPI形式のAPIドキュメントを吐き出すライブラリを探していました。 いくつかそれっぽいライブラリはあるのですが、プロダクトションコードに設定書かないといけなかったり、テストに設定書かないといけないし(テストコードが全部あるわけじゃない)で要望を完全に満たしてもらえませんでした... 最近、完全に要望を満たしてくれる springdoc-openapi というライブラリを見つけましたので紹介します!!!!!

springdoc-openapiとは

springdoc-openapiは、Spring Bootを利用しているプロジェクトで簡単にOpenAPI形式のAPIドキュメントを生成してくれるライブラリです。 Web MVC, WebFluxともに対応しているようです。

サポートしているライブラリ
- Open API 3
- Spring Boot(v1 and v2)
- JSR-303, specifically for @NotNull, @Min, @Max, and @Size.
- Swagger-ui
- OAuth 2

サンプルコード

今回は、Web MVCでのサンプルを紹介します。

API

SakeController.java

@RestController
@RequestMapping("/sakes")
@RequiredArgsConstructor
public class SakeController {

    private final SakeService sakeService;

    @GetMapping
    public Page<SakeResponse> page(@PageableDefault Pageable pageable) {
        return sakeService.page(pageable);
    }

    @GetMapping("list")
    public List<SakeResponse> list() {
        return sakeService.list();
    }

    @GetMapping("{id}")
    public SakeResponse get(@PathVariable Integer id) {
        return sakeService.get(id);
    }

    @PostMapping
    public SakeResponse create(@RequestBody @Validated SakeCreateRequest request) {
        return sakeService.create(request);
    }

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(NotFoundException.class)
    public ResponseEntity<ErrorResponse> handleException(NotFoundException e) {
        return ResponseEntity.of(Optional.of(new ErrorResponse(e.getMessage())));
    }
}

SakeResponse.java

@Value
public class SakeResponse {
    Integer id;
    String name;
    String brewingName;

    public static SakeResponse newInstance(Sake sake) {
        return new SakeResponse(sake.getId(), sake.getName(), sake.getBrewingName());
    }
}

SakeCreateRequest.java

@Value
public class SakeCreateRequest {
    @NotNull
    @Size(min = 1, max = 20)
    String name;
    @NotNull
    String brewingName;
}

GlobalControllerHandler.java

@RestControllerAdvice
public class GlobalControllerHandler {

    @ExceptionHandler(RuntimeException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ResponseEntity<ErrorResponse> handleNotFoundException(RuntimeException e) {
        return ResponseEntity.of(Optional.of(new ErrorResponse(e.getMessage())));
    }
}

ただのController周りの実装なので、説明は省略します。

springdoc-openapiの導入

build.gradle

dependencies {
  ...
  
    implementation 'org.springdoc:springdoc-openapi-ui:1.4.6'
    
  ...
}

この1行追加します。

アプリケーションの起動

アプリケーションを起動して、デフォルトで用意されているURLにアクセスすると、それぞれjson, yaml, htmlを返してくれすます。

GET http://localhost:8080/v3/api-docs/

{
    "openapi": "3.0.1",
    "info": {
        "title": "OpenAPI definition",
        "version": "v0"
    },
    "servers": [
        {
            "url": "http://localhost:8080/api",
            "description": "Generated server url"
        }
    ],
    "paths": {
        "/sakes/{id}": {
            "get": {
                "tags": [
                    "sake-controller"
                ],
                "operationId": "get",
                "parameters": [
                    {
                        "name": "id",
                        "in": "path",
                        "required": true,
                        "schema": {
                            "type": "integer",
                            "format": "int32"
                        }
                    }
                ],
                "responses": {
                    "400": {
                        "description": "Bad Request",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/ErrorResponse"
                                }
                            }
                        }
                    },
                    "500": {
                        "description": "Internal Server Error",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/ErrorResponse"
                                }
                            }
                        }
                    },
                    "200": {
                        "description": "OK",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/SakeResponse"
                                }
                            }
                        }
                    }
                }
            }
        },
        "/sakes/list": {
            "get": {
                "tags": [
                    "sake-controller"
                ],
                "operationId": "list",
                "responses": {
                    "400": {
                        "description": "Bad Request",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/ErrorResponse"
                                }
                            }
                        }
                    },
                    "500": {
                        "description": "Internal Server Error",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/ErrorResponse"
                                }
                            }
                        }
                    },
                    "200": {
                        "description": "OK",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "type": "array",
                                    "items": {
                                        "$ref": "#/components/schemas/SakeResponse"
                                    }
                                }
                            }
                        }
                    }
                }
            }
        },
        "/sakes": {
            "get": {
                "tags": [
                    "sake-controller"
                ],
                "operationId": "page",
                "parameters": [
                    {
                        "name": "pageable",
                        "in": "query",
                        "required": true,
                        "schema": {
                            "$ref": "#/components/schemas/Pageable"
                        }
                    }
                ],
                "responses": {
                    "400": {
                        "description": "Bad Request",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/ErrorResponse"
                                }
                            }
                        }
                    },
                    "500": {
                        "description": "Internal Server Error",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/ErrorResponse"
                                }
                            }
                        }
                    },
                    "200": {
                        "description": "OK",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/PageSakeResponse"
                                }
                            }
                        }
                    }
                }
            },
            "post": {
                "tags": [
                    "sake-controller"
                ],
                "operationId": "create",
                "requestBody": {
                    "content": {
                        "application/json": {
                            "schema": {
                                "$ref": "#/components/schemas/SakeCreateRequest"
                            }
                        }
                    },
                    "required": true
                },
                "responses": {
                    "400": {
                        "description": "Bad Request",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/ErrorResponse"
                                }
                            }
                        }
                    },
                    "500": {
                        "description": "Internal Server Error",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/ErrorResponse"
                                }
                            }
                        }
                    },
                    "200": {
                        "description": "OK",
                        "content": {
                            "*/*": {
                                "schema": {
                                    "$ref": "#/components/schemas/SakeResponse"
                                }
                            }
                        }
                    }
                }
            }
        }
    },
    "components": {
        "schemas": {
            "ErrorResponse": {
                "type": "object",
                "properties": {
                    "message": {
                        "type": "string"
                    }
                }
            },
            "SakeResponse": {
                "type": "object",
                "properties": {
                    "id": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "name": {
                        "type": "string"
                    },
                    "brewingName": {
                        "type": "string"
                    }
                }
            },
            "SakeCreateRequest": {
                "required": [
                    "brewingName",
                    "name"
                ],
                "type": "object",
                "properties": {
                    "name": {
                        "maxLength": 20,
                        "minLength": 1,
                        "type": "string"
                    },
                    "brewingName": {
                        "type": "string"
                    }
                }
            },
            "Pageable": {
                "type": "object",
                "properties": {
                    "offset": {
                        "type": "integer",
                        "format": "int64"
                    },
                    "sort": {
                        "$ref": "#/components/schemas/Sort"
                    },
                    "pageNumber": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "pageSize": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "paged": {
                        "type": "boolean"
                    },
                    "unpaged": {
                        "type": "boolean"
                    }
                }
            },
            "Sort": {
                "type": "object",
                "properties": {
                    "sorted": {
                        "type": "boolean"
                    },
                    "unsorted": {
                        "type": "boolean"
                    },
                    "empty": {
                        "type": "boolean"
                    }
                }
            },
            "PageSakeResponse": {
                "type": "object",
                "properties": {
                    "totalPages": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "totalElements": {
                        "type": "integer",
                        "format": "int64"
                    },
                    "size": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "content": {
                        "type": "array",
                        "items": {
                            "$ref": "#/components/schemas/SakeResponse"
                        }
                    },
                    "numberOfElements": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "sort": {
                        "$ref": "#/components/schemas/Sort"
                    },
                    "first": {
                        "type": "boolean"
                    },
                    "pageable": {
                        "$ref": "#/components/schemas/Pageable"
                    },
                    "last": {
                        "type": "boolean"
                    },
                    "number": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "empty": {
                        "type": "boolean"
                    }
                }
            }
        }
    }
}

GET http://localhost:8080/v3/api-docs.yaml

openapi: 3.0.1
info:
  title: OpenAPI definition
  version: v0
servers:
- url: http://localhost:8080/api
  description: Generated server url
paths:
  /sakes/{id}:
    get:
      tags:
      - sake-controller
      operationId: get
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
          format: int32
      responses:
        "400":
          description: Bad Request
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "500":
          description: Internal Server Error
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "200":
          description: OK
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/SakeResponse'
  /sakes/list:
    get:
      tags:
      - sake-controller
      operationId: list
      responses:
        "400":
          description: Bad Request
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "500":
          description: Internal Server Error
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "200":
          description: OK
          content:
            '*/*':
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/SakeResponse'
  /sakes:
    get:
      tags:
      - sake-controller
      operationId: page
      parameters:
      - name: pageable
        in: query
        required: true
        schema:
          $ref: '#/components/schemas/Pageable'
      responses:
        "400":
          description: Bad Request
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "500":
          description: Internal Server Error
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "200":
          description: OK
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/PageSakeResponse'
    post:
      tags:
      - sake-controller
      operationId: create
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SakeCreateRequest'
        required: true
      responses:
        "400":
          description: Bad Request
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "500":
          description: Internal Server Error
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "200":
          description: OK
          content:
            '*/*':
              schema:
                $ref: '#/components/schemas/SakeResponse'
components:
  schemas:
    ErrorResponse:
      type: object
      properties:
        message:
          type: string
    SakeResponse:
      type: object
      properties:
        id:
          type: integer
          format: int32
        name:
          type: string
        brewingName:
          type: string
    SakeCreateRequest:
      required:
      - brewingName
      - name
      type: object
      properties:
        name:
          maxLength: 20
          minLength: 1
          type: string
        brewingName:
          type: string
    Pageable:
      type: object
      properties:
        offset:
          type: integer
          format: int64
        sort:
          $ref: '#/components/schemas/Sort'
        pageNumber:
          type: integer
          format: int32
        pageSize:
          type: integer
          format: int32
        paged:
          type: boolean
        unpaged:
          type: boolean
    Sort:
      type: object
      properties:
        sorted:
          type: boolean
        unsorted:
          type: boolean
        empty:
          type: boolean
    PageSakeResponse:
      type: object
      properties:
        totalPages:
          type: integer
          format: int32
        totalElements:
          type: integer
          format: int64
        size:
          type: integer
          format: int32
        content:
          type: array
          items:
            $ref: '#/components/schemas/SakeResponse'
        numberOfElements:
          type: integer
          format: int32
        sort:
          $ref: '#/components/schemas/Sort'
        first:
          type: boolean
        pageable:
          $ref: '#/components/schemas/Pageable'
        last:
          type: boolean
        number:
          type: integer
          format: int32
        empty:
          type: boolean

GET http://localhost:8080/swagger-ui.html

これだけで、APIドキュメントが生成されるなんて感動しませんか? Controllerクラスや@RestControllerAdviceが付与されているクラスの@ExceptionHandlerを読み取ってレスポンスに適用してくれます。

@NotNull@NotBlank@Size@Min@Max のような、JSR-303 Bean Validationのアノテーションについても適用してくれます。

カスタマイズ

application.properties に書けば、デフォルト設定を変更できます。設定値一覧は、springdoc-propertiesを参考にしてください。

設定値の抜粋です。値はデフォルト値です。

- JSONのOpenAPIドキュメントのパス
  - springdoc.api-docs.path=/v3/api-docs
- OpenAPIエンドポイントの有効・無効
  - springdoc.api-docs.enabled=true
- `@ControllerAdvice` が付与されたクラスに書かれたレスポンスを全レスポンスに適用するかどうか
  - springdoc.override-with-generic-response=true
- Swagger UIが適用されたHTMLドキュメント表示するパス
  - springdoc.swagger-ui.path=/swagger-ui.html

まとめ

特に設定なしにAPIドキュメントを生成してくるのはとてもよいですね。 気になったところは、タグがController名のケバブケースになってしまうところですが、 @Tag(name="sake") をControllerに付与してあげれば変更は可能でした。 springdoc-openapiでフロント、モバイルチームとのコミュニケーションが円滑に進みそうです!

Spring Cloud OpenFeignで遊ぶ

先日のSpring Oneで、Unleash the True Power of Spring Cloud: Learn How to Customize Spring Cloudで紹介されていたSpring Cloud OpenFeignで遊んでみました。 素敵すぎて、もっと早く知りたかった...と感じました。

Spring Cloud OpenFeignとは

2018年6月にリリースされたSpring BootのためのOpenFeignインテグレーションです。 Spring Cloud OpenFeignを使うと宣言的にRESTクライアントを作成することができます。

サンプルコード

サンプルで使用しているライブラリ等は次の通りです。

Java 11
Maven
Spring Boot 2.3.3.RELEASE
Spring MVC
Spring Cloud OpenFeign 2.2.5.RELEASE

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  
  ...

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
            <version>2.2.5.RELEASE</version>
        </dependency>
    </dependencies>
  
  ...

</project>

spring-cloud-starter-openfeignを依存関係に追加してください。

FeignConfig.java

@Configuration(proxyBeanMethods = false)
@EnableFeignClients(basePackages = {"com.b1a9idps.consumer.client"})
public class FeignConfig {}

@EnableFeignClients を付与することでデフォルト設定を使うことができるようになります。

SakeClient.java

@FeignClient(value = "sakes", url = "${producer.url}")
public interface SakeClient {
    @GetMapping(value = "/sakes")
    List<SakeResponse> list();

    @GetMapping(value = "/sakes/{id}")
    SakeResponse get(@PathVariable Integer id);

    @PostMapping(value = "/sakes")
    SakeResponse create(SakeCreateRequest request);
}

クライアントインターフェースを用意します。コントローラを実装する感覚でインターフェースを書くことができます。 インターフェースに @FeignClient を付与して、コンポーネントスキャンの対象にします。

@FeignClientのプロパティ
- url:リクエスト先のURL。
- name(value):任意のクライアント名。RibbonロードバランサーやSpring Cloud LoadBalancerを利用する際に使われる。

SakeController.java

@RestController
@RequestMapping("/sakes")
public class SakeController {

    final SakeClient sakeClient;

    public SakeController(SakeClient sakeClient) {
        this.sakeClient = sakeClient;
    }

    @GetMapping
    public List<SakeResponse> list() {
        return sakeClient.list();
    }

    @GetMapping("{id}")
    public SakeResponse get(@PathVariable Integer id) {
        return sakeClient.get(id);
    }

    @PostMapping
    public SakeResponse create() {
        return sakeClient.create(new SakeCreateRequest("寫樂", "宮泉銘醸"));
    }
}

クライアントをDIしてメソッドコールするだけです。

実行結果

アプリケーションを起動してcurlAPIを叩いた結果です。

$ curl -v http://localhost:8080/api/sakes | jq .
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /api/sakes HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
> 
< HTTP/1.1 200 
< Content-Type: application/json
< Transfer-Encoding: chunked
< Date: Fri, 04 Sep 2020 04:12:38 GMT
< 
{ [172 bytes data]
100   166    0   166    0     0  14628      0 --:--:-- --:--:-- --:--:-- 15090
* Connection #0 to host localhost left intact
[
  {
    "id": 1,
    "name": "若波",
    "brewingName": "若波酒造"
  },
  {
    "id": 2,
    "name": "新政",
    "brewingName": "新政酒造"
  },
  {
    "id": 3,
    "name": "十四代",
    "brewingName": "高木酒造"
  }
]

補足

リクエストログをみたい

logging.level.[clientが置いてあるパッケージ]=debug
feign.client.config.default.logger-level=basic

Spring Boot 1.5.xから2.0.xに上げた Spring Test編

Spring Boot 2系にあげたときにやったことをまとめようと思います。関連記事 今回は、Testingの話です。Spring Boot 2.0.9.RELEASEに上げた話になります。2.2対応はまたいつか書きます。

依存しているライブラリのバージョン

Spring Bootのバージョンを1.5.22から2.0.9に上げると依存しているライブラリのバージョンが次のようになります。

Spring Boot 1.5.22.RELEASESpring Boot 2.0.9.RELEASE

Spring Boot 2.xに上げると、Mockito 2.xになります。 JUnit 5も対応しましたが、対応しただけでデフォルトはJUnit 4になっていますのでご注意ください。Spring Booot 2.2.xからデフォルトがJUnit 5になります。

パッケージ変更

- import org.mockito.runners.MockitoJUnitRunner;
+ import org.mockito.junit.MockitoJUnitRunner;
- import org.mockito.Matchers.*
+ import org.mockito.ArgumentMatchers.*
- org.mockito.Matchers.anyListOf.(Hoge.class);
+ org.mockito.ArgumentMatchers.anyList();

など

不要なスタブの削除

利用していないスタブがある場合は、UnnecessaryStubbingExceptionを投げるようになりました。 行番号を教えてくれるのでひたすら削除します。

例えば、このようなテストを書いていたとします。

String result = translator.translate("one");

// test
when(translator.translate("one")).thenReturn("jeden");
when(translator.translate("two")).thenReturn("dwa");

when(translator.translate("two")).thenReturn("dwa");のスタブは作ったけど、実際呼ばれることがないのでUnnecessaryStubbingExceptionが投げられます。 歴史のあるコードだと消し忘れはいっぱいあると思うので地味に辛いですね...

まとめ

Spring Boot 2.0.xへのテストのマイグレーションは特に難しくはなかったですけど、単なる書き換えが多くて眠かったです。2.1.x, 2.2.xの変更の方が大変です。 ちなみにJUnit 5からHamcrestがデフォルトで入らなくなりましたのでこれを機にAssertJに移行してみてはいかかでしょうか?

Spring Boot 1.5.xから2.0.xに上げた Spring Data編

Spring Boot 2系にあげたときにやったことをまとめようと思います。関連記事 今回は、Spring Data JPAの話です。Spring Boot 2.0.9.RELEASEに上げた話になります。2.2対応はまたいつか書きます。

依存しているライブラリのバージョン

Spring Bootのバージョンを1.5.22から2.0.9に上げると依存しているライブラリのバージョンが次のようになります。

Spring Boot 1.5.22.RELEASESpring Boot 2.0.9.RELEASE

DataSource

コネクションプールの変更

デフォルトのコネクションプールがTomcatからHikariCPになった。

カスタマイズしたDataSourceを利用する際はプロパティに気をつける

コネクションプールにHikariCPを利用してDataSourceをカスタマイズする際、urlでなく、jdbc-urlにする。参考 application.properties

app.datasource.jdbc-url=jdbc:mysql://localhost/test
app.datasource.username=dbuser
app.datasource.password=dbpass
app.datasource.maximum-pool-size=30
@Bean
@ConfigurationProperties("app.datasource")
public HikariDataSource dataSource() {
    return DataSourceBuilder.create().type(HikariDataSource.class).build();
}

X-RaySQLトレースする際はHikariCPは使えない

X-Rayを使ってSQLクエリをトレースする際、org.apache.tomcat.jdbc.pool.JdbcInterceptorを実装したインターセプタを利用するため、HikariCPを依存関係から除外して、tomcat-jdbcを追加しなければなりません。参考

build.gradle

dependencies {
  implementation("org.springframework.boot:spring-boot-starter-data-jpa") {
          exclude group: 'com.zaxxer'
  }
  implementation "org.apache.tomcat:tomcat-jdbc"
}

pom.xml

<dependencies>
  <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-jdbc</artifactId>
      <exclusions>
          <exclusion>
              <groupId>com.zaxxer</groupId>
              <artifactId>HikariCP</artifactId>
          </exclusion>
      </exclusions>
  </dependency>
  <dependency>
      <groupId>org.apache.tomcat</groupId>
      <artifactId>tomcat-jdbc</artifactId>
  </dependency>
</dependencies>

Spring Data

PageRequest.java, Sort.javaのコンストラクタが非推奨になった

コンストラクタが非推奨になって、staticコンストラクタを使用するようになった。

- new PageRequest(1, 10);
+ PageRequest.of(1, 10);
- new Sort(orders);
+ Sort.by(orders);

Spring Data JPA

JpaRepository.javaが結構変わった

メソッドの戻り値にOptionalを使えるようになった

- User findByName(String name);
+ Optional<User> findByName(String name);

メソッド名が変わった

- <S extends T> List<S> save(Iterable<S> entities);
+ <S extends T> List<S> saveAll(Iterable<S> entities);

などなど

Spring Boot 1.5.xから2.0.xに上げた Spring Web編

Spring Boot 2系にあげたときにやったことをまとめようと思います。(関連記事) 今回は、Spring Webの話です。Spring Boot 2.0.9.RELEASEに上げた話になります。2.2対応はまたいつか書きます。

依存しているライブラリのバージョン

Spring Bootのバージョンを1.5.22から2.0.9に上げると依存しているライブラリのバージョンが次のようになります。

Spring Boot 1.5.22.RELEASESpring Boot 2.0.9.RELEASE

Hibernate Validator

@NotBlank, @NotEmpty, @Emailが非推奨になった

Hibernate Validator 6.0.0.Finalからorg.hibernate.validator.constraints.NotBlank, org.hibernate.validator.constraints.NotEmpty, org.hibernate.validator.constraints.Emailが非推奨になりました。参考 Spring Boot 2系からはjavax-validationが提供するアノテーションを利用します。

- org.hibernate.validator.constraints.NotBlank
+ javax.validation.constraints.NotBlank

Jackson /JSON Support

Spring Boot 2からspring-boot-starter-jsonが作られました。jackson-databind, jackson-datatype-jdk8, jackson-datatype-jsr310, jackson-module-parameter-namesが含まれていますので、これらをspring-boot-starter-jsonに置き換えることができます。参考

Spring Web

WebMvcConfigurerAdapter.classが非推奨

Spring Boot 2(Spring 5)からWebMvcConfigurerAdapter.classが非推奨となり、先のクラスを拡張するのではなく、WebMvcConfigurer.classを実装するようになりました。 Spring 5からJava8+になってdefaultメソッドが使えるようになり、WebMvcConfigurerAdapter.classを使わずともインターフェースに実装できるようになったからです。Javadoc

パッケージ変更

Spring Boot 2(Spring 5)からWebと言ってもServletとReactiveに分かれたためです。

- org.springframework.boot.autoconfigure.web.ErrorAttributes
+ org.springframework.boot.web.servlet.error.ErrorAttributes
- org.springframework.boot.autoconfigure.web.ErrorController
+ org.springframework.boot.web.servlet.error.ErrorController

プロパティ変更

Boot 2.0.9.RELEASEの設定値一覧を見れば書いてあります。

- server.context-path
+ server.servlet.context-path

おまけ

org.springframework.web.util.HtmlUtils.htmlEscape(String input, String encoding)の実装が変わった

見出しの通りです。4.3.x.RELEASEから5.0.x.RELEASEに変わりました。4.3.xでは、input == nullのときnullが返ってきていましたが、5.0.xからはExceptionを投げるようになりました。

このように書いていたので本番で障害が起きてしまいました...みなさんお気をつけて

// inputはnullのとき
String result = HtmlUtils.htmlEscape(input, encoding);
if (result == null) {
  何か処理
}