アイキャッチ

実装・エラー解決

Springの@RequestMappingの使い方|URL・HTTPメソッドと405・415・406の原因

このページに書いてあること

Spring MVCの @RequestMapping は、どのURL・HTTPメソッド・データ形式を、どのControllerメソッドが受け付けるかを決めます。URLが合っていても、HTTPメソッド・Content-Type・Acceptのいずれかが条件と違うと、メソッドに入る前に405・415・406で止まります。

methodはGET・POSTなどの操作、consumesは送る本文の形式、producesは返せる形式の条件です。この記事では、同じURLにJSONとフォームを送る例で、どの条件を直せば通るかを確認します。

前提

  • サンプルはJava 17、Spring Boot 3.5.16(Spring Framework 6.2.19)のMockMvcで検証しています。Spring MVCの標準的な例外処理を前提にします。
  • 実行する場合はSpring Web入りのアプリへ配置し、packageをコンポーネントスキャン対象に合わせてください。Spring Securityや独自の例外処理が先に応答する場合、その結果を分けて調べます。

405・415・406を同じURLで再現する

本文のController 2本と、テスト14件、pom.xmlを用意しました。JDK 17とMaven 3.9系でZIPを展開し、mvn clean testを実行します。MockMvcで各Controllerを別々に登録するので、HTTPサーバーの起動は不要です。

最初にJSONを送るテストの200を確認し、次にメソッドをGETへ変えたテストの405とAllow: POSTを見ます。Content-TypeやAcceptだけを変えた場合の415・406も同じテストクラスにあります。

RequestMappingTest.javaの全文を見る
package com.example.demo;

import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.HttpMediaTypeNotAcceptableException;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.http.converter.HttpMessageNotReadableException;

class RequestMappingTest {
    private MockMvc mvc;

    @BeforeEach
    void setUp() {
        mvc = MockMvcBuilders.standaloneSetup(new SampleController()).build();
    }

    @Test
    void jsonRequestSelectsJsonHandler() throws Exception {
        mvc.perform(post("/sample").contentType(MediaType.APPLICATION_JSON)
                .accept(MediaType.APPLICATION_JSON).content("{\"message\":\"hello\"}"))
            .andExpect(status().isOk())
            .andExpect(handler().methodName("receiveJson"))
            .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
            .andExpect(content().json("{\"receivedBy\":\"json\"}"));
    }

