こんにちは、エキサイト株式会社の平石です。
エキサイトホールディングス 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型のstringFieldとInteger型の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メソッドが使われているからだと考えられます。
このソースコードの以下の部分で、readMethodがnullでないものをパラメータとして残すような実装をしています。
.map(PropertyDescriptor::getReadMethod)
.filter(Objects::nonNull)
getBeanInfoはget{フィールド名}のメソッドを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を付与してみる
@JsonIgnoreはJSONにシリアライズして欲しくないフィールドに付与することで、シリアライズ時に無視してくれるようになるアノテーションです。
integerFieldに付与してみます。
private final String stringField; @JsonIgnore private final Integer integerField;

@JsonIgnoreを付与したintegerFieldはパラメータとして認識されておらず、@JsonIgnoreは有効なようです。
ここまでのまとめと補足(@ParameterObjectを付与している場合)
- Springdocは、フィールドとして存在し、かつget{フィールド名}という名前のメソッドがあるものをパラメータとして認識する
- 補足:戻り値の型とメソッド名が一致していれば「Getter」が何を返しているかまでは考慮しない
- フィールドとして存在すれば、実際に外部からのリクエストをもとに値をセットしているかどうかは考慮しない
- 補足:コンストラクタは無関係な模様
- staticフィールドもGetterがあればパラメータとみなす
@JsonIgnoreで特定のフィールドをパラメータとして認識させないようにできる
@ParameterObjectを文字通り解釈すれば「パラーメータを格納したオブジェクト」なので、フィールドとして存在しているものをパラメータとしてみなすのは、ある意味自然な動作なのかもしれません。
Javaのレコードを使う場合
Javaのレコードは、通常のクラスで表現するより少ない記述で「データのまとまり」を表現できる特殊なクラスです。
例えば、今回の例のリクエストモデルは以下のように表現できます。
public record SampleRequestDto(
String stringField,
Integer integerField
) {
}
このように記述することで、SampleRequestDtoのインスタンスを作成するためのコンストラクタや、stringFieldとintegerFieldにアクセスするためのメソッドが自動で生成されます。
この状態で、Swagger UIを確認してみましょう。

特に、問題なくパラメータを認識してくれているようです。
ここで、疑問に思う点がありました。
先ほどの通常のクラスの場合で見た時には、フィールドが存在しget{フィールド名}という名前の「Getter」が存在するものをパラメータとしてみなすのでした。
しかし、レコードで生成される「Getter」のメソッド名は{フィールド名}で、パラメータとしてみなされる条件にマッチしません。
これは、どういうことなのでしょうか。
わかってしまえば単純なのですが、Springdocライブラリの内部では@ParameterObjectが付与されたクラスがレコードであるかどうかにより内部で分岐が行われています。
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のようなアノテーションを利用していれば何も意識する必要はないので、知らなくても問題ない内容ではあります。
しかし、リクエストモデル内部で何か処理をしたいと思ったときに役立つかもしれません。
では、また次回。