【Spring Initializr 読解】initializr-web: ProjectMetadataController.java 編

[【Spring Initializr 読解】initializr 特有の資材](https://qiita.com/nimzo6689/items/b33ca63948bf445a7548) の続編です。

この記事では、 initializr のサブモジュールである、 `initializr-web` についてコードを追っていきます。

https://github.com/spring-io/initializr/tree/ad03a71a30b89457fb97dff11dcb103565735fba/initializr-web

# 読み始める起点を探す

コードを追っていく上でいい感じのエントリーポイント（処理の起点）を探します。
今回は、 http://localhost:8080/ で実行されるメソッドの起点をまず探します。

`spring-web` の `controller` パッケージの `ProjectMetadataController` クラスにて、 `@GetMapping` でルートパスが指定されているメソッドがあるので、そこを目当てに見てみます。すると、４つほど候補がありました。

```java:ProjectMetadataController.java
	@GetMapping(path = { "/", "/metadata/client" }, produces = "application/hal+json")
	public ResponseEntity<String> serviceCapabilitiesHal() {
		return serviceCapabilitiesFor(InitializrMetadataVersion.V2_1, HAL_JSON_CONTENT_TYPE);
	}

	@GetMapping(path = { "/", "/metadata/client" }, produces = { "application/vnd.initializr.v2.2+json" })
	public ResponseEntity<String> serviceCapabilitiesV22() {
		return serviceCapabilitiesFor(InitializrMetadataVersion.V2_2);
	}

	@GetMapping(path = { "/", "/metadata/client" },
			produces = { "application/vnd.initializr.v2.1+json", "application/json" })
	public ResponseEntity<String> serviceCapabilitiesV21() {
		return serviceCapabilitiesFor(InitializrMetadataVersion.V2_1);
	}

	@GetMapping(path = { "/", "/metadata/client" }, produces = "application/vnd.initializr.v2+json")
	public ResponseEntity<String> serviceCapabilitiesV2() {
		return serviceCapabilitiesFor(InitializrMetadataVersion.V2);
	}
```

`@GetMapping` で複数パス指定できるのは知らなかったですね。
あと、produces（クライアントから送信される HTTP ヘッダーの `Accept`）でメソッドを分割しているコードを始めてみました。
Spring はパスや HTTP メソッドのほかに、Header や パラメータ、Content-Type 別にハンドラとなるメソッドを分けられるそうです。
ただ、Accept 未指定ですと、どのメソッドが呼ばれるか自信がないので、ブレークポイントを貼り、デバッグ実行して確認してみます。[^1]

[^1]: VSCode では、ブレークポイントを貼らなくても `launch.json` の `stopOnEntry: true` でアプリ起動時に最初の行で自動的にブレークしてくる機能がありますが、 Spring も `@GetMapping` の最初の行で自動的にブレークしてくると楽ですね。（願望）

では、 `ServiceApplication` を Debug で実行します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114728/11d1aec1-6297-3a3f-d528-993056db8ce1.png)

起動できたら、ブラウザで http://localhost:8080/ にアクセスします。
すると、 `initializr-web` の `ProjectMetadataController.serviceCapabilitiesHal` にブレークされました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114728/e0bc9344-7aff-9a1e-65d4-d688b8a5f91b.png)

ということで、Accept が未指定の場合、 `Accept: application/hal+json` のメソッドが選択されるようです。

この辺りの挙動を調べてみると、Accept が未指定（media type が `*/*` とみなされる）の場合、より具体的（単純に文字列が長いもの）に media type が指定されているメソッドが優先的に適用されるそうです。ただ、 Spring HATEOAS を使っている場合、HATEOAS でサポートされている media type が優先されるため、 `hal+` で指定されているメソッドが選択されたようです。

この当たりはドキュメントには特に記載が見当たらず、ソースコードを追ってみました。

