ルーティングと最初のエンドポイント
ここまでの章で、アプリは起動し、注文を保存して読み戻せるようになりました。この章では、それを HTTP で呼び出せるようにします。注文を作る POST /api/v1/orders と、作った注文を取得する GET /api/v1/orders/{id} の 2 つのエンドポイントを作り、curl で呼び出します。処理はまずコントローラの中に直接書き、サービス層への切り出しは後の章で行います。
この章から、記事に載せる curl の例と応答を、サンプルリポジトリのテストで確かめます。章の後半で、その仕組みも扱います。
この章のコードは tag api-10 にあります。
コントローラ
HTTP のリクエストを受けるクラスを コントローラ と呼びます。前の章までの DispatcherServlet の流れでいうと、URL と HTTP メソッドに合うハンドラとして呼ばれるのがコントローラのメソッドです。
data class CreateOrderRequest(
val contactEmail: String,
val lines: List<CreateOrderLineRequest>,
)
data class CreateOrderLineRequest(
val productId: UUID,
val quantity: Int,
)
@RestController
@RequestMapping("/api/v1/orders")
class OrderController(
private val orderRepository: OrderRepository,
private val productRepository: ProductRepository,
private val idGenerator: IdGenerator,
private val clock: Clock,
) {
@PostMapping
fun create(@RequestBody request: CreateOrderRequest): ResponseEntity<Order> {
val products = productRepository.findAllById(request.lines.map { it.productId }).associateBy { it.id }
val lines = request.lines.map { line ->
val product = products[line.productId] ?: return ResponseEntity.unprocessableContent().build()
OrderLine(
productId = product.id,
quantity = line.quantity,
unitPrice = product.price,
taxRate = product.taxRate,
)
}
val order = orderRepository.save(
Order(
id = idGenerator.newId(),
contactEmail = request.contactEmail,
status = OrderStatus.PENDING,
orderedAt = clock.instant(),
lines = lines,
),
)
return ResponseEntity.created(URI.create("/api/v1/orders/${order.id}")).body(order)
}
@GetMapping("/{id}")
fun find(@PathVariable id: UUID): ResponseEntity<Order> {
val order = orderRepository.findByIdOrNull(id) ?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(order)
}
}
@RestController は、このクラスがコントローラで、メソッドの戻り値を JSON にして応答の本文に書くことを表します。@RequestMapping("/api/v1/orders") はクラスの中のメソッドが受け持つ URL の頭で、@PostMapping と @GetMapping が、それぞれ POST と GET を受けるメソッドを決めます。@GetMapping("/{id}") の {id} は URL の一部を変数として受け取る書き方で、同じ名前の引数に付けた @PathVariable がその値を受け取ります。引数の型を UUID にしておくと、Spring MVC が文字列を UUID に変えてから渡します。
@RequestBody を付けた引数には、リクエストの本文の JSON が入ります。JSON を Kotlin のオブジェクトに変えるのは Jackson で、data class のコンストラクタに JSON の値を渡してインスタンスを作ります。
コントローラが必要とする repository と、前の章の ID の採番と時計は、コンストラクタで受け取っています。前の章の DI のとおり、Spring がそれぞれの bean を渡します。
注文を作る処理
create は、リクエストの明細が指す商品をまとめて読み、商品ごとに注文明細を作ります。注文明細の単価と税率は、その時点の商品の価格と税率を写します。ID は IdGenerator から、受け付けた日時は Clock から取り、受付 (PENDING) の注文として保存します。在庫にはまだ触りません。在庫の引き当ては、サービス層の章で、トランザクションと一緒に入れます。
指した商品が無い明細があれば、その時点で 422 (Unprocessable Content) を返して処理を止めます。map に渡したラムダの中の return は、第 1 部の 関数とラムダ で見た、外側の関数 (create) から戻る return です。map が inline 関数なので書けます。
ResponseEntity は、ステータスコードとヘッダと本文をまとめて返すための型です。ResponseEntity.created(...) は 201 (Created) と、作った注文の URL を入れた Location ヘッダを返します。Location には、ホスト名を含まない相対の URL を入れています。HTTP の仕様 (RFC 9110) は Location に相対の参照を認めていて1、相対にしておけば、アプリがホスト名を組み立てずに済みます。
URL とステータスコード
URL はリソースを指す
URL には、操作の名前でなく、操作の対象 (リソース) を置きます。注文の集まりは /api/v1/orders、1 件の注文は /api/v1/orders/{id} です。作るのか読むのかは、HTTP メソッド (POST と GET) で表します。/api/v1/createOrder のように URL に動詞を入れると、同じリソースへの操作ごとに URL が増え、どの URL が同じものを指すのかが URL から読めなくなります。
バージョンを URL に持つ
URL の頭の /api/v1 は、API の版です。応答の形を後から互換性の無い形に変えるとき、/api/v2 を別に用意すれば、/api/v1 を使っているクライアントを動かしたまま、新しい形を出せます。
Spring Framework 7 には、版をヘッダ、クエリパラメータ、メディアタイプのパラメータ、URL のパスのいずれかから読み取り、同じ URL のメソッドを版ごとに呼び分ける仕組みもあります2。このガイドの API は版を 1 つしか持たないので、その仕組みは使わず、@RequestMapping に /api/v1 を書くだけにしています。
ステータスコード
この章の 2 つのエンドポイントは、次のステータスコードを返します。
| 場面 | ステータスコード |
|---|---|
| 注文を作った | 201 Created (Location ヘッダに作った注文の URL) |
| 指した商品が無い | 422 Unprocessable Content |
| 注文を取得した | 200 OK |
| 注文が無い | 404 Not Found |
422 は、リクエストの形は正しいが、中身を処理できない、という意味のステータスコードです (RFC 9110 で名前が Unprocessable Content になりました)。Spring Framework 7 は、この名前に合わせて ResponseEntity.unprocessableContent() を足し、それまでの unprocessableEntity() を非推奨にしています3。
404 は URL が指すリソースが無いときに使います。注文の作成で無い商品を指したときに 404 を返すと、/api/v1/orders という URL が無いのか、本文で指した商品が無いのかを、クライアントが区別できません。そのため、作成のほうは 422 にしています。
この章の 422 と 404 は、本文を持たない応答です。何がいけなかったのかを本文で伝える形は、エラーレスポンスの章で作ります。
curl で呼び出す
アプリを ./gradlew :app:bootRun で起動して、別のターミナルから呼び出します。この節の例は、手元の DB が初期データだけの状態から順に打つことを前提にしています。前の章までに試した注文が残っているときは、手元の DB を確かめる、戻す の手順で戻してから打ってください。
例で使う商品は、初期データの次の 2 つです。
insert into products (id, name, price, tax_rate, version) values
('0199f3a8-0000-7000-8000-000000000001', 'ノート', 330, 10, 0),
-- ...
('0199f3a8-0000-7000-8000-000000000005', 'コーヒー豆', 1280, 8, 0),
注文を作る
ノートを 2 冊とコーヒー豆を 1 袋、注文します。
curl -s -w '\n%{http_code} %header{location}\n' http://localhost:8080/api/v1/orders \
-H 'Content-Type: application/json' \
-d '{
"contactEmail": "taro@example.com",
"lines": [
{"productId": "0199f3a8-0000-7000-8000-000000000001", "quantity": 2},
{"productId": "0199f3a8-0000-7000-8000-000000000005", "quantity": 1}
]
}'
{
"id" : "019a1c00-0000-7000-8000-000000000001",
"contactEmail" : "taro@example.com",
"status" : "PENDING",
"orderedAt" : "2026-10-24T01:00:00Z",
"lines" : [ {
"productId" : "0199f3a8-0000-7000-8000-000000000001",
"quantity" : 2,
"unitPrice" : 330,
"taxRate" : 10
}, {
"productId" : "0199f3a8-0000-7000-8000-000000000005",
"quantity" : 1,
"unitPrice" : 1280,
"taxRate" : 8
} ],
"version" : 0
}
201 /api/v1/orders/019a1c00-0000-7000-8000-000000000001
-d はリクエストの本文で、-d を付けると curl は POST で送ります。-H 'Content-Type: application/json' は、本文が JSON であることをサーバーに伝えるヘッダです。curl はこのヘッダが無いと、-d の本文をフォームの形式 (application/x-www-form-urlencoded) として送ります。-w の %header{location} は、応答の Location ヘッダの値を出す書き方です (curl 7.84.0 から使えます)。
手元で打つと、id と orderedAt は記事と違う値になります。 記事の結果は、後の節のテストで、ID と日時を決まった値にして送ったときの応答です。手元のアプリは、前の章の UuidV7Generator と、実際の時計を使います。そのほかの値と応答の形は、記事と同じになります。
応答の本文は、保存した Order を、そのまま JSON にしたものです。version のような、データベースの都合で持っている値まで出ています。応答の形を決めて返す方法は、レスポンスの形の章で扱います。
作った注文を取得する
URL の注文 ID は、手元で作った注文の応答の id に置き換えて打ってください。
curl -s -w '\n%{http_code}\n' http://localhost:8080/api/v1/orders/019a1c00-0000-7000-8000-000000000001
{
"id" : "019a1c00-0000-7000-8000-000000000001",
"contactEmail" : "taro@example.com",
"status" : "PENDING",
"orderedAt" : "2026-10-24T01:00:00Z",
"lines" : [ {
"productId" : "0199f3a8-0000-7000-8000-000000000001",
"quantity" : 2,
"unitPrice" : 330,
"taxRate" : 10
}, {
"productId" : "0199f3a8-0000-7000-8000-000000000005",
"quantity" : 1,
"unitPrice" : 1280,
"taxRate" : 8
} ],
"version" : 0
}
200
作ったときの応答と同じ注文が、データベースから読み出されて返っています。前の章で時計をマイクロ秒で刻むようにしたのは、この 2 つの応答の orderedAt を同じ値にするためです。
無い注文と、無い商品
どの注文も持たない ID を指すと、404 が返ります。
curl -s -w '\n%{http_code}\n' http://localhost:8080/api/v1/orders/019a1c00-0000-7000-8000-000000000099
404
初期データに無い商品を指して注文を作ると、422 が返ります。
curl -s -w '\n%{http_code}\n' http://localhost:8080/api/v1/orders \
-H 'Content-Type: application/json' \
-d '{
"contactEmail": "taro@example.com",
"lines": [
{"productId": "0199f3a8-0000-7000-8000-000000000099", "quantity": 1}
]
}'
422
どちらも本文は空です。curl は空の本文の後に -w の改行を出すので、手元の端末では、ステータスコードの前に空の行が 1 行入ります。
記事の例を確かめるテスト
この章の curl の例と結果は、サンプルリポジトリのテストで確かめています。記事の curl のコマンドは app/src/test/resources/snippets/<名前>.curl に、結果は <名前>.out に置いてあり、テストはコマンドを読んでサーバーに送り、応答を .out と比べます。記事の側のフェンスと 2 つのファイルが同じ中身であることは、このガイドのリポジトリの側で別に確かめています。2 つを合わせて、記事の結果が、記事の curl を送ったときの応答であることになります。
章ごとのテストのクラスは、送る例の名前を、記事に載せた順に並べます。練習問題の例のように、応答のほかにも確かめることがある例は、そのためのメソッドを持ちます (下の抜粋では省いています)。
class Ch10ResponseSnippetsTest : ResponseSnippetTest() {
// ...
@Test
@Order(1)
fun `注文を作る`() = assertSnippetResponse("ch10-create-order")
@Test
@Order(2)
fun `作った注文を取得する`() = assertSnippetResponse("ch10-get-order")
@Test
@Order(3)
fun `無い注文を取得する`() = assertSnippetResponse("ch10-get-order-not-found")
@Test
@Order(4)
fun `無い商品を指して注文を作る`() = assertSnippetResponse("ch10-create-order-unknown-product")
// ...
}
@Order は、クラスの中のテストを動かす順です。2 つ目の例は 1 つ目で作った注文を取得するので、記事に載せた順に送る必要があります。JUnit は、何も指定しないとテストのメソッドを宣言の順に動かすとは限らないので、順を明示しています。
サーバーの起動、クラスの初めの準備、応答を .out と比べる assertSnippetResponse は、親のクラス ResponseSnippetTest が持ちます。curl のコマンドを読んでリクエストを送る処理は、CurlCommand が持ちます。
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
// DB を戻すために Flyway の clean を使う。本番の設定では無効のまま。
properties = ["spring.flyway.clean-disabled=false"],
)
@ActiveProfiles("dev")
@Import(TestcontainersConfiguration::class, FixedValuesConfiguration::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.OrderAnnotation::class)
abstract class ResponseSnippetTest {
// ...
@BeforeAll
fun resetState() {
flyway.clean()
flyway.migrate()
clock.reset()
ids.reset()
}
// ...
}
注釈が決めていることを、上から順に挙げます。
RANDOM_PORT: アプリを、空いているポートで実際のサーバーとして起動します。テストは、そのポートに HTTP でリクエストを送ります@ActiveProfiles("dev"): 読者がbootRunで動かすのと同じdevの profile にします。初期データが入り、JSON は字下げされます@Import: テストの DB (Testcontainers) と、時計と ID の採番を差し替える設定を読み込みますPER_CLASSと@BeforeAll: クラスの最初に 1 回だけ、DB を初期データだけの状態に戻し、時計と ID の採番を最初の値に戻します
Spring Boot には、サーバーを起動せずに Spring MVC だけを動かして試す MockMvc もあります。記事の例の確かめには使っていません。MockMvc はサーブレットコンテナを通らないので、7 章で見たエラーページ (/error) への転送が起きず、エラーのときの応答が、読者が bootRun に打ったときと変わるためです4。
curl のコマンドは、テストの側で curl の既定の振る舞いを写して読み替えます。-d があれば POST にし、Content-Type のヘッダが無ければフォームの形式にし、Accept のヘッダが無ければどの形式でも受け取る値を付けます。このほかにテストが付けるヘッダは、curl も自分で付ける、接続に要るもの (Host と本文の長さを表す Content-Length) と User-Agent だけです。そのため、記事から -H 'Content-Type: application/json' を消すと、テストの側でも JSON として送られず、応答が .out と食い違ってテストが落ちます。ただし、本文の無い POST には、テストだけが Content-Length: 0 を付けます。12 章の取り消しの例が当たります。
テストの送り方は、Spring Boot の道具でなく JDK の java.net.http.HttpClient です。HttpClient は HTTP/1.1 で送れば、接続に要るものと User-Agent のほかにヘッダを足しません。そのため、送るヘッダを、記事の curl に書いたものと上の既定の写しだけに決められます。実際に送ったヘッダがこのとおりであることも、テストで確かめています。この章の Web 層のテストは、次の節のとおり Spring Boot の道具で書きます。
時計と ID を決まった値にする
記事の結果の id と orderedAt が決まった値になっているのは、テストで時計と ID の採番を差し替えているためです。
/** 本体の時計と ID の採番を、決まった並びを返すものに差し替える。どちらも @Primary なので、本体の bean より優先される。 */
@TestConfiguration(proxyBeanMethods = false)
class FixedValuesConfiguration {
@Bean
@Primary
fun sequenceClock() = SequenceClock(start = Instant.parse("2026-10-24T01:00:00Z"), step = Duration.ofMinutes(1))
@Bean
@Primary
fun sequenceIdGenerator() = SequenceIdGenerator(prefix = "019a1c00-0000-7000-8000-")
}
SequenceClock は、呼ばれるたびに 1 分ずつ進んだ時刻を返す時計で、SequenceIdGenerator は、呼ばれるたびに末尾の番号を 1 つ増やした ID を返します。どちらも同じテストのファイルにあります。@Primary を付けた bean は、同じ型の bean がほかにもあるとき、優先して渡されます。コントローラは Clock と IdGenerator の型で受け取るので、本体の clock と UuidV7Generator より、この 2 つが渡されます。
1 つの決まった値だけを返す時計や採番にしないのは、2 件目の注文を作ったときに、主キーが重なって保存に失敗し、日時も区別できなくなるためです。bean の差し替えの考え方は、サービス層の章で詳しく扱います。
Web 層をテストする
HTTP のリクエストを受けてから、コントローラが応答を返すまでを、ここでは Web 層 と呼びます。URL とメソッドの対応付け、本文の JSON の読み取り、ステータスコードとヘッダは、Web 層の仕事です。この章では、Web 層のテストを、Spring Boot の RestTestClient で、起動したサーバーに送って書きます5。
@Import(TestcontainersConfiguration::class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
@ActiveProfiles("dev")
class OrderControllerTest(@Autowired private val client: RestTestClient) {
private val notebook = UUID.fromString("0199f3a8-0000-7000-8000-000000000001")
@Test
fun `作った注文は 201 で返り、Location の URL で同じ注文を取得できる`() {
val created = client.post().uri("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.body(CreateOrderRequest(contactEmail = "taro@example.com", lines = listOf(CreateOrderLineRequest(notebook, 2))))
.exchange()
.expectStatus().isCreated()
.expectBody<Order>()
.returnResult()
val order = requireNotNull(created.responseBody)
assertEquals(URI.create("/api/v1/orders/${order.id}"), created.responseHeaders.location)
client.get().uri("/api/v1/orders/${order.id}")
.exchange()
.expectStatus().isOk()
.expectBody<Order>().isEqualTo(order)
}
// ...
}
@AutoConfigureRestTestClient を付けると、起動したサーバーに向けた RestTestClient が bean になり、コンストラクタで受け取れます。URL はパスだけを書けば、テストが選んだポートのサーバーに送られます。exchange() でリクエストを送り、その後の expectStatus() や expectBody<Order>() で応答を確かめます。本文は Order に読み直して、取得の応答が作成の応答と同じ注文であることを、data class の等しさで確かめています。初期データの商品を指すので、profile は dev にしています。
記事の例を確かめるテストとの違いは、目的です。こちらは、アプリの約束 (どの URL に何を送れば、どのステータスコードとヘッダが返るか) を確かめるテストで、記事の文面とは独立に書きます。データベースまで通さずに Web 層だけを切り出して試す方法と、テストの分け方は、テスト戦略の章で扱います。
本番で効く注意点
保存した集約をそのまま返さない
この章のコントローラは、保存した Order をそのまま JSON にしています。そのため、version のような、クライアントに見せる意味の無い値も応答に出ています。集約にプロパティを 1 つ足すと、コントローラを変えなくても、その値が応答に加わります。データベースの都合で足したプロパティが、API の利用者に見えてしまう形です。応答の形を集約から切り離す方法は、レスポンスの形の章で扱います。
version 7 の UUID からは作った時刻が読める
URL に出している注文の ID は version 7 の UUID で、頭の 48 ビットが作ったときの時刻です。ID を見た人は、その注文がいつ作られたかを読み取れます。このガイドの注文はそれを隠す必要が無いので、索引に向いた version 7 を選んでいます。作った時刻を外に出したくないリソースでは、URL に出す ID を version 4 にするか、内部の ID とは別の値を URL に出します。ID が推測しにくいことを、他人の注文を見せないための守りにはしません。それは後の部で扱う認可の仕事です。
作成のリクエストを送り直すと、注文が 2 件できる
POST /api/v1/orders は、届くたびに新しい注文を作ります。クライアントが応答を受け取る前に通信が切れて、同じリクエストを送り直すと、同じ内容の注文が 2 件できます。この問題への対処は、後の部で扱います。
まとめ
@RestControllerのメソッドが、URL と HTTP メソッドに合うリクエストを受けます。@PathVariableは URL の一部を、@RequestBodyは本文の JSON を受け取ります- URL にはリソースを置き、操作は HTTP メソッドで表します。
/api/v1は API の版です - 作成は 201 と
Locationヘッダ、取得は 200、無いリソースは 404、処理できない中身は 422 を返します。Spring Framework 7 では、422 の名前が Unprocessable Content になりました - この章の Web 層のテストは、
RestTestClientで起動したサーバーに送り、ステータスコード・ヘッダ・本文を確かめます - 記事の curl の例は、起動したサーバーに送るテストで、応答が記事の結果と一致することを確かめています。テストでは時計と ID の採番を
@Primaryの bean に差し替え、値を決まった並びにします - 手元で打つと、ID と日時のほかは記事と同じ応答になります
次に読む
前の章は Spring Data JDBC で読み書きする です。
次章 入力の検証 では、数量に 0 や負の数が届いたときのように、リクエストの中身がおかしいときに、保存する前に断る方法を扱います。
練習問題
注文の取得の URL に、UUID の形でない文字列 (abc) を入れると、何が返りますか。それはなぜですか
解答例
curl -s -w '\n%{http_code}\n' http://localhost:8080/api/v1/orders/abc
{
"timestamp" : "2026-10-23T23:13:52.021Z",
"status" : 400,
"error" : "Bad Request",
"path" : "/api/v1/orders/abc"
}
400
400 (Bad Request) が返ります。find の引数 id の型が UUID なので、Spring MVC は URL の abc を UUID に変えようとして失敗し、find を呼ばずに 400 で応答します。コントローラのメソッドは、引数の型に合わない値では呼ばれません。
本文は、7 章で見た /error の既定の応答です。日時を含むので、記事の結果はテストで照合していません。テストはステータスコードが 400 であることを確かめています。
この章のコードで、明細の数量を 0 にして注文を作ると、何が返りますか。それはなぜですか
解答例
curl -s -w '\n%{http_code}\n' http://localhost:8080/api/v1/orders \
-H 'Content-Type: application/json' \
-d '{
"contactEmail": "taro@example.com",
"lines": [
{"productId": "0199f3a8-0000-7000-8000-000000000001", "quantity": 0}
]
}'
{
"timestamp" : "2026-10-23T23:13:52.198Z",
"status" : 500,
"error" : "Internal Server Error",
"path" : "/api/v1/orders"
}
500
500 (Internal Server Error) が返ります。この章のコントローラは数量を確かめないので、数量 0 の明細のまま保存しようとします。保存の途中で、8 章で order_lines に付けた check (quantity > 0) の制約に当たり、データベースが行を入れるのを断ります。その例外を誰も捕まえないので、応答は 500 です。アプリのログには、次のように制約の名前を含む例外が出ます。
org.postgresql.util.PSQLException: ERROR: new row for relation "order_lines" violates check constraint "order_lines_quantity_check"
save は 1 つのトランザクションで動くので、先に入れた注文の行も一緒に戻ります。テストは、このリクエストの前後で注文の行の数が変わらないことも確かめています。データベースに矛盾した注文は残りませんが、クライアントには「サーバーの誤り」としか伝わりません。数量 0 は利用者の入力の誤りなので、本来は保存する前に断り、400 で返すべきものです。その方法を次の章で扱います。