    @Test
    void formRequestSelectsFormHandler() throws Exception {
        mvc.perform(post("/sample").contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .accept(MediaType.APPLICATION_XML).param("message", "hello"))
            .andExpect(status().isOk())
            .andExpect(handler().methodName("receiveForm"))
            .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_XML))
            .andExpect(content().string("<result>form</result>"));
    }

    @Test
    void unsupportedMethodIs405WithAllowPost() throws Exception {
        mvc.perform(get("/sample").accept(MediaType.APPLICATION_JSON))
            .andExpect(status().isMethodNotAllowed())
            .andExpect(header().string("Allow", "POST"))
            .andExpect(result -> assertInstanceOf(HttpRequestMethodNotSupportedException.class,
                    result.getResolvedException()));
    }

    @Test
    void wrongContentTypeIs415() throws Exception {
        mvc.perform(post("/sample").contentType(MediaType.APPLICATION_XML)
                .accept(MediaType.APPLICATION_JSON).content("<message>hello</message>"))
            .andExpect(status().isUnsupportedMediaType())
            .andExpect(result -> assertInstanceOf(HttpMediaTypeNotSupportedException.class,
                    result.getResolvedException()));
    }

    @Test
    void jsonRequestCannotDemandXmlResponse() throws Exception {
        mvc.perform(post("/sample").contentType(MediaType.APPLICATION_JSON)
                .accept(MediaType.APPLICATION_XML).content("{\"message\":\"hello\"}"))
            .andExpect(status().isNotAcceptable())
            .andExpect(result -> assertInstanceOf(HttpMediaTypeNotAcceptableException.class,
                    result.getResolvedException()));
    }

    @Test
    void formRequestCannotDemandJsonResponse() throws Exception {
        mvc.perform(post("/sample").contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .accept(MediaType.APPLICATION_JSON).param("message", "hello"))
            .andExpect(status().isNotAcceptable());
    }

    @Test
    void unknownPathIs404() throws Exception {
        mvc.perform(post("/missing").contentType(MediaType.APPLICATION_JSON)
                .accept(MediaType.APPLICATION_JSON).content("{\"message\":\"hello\"}"))
            .andExpect(status().isNotFound());
    }

    @Test
    void missingContentTypeIs415ForThisController() throws Exception {
        mvc.perform(post("/sample").accept(MediaType.APPLICATION_JSON)
                .content("{\"message\":\"hello\"}"))
            .andExpect(status().isUnsupportedMediaType());
    }

    @Test
    void malformedJsonIs400AfterMatchingMediaTypes() throws Exception {
        mvc.perform(post("/sample").contentType(MediaType.APPLICATION_JSON)
                .accept(MediaType.APPLICATION_JSON).content("{invalid}"))
            .andExpect(status().isBadRequest())
            .andExpect(result -> assertInstanceOf(HttpMessageNotReadableException.class,
                    result.getResolvedException()));
    }

    @Test
    void absentAcceptAllowsJson() throws Exception {
        mvc.perform(post("/sample").contentType(MediaType.APPLICATION_JSON)
                .content("{\"message\":\"hello\"}"))
            .andExpect(status().isOk())
            .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON));
    }

    @Test
    void wildcardAcceptAllowsJson() throws Exception {
        mvc.perform(post("/sample").contentType(MediaType.APPLICATION_JSON)
                .accept(MediaType.ALL).content("{\"message\":\"hello\"}"))
            .andExpect(status().isOk());
    }

    @Test
    void omittingConsumesAndProducesDoesNotDisableJsonConversion() throws Exception {
        MockMvc automatic = MockMvcBuilders.standaloneSetup(new AutomaticController()).build();
        automatic.perform(post("/automatic").contentType(MediaType.APPLICATION_JSON)
                .accept(MediaType.APPLICATION_JSON).content("{\"message\":\"hello\"}"))
            .andExpect(status().isOk())
            .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
            .andExpect(content().json("{\"message\":\"hello\"}"));
    }

    @Test
    void separateGetExampleDoesNotNeedContentType() throws Exception {
        MockMvc getOnly = MockMvcBuilders.standaloneSetup(new GetOnlyController()).build();
        getOnly.perform(get("/sample")).andExpect(status().isOk())
            .andExpect(handler().methodName("execute1"))
            .andExpect(content().string("success"));
        getOnly.perform(get("/sample2")).andExpect(status().isOk())
            .andExpect(handler().methodName("execute2"));
    }

    @Test
    void postToSeparateGetExampleIs405() throws Exception {
        MockMvc getOnly = MockMvcBuilders.standaloneSetup(new GetOnlyController()).build();
        getOnly.perform(post("/sample")).andExpect(status().isMethodNotAllowed());
    }

    @org.springframework.web.bind.annotation.RestController
    static class AutomaticController {
        @org.springframework.web.bind.annotation.PostMapping("/automatic")
        java.util.Map<String, String> echo(
                @org.springframework.web.bind.annotation.RequestBody SampleController.SampleRequest request) {
            return java.util.Map.of("message", request.message());
        }
    }
}
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">
  <modelVersion>4.0.0</modelVersion>
  <parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.5.16</version><relativePath/></parent>
  <groupId>info.syachiku.blog</groupId><artifactId>requestmapping-609</artifactId><version>1.0.0</version>
  <properties><java.version>17</java.version></properties>
  <dependencies>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
  </dependencies>
</project>

まずは結論とサンプル

package com.example.demo;

import java.util.Map;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class SampleController {

    @RequestMapping(method = RequestMethod.POST, value = "/sample",
            consumes = MediaType.APPLICATION_JSON_VALUE,
            produces = MediaType.APPLICATION_JSON_VALUE)
    public Map<String, String> receiveJson(@RequestBody SampleRequest request) {
        return Map.of("receivedBy", "json");
    }

    @RequestMapping(method = RequestMethod.POST, value = "/sample",
            consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
            produces = MediaType.APPLICATION_XML_VALUE)
    public String receiveForm(@RequestParam("message") String message) {
        return "<result>form</result>";
    }

    public record SampleRequest(String message) { }
}

どちらもPOST /sampleですが、Content-Type: application/jsonならreceiveJson()、application/x-www-form-urlencodedならreceiveForm()が選ばれます。JSONは@RequestBody、フォームの値は@RequestParamで受けています。

