JavaのSpringdocはAPIのリクエストパラメータをどのように認識しているのか?

こんにちは、エキサイト株式会社の平石です。

エキサイトホールディングス Advent Calendar 2024の10日目を担当いたします。

Springdocライブラリを利用することで、簡単にOpenAPI仕様に則ったAPIドキュメントを自動生成することができます。

しかし、このライブラリを使って開発を行う中で、APIのパラメータをどのように認識しているのか少し気になったので、その調査をしてみました。
今回は、その内容をブログとして残したいと思います。

なお、今回紹介するのはspringdoc-openapi v2.7.0での動作であり、今後変更される可能性はありますのでご注意ください。

共通で使う例

今回は、以下のようなControllerを例に挙げて説明していきます。
なお、@ParameterObjectというアノテーションを付与していない場合には動作が異なります。
今回は、@ParameterObjectが付与されている場合の動きを紹介します。

@RestController
@RequestMapping("hello-world")
public class SampleController {
    @GetMapping
    public String getHelloWorld(
            @ParameterObject SampleRequestDto requestDto
    ) {
        return "Hello java";
    }
}

SampleRequestDtoはリクエストのパラメータをまとめたDTOです。
このDTOにはString型のstringFieldInteger型のintegerFieldが含まれているとします。

通常のクラスを使う場合

SampleRequestDtoを通常のクラスで表現してみます。
この場合、以下のようになるでしょう。

public class SampleRequestDto {

    private final String stringField;

    private final Integer integerField;
    
    public SampleRequestDto2(String stringField, Integer integerField) {
        this.stringField = stringField;
        this.integerField = integerField;
    }
}

この状態でSwagger UI*1を確認してみると以下のようになっています。

Parametersの欄がNo parametersとなっています。
どうやらフィールドとして記述しただけでは、パラメータとして認識されないようです。

Getterを作成してみる

現状では、このDTOを受け取ったControllerはDTO内のフィールドを利用できません。
Getterを追加してみましょう。

    public String getStringField() {
        return stringField;
    }

    public Integer getIntegerField() {
        return integerField;
    }

この状態で、Swagger UIを確認してみます。

今度は、2つのフィールドがパラメータとして認識されています。
どうやら、SpringdocのライブラリはGetterがあるフィールドをパラメータとして認識するようです。

Getterの名前を変えてみる

Getterの名前は何でも良いのでしょうか。
コードを以下のように変更して確認してみます。

    public String getFieldOfString() {
        return stringField;
    }

    public Integer integerField() {
        return integerField;
    }

No parametersに戻ってしまっています。
どうやら、get{フィールド名}という名前でないと認識してくれないようです。

これは、内部でjava.beans.IntrospectorクラスのgetBeanInfoメソッドが使われているからだと考えられます。

https://github.com/springdoc/springdoc-openapi/blob/main/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/extractor/MethodParameterPojoExtractor.java#L290

このソースコードの以下の部分で、readMethodnullでないものをパラメータとして残すような実装をしています。

   .map(PropertyDescriptor::getReadMethod)
    .filter(Objects::nonNull)

getBeanInfoget{フィールド名}のメソッドをreadMethodとして扱うため、この名前のメソッドが存在している時のみ、パラメータとして認識したようです。

Getterだけを作成してみる

Getterだけを作成した場合はどうなるのでしょうか。

    public String getStringField2() {
        return stringField + "2";
    }

内部では、「フィールドの一覧を取得」→「ゲッターがあるかどうかを判定」の順で処理が行われるため、そもそもフィールドが存在していなければパラメータとして認識されることはありません。

staticフィールドを作成してみる

staticを付けたフィールドもパラメータとして認識されるのでしょうか。

    private static final Integer constInteger = 1;

    //(中略)

    public Integer getConstInteger() {
        return constInteger;
    }

どうやら、フィールドとして存在し、getterが存在すればstaticフィールドもパラメータとして認識するようです。
APIのリクエストにconstIntegerを指定してもDTO内の値を書き換えることはできないので、無意味なものではありますが.....。

慣習的に定数は大文字で記述しますので、問題にはならないかと思いますが、staticがついているかどうかは無関係なようです。

