実際に何が起きているか
RESTエンドポイントにリクエストを送ったところ、HTTP 415 Unsupported Media Typeが返り、ログに以下が記録されます:
org.springframework.web.HttpMediaTypeNotSupportedException: Content type 'application/x-www-form-urlencoded' not supported
Springが伝えているのは、クライアントが送信したContent-Typeがコントローラーの期待するものと一致していないということです。エンドポイントは@RequestBodyを使用しており、JSON(または他の構造化フォーマット)を必要とします。クライアントはフォームエンコードされたデータを送信しています――プレーンなHTMLフォームをブラウザが送信するときと同じ形式です。
このエラーを繰り返し引き起こすシナリオは3つあります:フォームエンコーディングをデフォルトとするツールでテストしている場合、フロントエンド開発者が誤ったContent-Typeを使用している場合、または古いMVCコントローラーをRESTコントローラーに移行する際にリクエスト形式を更新し忘れた場合です。
根本原因の再現と確認
2つのことを確認して、不一致を特定します。
1. クライアントが送信しているもの
このcurlコマンドは一見無害に見えますが、デフォルトでフォームエンコードされたデータを送信します:
curl -X POST http://localhost:8080/api/users \
-d "name=Alice&email=alice@example.com"
Springの@RequestBodyはそのデータをデシリアライズできません。@RequestBodyエンドポイントでフォームデータを処理するコンバーターは登録されていません――それが415の原因です。
PostmanやInsomniaを使用していますか?Bodyタブを確認してください。form-dataまたはx-www-form-urlencodedに設定されていれば、それが問題です。
2. コントローラーが期待しているもの
@RestController
@RequestMapping("/api/users")
public class UserController {
@PostMapping
public ResponseEntity<User> createUser(@RequestBody UserRequest request) {
// @RequestBodyはContent-Type: application/jsonを必要とする
return ResponseEntity.ok(userService.create(request));
}
}
@RequestBodyはSpringに対して、HttpMessageConverter経由でHTTPボディをデシリアライズするよう指示します。Spring BootはJacksonをJSONに対して自動的に登録します。フォームエンコードされたデータには対応するコンバーターがないため、415が発生します。
修正1 — クライアントをJSONを送信するよう変更する(最も一般的な修正)
コントローラーが正しく、適切なREST APIを構築している場合は、クライアント側を修正します。
curl
curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{"name": "Alice", "email": "alice@example.com"}'
Postman / Insomnia
- Bodyタブを開く
- rawを選択する
- ドロップダウンからJSONを選択する
- JSONペイロードを貼り付ける
JavaScript(fetch API)
fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: 'Alice', email: 'alice@example.com' })
});
Axios
// プレーンオブジェクトを渡すと、AxiosはContent-Type: application/jsonを自動的に設定する
axios.post('/api/users', { name: 'Alice', email: 'alice@example.com' });
Axiosで注意すべき落とし穴があります:データをqs.stringify()やURLSearchParamsでラップすると、こっそりフォームエンコーディングに戻ってしまいます。これはAxiosの呼び出しが一見正しく見えるときによくある混乱の原因です。
修正2 — コントローラーがフォームデータを受け入れるよう変更する
一部の連携では、フォームエンコードされたPOSTリクエストを設計上送信します――決済ゲートウェイ、OAuthコールバック、レガシーWebhookなどです。そのような場合は、@RequestBodyを@RequestParamに置き換えます:
@PostMapping(consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public ResponseEntity<User> createUserFromForm(
@RequestParam String name,
@RequestParam String email) {
return ResponseEntity.ok(userService.create(name, email));
}
フィールドが十数個あるDTOの場合は、代わりに@ModelAttributeを使用します:
@PostMapping(consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public ResponseEntity<User> createUserFromForm(@ModelAttribute UserRequest request) {
return ResponseEntity.ok(userService.create(request));
}
consumes属性には二重の役割があります:期待するメディアタイプを強制し、APIを自己文書化します。誤ったフォーマットを送信した呼び出し元には、Springがきれいな415を返します。
修正3 — JSONとフォームエンコーディングの両方をサポートする
まれなケースですが、両方のフォーマットを受け入れるハイブリッドエンドポイントを構築する必要がある場合があります――たとえば、モバイルアプリ(JSON)と旧来のサーバーサイドフォーム(フォームエンコード)の両方から使用されるログインエンドポイントなど:
@PostMapping(
value = "/users",
consumes = { MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_FORM_URLENCODED_VALUE }
)
public ResponseEntity<User> createUser(
@RequestBody(required = false) UserRequest jsonRequest,
@ModelAttribute(binding = false) UserRequest formRequest,
@RequestHeader("Content-Type") String contentType) {
UserRequest request = contentType.contains("application/json") ? jsonRequest : formRequest;
return ResponseEntity.ok(userService.create(request));
}
これは動作しますが、すぐに煩雑になります。RESTクライアント用とフォームベースクライアント用に別々のエンドポイントを2つ用意する方が、通常はよりすっきりしてテストしやすくなります。
修正4 — Spring Securityフィルターを確認する(予期しない415)
クライアントが確実にJSONを送信しているにもかかわらず415が表示されることがあります。Spring Securityがよくある原因です。そのCSRFフィルターが、コントローラーに到達する前にリクエストボディを消費してしまうことがあります。特にSpring Security 5.x/6.xで発生します。チェーンのより上位にあるフィルターがヘッダーを書き換えている場合もあります。
ステートレスなREST APIの場合は、CSRFを無効にします:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // ステートレスREST — CSRFは不要
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
return http.build();
}
}
コントローラーに何が届いているか確認できませんか?Springが処理する前の生のContent-Typeを確認するためのロギングフィルターを追加します:
@Component
public class LoggingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
log.info("Content-Type: {}", request.getContentType());
filterChain.doFilter(request, response);
}
}
確認
以下を実行して、2xxが返ることを確認します:
curl -v -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{"name": "Alice", "email": "alice@example.com"}'
-vフラグはレスポンス全体を表示します。HTTP/1.1 200 OKまたは201 Createdを確認してください。415は発生しなくなります。
インテグレーションテストでは、正常系と拒否の両方をカバーします:
@Test
void createUser_withJsonBody_returns201() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\": \"Alice\", \"email\": \"alice@example.com\"}"))
.andExpect(status().isCreated());
}
@Test
void createUser_withFormEncoding_returns415() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("name", "Alice")
.param("email", "alice@example.com"))
.andExpect(status().isUnsupportedMediaType());
}
ヒント
サービス間でContent-Typeの問題をデバッグしていますか?URLエンコーディングは予期しない場所――ミドルウェア層、プロキシ、または誤動作するライブラリ――に紛れ込む癖があります。ToolCraftのURL Encoder/Decoderを使えば、データをどこにも送信せずに、サードパーティのWebhookが実際に送信しているものをすばやくデコードできます。
このクラスのバグを完全に防ぐためのいくつかの習慣:
@PostMappingには常にconsumesを設定する。曖昧な400を、有用なメッセージを含む明確な415に変えられます。- 正しいContent-Typeが強制されることを確認するインテグレーションテストをエンドポイントごとに追加する――10分で実装でき、何時間ものデバッグを節約できます。
- Springdoc OpenAPI(Springfoxの後継)を導入する。Swagger UIで期待するメディアタイプが公開されるため、フロントエンド開発者は最初のリクエストを書く前に何を送るべきかがわかります。
問題の本質
HTTP 415はコントラクト違反です。コントローラーが受け入れるものを宣言し、クライアントはそれに合わせなければなりません。@RequestBodyはJSONを意味します。フォームデータには@RequestParamまたは@ModelAttributeが必要です。修正はほぼ常に1行ですが――どの行かを見つけるには、不一致がどこにあるかを知る必要があります。最初からconsumesを明示しておけば、エラーはすぐに明確なメッセージとともに表示されます。それがなければ、わかりにくい400や500として下流のどこかで顕在化することになります。