受信値は保存せず、到達したメソッドを固定のJSON/XMLで返します。JSON側はMapを変換し、XML側は固定文字列を返す例です。producesを付けるだけで、任意のJavaオブジェクトをXMLへ変換できるわけではありません。

リクエスト:Content-TypeとAcceptを別々に指定する

Content-Typeは「いま送る本文は何形式か」、Acceptは「どの形式の応答を受け取れるか」です。次はmacOS・Linuxなどのシェルで使えるcurl例です。レスポンス先頭のステータスと、応答のContent-Typeも確認してください。

# JSONを送り、JSONの応答を求める → 200 / {"receivedBy":"json"}
curl -i -X POST http://localhost:8080/sample \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  --data '{"message":"hello"}'

# フォームを送り、XMLの応答を求める → 200 / <result>form</result>
curl -i -X POST http://localhost:8080/sample \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -H 'Accept: application/xml' \
  --data 'message=hello'

405・415・406は、直す条件が違う

結果このサンプルで合っていない条件まず確認・修正するもの
404 Not Found/missingなど、対応するパスがないURL、コンテキストパス、Controllerの登録
405 Method Not AllowedGET /sample。このURLはPOSTだけAPI仕様とHTTPメソッド。応答のAllow: POSTも確認
415 Unsupported Media TypePOSTの本文をapplication/xmlで送った本文とContent-Type、受け側のconsumes
406 Not AcceptableJSONを送る入口にAccept: application/xmlを要求したAcceptと、その入口のproduces
# 405:/sampleはあるが、GETを受けるメソッドがない
curl -i http://localhost:8080/sample

# 415:POSTは合っているが、XMLを受ける入口がない
curl -i -X POST http://localhost:8080/sample \
  -H 'Content-Type: application/xml' \
  -H 'Accept: application/json' \
  --data '<message>hello</message>'

# 406:JSONの入口は、XMLの応答を返す条件になっていない
curl -i -X POST http://localhost:8080/sample \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/xml' \
  --data '{"message":"hello"}'

405なら正しい操作のPOSTで送る、415なら受け側が対応する本文とContent-Typeをそろえる、406なら受け取れる応答形式をAcceptで指定する、という順に切り分けます。ヘッダだけJSONへ偽装してXML本文を送っても解決しません。

これらはこのControllerを標準のSpring MVCで動かした場合の結果です。JSONの形式自体が壊れていれば、条件が合っていても読み取り時に400になります。また、415や406は変換器の不足でも発生するため、ヘッダが一致している場合は例外名とメッセージまで確認します。

@RequestMappingの使い方

@RequestMapping はControllerのクラスとメソッドに付けられます。クラスに @RequestMapping("/orders")、参照メソッドに @GetMapping("/{id}") を付けると、両方のパスを合わせて GET /orders/42 のようなリクエストに対応します。下のサンプルはクラス側の共通パスを付けず、メソッド側に /sample を指定しています。

ここからはGETだけを受ける別の例です。先ほどのPOST用Controllerと併用せず、置き換えて確認してください。/sampleと/sample2を用意します。

package com.example.demo;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class GetOnlyController {
    @RequestMapping(method = RequestMethod.GET, value = "/sample")
    @ResponseBody
    public String execute1() {
        return "success";
    }

    @RequestMapping(method = RequestMethod.GET, value = "/sample2")
    @ResponseBody
    public String execute2() {
        return "success";
    }
}

このコードでは@RequestMappingにより以下のことをしています。

  • GETメソッドを受信
  • URLは/sampleと/sample2が使用可能

このようになっています。

「method=XX」で受信可能なHTTPメソッドを指定します。このGET専用例へPOST /sampleを送ると、パスは存在していても405 Method Not Allowedです。対応するパス自体がない場合の404と区別してください。GETは@GetMapping、POSTは@PostMappingでも表せます。

「value=xxx」はリクエストのパスを指定します。この例ならGET /sampleでexecute1()が実行されます。URLだけで決まるのではなく、HTTPメソッドや後述のメディアタイプなど、指定した条件が合う必要があります。

本文を送らない普通のGETでは、送信本文の形式を表すconsumesを付ける必要はありません。返してほしい形式を指定する場合はAcceptを使います。次に、POSTで本文を送る例へ戻ってconsumesとproducesを見ます。

consumes:送る本文のContent-Typeで入口を分ける

