Spring Bootで最初のREST APIを作成する

1. 概要

このチュートリアルは、Spring Bootを使ってREST APIを完全に構築する方法を紹介します。例としてOrder(注文)管理を使用します。終わった後は、プロジェクトを構築し、CRUDを処理するcontrollerを書き、HTTP規格に則ったエラー処理をし、MockMvcを使ってAPIをテストできるようになります。

2. プロジェクトの初期化

Spring Initializrで以下の依存関係を指定してプロジェクトを作成します:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

spring-boot-starter-webは、組み込みのTomcatとSpring MVCを提供します。spring-boot-starter-validationはリクエストボディのvalidationに使用します(セクション5)。spring-boot-starter-testはJUnit 5、AssertJ、MockMvcが含まれています。

アプリケーションが実行できることを確認します:

@SpringBootApplication
public class OrderApiApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApiApplication.class, args);
    }
}

mvn spring-boot:runを実行すると、Tomcatが8080ポートで起動するのが表示されます。

3. モデルの定義

public class Order {
    private Long id;
    private String customerName;
    private BigDecimal amount;

    public Order(Long id, String customerName, BigDecimal amount) {
        this.id = id;
        this.customerName = customerName;
        this.amount = amount;
    }

    // getters, setters
}

これは単純なモデルで、JPAは使用していません。このチュートリアルの目標は、REST layerを習得することです。persistenceの部分は別のチュートリアルで扱います。

4. 一時的にデータを保存するServiceを書く

@Service
public class OrderService {

    private final Map<Long, Order> store = new ConcurrentHashMap<>();
    private final AtomicLong idSequence = new AtomicLong(0);

    public Order create(Order order) {
        long id = idSequence.incrementAndGet();
        Order saved = new Order(id, order.getCustomerName(), order.getAmount());
        store.put(id, saved);
        return saved;
    }

    public List<Order> findAll() {
        return new ArrayList<>(store.values());
    }

    public Optional<Order> findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }

    public void deleteById(Long id) {
        store.remove(id);
    }
}

ConcurrentHashMapが一時的な「データベース」として機能し、DBを設定することなくデモを実行するのに役立ちます。AtomicLongは増加するIDを生成し、複数のスレッドがcreateを呼び出す場合にスレッドセーフです。

注意: このデータ保存方式は学習用のみで、アプリを再起動するとデータは失われます。本番環境ではSpring Data JPAまたは同等のものが必要です。

5. REST Controllerを書く

5.1. エンドポイント作成とリスト取得

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    public ResponseEntity<Order> create(@Valid @RequestBody OrderRequest request) {
        Order order = new Order(null, request.customerName(), request.amount());
        Order saved = orderService.create(order);
        return ResponseEntity.status(HttpStatus.CREATED).body(saved);
    }

    @GetMapping
    public List<Order> findAll() {
        return orderService.findAll();
    }
}

@RestControllerは@Controllerと@ResponseBodyを組み合わせており、viewではなくJSONを返します。@RequestMapping("/api/orders")はクラス内のすべてのエンドポイントに共通のプレフィックスを付与します。@PostMappingはbodyを@RequestBodyで受け取り、Springはspring-boot-starter-webに含まれるJacksonのおかげでJSONをOrderRequestに自動変換します。

OrderRequestはinputのvalidationに使用するrecordで、Orderとは分離しています:

public record OrderRequest(
        @NotBlank String customerName,
        @NotNull @Positive BigDecimal amount
) {}

5.2. IDで取得と削除のエンドポイント

@GetMapping("/{id}")
public ResponseEntity<Order> findById(@PathVariable Long id) {
    return orderService.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
}

@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
    orderService.deleteById(id);
    return ResponseEntity.noContent().build();
}

@PathVariableはURLの{id}値を取得します。orderが見つからない場合、APIはnullではなく404 Not Foundを返します — これはREST APIが初めての人が忘れやすいポイントです。

curlでテストしてみましょう:

curl -X POST http://localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -d '{"customerName":"Nguyen Van A","amount":150000}'
HTTP/1.1 201 Created
Content-Type: application/json

{"id":1,"customerName":"Nguyen Van A","amount":150000}

6. 集中的なエラーハンドリング

処理しないと、validation errorは読みにくいデフォルトJSONレスポンスを返します。@RestControllerAdviceで標準化しましょう:

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidation(
            MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors()
                .forEach(error -> errors.put(error.getField(), error.getDefaultMessage()));
        return ResponseEntity.badRequest().body(errors);
    }
}

このクラスは@Validから投げられるすべてのMethodArgumentNotValidExceptionをキャッチし、エラーをフィールド別に集約してから、読みやすいボディで400 Bad Requestを返します:

{
  "amount": "must be greater than 0",
  "customerName": "must not be blank"
}

注意: Springのデフォルト例外をproductionに公開しないでください — スタックトレースが公開され、セキュリティに影響を与える可能性があります。

7. Controllerのテストを書く

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @Test
    void shouldReturn201WhenCreateOrderSuccessfully() throws Exception {
        Order saved = new Order(1L, "Tran Thi B", new BigDecimal("200000"));
        given(orderService.create(any(Order.class))).willReturn(saved);

        mockMvc.perform(post("/api/orders")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"customerName":"Tran Thi B","amount":200000}
                                """))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.customerName").value("Tran Thi B"));
    }

    @Test
    void shouldReturn404WhenOrderNotFound() throws Exception {
        given(orderService.findById(99L)).willReturn(Optional.empty());

        mockMvc.perform(get("/api/orders/99"))
                .andExpect(status().isNotFound());
    }
}

@WebMvcTestはweb layer(controller、filter、validation)のみをロードし、全体のcontextは初期化しないため、テストが高速に実行されます。@MockBeanは実際のOrderServiceをmockに置き換え、controllerをserviceのロジックとは独立してテストするのに役立ちます。2つのテストケースは成功パスとエラーパスの両方をチェック — これはすべてのエンドポイントで持つべき習慣です。

serviceレベルでもAssertJで検証してみましょう:

@Test
void shouldGenerateIncrementalIdWhenCreatingMultipleOrders() {
    OrderService service = new OrderService();

    Order first = service.create(new Order(null, "A", BigDecimal.TEN));
    Order second = service.create(new Order(null, "B", BigDecimal.ONE));

    assertThat(first.getId()).isEqualTo(1L);
    assertThat(second.getId()).isEqualTo(2L);
}

8. よくある間違い

  • @RequestBodyを忘れる: Springはjsonをobjectにバインドしないため、fieldは常にnullですが、明確なエラーは報告されません。
  • nullを返す代わりにResponseEntity.notFound()を返さない: clientは200 OKとbodyにnullを受け取り、frontendのバグを引き起こしやすいです。
  • inputをvalidateしない: APIが負のamountや空の名前を受け入れ、ロジック層でエラーが発生しやすく、追跡が難しいです。
  • @RestControllerの代わりに@Controllerを使う: 各methodで手動で@ResponseBodyを追加する必要があり、忘れやすいです。

注意: APIが複数のclient(mobile、web、partner)に対応する場合、後の破壊的変更を避けるため、最初からversioningを考慮してください(例えば/api/v1/orders)。

9. まとめ

Spring BootでシンプルなREST APIを構築しました:modelからserviceまで、controllerやエラーハンドリング、MockMvcによるテストまで。次のステップとしては、ConcurrentHashMapをSpring Data JPAに置き換えてデータを永続化し、@RestControllerAdviceに他の例外(EntityNotFoundExceptionなど)のハンドリングを追加することをお勧めします。

spring-bootrest-apijavaspring-web