[HypermediaMappingInformationComparator.java](https://github.com/spring-projects/spring-hateoas/blob/2.3.3/src/main/java/org/springframework/hateoas/config/HypermediaMappingInformationComparator.java#L53) にそういう処理が入っています。

[EnableHypermediaSupport.java](https://github.com/spring-projects/spring-hateoas/blob/2.3.3/src/main/java/org/springframework/hateoas/config/EnableHypermediaSupport.java) は HATEOAS でサポートしている media type です。

# クライアントに Response を返すまでの処理の流れを追う

上記で見つけた起点から処理を追っていくと、以下のメソッドに対して、`version="application/vnd.initializr.v2.1+json", contentType="application/hal+json"` で実行していることがわかります。

```java:ProjectMetadataController.java
private ResponseEntity<String> serviceCapabilitiesFor(InitializrMetadataVersion version,
MediaType contentType) {
	String appUrl = generateAppUrl();
	InitializrMetadata metadata = this.metadataProvider.get();
	String content = getJsonMapper(version).write(metadata, appUrl);
	return ResponseEntity.ok()
		.contentType(contentType)
		.eTag(createUniqueId(content))
		.varyBy("Accept")
		.cacheControl(determineCacheControlFor(metadata))
		.body(content);
}
```

このメソッドの実装を要約してみると以下のようになります。

**全体的な処理の説明**

Spring Initializr が提供する機能(dependencies、Java versions、Spring Boot versions など)のメタデータを JSON 形式で返す。

**コードの流れ**

1. `String appUrl = generateAppUrl();`

アプリケーションのベース URL を生成する。ローカルで実行した場合は、`http://localhost:8080` となる。通常は、Spring Initializr がデプロイされている環境のベース URL になるはず。

2. `InitializrMetadata metadata = this.metadataProvider.get();`

メタデータプロバイダーから現在の設定情報を取得する。依存関係、バージョン、パッケージング形式など。

3. `String content = getJsonMapper(version).write(metadata, appUrl);`

指定されたバージョンの JSON マッパーを使用して、メタデータを JSON 文字列に変換します。

4. `ResponseEntity.ok()` 以降の部分では、HTTP レスポンスを構築しています

- `.contentType(contentType)`: レスポンスの Content-Type を設定
- `.eTag(createUniqueId(content))`: コンテンツの一意性を示す ETag を設定（キャッシュの最適化に使用）
- `.varyBy("Accept")`: クライアントの Accept ヘッダーに応じてレスポンスが変わることを示す
- `.cacheControl(determineCacheControlFor(metadata))`: キャッシュ制御の設定
- `.body(content)`: レスポンスボディに JSON 文字列を設定

以下、 `http://localhost:8080/` で取得できた JSON を載せておきます。

<details><summary>Response（JSON）</summary>

```javascript
{
  "_links": {
    "maven-project": {
      "href": "http://localhost:8080/starter.zip?type=maven-project{&dependencies,packaging,javaVersion,language,bootVersion,groupId,artifactId,version,name,description,packageName}",
      "templated": true
    },
    "gradle-project": {
      "href": "http://localhost:8080/starter.zip?type=gradle-project{&dependencies,packaging,javaVersion,language,bootVersion,groupId,artifactId,version,name,description,packageName}",
      "templated": true
    },
    "dependencies": {
      "href": "http://localhost:8080/dependencies{?bootVersion}",
      "templated": true
    }
  },
  "dependencies": {
    "type": "hierarchical-multi-select",
    "values": [
      {
        "name": "Web",
        "values": [
          {
            "id": "web",
            "name": "Web",
            "description": "Servlet web application with Spring MVC and Tomcat"
          }
        ]
      }
    ]
  },
  "type": {
    "type": "action",
    "default": "maven-project",
    "values": [
      {
        "id": "maven-project",
        "name": "Maven Project",
        "description": "Generate a Maven based project archive",
        "action": "/starter.zip",
        "tags": {
          "build": "maven",
          "format": "project"
        }
      },
      {
        "id": "gradle-project",
        "name": "Gradle Project",
        "description": "Generate a Gradle based project archive",
        "action": "/starter.zip",
        "tags": {
          "build": "gradle",
          "format": "project"
        }
      }
    ]
  },
  "packaging": {
    "type": "single-select",
    "default": "jar",
    "values": [
      {
        "id": "jar",
        "name": "Jar"
      },
      {
        "id": "war",
        "name": "War"
      }
    ]
  },
  "javaVersion": {
    "type": "single-select",
    "default": "17",
    "values": [
      {
        "id": "17",
        "name": "17"
      },
      {
        "id": "11",
        "name": "11"
      },
      {
        "id": "1.8",
        "name": "8"
      }
    ]
  },
  "language": {
    "type": "single-select",
    "default": "java",
    "values": [
      {
        "id": "java",
        "name": "Java"
      },
      {
        "id": "kotlin",
        "name": "Kotlin"
      },
      {
        "id": "groovy",
        "name": "Groovy"
      }
    ]
  },
  "bootVersion": {
    "type": "single-select",
    "default": "3.4.0.RELEASE",
    "values": [
      {
        "id": "3.4.1.BUILD-SNAPSHOT",
        "name": "3.4.1 (SNAPSHOT)"
      },
      {
        "id": "3.4.0.RELEASE",
        "name": "3.4.0"
      },
      {
        "id": "3.3.7.BUILD-SNAPSHOT",
        "name": "3.3.7 (SNAPSHOT)"
      },
      {
        "id": "3.3.6.RELEASE",
        "name": "3.3.6"
      },
      {
        "id": "3.2.12.RELEASE",
        "name": "3.2.12"
      },
      {
        "id": "3.1.12.RELEASE",
        "name": "3.1.12"
      },
      {
        "id": "3.0.13.RELEASE",
        "name": "3.0.13"
      },
      {
        "id": "2.7.18.RELEASE",
        "name": "2.7.18"
      }
    ]
  },
  "groupId": {
    "type": "text",
    "default": "org.acme"
  },
  "artifactId": {
    "type": "text",
    "default": "demo"
  },
  "version": {
    "type": "text",
    "default": "0.0.1-SNAPSHOT"
  },
  "name": {
    "type": "text",
    "default": "demo"
  },
  "description": {
    "type": "text",
    "default": "Demo project for Spring Boot"
  },
  "packageName": {
    "type": "text",
    "default": "org.acme.demo"
  }
}
```

</details>