consumesは、リクエストのContent-Typeを使って、どのメソッドで受け付けるかを絞る条件です。ここではJSON本文のPOSTを受け付けます。

@RequestMapping(method = RequestMethod.POST, value = "/sample",
        consumes = MediaType.APPLICATION_JSON_VALUE)

Content-Type: application/jsonを送ると、この条件に一致します。今回のサンプルでXML本文を送ると、XMLを受けるメソッドがないため415になります。本文がJSONでもContent-Typeを省略すると、このサンプルでは415です。

ただし、consumesが本文を変換するわけではありません。JSONを@RequestBodyのJava型へ読むのはHttpMessageConverterです。対応する変換器・Java型・正しい本文が必要です。

どんなときに役立つか

同じ登録機能をJSONのAPIとHTMLフォームの両方から呼ぶ場面です。JSONとPOSTは対になる分類ではありません。POSTはHTTPメソッドで、JSONとフォームは本文の形式です。どちらもPOSTで送れます。

consumesを書かなくても、Springの本文変換は使えます。「指定しないと自分でJSONを解析する必要がある」という意味ではありません。指定する目的は、APIが受け付ける形式を契約として明示し、必要なら入口を分けることです。

最初の例はJSONを@RequestBody SampleRequest、フォームを@RequestParam("message")で受けています。形式ごとに受け取った後の業務処理が同じなら、共通のServiceへ渡し、登録処理そのものは重複させません。

入口を分けると、次の対応になります。URLとHTTPメソッドは同じでも、送る本文とヘッダは別です。

POST /sampleに送るもの選ばれるメソッド値の受け方
JSON本文+Content-Type: application/jsonreceiveJson()@RequestBody
フォーム本文+Content-Type: application/x-www-form-urlencodedreceiveForm()@RequestParam

レビューでは「JSONとフォームの入口を分け、受信後は同じServiceへ渡します。XMLは仕様に含めないので415です」のように、受け付ける形式と、受け付けない理由を説明できれば十分です。

415を消すためだけにconsumesを削除せず、まずAPI仕様を確認します。送信側のヘッダが間違っているのか、受信側で新しい形式に対応する必要があるのかを決めてから直します。

produces:返せる形式をAcceptと照合する

producesは、そのメソッドが返せるメディアタイプの条件です。リクエストのAcceptと照合してメソッドを絞ります。リクエスト本文のContent-Typeと混同しないでください。

最初の例でJSONを受けるメソッドはproduces = application/jsonです。そこへAccept: application/xmlを指定すると、受け付ける本文の形式は合っていても、返せる形式が合わず406になります。

@RequestMapping(method = RequestMethod.POST, value = "/sample",
        consumes = MediaType.APPLICATION_JSON_VALUE,
        produces = MediaType.APPLICATION_JSON_VALUE)

どんなときに役立つか

APIがJSONだけを返すと明示したい場合や、同じパスからJSONと別形式の表現を返したい場合に使います。クライアントがAcceptを省略したり*/*を指定したりした場合、このサンプルではJSONの応答を返せます。

producesを省略しても、@RestControllerや@ResponseBodyの戻り値は、利用可能な変換器とAcceptなどに基づいてレスポンスへ変換されます。省略すると必ずif文による自前判定が必要、という説明は誤りです。

修正前に確認すること

API仕様と、ブラウザの開発者ツールやcurlで確認した実際のリクエストを並べます。ソースコードの見た目だけで判断しないことが大切です。

  • パスとHTTPメソッドは一致しているか。405なのにURLだけを直そうとしていないか。
  • 送信本文とContent-Typeは同じ形式か。フォームなのにapplication/jsonを付けていないか。
  • Acceptで、APIが返せない形式だけを要求していないか。
  • 条件が合うのに失敗する場合、JSONの構文、引数型、変換器、Security、独自の例外処理を確認したか。

405・415・406を切り分けたら、次はControllerに届いた後の処理を追います。注文APIの例で、HTTPの応答とDBの状態をどのように照合するか確かめられます。

次の学習|HTTP応答と保存結果Spring Boot初心者におすすめの本3冊|現場で必要な力を身につける 注文APIで、201なら注文が1件増えて在庫が減り、400なら注文と在庫が変わらないことを確かめます。 注文APIの例を読む

根拠:Spring公式のRequest Mapping、標準例外とHTTPステータス、@RequestBody・@ResponseBody。

-実装・エラー解決
-,