@JsonIgnoreを付与してみる

@JsonIgnoreJSONシリアライズして欲しくないフィールドに付与することで、シリアライズ時に無視してくれるようになるアノテーションです。

integerFieldに付与してみます。

    private final String stringField;

    @JsonIgnore
    private final Integer integerField;

@JsonIgnoreを付与したintegerFieldはパラメータとして認識されておらず、@JsonIgnoreは有効なようです。

ここまでのまとめと補足(@ParameterObjectを付与している場合)

  • Springdocは、フィールドとして存在し、かつget{フィールド名}という名前のメソッドがあるものをパラメータとして認識する
    • 補足:戻り値の型とメソッド名が一致していれば「Getter」が何を返しているかまでは考慮しない
  • フィールドとして存在すれば、実際に外部からのリクエストをもとに値をセットしているかどうかは考慮しない
    • 補足:コンストラクタは無関係な模様
  • staticフィールドもGetterがあればパラメータとみなす
  • @JsonIgnoreで特定のフィールドをパラメータとして認識させないようにできる
    • 補足:リクエストパラメータをDTO内で加工したときに、加工後の値を変数に格納したいときに利用できそう

@ParameterObjectを文字通り解釈すれば「パラーメータを格納したオブジェクト」なので、フィールドとして存在しているものをパラメータとしてみなすのは、ある意味自然な動作なのかもしれません。

Javaのレコードを使う場合

Javaのレコードは、通常のクラスで表現するより少ない記述で「データのまとまり」を表現できる特殊なクラスです。

例えば、今回の例のリクエストモデルは以下のように表現できます。

public record SampleRequestDto(
        String stringField,
        Integer integerField
) {
}

このように記述することで、SampleRequestDtoインスタンスを作成するためのコンストラクタや、stringFieldintegerFieldにアクセスするためのメソッドが自動で生成されます。

この状態で、Swagger UIを確認してみましょう。

特に、問題なくパラメータを認識してくれているようです。

ここで、疑問に思う点がありました。 先ほどの通常のクラスの場合で見た時には、フィールドが存在しget{フィールド名}という名前の「Getter」が存在するものをパラメータとしてみなすのでした。

しかし、レコードで生成される「Getter」のメソッド名は{フィールド名}で、パラメータとしてみなされる条件にマッチしません。

これは、どういうことなのでしょうか。

わかってしまえば単純なのですが、Springdocライブラリの内部では@ParameterObjectが付与されたクラスがレコードであるかどうかにより内部で分岐が行われています。

https://github.com/springdoc/springdoc-openapi/blob/main/springdoc-openapi-starter-common/src/main/java/org/springdoc/core/extractor/MethodParameterPojoExtractor.java#L280

           Parameter parameter = field.getAnnotation(Parameter.class);
            boolean isNotRequired = parameter == null || !parameter.required();
            // レコードかどうかによって分岐
            if (paramClass.getSuperclass() != null && paramClass.isRecord()) {
                return Stream.of(paramClass.getRecordComponents())
                        .filter(d -> d.getName().equals(field.getName()))
                        .map(RecordComponent::getAccessor)
                        .map(method -> new MethodParameter(method, -1))
                        .map(methodParameter -> DelegatingMethodParameter.changeContainingClass(methodParameter, paramClass))
                        .map(param -> new DelegatingMethodParameter(param, fieldNamePrefix + field.getName(), fieldAnnotations, true, isNotRequired));

            }

getRecordComponents()でレコード内のコンポーネントの配列を取得しており、getReadMethodメソッドは使用していません。
そのため、get{フィールド名}でないGetterでなくてもパラメータとして認識されたのです。

終わりに

今回は、springdocライブラリによるリクエストパラメータの認識方法が少し気になったため、調査した結果をブログとして記述しました。

レコードやLombok@Valueのようなアノテーションを利用していれば何も意識する必要はないので、知らなくても問題ない内容ではあります。
しかし、リクエストモデル内部で何か処理をしたいと思ったときに役立つかもしれません。

では、また次回。

参考文献

*1:APIドキュメントをYAML形式やJSON形式のドキュメントよりも、見やすい形式で表示してくれる